diff --git a/.ameba.yml b/.ameba.yml
new file mode 100644
index 00000000000..5e2fb336458
--- /dev/null
+++ b/.ameba.yml
@@ -0,0 +1,61 @@
+Excluded:
+ - repositories/**/*.cr
+
+Lint/DebugCalls:
+ Excluded:
+ - drivers/**/*_spec.cr
+
+# NOTE: These should all be reviewed on an individual basis to see if their
+# complexity can be reasonably reduced.
+Metrics/CyclomaticComplexity:
+ Description: Disallows methods with a cyclomatic complexity higher than `MaxComplexity`
+ MaxComplexity: 10
+ Excluded:
+ - drivers/helvar/net.cr
+ - drivers/mulesoft/booking_api.cr
+ - drivers/samsung/displays/mdc_protocol.cr
+ - drivers/cisco/dna_spaces.cr
+ - drivers/cisco/meraki/dashboard.cr
+ - drivers/cisco/switch/snooping_catalyst.cr
+ - drivers/gantner/relaxx/protocol_json.cr
+ - drivers/place/bookings.cr
+ - drivers/place/area_management.cr
+ - drivers/place/smtp.cr
+ - drivers/hitachi/projector/cp_tw_series_basic.cr
+ - drivers/panasonic/projector/nt_control.cr
+ - drivers/lumens/dc193.cr
+ Enabled: false
+ Severity: Convention
+
+Lint/UselessAssign:
+ Description: Disallows useless variable assignments
+ # NOTE: Not enabled due to the extremely large hit count.
+ # Discussion with driver authors on whether this pattern is intended.
+ Enabled: false
+ Severity: Warning
+
+Style/VerboseBlock:
+ Description: Identifies usage of collapsible single expression blocks.
+ ExcludeCallsWithBlock: false
+ ExcludeMultipleLineBlocks: true
+ ExcludeOperators: false
+ ExcludePrefixOperators: false
+ ExcludeSetters: true
+ Enabled: false
+ Severity: Convention
+
+Style/VariableNames:
+ Description: Enforces variable names to be in underscored case
+ # NOTE: Not enabled due to the extremely large hit count.
+ # Discussion with driver authors on whether this pattern is intended.
+ Enabled: false
+ Severity: Convention
+
+# NOTE: These appear to be triggered by assignment in case expressions, could be an ameba bug
+Lint/ShadowingOuterLocalVar:
+ Description: Disallows the usage of the same name as outer local variables for block
+ or proc arguments.
+ Excluded:
+ - drivers/cisco/switch/snooping_catalyst.cr
+ Enabled: true
+ Severity: Warning
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 00000000000..abad59c9f43
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,33 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: 'Bug: A concise description of the behaviour'
+labels: bug
+assignees: ''
+
+---
+
+**Describe the bug**
+
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+
+Steps to reproduce the behaviour or a minimal code snippet that demonstrates the behaviour.
+
+**Expected behaviour**
+
+A clear and concise description of what you expected to happen.
+
+**Screenshots or a paste of terminal output**
+
+If applicable, add screenshots to help explain your problem.
+
+**Versions (please complete the following information):**
+
+- Output of `$ crystal version`
+- Driver version [e.g. 3.x]
+
+**Additional context**
+
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/driver_migration.md b/.github/ISSUE_TEMPLATE/driver_migration.md
new file mode 100644
index 00000000000..bc50ed19207
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/driver_migration.md
@@ -0,0 +1,20 @@
+---
+name: Driver Migration
+about: Migrate existing Ruby Engine Driver to Crystal
+title: 'Driver Migration: Migrate existing Ruby driver'
+labels: driver
+assignees: ''
+
+---
+
+**Driver to be Migrated**
+
+Information about the driver to be migrated.
+
+**Link to Existing Driver**
+
+Link to existing Driver on Ruby Drivers Repo.
+
+**Additional context**
+
+Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/driver_request.md b/.github/ISSUE_TEMPLATE/driver_request.md
new file mode 100644
index 00000000000..b68b3c805a5
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/driver_request.md
@@ -0,0 +1,32 @@
+---
+name: Driver Request
+about: Request a new driver to be created
+title: 'Driver Request: Information required to create a new driver'
+labels: driver
+assignees: ''
+
+---
+
+**Driver Type**
+
+Logic/Device/SSH/Websocket
+
+**Manufacturer**
+
+Manufacturer of device, software or service
+
+**Model/Service**
+
+Model or Service
+
+**Link to or Attach Device API or Protocol**
+
+If applicable, add screenshots to help explain your problem.
+
+**Describe any desired functionality**
+
+- Control all aspects of device
+
+**Additional context**
+
+Add any other context about the driver request here.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 00000000000..01f460a18d6
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,24 @@
+---
+name: Feature request
+about: Suggest an idea for this project
+title: 'RFC: Concise description of desired feature'
+labels: ''
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+
+Add any other context or screenshots about the feature request here.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000000..5ace4600a1f
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,6 @@
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000000..482c1694993
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,48 @@
+name: Build and Publish Drivers
+on:
+ push:
+ branches: [master]
+
+env:
+ CRYSTAL_VERSION: latest
+ PLACE_BUILD_TAG: nightly
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ environment: Build
+ steps:
+ - uses: actions/checkout@v4
+
+ # Binary Cache Logic
+ #############################################################################################
+
+ - uses: actions/cache@v3
+ with:
+ path: binaries
+ key: drivers-${{ env.CRYSTAL_VERSION }}-${{ github.run_id }}
+ restore-keys: drivers-${{ env.CRYSTAL_VERSION }}-
+
+ #############################################################################################
+
+ - uses: FranzDiebold/github-env-vars-action@v2 # https://github.com/github/feedback/discussions/5251
+ - name: Build Drivers
+ run: |
+ ./harness build \
+ --discover \
+ --strict-driver-info \
+ --repository-uri https://github.com/${{ github.repository }} \
+ --repository-path ./repositories/local \
+ --ref ${{ github.sha }} \
+ --branch ${{ github.ref_name }}
+ env:
+ CRYSTAL_VERSION: ${{ env.CRYSTAL_VERSION }}
+ AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
+ AWS_SECRET: ${{ secrets.AWS_SECRET }}
+ AWS_KEY: ${{ secrets.AWS_KEY }}
+ AWS_REGION: ${{ secrets.AWS_REGION }}
+ # CLOUD_BUILD_SERVER: CLOUD_BUILD_SERVICE_ROOT_ENDPOINT
+ # GIT_TOKEN: GIT_TOKEN_FOR_PRIVATE_REPO_IF_REQUIRED
+ PLACE_BUILD_TAG: ${{ env.PLACE_BUILD_TAG }}
+ BUILD_SERVICE_DISABLED: false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000000..cba025fcd2a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,136 @@
+name: CI
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ branches: [master]
+ schedule:
+ - cron: "0 6 * * 1"
+
+env:
+ PARALLEL_TESTS: 10
+ PARALLEL_BUILDS: 2
+
+jobs:
+ docs:
+ if: false # Temporarily disable as docs just _do not work_ for a driver
+ name: "Crystal Docs"
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ container: crystallang/crystal
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install Shards
+ run: shards install --ignore-crystal-version
+ - name: Docs
+ run: crystal docs
+
+ style:
+ name: "Style"
+ uses: PlaceOS/.github/.github/workflows/crystal-style.yml@main
+
+ subset-report:
+ name: "Subset Report - crystal: ${{ matrix.crystal }}, stable: ${{ matrix.stable }}"
+ runs-on: ubuntu-latest
+ continue-on-error: ${{ !matrix.stable }}
+ strategy:
+ fail-fast: false
+ matrix:
+ stable: [true]
+ crystal:
+ - latest
+ include:
+ - stable: false
+ crystal: nightly
+ steps:
+ - id: changes
+ uses: trilom/file-changes-action@v1.2.4
+ with:
+ output: ' '
+ - uses: actions/checkout@v4
+ - name: Cache shards
+ uses: actions/cache@v3
+ with:
+ path: lib
+ key: ${{ hashFiles('shard.lock') }}
+ - name: Driver Report
+ # Skip subset report if dependencies have changed
+ if: ${{ !contains(steps.changes.outputs.files, 'shard.yml') && !contains(steps.changes.outputs.files, 'shard.lock') }}
+ run: |
+ ./harness \
+ report \
+ --verbose \
+ --tests=${{ env.PARALLEL_TESTS }} \
+ --builds=${{ env.PARALLEL_BUILDS }} \
+ ${{ steps.changes.outputs.files }}
+ env:
+ AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
+ AWS_REGION: ${{ secrets.AWS_REGION }}
+ BUILD_SERVICE_DISABLED: true
+ CRYSTAL_VERSION: ${{ matrix.crystal }}
+ - name: Upload failure logs
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: logs-${{ matrix.crystal }}-${{ github.sha }}
+ path: .logs/*.log
+
+ full-report:
+ name: "Full Report - crystal: ${{ matrix.crystal }}, stable: ${{ matrix.stable }}"
+ needs: subset-report
+ runs-on: ubuntu-latest
+ continue-on-error: ${{ !matrix.stable }}
+ strategy:
+ fail-fast: false
+ matrix:
+ stable: [true]
+ crystal:
+ - latest
+ include:
+ - stable: false
+ crystal: nightly
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache shards
+ uses: actions/cache@v3
+ with:
+ path: lib
+ key: ${{ hashFiles('shard.lock') }}
+
+ # Binary Cache Logic
+ #############################################################################################
+
+ - uses: actions/cache@v3
+ with:
+ path: binaries
+ key: drivers-${{ env.CRYSTAL_VERSION }}-${{ github.run_id }}
+ restore-keys: drivers-${{ env.CRYSTAL_VERSION }}-
+
+ #############################################################################################
+
+ - name: Driver Report
+ run: |
+ ./harness \
+ report \
+ --verbose \
+ --tests=${{ env.PARALLEL_TESTS }} \
+ --builds=${{ env.PARALLEL_BUILDS }}
+ env:
+ AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
+ AWS_REGION: ${{ secrets.AWS_REGION }}
+ BUILD_SERVICE_DISABLED: false
+ CRYSTAL_VERSION: ${{ matrix.crystal }}
+ - name: Show build container logs
+ if: ${{ failure() }}
+ run: docker compose logs build
+ - name: Show drivers container logs
+ if: ${{ failure() }}
+ run: docker compose logs drivers
+ - name: Upload failure logs
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: logs-${{ matrix.crystal }}-${{ github.sha }}
+ path: .logs/*.log
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000000..430da3ac9f2
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,24 @@
+name: Deploy docs
+
+on:
+ push:
+ branches: [ master ]
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - uses: crystal-lang/install-crystal@v1
+ with:
+ crystal: latest
+ - name: "Install shards"
+ run: shards install --skip-postinstall --skip-executables
+ - name: "Generate docs"
+ run: crystal docs
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./docs
diff --git a/.gitignore b/.gitignore
index 0792935e4a3..4313fa25e98 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,14 @@
-doc
-lib
+*.dwarf
+*.rdb
+.DS_Store
.crystal
.shards
app
-*.dwarf
+bin
+doc
+docs
+binaries
+lib
+.logs
+repositories/*
+src
diff --git a/src/models/.keep b/.logs/.keep
similarity index 100%
rename from src/models/.keep
rename to .logs/.keep
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index ffc7b6ac56d..00000000000
--- a/.travis.yml
+++ /dev/null
@@ -1 +0,0 @@
-language: crystal
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000000..c1424e57eb7
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,16 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Debug",
+ "type": "gdb",
+ "request": "launch",
+ "target": "./bin/test-harness",
+ "cwd": "${workspaceRoot}",
+ "preLaunchTask": "Compile",
+ "setupCommands": [
+ { "text": "-gdb-set follow-fork-mode child" }
+ ]
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 00000000000..ce3aa5cfd9f
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,10 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Compile",
+ "command": "shards build --debug drivers",
+ "type": "shell"
+ }
+ ]
+}
diff --git a/LICENSE b/LICENSE
index 58b2d5683ef..c4e5872a57e 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2018 *YOUR COMPANY NAME HERE*
+Copyright (c) 2021 Place Technology Limited.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 1bd8efef9e8..08ff49a12a1 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,64 @@
-# Spider-Gazelle Application Template
+# PlaceOS Drivers
-[](https://travis-ci.org/spider-gazelle/spider-gazelle)
+[](https://github.com/PlaceOS/drivers/actions/workflows/ci.yml)
-Clone this repository to start building your own spider-gazelle based application
+Manage and test [PlaceOS](https://place.technology) drivers.
-## Documentation
+## Development
+
+### `harness`
+
+`harness` is a helper for easing development of PlaceOS Drivers.
-* [Action Controller](https://github.com/spider-gazelle/action-controller) base class for building [Controllers](http://guides.rubyonrails.org/action_controller_overview.html)
-* [Active Model](https://github.com/spider-gazelle/active-model) base class for building [ORMs](https://en.wikipedia.org/wiki/Object-relational_mapping)
-* [Habitat](https://github.com/luckyframework/habitat) configuration and settings for Crystal projects
-* [router.cr](https://github.com/tbrand/router.cr) base request handling
-* [Radix](https://github.com/luislavena/radix) Radix Tree implementation for request routing
-* [HTTP::Server](https://crystal-lang.org/api/latest/HTTP/Server.html) built-in Crystal Lang HTTP server
- * Request
- * Response
- * Cookies
- * Headers
- * Params etc
+```
+Usage: ./harness [-h|--help] [command]
+Helper script for interfacing with the PlaceOS Driver spec runner
-Spider-Gazelle builds on the amazing performance of **router.cr** [here](https://github.com/tbrand/which_is_the_fastest).:rocket:
+Command:
+ report check all drivers' compilation status
+ up starts the harness
+ down stops the harness
+ build builds drivers and uploads them to S3
+ format formats driver code
+ help display this message
+```
+To spin up the test harness, clone the repository and run...
-## Testing
+```shell-session
+$ ./harness up
+```
-`crystal spec`
+Point a browser to [localhost:8085/index.html](http://localhost:8085/index.html), and you're good to go.
-* to run in development mode `crystal ./src/app.cr`
+When the environment is not in use, remember to run...
-## Compiling
+```shell-session
+$ ./harness down
+```
-`crystal build ./src/app.cr`
+Before committing, please run...
+
+```shell-session
+$ ./harness format
+```
+
+## Documentation
-### Deploying
+- [Existing Driver Docs](https://placeos.github.io/drivers/)
+- [Writing a PlaceOS Driver](https://docs.placeos.com/tutorials/backend/write-a-driver)
+- [Testing a PlaceOS Driver](https://docs.placeos.com/tutorials/backend/write-a-driver/testing-drivers)
+- [Sending Emails](docs/guide-event-emails.md)
+- [Environment Setup](docs/setup.md)
+- [Runtime Debugging](docs/runtime-debugging.md)
+- [Directory Structure](docs/directory_structure.md)
+- [PlaceOS Spec Runner HTTP API](docs/http-api.md)
-Once compiled you are left with a binary `./app`
+## Contributing
-* for help `./app --help`
-* viewing routes `./app --routes`
-* run on a different port or host `./app -h 0.0.0.0 -p 80`
+1. [Fork it](https://github.com/PlaceOS/drivers/fork)
+2. Create your feature branch (`git checkout -b my-new-feature`)
+3. Commit your changes (`git commit -am 'Add some feature'`)
+4. Push to the branch (`git push origin my-new-feature`)
+5. Create a new Pull Request
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000000..b013baf51c0
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,76 @@
+version: "3.7"
+
+x-build-client-env: &build-client-env
+ PLACEOS_BUILD_HOST: ${PLACEOS_BUILD_HOST:-build}
+ PLACEOS_BUILD_PORT: ${PLACEOS_BUILD_PORT:-3000}
+
+services:
+ # Driver test harness
+ drivers:
+ image: placeos/drivers-spec:latest
+ restart: always
+ container_name: placeos-drivers
+ hostname: drivers
+ depends_on:
+ - build
+ - redis
+ - install-shards
+ ports:
+ - 127.0.0.1:8085:8080
+ - 127.0.0.1:4444:4444
+ volumes:
+ - ${PWD}/.logs:/app/report_failures
+ - ${PWD}/repositories:/app/repositories
+ - ${PWD}:/app/repositories/local
+ environment:
+ <<: *build-client-env
+ CI: ${CI:-}
+ CRYSTAL_PATH: lib:/lib/local-shards:/usr/share/crystal/src
+ REDIS_URL: redis://redis:6379
+ TZ: $TZ
+ cap_add:
+ - "SYS_PTRACE"
+ security_opt:
+ - "seccomp:unconfined"
+
+ build:
+ image: placeos/build:${PLACE_BUILD_TAG:-nightly}
+ restart: always
+ hostname: build
+ volumes:
+ - ${PWD}/repositories:/app/repositories
+ - ${PWD}:/app/repositories/local
+ - ${PWD}/binaries:/app/bin/drivers
+ environment:
+ AWS_REGION: ${AWS_REGION:-ap-southeast-2}
+ AWS_S3_BUCKET: ${AWS_S3_BUCKET:-placeos-drivers}
+ AWS_KEY: ${AWS_KEY}
+ AWS_SECRET: ${AWS_SECRET}
+ GIT_DISCOVERY_ACROSS_FILESYSTEM: 1
+ PLACEOS_BUILD_LOCAL: 1
+ PLACEOS_ENABLE_TRACE: 1
+ TZ: $TZ
+ BUILD_SERVICE_DISABLED: ${BUILD_SERVICE_DISABLED:-true}
+
+ redis:
+ image: eqalpha/keydb
+ restart: always
+ hostname: redis
+ environment:
+ TZ: $TZ
+
+ # Ensures shards are installed.
+ install-shards:
+ image: placeos/crystal:${CRYSTAL_VERSION:-latest}
+ restart: "no"
+ working_dir: /wd
+ entrypoint: ''
+ command: sh -c 'shards check -q || shards install'
+ environment:
+ SHARDS_OPTS: "--ignore-crystal-version"
+ volumes:
+ - ${PWD}/shard.lock:/wd/shard.lock
+ - ${PWD}/shard.yml:/wd/shard.yml
+ - ${PWD}/shard.override.yml:/wd/shard.override.yml
+ - ${PWD}/.shards:/wd/.shards
+ - ${PWD}/lib:/wd/lib
diff --git a/docs/directory_structure.md b/docs/directory_structure.md
new file mode 100644
index 00000000000..f70e5b68b5b
--- /dev/null
+++ b/docs/directory_structure.md
@@ -0,0 +1,23 @@
+# Directory Structures
+
+[PlaceOS Core](https://github.com/PlaceOS/core) and [PlaceOS Driver Spec Runner](https://github.com/PlaceOS/driver-spec-runner) make the assumption that the working directory is one level
+up from the `drivers` directory.
+
+An example deployment structure:
+
+* Working directory: `/home/placeos/core`
+* Executable: `/home/placeos/core/bin/core`
+* Driver repositories: `/home/placeos/repositories`
+ * PlaceOS Drivers: `/home/placeos/repositories/drivers`
+* Driver executables: `/home/placeos/core/bin/drivers`
+ * Samsung driver: `/home/placeos/core/bin/drivers/353b53_samsung_display_md_series_cr`
+
+However when developing the structure will look more like:
+
+* Working directory: `/home/placeos/drivers`
+* Driver repository: `/home/placeos/drivers`
+* Driver executables: `/home/placeos/drivers/bin/drivers`
+ * Samsung driver: `/home/placeos/core/bin/drivers/353b53_samsung_display_md_series_cr`
+
+The primary difference between production and development is [PlaceOS Core](https://github.com/PlaceOS/core).
+In a production environment, PlaceOS Core handles cloning repositories, installing packages, and building Drivers as required.
diff --git a/docs/gdb-entitlement.xml b/docs/gdb-entitlement.xml
new file mode 100644
index 00000000000..9d9251f55d9
--- /dev/null
+++ b/docs/gdb-entitlement.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.cs.debugger
+
+
+
+
+
diff --git a/docs/guide-event-emails.md b/docs/guide-event-emails.md
new file mode 100644
index 00000000000..d9176bf4bae
--- /dev/null
+++ b/docs/guide-event-emails.md
@@ -0,0 +1,443 @@
+# How to email people when an event occurs
+
+There are three aspects to this
+
+1. Sending an email in real-time as an event occurs
+2. Batching events (either periodically or via a [CRON](https://crontab.guru/))
+3. Managing state (state machine management)
+
+For example...
+- Send an email straight away if the event is today, otherwise, send them at 7 am every morning and mark the emails as sent.
+- Poll every 15min to send any emails that were missed due to an outage (by checking state)
+
+
+## Example logic driver
+
+```crystal
+require "placeos-driver/interface/mailer"
+
+class DeskBookingNotification < PlaceOS::Driver
+ descriptive_name "Desk Booking Approval"
+ generic_name :BookingApproval
+
+ default_settings({
+ # https://www.iana.org/time-zones
+ timezone: "Australia/Sydney",
+ # https://crystal-lang.org/api/latest/Time/Format.html
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+ booking_type: "desk",
+ buildings: ["zone-123", "zone-456"],
+ })
+
+ # this ensures these variables are not nilable
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+ @booking_type : String = "desk"
+ @buildings : Array(String) = [] of String
+
+ def on_update
+ # Update the instance variables based on the settings
+ time_zone = setting?(String, :calendar_time_zone).presence || "Australia/Sydney"
+ @time_zone = Time::Location.load(time_zone)
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+ @buildings = setting?(Array(String), :buildings) || [] of String
+
+ # configure any schedules here
+ # https://github.com/spider-gazelle/tasker
+ schedule.clear
+ schedule.every(5.minutes) { poll_bookings }
+ schedule.cron("30 7 * * *", @time_zone) { poll_bookings }
+ end
+
+ def on_load
+ # Some form of asset booking has occurred (such as a desk booking)
+ monitor("staff/booking/changed") { |_subscription, payload| check_booking(payload) }
+
+ on_update
+ end
+
+ # Get a reference to a module that can be used to send emails
+ def mailer
+ system.implementing(Interface::Mailer)
+ end
+
+ # Access another module in the system
+ accessor staff_api : StaffAPI_1
+
+ protected def check_booking(payload : String)
+ logger.debug { "received booking event payload: #{payload}" }
+ booking_details = Booking.from_json payload
+ process_booking(booking_details)
+ end
+
+ # ensure we don't have two fibers processing this at once
+ # (technically the driver is thread-safe, but it is concurrent)
+ @check_bookings_mutex = Mutex.new
+
+ @[Security(Level::Support)]
+ def poll_bookings(months_from_now : Int32 = 2)
+ # Clean up old debounce data
+ expired = 5.minutes.ago.to_unix
+ @debounce.reject! { |_, (_event, entered)| expired > entered }
+
+ now = Time.utc.to_unix
+ later = months_from_now.months.from_now.to_unix
+
+ @check_bookings_mutex.synchronize do
+ @buildings.each do |building_zone|
+ # bookings that haven't been approved
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: now,
+ period_end: later,
+ zones: [building_zone],
+ approved: false,
+ rejected: false,
+ created_before: 2.minutes.ago.to_unix
+ ).get.as_a
+
+ # bookings that have been approved
+ bookings = bookings + staff_api.query_bookings(
+ type: @booking_type,
+ period_start: now,
+ period_end: later,
+ zones: [building_zone],
+ approved: true,
+ rejected: false,
+ created_before: 2.minutes.ago.to_unix
+ ).get.as_a
+
+ # Convert to nice objects
+ bookings = Array(Booking).from_json(bookings.to_json)
+
+ logger.debug { "checking #{bookings.size} requested bookings in #{building_zone}" }
+ bookings.each { |booking_details| process_booking(booking_details) }
+ end
+ end
+ end
+
+ # Booking id => event action, timestamp
+ @debounce = {} of Int64 => {String?, Int64}
+ @bookings_checked = 0_u64
+
+ # See the booking model at the end of this document
+ protected def process_booking(booking_details : Booking)
+ # Ignore when a bookings state is updated
+ return if {"process_state", "metadata_changed"}.includes?(booking_details.action)
+
+ # Ignore the same event in a short period of time
+ previous = @debounce[booking_details.id]?
+ return if previous && previous[0] == booking_details.action
+ @debounce[booking_details.id] = {booking_details.action, Time.utc.to_unix}
+
+ # timezone, if different from the default
+ timezone = booking_details.timezone.presence || @time_zone.name
+ location = Time::Location.load(timezone)
+
+ # https://crystal-lang.org/api/0.35.1/Time/Format.html
+ # date and time (Tue Apr 5 10:26:19 2016)
+ starting = Time.unix(booking_details.booking_start).in(location)
+ ending = Time.unix(booking_details.booking_end).in(location)
+
+ # Ignore changes to meetings that have already ended
+ return if Time.utc > ending
+
+ building_zone, building_name = get_building_details(booking_details.zones)
+
+ # These are the available keys for use in the templates
+ args = {
+ booking_id: booking_details.id,
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+ starting_unix: booking_details.booking_start,
+
+ desk_id: booking_details.asset_id,
+ user_id: booking_details.user_id,
+ user_email: booking_details.user_email,
+ user_name: booking_details.user_name,
+ reason: booking_details.title,
+
+ level_zone: booking_details.zones.reject { |z| z == building_zone }.first?,
+ building_zone: building_zone,
+ building_name: building_name,
+ support_email: support_email,
+
+ approver_name: booking_details.approver_name,
+ approver_email: booking_details.approver_email,
+
+ booked_by_name: booking_details.booked_by_name,
+ booked_by_email: booking_details.booked_by_email,
+ }
+
+ case booking_details.action
+ when "create", "changed"
+ # check if email already sent and we can ignore this one
+ next if booking_details.process_state == "notification_sent"
+
+ mailer.send_template(
+ to: booking_details.user_email,
+ template: {"bookings", "booking_notification"},
+ args: args
+ )
+
+ # update the booking state (if there are multiple states a booking can be in)
+ staff_api.booking_state(booking_details.id, "notification_sent").get
+ when "approved"
+ # if there is an approval process
+ mailer.send_template(
+ to: booking_details.user_email,
+ template: {"bookings", "booking_approved"},
+ args: args
+ )
+
+ staff_api.booking_state(booking_details.id, "approval_sent").get
+ when "rejected", "checked_in"
+ mailer.send_template(
+ to: booking_details.user_email,
+ template: {"bookings", booking_details.action},
+ args: args
+ )
+ when "cancelled"
+ # maybe someone else cancelled your booking and you have a custom template for that
+ third_party = booking_details.approver_email && booking_details.approver_email != booking_details.user_email.downcase
+
+ mailer.send_template(
+ to: booking_details.user_email,
+ template: {"bookings", third_party ? "cancelled_by" : "cancelled"},
+ args: args
+ )
+
+ # maybe you want to notifty the persons manager about this
+ if manager_email = get_manager(user_email).try(&.at(0))
+ mailer.send_template(
+ to: manager_email,
+ template: {"bookings", "manager_notify_cancelled"},
+ args: args
+ )
+ end
+ end
+
+ # nice to see some status in backoffice
+ @bookings_checked += 1
+ self[:bookings_checked] = @bookings_checked
+ end
+
+ # id => tags, name
+ @zone_cache = {} of String => Tuple(Array(String), String)
+
+ def get_building_details(zones : Array(String))
+ zones.each do |zone_id|
+ zone_info = @zone_cache[zone_id]? || get_zone(zone_id)
+ next unless zone_info
+ next unless zone_info[0].includes?("building")
+
+ return {zone_id, zone_info[1]}
+ end
+
+ nil
+ end
+
+ def get_zone(zone_id : String)
+ zone = staff_api.zone(zone_id).get
+ tags = zone["tags"].as_a.map(&.as_s)
+ name = zone["name"].as_s
+ tuple = {tags, name}
+ @zone_cache[zone_id] = tuple
+ tuple
+ rescue error
+ logger.warn(exception: error) { "error obtaining zone details for #{zone_id}" }
+ nil
+ end
+
+ @[Security(Level::Support)]
+ def get_manager(staff_email : String)
+ # The Calendar driver is hooked up to MS Graph API for example
+ # could have used an accessor here like `staff_api`, that's optional
+ manager = system[:Calendar_1].get_user_manager(staff_email).get
+ {(manager["email"]? || manager["username"]).as_s, manager["name"].as_s}
+ rescue error
+ logger.warn(exception: error) { "failed to obtain manager of #{staff_email}" }
+ {nil, nil}
+ end
+end
+
+```
+
+
+### List of Staff API events
+
+These are events that can be monitored `monitor("event/path") { |sub, payload| }`
+
+* booking (desk, car space etc) - `"staff/booking/changed"`
+ * [boooking event model](https://github.com/place-labs/staff-api/blob/master/src/controllers/bookings.cr#L80)
+ * `action` types: create, cancelled, changed, metadata_changed, approved, rejected, checked_in, process_state
+* events (calendar events) - `"staff/event/changed"`
+ * [event event model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L130)
+ * `action` types: create, update, cancelled
+* a guest has been invited onsite - `"staff/guest/attending"`
+ * [guest attending model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L195)
+ * `action` types: meeting_created, meeting_update
+* a guest has arrived onsite - `"staff/guest/checkin"`
+ * [guest checkin model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L723)
+
+
+### Booking Model
+
+This model covers events and API responses
+
+```crystal
+
+class Booking
+ include JSON::Serializable
+
+ # This is to support events
+ property action : String?
+
+ property id : Int64
+ property booking_type : String
+ property booking_start : Int64
+ property booking_end : Int64
+ property timezone : String?
+
+ # events use resource_id instead of asset_id
+ property asset_id : String?
+ property resource_id : String?
+
+ def asset_id : String
+ (@asset_id || @resource_id).not_nil!
+ end
+
+ property user_id : String
+ property user_email : String
+ property user_name : String
+
+ property zones : Array(String)
+
+ property checked_in : Bool?
+ property rejected : Bool?
+ property approved : Bool?
+ property process_state : String?
+ property last_changed : Int64?
+
+ property approver_name : String?
+ property approver_email : String?
+
+ property booked_by_name : String
+ property booked_by_email : String
+
+ property checked_in : Bool?
+ property title : String?
+ property description : String?
+
+ property extension_data : Hash(String, JSON::Any)
+
+ def in_progress?
+ now = Time.utc.to_unix
+ now >= @booking_start && now < @booking_end
+ end
+
+ def changed
+ Time.unix(last_changed.not_nil!)
+ end
+end
+
+```
+
+### Email templates
+
+Email templates are applied to the mailer driver and then other drivers can use them to send emails.
+
+see the [mailer interface](https://github.com/PlaceOS/driver/blob/master/src/placeos-driver/interface/mailer.cr#L27) for details on available params
+
+The templates are settings, structured like:
+
+```yaml
+
+email_templates:
+ category:
+ template_name:
+ subject: the email subject line with %{variables}
+ text: the text version of an email
+ html:
the HTML version of the email
+
+```
+
+typically only the `html` version of an email is required
+
+```yaml
+
+email_templates:
+ bookings:
+ rejected:
+ subject: 'Desk Booking: Manager rejection'
+ html: >
+
+
+ This is a short note to advise that your desk booking request for
+ %{start_date} at %{building_name} has been rejected.
+
+
+
+ Please reach out to your manager %{approver_name} if you would like
+ to follow up.
+
+
+
+ Your request has been removed from the system and we look forward to
+ welcoming you to our workplace in the future.
+
+
+
+ Kind Regards
+
+
+
+ The Corporate Real Estate Team
+
+
+ cancelled:
+ subject: Desk booking cancellation confirmation
+ text: >
+ Thank you for taking the time to cancel your booking which we appreciate
+ so we can continue to operate with efficiency and excellence.
+
+
+ Your desk booking on %{start_date} at %{building_name} has been
+ cancelled.
+
+
+ Please reach out to your workplace support team should you have any
+ other queries, otherwise we look forward to seeing you soon
+ html: >
+
+
+ Thank you for taking the time to cancel your booking which we appreciate
+ so we can continue to operate with efficiency and excellence.
+
+
+
+ Your desk booking on %{start_date} at %{building_name} has been
+ cancelled.
+
+
+
+ Please reach out to your workplace support team should you have any other queries,
+ otherwise, we look forward to seeing you soon
+
+
+
+```
diff --git a/docs/http-api.md b/docs/http-api.md
new file mode 100644
index 00000000000..9e908404b81
--- /dev/null
+++ b/docs/http-api.md
@@ -0,0 +1,154 @@
+# HTTP API
+
+Primarily for development.
+
+
+## GET /build
+
+Returns the list of available drivers
+
+* `repository=folder_name` (optional) if you wish to specify a third party repository
+* `compiled=true` (optional) if you only want the list of compiled drivers
+
+```json
+
+["drivers/place/spec_helper.cr", "..."]
+```
+
+
+### GET /build/repositories
+
+Returns the list of 3rd party repositories
+
+```json
+
+["private_drivers", "..."]
+```
+
+
+### GET /build/repository_commits
+
+Returns the list of available commits at the repository level
+
+* `repository=folder_name` (optional) if you wish to specify a third party repository
+* `count=50` (optional) if you want more or less commits
+
+```json
+
+{
+ "commit": "01519d6",
+ "date": "2019-06-02T23:59:22+10:00",
+ "author": "Stephen von Takach",
+ "subject": "implement websocket spec runner"
+}
+```
+
+
+### GET /build/{{escaped driver path}}
+
+Returns the list of compiled versions of the specified file are available
+
+```json
+
+["private_drivers_cr_01519d6", "..."]
+```
+
+
+### GET /build/{{escaped driver path}}/commits
+
+Returns the list of available commits for the current driver
+
+* `repository=folder_name` (optional) if you wish to specify a third party repository
+* `count=50` (optional) if you want more or less commits
+
+```json
+
+{
+ "commit": "01519d6",
+ "date": "2019-06-02T23:59:22+10:00",
+ "author": "Stephen von Takach",
+ "subject": "implement websocket spec runner"
+}
+```
+
+
+### POST /build
+
+compiles a driver
+
+* `driver=drivers/path.cr` (required) the path to the driver
+* `commit=01519d6` (optional) defaults to head
+
+
+### DELETE /build/{{escaped driver path}}
+
+deletes compiled versions of a driver
+
+* `repository=folder_name` (optional) if you wish to specify a third party repository
+* `commit=01519d6` (optional) deletes all versions of a driver if not specified
+
+
+## GET /test
+
+Lists the available specs
+
+```json
+
+["drivers/place/spec_helper_spec.cr", "..."]
+```
+
+
+### GET /test/{{escaped spec path}}/commits
+
+Returns the list of available commits for the specified spec
+
+* `repository=folder_name` (optional) if you wish to specify a third party repository
+* `count=50` (optional) if you want more or less commits
+
+```json
+
+{
+ "commit": "01519d6",
+ "date": "2019-06-02T23:59:22+10:00",
+ "author": "Stephen von Takach",
+ "subject": "implement websocket spec runner"
+}
+```
+
+
+### POST /test
+
+Compiles and runs a spec and returns the output
+
+* `repository=folder_name` (optional) if you wish to specify a third party repository
+* `driver=drivers/path/to/file.cr` (required) the driver you want to test
+* `spec=drivers/path/to/file_spec.cr` (required) the spec you want to run on the driver
+* `commit=01519d6` (optional) the commit you would like the driver to be running at
+* `spec_commit=01519d6` (optional) the commit you would like the spec to be running at
+* `force=true` (optional) forces a re-compilation of the driver and spec
+* `debug=true` (optional) compiles the files with debugging symbols
+
+```text
+Launching spec runner
+Launching driver: /Users/steve/Documents/projects/placeos/drivers/bin/drivers/drivers_place_private_helper_cr_4f6e0cd
+... starting driver IO services
+... starting module
+... waiting for module
+... module connected
+... enabling debug output
+... starting spec
+... spec complete
+... terminating driver gracefully
+Driver terminated with: 0
+
+
+Finished in 15.65 milliseconds
+0 examples, 0 failures, 0 errors, 0 pending
+
+spec runner exited with 0
+```
+
+
+### WebSocket /test/run_spec
+
+Same requirements as `POST /test` above however it streams the response
diff --git a/docs/runtime-debugging.md b/docs/runtime-debugging.md
new file mode 100644
index 00000000000..9fe51783978
--- /dev/null
+++ b/docs/runtime-debugging.md
@@ -0,0 +1,195 @@
+# Runtime Debugging
+
+This is supported via VS Code on OSX or Linux platforms.
+It might be possible to do remote debugging on Windows in conjunction with the Linux Layer.
+
+* Requires [VS Code](https://code.visualstudio.com/)
+ * install [Crystal Lang](https://marketplace.visualstudio.com/items?itemName=faustinoaq.crystal-lang) extension
+ * install [Native Debug](https://marketplace.visualstudio.com/items?itemName=webfreak.debug) extension
+* Requires [GDB](https://www.gnu.org/software/gdb/)
+ * On OSX install using [Homebrew](https://brew.sh/)
+ * Then code sign the executable: https://sourceware.org/gdb/wiki/PermissionsDarwin
+ * The `gdb-entitlement.xml` file is in this folder
+ * When creating the signing certificate follow [this guide](https://apple.stackexchange.com/questions/309017/unknown-error-2-147-414-007-on-creating-certificate-with-certificate-assist)
+
+This should also work with [LLDB](https://lldb.llvm.org/) on OSX however [has issues](https://github.com/crystal-lang/crystal/issues/4457).
+
+
+## Debug on VSCode
+
+By convention the project directory name is the same as your application name, if you have changed it, please update `${workspaceFolderBasename}` with the name configured inside `shards.yml`
+
+### 1. `tasks.json` configuration to compile a crystal project
+
+```javascript
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "Compile",
+ "command": "shards build --debug ${workspaceFolderBasename}",
+ "type": "shell"
+ }
+ ]
+}
+```
+
+### 2. `launch.json` configuration to debug a binary
+
+#### Using GDB
+
+```javascript
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Debug",
+ "type": "gdb",
+ "request": "launch",
+ "target": "./bin/${workspaceFolderBasename}",
+ "cwd": "${workspaceRoot}",
+ "preLaunchTask": "Compile"
+ }
+ ]
+}
+```
+
+#### Using LLDB
+
+```javascript
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Debug",
+ "type": "lldb-mi",
+ "request": "launch",
+ "target": "./bin/${workspaceFolderBasename}",
+ "cwd": "${workspaceRoot}",
+ "preLaunchTask": "Compile"
+ }
+ ]
+}
+```
+
+### 3. Then hit the DEBUG green play button
+
+
+
+## Tips and Tricks for debugging Crystal applications
+
+### 1. Use debugger keyword
+
+Instead of putting breakpoints using commands inside GDB or LLDB you can try to set a breakpoint using `debugger` keyword.
+
+```ruby
+i = 0
+while i < 3
+ i += 1
+ debugger # => breakpoint
+end
+```
+
+### 2. Avoid breakpoints inside blocks
+
+Currently, Crystal lacks support for debugging inside of blocks. If you put a breakpoint inside a block, it will be ignored.
+
+As a workaround, use `pp` to pretty print objects inside of blocks.
+
+```ruby
+3.times do |i|
+ pp i
+end
+# i => 1
+# i => 2
+# i => 3
+```
+
+### 3. Try `@[NoInline]` to debug arguments data
+
+Sometimes crystal will optimize argument data, so the debugger will show `` instead of the arguments. To avoid this behavior use the `@[NoInline]` attribute before your function implementation.
+
+```ruby
+@[NoInline]
+def foo(bar)
+ debugger
+end
+```
+
+### 4. Printing strings objects \(GDB\)
+
+To print string objects in the debugger:
+
+First, setup the debugger with the `debugger` statement:
+
+```ruby
+foo = "Hello World!"
+debugger
+```
+
+Then use `print` in the debugging console.
+
+```bash
+(gdb) print &foo.c
+$1 = (UInt8 *) 0x10008e6c4 "Hello World!"
+```
+
+Or add `&foo.c` using a new variable entry on watch section in VSCode debugger
+
+
+
+### 5. Printing array variables
+
+To print array items in the debugger:
+
+First, setup the debugger with the `debugger` statement:
+
+```ruby
+foo = ["item 0", "item 1", "item 2"]
+debugger
+```
+
+Then use `print` in the debugging console:
+
+```bash
+(gdb) print &foo.buffer[0].c
+$19 = (UInt8 *) 0x10008e7f4 "item 0"
+```
+
+Change the buffer index for each item you want to print.
+
+### 6. Printing instance variables
+
+For printing `@foo` var in this code:
+
+```ruby
+class Bar
+ @foo = 0
+ def baz
+ debugger
+ end
+end
+
+Bar.new
+```
+
+You can use `self.foo` in the debugger terminal or VSCode GUI.
+
+### 7. Print hidden objects
+
+Some objects do not show at all. You can unhide them using the `.to_s` method and a temporary debugging variable, like this:
+
+```ruby
+def bar(hello)
+ "#{hello} World!"
+end
+
+def foo(hello)
+ bar_hello_to_s = bar(hello).to_s
+ debugger
+end
+
+foo("Hello")
+```
+
+This trick allows showing the `bar_hello_to_s` variable inside the debugger tool.
diff --git a/docs/setup.md b/docs/setup.md
new file mode 100644
index 00000000000..30228be8248
--- /dev/null
+++ b/docs/setup.md
@@ -0,0 +1,17 @@
+# Setup
+
+Usage of [PlaceOS Driver Spec Runner](https://github.com/PlaceOS/driver-spec-runner) allows you to build and test
+drivers without installing or running the complete PlaceOS service.
+
+## Installation
+
+Clone the drivers repository: `git clone https://github.com/placeos/drivers drivers`
+
+## Reports
+
+Test your driver with `./harness report `.
+If the spec file argument is omitted, the harness will run specs for every driver in the current repository.
+
+## Developing
+
+After running `./harness up`, the harness will expose a development interface on [localhost:8085](http://localhost:8085).
diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md
new file mode 100644
index 00000000000..94877792f3d
--- /dev/null
+++ b/docs/writing-a-driver.md
@@ -0,0 +1,505 @@
+# How to write a PlaceOS Driver
+
+There are three kinds of PlaceOS Drivers...
+
+- [Streaming IO (TCP, SSH, UDP, Multicast, etc.)](#streaming-io)
+- [HTTP Client](#http-client)
+- [Logic](#logic-drivers)
+
+From a Driver structure standpoint, there is no difference between these types.
+
+- The same Driver can be used over a TCP, UDP or SSH transport.
+- All Drivers support HTTP methods if a URI endpoint is defined.
+- If a Driver is associated with a System then it has access to logic helpers
+
+However, typically a Driver will only implement one of these interfaces.
+
+
+## Concepts
+
+There are a few components of the PlaceOS Driver system...
+
+- [Lifecycle](#lifecycle)
+- [Queue](#queue)
+- [Transport](#transport)
+- [Subscriptions](#subscriptions)
+- [Scheduler](#scheduler)
+- [Settings](#settings)
+- [Logger](#logger)
+- [Metadata](#metadata)
+- [Security](#security)
+- [Interfaces](#interfaces)
+
+### Lifecycle
+
+All PlaceOS Drivers have a lifecycle that is managed by the system.
+
+There are 5 lifecycle events:
+
+* `#on_load` - Called when a driver is added to a system.
+* `#on_update` - Called when settings are updated.
+* `#on_unload` - Called when a driver is removed from a system.
+* `#connected` - Called when a driver becomes active.
+* `#disconnected` - Called when a driver becomes inactive.
+
+For more information on these and other driver methods, see [PlaceOS Driver](https://github.com/PlaceOS/driver).
+
+### Queue
+
+The queue is a list of potentially asynchronous tasks that should be performed in a sequence.
+
+* Each task has a priority (defaults to `50`) - higher priority tasks run first
+* Tasks can be named. If a new task is added with the same name it replaces the existing task.
+* Tasks have a timeout (defaults to `5.seconds`)
+* Tasks can be retried (defaults to `3` before failing)
+
+Tasks have a callback that is used to run the task
+
+```crystal
+
+# => you can set queue defaults globally
+
+# set a delay between the current task completing and the next task
+queue.delay = 1.second
+queue.retries = 5
+
+queue(priority: 20, timeout: 1.second) do |task|
+ # perform action here
+
+ # signal result
+ task.success("optional success value")
+ task.abort("optional failure message")
+ task.retry
+
+ # Give me more time to complete the task
+ task.reset_timers
+end
+
+```
+
+In most cases, you won't need to use the queue explicitly however it is good to understand that it is there and how it functions.
+
+
+### Transport
+
+The transport loaded is defined by settings in the database.
+
+#### Streaming IO
+
+You should always tokenise your streams.
+This can be handled automatically by the [built in tokeniser](https://github.com/spider-gazelle/tokenizer)
+
+```crystal
+
+def on_load
+ transport.tokenizer = Tokenizer.new("\r\n")
+end
+
+```
+
+There are a few ways to use streaming IO methods:
+
+1. send and receive
+
+```crystal
+
+def perform_action
+ # You call send with some data.
+ # You can also optionally pass some queue options to the function
+ send("message data", priority: 30, name: "generic-message")
+end
+
+# A common received function for handling responses
+def received(data, task)
+ # data is always `Bytes`
+ # task is always `PlaceOS::Driver::Task?` (i.e. could be nil if no active task)
+
+ # convert data into the appropriate format
+ data = String.new(data)
+
+ # decide if the request was a success or not
+ # you can pass any value that is JSON serialisable to success
+ # (if it can't be serialised then nil is sent)
+ task.try &.success(data)
+end
+
+```
+
+2. send and callback
+
+```crystal
+
+def perform_action
+ request = "build request"
+
+ send(request, priority: 30, name: "generic-message") do |data, task|
+ data = String.new(data)
+
+ # process response here (might need to know the request context)
+
+ task.try &.success(data)
+ end
+end
+
+```
+
+3. send immediately (no queuing)
+
+```crystal
+
+def perform_action_now!
+ transport.send("no queue")
+end
+
+```
+
+You can also add a pre-processor to data coming in. This can be useful
+if you want to strip away a protocol layer i.e. you are communicating
+over Telnet and want to remove the telnet signals leaving the raw
+comms for tokenising
+
+```crystal
+
+def on_load
+ transport.pre_processor do |bytes|
+ # you must return some byte data or nil if no processing is required
+ # tokenisation occurs on the data returned here
+ bytes[1..-2]
+ end
+end
+
+def received(data, task)
+ # data coming in here is both pre_processed and tokenised
+end
+
+```
+
+
+#### HTTP Client
+
+All PlaceOS Drivers have built-in methods for performing HTTP requests.
+
+* For streaming IO devices this defaults to `http://device.ip.address` or `https` if the transport is using TLS / SSH.
+* All devices can provide a custom HTTP base URI.
+
+There are methods for all the typical HTTP verbs: get, post, put, patch, delete
+
+```crystal
+
+def perform_action
+ basic_auth = "Basic #{Base64.strict_encode("#{@username}:#{@password}")}"
+
+ response = post("/v1/message/path", body: {
+ messages: numbers,
+ }.to_json, headers: {
+ "Authorization" => basic_auth,
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ }, params: {
+ "key" => "value"
+ })
+
+ raise "request failed with #{response.status_code}" unless (200...300).include?(data.status_code)
+end
+
+```
+
+
+#### Special SSH methods
+
+SSH connections will attempt to open a shell to the remote device however sometimes you may be able to execute operations independently.
+
+```crystal
+
+def perform_action
+ # if the application launched supports input you can use the bidirectional IO
+ # to communicate with the app
+ io = exec("command")
+end
+
+```
+
+
+#### Logic drivers
+
+The main difference between Logic Drivers and other transports is that a logic module is directly associated with a System and cannot be shared. (all other Drivers can appear in multiple systems)
+
+- You can access remote modules in the system via the `system` helper
+
+```crystal
+
+# Get a system proxy
+sys = system
+sys.name #=> "Name of system"
+sys.email #=> "resource@email.address"
+sys.capacity #=> 12
+sys.bookable #=> true
+sys.id #=> "sys-tem~id"
+sys.modules #=> ["Array", "Of", "Unique", "Module", "Names", "In", "System"]
+sys.count("Module") #=> 3
+sys.implementing(PlaceOS::Driver::Interface::Powerable) #=> ["Camera", "Display"]
+
+# Look at status on a remote module
+system[:Display][:power] #=> true
+
+# Access a different module index
+system[:Display_2][:power]
+system.get(:Display, 2)[:power]
+
+# Access all modules of a type
+system.all(:Display)
+
+# Check if a module exists
+system.exists?(:Display) #=> true
+system.exists?(:Display_2) #=> false
+
+```
+
+- You can bind to state in remote modules
+
+```crystal
+
+bind Display_1, :power, :power_changed
+
+private def power_changed(subscription, new_value)
+ logger.debug new_value
+end
+
+
+# You can also bind to Driver's internal state (available in all Drivers)
+bind :power, :power_changed
+
+```
+
+It's also possible to create shortcuts to other modules.
+This is powerful as these shortcuts are exposed as metadata - allowing backoffice to perform system verification.
+
+For example, consider the following video conference system:
+
+```crystal
+
+# It requires at least one camera that can move and be turned on and off
+accessor camera : Array(Camera), implementing: [Powerable, Moveable]
+
+# Optional room blinds that can be opened and closed
+accessor blinds : Array(Blind)?, implementing: [Switchable]
+
+# A single display is required with an optional screen (maybe it's a projector)
+accessor main_display : Display_1, implementing: Powerable
+accessor screen : Screen?
+
+```
+
+Cross-system communication is possible if you know the ID of the remote system.
+
+```crystal
+# once you have reference to the remote system you can perform any
+# actions that you might perform on the local system
+sys = system("sys-12345")
+
+sys.name #=> "Name of remote system"
+sys[:Display_2][:power] #=> true
+```
+
+
+### Subscriptions
+
+You can dynamically bind to state of interest in remote modules
+
+```crystal
+
+# Subscription is returned and provided with every status update in the callback
+subscription = system.subscribe(:Display_1, :power) do |subscription, new_value|
+ # values are always raw JSON strings
+ JSON.parse(new_value)
+end
+
+# Local subscriptions
+subscription = subscribe(:state) do |subscription, new_value|
+ # values are always raw JSON strings
+ JSON.parse(new_value)
+end
+
+# Clearing all subscriptions
+subscriptions.clear
+
+```
+
+Similarly to subscriptions, channels can be set up for broadcasting
+arbitrary data that might not need to be exposed as Driver state.
+
+```crystal
+
+subscription = monitor(:channel_name) do |subscription, new_value|
+ # values are always raw JSON strings
+ JSON.parse(new_value)
+end
+
+# Publish something on the channel to all listeners
+publish(:channel_name, "some event")
+
+```
+
+
+### Scheduler
+
+There is a built-in scheduler: https://github.com/spider-gazelle/tasker
+
+```crystal
+
+def connected
+ schedule.every(40.seconds) { poll_device }
+ schedule.in(200.milliseconds) { send_hello }
+end
+
+def disconnected
+ schedule.clear
+end
+
+```
+
+
+### Settings
+
+Settings are stored as JSON and then extracted as required, serialising to the specified type
+There are two types:
+
+* Required settings - raise an error if the setting is unavailable
+* Optional settings - return `nil` if the setting is unavailable
+
+NOTE:: All settings will raise an error if they exist but fail to serialise (as they are not formatted correctly etc)
+
+```crystal
+
+# Required settings
+def on_update
+ @display_id = setting(Int32, :display_id)
+
+ # Can extract deeply nested values
+ # i.e. {input: {list: ["HDMI", "VGA"] }}
+ @primary_input = setting(InputEnum, :input, :list, 0)
+end
+
+# Optional settings (you can optionally provide a default)
+def on_update
+ @display_id = setting?(Int32, :display_id) || 1
+ @primary_input = setting?(InputEnum, :input, :list, 0) || InputEnum::HDMI
+end
+
+```
+
+You can update the local settings of a module, persisting them to the database. Settings must be JSON serialisable
+
+```crystal
+define_setting(:my_setting_name, "some JSON serialisable data")
+```
+
+
+### Logger
+
+There is a logger available: https://crystal-lang.org/api/latest/Logger.html
+
+* Warning and above are written to disk.
+* debug and info are only available when there is an open debugging session.
+
+```crystal
+
+logger.warn { "error unknown response" }
+logger.debug { "function called with #{value}" }
+
+```
+
+The logging format has been pre-configured so all logging from PlaceOS is uniform and simple to parse
+
+
+### Metadata
+
+Metadata is used by various components to simplify configuration.
+
+* `generic_name` => the name that should be used in a system to access the module
+* `descriptive_name` => the manufacturers name for the device
+* `description` => notes or any other descriptive information you wish to add
+* `tcp_port` => TCP port the TCP transport should connect to
+* `udp_port` => UDP port the UDP transport should connect to
+* `uri_base` => The HTTP base for any HTTP requests
+* `default_settings` => Defaults or example settings that should be used to configure a module
+
+
+```crystal
+
+class MyDevice < PlaceOS::Driver
+ generic_name :Driver
+ descriptive_name "Driver model Test"
+ description "This is the Driver used for testing"
+ tcp_port 22
+ default_settings({
+ name: "Room 123",
+ username: "steve",
+ password: "$encrypt",
+ complex: {
+ crazy_deep: 1223,
+ },
+ })
+
+ # ...
+
+end
+
+```
+
+
+### Security
+
+By default, all public functions are exposed for execution.
+However, you can limit who can execute sensitive functions.
+
+```crystal
+
+@[Security(Level::Administrator)]
+def perform_task(name : String | Int32)
+ queue &.success("hello #{name}")
+end
+
+```
+
+Use the `Security` annotation to define the access level of the function.
+The options are:
+
+* Administrator `Level::Administrator`
+* Support `Level::Support`
+
+### Interfaces
+
+PlaceOS Drivers can expose any methods that make sense for the device, service or logic they encapsulate.
+Across these, there are often core sets of similar functionality.
+Interfaces provide a standard way of implementing and interacting with this.
+
+Their usage is optional but highly encouraged as it both improves modularity and reduces complexity in Driver implementations.
+
+A full list of interfaces is [available in the PlaceOS Driver framework](https://github.com/PlaceOS/driver/tree/master/src/placeos-driver/interface).
+This will expand over time to cover common, repeated patterns as they emerge.
+
+#### Implementing an Interface
+
+Each interface is a module containing abstract methods, types and functionality built from these.
+
+First, include the module within the Driver body.
+```crystal
+include Interface::Powerable
+```
+You will then need to provide implementations of the abstract methods.
+The compiler will guide you in this.
+
+Some interfaces will also provide a default implementation for other methods.
+These may be overridden if the device or service provides a more efficient way to directly execute the desired behaviour.
+To keep compatibility, overridden methods must maintain feature and functional parity with the original implementation.
+
+#### Using an Interface
+
+Drivers that provide an Interface can be discovered using the `system.implementing` method from any logic module.
+This will return a list of all Drivers in the system which implement the Interface.
+
+Similarly, the `accessor` macro provides a way to declare a dependency on a sibling Driver that provides specific functionality.
+
+For more information on these and usage examples, see [Logic Drivers](#logic-drivers).
+
diff --git a/docs/writing-a-spec.md b/docs/writing-a-spec.md
new file mode 100644
index 00000000000..450b7b37f3e
--- /dev/null
+++ b/docs/writing-a-spec.md
@@ -0,0 +1,231 @@
+# How to test a PlaceOS Driver
+
+There are three kinds of PlaceOS Driver...
+
+* [Streaming IO (TCP, SSH, UDP, Multicast, etc.)](#testing-streaming-io)
+* [HTTP Client](#testing-http-requests)
+* [Logic](#testing-logic)
+
+From a PlaceOS Driver code structure standpoint, there is no difference between these types of Driver.
+
+* The same driver can be used over a TCP, UDP or SSH transport.
+* All drivers support HTTP methods if a URI endpoint is defined.
+* If a driver is associated with a System then it has access to logic helpers
+
+During a test, the loaded module is loaded with a TCP transport, HTTP enabled and logic module capabilities.
+This allows for testing the full capabilities of any driver.
+
+The driver is launched as it would be in production.
+
+
+## Expectations
+
+Specs have access to Crystal lang spec expectations. This allows you to confirm expectations.
+https://crystal-lang.org/api/latest/Spec/Expectations.html
+
+```crystal
+
+variable = 34
+variable.should eq(34)
+
+```
+
+There is a good overview on how to use expectations here: https://crystal-lang.org/reference/guides/testing.html
+
+
+### Status
+
+Expectations are primarily there to test the state of the module.
+
+* You can access state via the status helper: `status[:state_name]`
+* Then you can check it an expected value: `status[:state_name].should eq(14)`
+
+
+## Testing Streaming IO
+
+The following functions are available for testing streaming IO:
+
+* `transmit(data)` -> transmits the object to the module over the streaming IO interface
+* `responds(data)` -> alias for `transmit`
+* `should_send(data, timeout = 500.milliseconds)` -> expects the module to respond with the data provided
+* `expect_send(timeout = 500.milliseconds)` -> returns the next `Bytes` sent by the module (useful if the data sent is not deterministic, i.e. has a time stamp)
+
+A common test case is to ensure that module state updates as expected after transmitting some data to it:
+
+```crystal
+
+# Transmit some data
+transmit(">V:2,C:11,G:2001,B:1,S:1,F:100#")
+
+# Check that the state was updated as expected
+status[:area2001].should eq(1)
+
+```
+
+
+## Testing HTTP requests
+
+The test suite emulates an HTTP server so you can inspect HTTP requests and send canned responses to the module.
+
+```crystal
+
+expect_http_request do |request, response|
+ io = request.body
+ if io
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request["message"] == "hello steve"
+ response.status_code = 202
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include dialing details #{request.inspect}"
+ end
+end
+
+# Check that the state was updated as expected
+status[:area2001].should eq(1)
+
+```
+
+Use `expect_http_request` to access an expected request coming from the module.
+
+* When the block completes, the response is sent to the module
+* You can see `request` object details here: https://crystal-lang.org/api/latest/HTTP/Request.html
+* You can see `response` object details here: https://crystal-lang.org/api/latest/HTTP/Server/Response.html
+
+
+## Executing functions
+
+Functions allow you to request methods to be performed in the module via the standard public interface.
+
+* `exec(:function_name, argument_name: argument_value)` -> `response` a response future (async return value)
+* You should send and `responds(data)` before inspecting the `response.get`
+
+```crystal
+
+# Execute a command
+response = exec(:scene?, area: 1)
+
+# Check that the command causes the module to send some data
+should_send("?AREA,1,6\r\n")
+# Respond to that command
+responds("~AREA,1,6,2\r\n")
+
+# Check if the functions return value is expected
+response.get.should eq(2)
+# Check if the module state is correct
+status[:area1].should eq(2)
+
+```
+
+
+## Testing Logic
+
+Logic modules typically expect a system to contain some drivers which the logic modules interact with.
+
+```crystal
+
+# define mock versions of the drivers it will interact with
+
+class Display < DriverSpecs::MockDriver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Inputs
+ HDMI
+ HDMI2
+ VGA
+ VGA2
+ Miracast
+ DVI
+ DisplayPort
+ HDBaseT
+ Composite
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(Inputs)
+
+ # Configure initial state in on_load
+ def on_load
+ self[:power] = false
+ self[:input] = Inputs::HDMI
+ end
+
+ # implement the abstract methods required by the interfaces
+ def power(state : Bool)
+ self[:power] = state
+ end
+
+ def switch_to(input : Inputs)
+ mute(false)
+ self[:input] = input
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ self[:mute] = state
+ self[:mute0] = state
+ end
+end
+
+```
+
+Then you can define the system configuration,
+you can also change the system configuration throughout your spec to test different configurations.
+
+```crystal
+
+DriverSpecs.mock_driver "Place::LogicExample" do
+
+ # Where `{Display, Display}` is referencing the `MockDriver` class defined above
+ # and `Display:` is the friendly name
+ # so this system would have `Display_1`, `Display_2`, `Switcher_1`
+ system({
+ Display: {Display, Display},
+ Switcher: {Switcher},
+ })
+
+ # ...
+end
+
+```
+
+Along with the physical system configuration, you can test different setting configurations.
+Settings can also be changed throughout the life cycle of your spec.
+
+```crystal
+
+DriverSpecs.mock_driver "Place::LogicExample" do
+
+ settings({
+ name: "Meeting Room 1",
+ map_id: "1.03"
+ })
+
+end
+
+```
+
+A Driver's method might be expected to update some state in the mock devices.
+You can access this state via the `system` helper
+
+```crystal
+
+DriverSpecs.mock_driver "Place::LogicExample" do
+
+ # Execute a function in your logic module
+ exec(:power, true)
+
+ # Check that the expected state has been updated in your mock device
+ system(:Display_1)[:power].should eq(true)
+
+end
+
+```
+
+All status queried in this manner is returned as a `JSON::Any` object
diff --git a/drivers/amber_tech/grandview.cr b/drivers/amber_tech/grandview.cr
new file mode 100644
index 00000000000..037a755907c
--- /dev/null
+++ b/drivers/amber_tech/grandview.cr
@@ -0,0 +1,155 @@
+require "placeos-driver"
+require "placeos-driver/interface/moveable"
+require "placeos-driver/interface/stoppable"
+
+# Documentation: https://aca.im/driver_docs/AmberTech/grandview-screen.pdf
+# https://www.ambertech.com.au/Documents/GV_IP%20CONTROL_Smart%20Screen_Trifold_Manual_April2020.pdf
+require "./grandview_models"
+
+class AmberTech::Grandview < PlaceOS::Driver
+ include Interface::Moveable
+ include Interface::Stoppable
+
+ # Discovery Information
+ generic_name :Screen
+ descriptive_name "Ambertech Grandview Projector Screen"
+ uri_base "http://192.168.0.2"
+
+ # The device requires the HTTP port closed after every request
+ # (even though it responds with HTTP1.1 and doesn't return any headers)
+ default_settings({
+ http_max_requests: 1,
+ })
+
+ def on_load
+ queue.delay = 2.seconds
+ schedule.every(1.minute) { status }
+ end
+
+ # moveable interface
+ def move(position : MoveablePosition, index : Int32 | String = 0)
+ command = case position
+ when .up?, .close?, .in?
+ "/Close.js?a=100"
+ when .down?, .open?, .out?
+ "/Open.js?a=100"
+ else
+ raise "unsupported move option: #{position}"
+ end
+
+ queue(name: "move") do |task|
+ response = get(command, headers: build_headers)
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ self[:status] = status = parse_state StatusResp.from_json(response.body).status
+ task.success status
+ end
+ end
+
+ # stoppable interface
+ def stop(index : Int32 | String = 0, emergency : Bool = false)
+ queue(name: "stop", priority: 999, clear_queue: emergency) do |task|
+ response = get("/Stop.js?a=100", headers: build_headers)
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ self[:status] = status = parse_state StatusResp.from_json(response.body).status
+ task.success status
+ end
+ end
+
+ def status
+ if queue.online
+ queue(name: "status", priority: 0) do |task|
+ response = perform_status_request
+ if response.success?
+ task.success parse_status(response)
+ else
+ task.abort "request failed with #{response.status_code}\n#{response.body}"
+ end
+ end
+ else
+ response = perform_status_request
+ parse_status(response) if response.success?
+ end
+ end
+
+ protected def perform_status_request
+ get("/GetDevInfoList.js", headers: build_headers)
+ end
+
+ protected def build_headers
+ {
+ "Host" => URI.parse(config.uri.not_nil!).host.not_nil!,
+ "Connection" => "keep-alive",
+ }
+ end
+
+ protected def parse_status(response)
+ info = AmberTech::Devices.from_json(response.body)
+ state = info.device_info.first
+
+ self[:ver] = state.ver
+ self[:id] = state.id
+ self[:ip] = state.ip
+ self[:ip_subnet] = state.ip_subnet
+ self[:ip_gateway] = state.ip_gateway
+ self[:name] = state.name
+ self[:status] = parse_state state.status
+ info
+ end
+
+ # compatibility with Screen Technics
+ def up(index : Int32 = 0)
+ move :up
+ end
+
+ def up?
+ {"opened", "opening"}.includes?(self["status"]?)
+ end
+
+ def down(index : Int32 = 0)
+ move :down
+ end
+
+ def down?
+ {"closed", "closing"}.includes?(self["status"]?)
+ end
+
+ protected def parse_state(state : AmberTech::Status | String)
+ case state
+ in String
+ self[:moving0] = false
+ self[:position0] = nil
+ self[:screen0] = "stopped"
+ in .stop?
+ self[:moving0] = false
+ self[:position0] = nil
+ self[:screen0] = "stopped"
+ in .opening?
+ self[:moving0] = true
+ self[:position0] = MoveablePosition::Open
+ self[:screen0] = "moving_bottom"
+ poll_state
+ in .opened?
+ self[:moving0] = false
+ self[:position0] = MoveablePosition::Open
+ self[:screen0] = "at_bottom"
+ in .closing?
+ self[:moving0] = true
+ self[:position0] = MoveablePosition::Close
+ self[:screen0] = "moving_top"
+ poll_state
+ in .closed?
+ self[:moving0] = false
+ self[:position0] = MoveablePosition::Close
+ self[:screen0] = "at_top"
+ end
+
+ state.to_s.downcase
+ end
+
+ protected def poll_state
+ schedule.clear
+ schedule.every(1.minute) { status; nil }
+ schedule.in(2.seconds) { status; nil }
+ end
+end
diff --git a/drivers/amber_tech/grandview_models.cr b/drivers/amber_tech/grandview_models.cr
new file mode 100644
index 00000000000..9691cdffe55
--- /dev/null
+++ b/drivers/amber_tech/grandview_models.cr
@@ -0,0 +1,45 @@
+require "json"
+
+module AmberTech
+ enum Status
+ Stop
+ Opening
+ Opened
+ Closing
+ Closed
+ end
+
+ class DevInfo
+ include JSON::Serializable
+
+ getter ver : String
+ getter id : String
+ getter ip : String
+
+ @[JSON::Field(key: "sub")]
+ getter ip_subnet : String
+
+ @[JSON::Field(key: "gw")]
+ getter ip_gateway : String
+ getter name : String
+ getter pass : String?
+ getter pass2 : String?
+ getter status : Status
+ end
+
+ class Devices
+ include JSON::Serializable
+
+ @[JSON::Field(key: "devInfo")]
+ getter device_info : Array(DevInfo)
+
+ @[JSON::Field(key: "currentIp")]
+ getter current_ip : String
+ end
+
+ class StatusResp
+ include JSON::Serializable
+
+ getter status : Status | String
+ end
+end
diff --git a/drivers/amber_tech/grandview_spec.cr b/drivers/amber_tech/grandview_spec.cr
new file mode 100644
index 00000000000..aea60dd10fd
--- /dev/null
+++ b/drivers/amber_tech/grandview_spec.cr
@@ -0,0 +1,40 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "AmberTech::Grandview" do
+ retval = exec(:status)
+
+ expect_http_request do |request, response|
+ raise "unexpected path #{request.path} for info" unless request.path == "/GetDevInfoList.js"
+ response.status_code = 200
+ response << %({
+ "currentIp":"10.142.196.27",
+ "devInfo":[
+ {
+ "ver":"1.0",
+ "id":"1015095851",
+ "ip":"10.142.196.27",
+ "sub":"255.255.255.128",
+ "gw":"10.142.196.1",
+ "name":"CII_Scrn",
+ "pass":"admin",
+ "pass2":"config",
+ "status":"Closed"
+ }
+ ]
+ })
+ end
+
+ retval.get
+ sleep 1
+ status[:status].should eq "closed"
+
+ retval = exec(:move, "down")
+ sleep 2
+ expect_http_request do |request, response|
+ raise "unexpected path #{request.path} for move down" unless request.path == "/Open.js"
+ response.status_code = 200
+ response << %({"status":"Opening"})
+ end
+ retval.get.should eq "opening"
+ status[:status].should eq "opening"
+end
diff --git a/drivers/amx/svsi/models.cr b/drivers/amx/svsi/models.cr
new file mode 100644
index 00000000000..53adb1770d1
--- /dev/null
+++ b/drivers/amx/svsi/models.cr
@@ -0,0 +1,10 @@
+require "json"
+
+module Amx
+ # Interface for enumerating devices
+ module Transmitter
+ end
+
+ module Receiver
+ end
+end
diff --git a/drivers/amx/svsi/n_series_decoder.cr b/drivers/amx/svsi/n_series_decoder.cr
new file mode 100644
index 00000000000..9e0d018890a
--- /dev/null
+++ b/drivers/amx/svsi/n_series_decoder.cr
@@ -0,0 +1,220 @@
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+require "inactive-support/mapped_enum"
+require "./models"
+
+# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf
+
+class Amx::Svsi::NSeriesDecoder < PlaceOS::Driver
+ include Interface::Muteable
+ include PlaceOS::Driver::Interface::InputSelection(Int32)
+ include Amx::Receiver
+
+ tcp_port 50002
+ descriptive_name "AMX SVSI N-Series Decoder"
+ generic_name :Decoder
+
+ @previous_stream : Int32? = nil
+ @mute : Bool = false
+ @stream : Int32? = nil
+
+ private DELIMITER = "\r"
+
+ mapped_enum Command do
+ GetStatus = "getStatus"
+ Set = "set"
+ SetSettings = "setSettings"
+ SwitchKVM = "KVMMasterIP"
+ Mute = "mute"
+ Unmute = "unmute"
+ SetAudio = "seta"
+ Live = "live"
+ Local = "local"
+ ScalerEnable = "scalerenable"
+ ScalerDisable = "scalerdisable"
+ ModeSet = "modeset"
+ end
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ end
+
+ def connected
+ schedule.every(50.seconds, true) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def do_poll
+ do_send(Command::GetStatus, priority: 0)
+ end
+
+ def switch_to(input : Int32)
+ switch_video(input)
+ switch_audio(0) # enable AFV
+ end
+
+ def switch_video(stream_id : Int32)
+ do_send(Command::Set, stream_id)
+ end
+
+ def switch_audio(stream_id : Int32)
+ @previous_stream = stream_id
+ unmute
+ end
+
+ def switch_kvm(ip_address : String, video_follow : Bool = true)
+ host = "#{ip_address},#{video_follow ? 1 : 0}"
+ do_send(Command::SwitchKVM, host)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo,
+ )
+ if state
+ do_send(Command::Mute, name: :mute)
+ do_send(Command::SetAudio, 0)
+ else
+ do_send(Command::SetAudio, @previous_stream || 0)
+ do_send(Command::Unmute, name: :mute)
+ end
+ end
+
+ def live(state : Bool = true)
+ state ? do_send(Command::Live) : local(self[:playlist].as_i)
+ end
+
+ def local(playlist : Int32 = 0)
+ do_send(Command::Local, playlist)
+ end
+
+ def scaler(state : Bool)
+ action = state ? Command::ScalerEnable : Command::ScalerDisable
+ do_send(action, name: :scaler)
+ end
+
+ OutputModes = [
+ "auto",
+ "1080p59.94",
+ "1080p60",
+ "720p60",
+ "4K30",
+ "4K25",
+ ]
+
+ def output_resolution(mode : String)
+ unless OutputModes.includes?(mode)
+ logger.error { "\"#{mode}\" is not a valid resolution" }
+ return
+ end
+ do_send(Command::ModeSet, mode)
+ end
+
+ def videowall(
+ width : Int32,
+ height : Int32,
+ x_pos : Int32,
+ y_pos : Int32,
+ scale : VideowallScalingMode = VideowallScalingMode::Auto,
+ )
+ if width > 1 && height > 1
+ videowall_size(width, height)
+ videowall_position(x_pos, y_pos)
+ videowall_scaling(scale)
+ videowall_enable
+ else
+ videowall_disable
+ end
+ end
+
+ def videowall_enable(state : Bool = true)
+ state = state ? "on" : "off"
+ do_send(Command::SetSettings, "wallEnable", state)
+ end
+
+ def videowall_disable
+ videowall_enable(false)
+ end
+
+ def videowall_size(width : Int32, height : Int32)
+ do_send(Command::SetSettings, "wallHorMons", width)
+ do_send(Command::SetSettings, "wallVerMons", height)
+ end
+
+ def videowall_position(x : Int32, y : Int32)
+ do_send(Command::SetSettings, "wallMonPosV", x)
+ do_send(Command::SetSettings, "wallMonPosH", y)
+ end
+
+ enum VideowallScalingMode
+ Auto # decoder decides best method
+ Fit # aspect distort
+ Stretch # fill and crop
+ end
+
+ def videowall_scaling(scaling_mode : VideowallScalingMode)
+ do_send(Command::SetSettings, "wallStretch", scaling_mode)
+ end
+
+ mapped_enum Response do
+ Stream = "stream"
+ StreamAudio = "streamaudio"
+ Name = "name"
+ Playmode = "playmode"
+ Playlist = "playlist"
+ Mute = "mute"
+ ScalerBypass = "scalerbypass"
+ Mode = "mode"
+ InputRes = "inputres"
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Received: #{data}" }
+
+ prop, value = data.split(':')
+
+ case Response.from_mapped_value?(prop.downcase)
+ in Response::Stream
+ self[:video] = @stream = value.to_i
+ in Response::StreamAudio
+ stream_id = value.to_i
+ self[:audio_actual] = stream_id
+ self[:audio] = stream_id == 0 ? (@mute ? 0 : @stream) : stream_id
+ in Response::Name
+ self[:device_name] = value
+ in Response::Playmode
+ self[:local_playback] = value == "local"
+ in Response::Playlist
+ self[:playlist] = value.to_i
+ in Response::Mute
+ self[:mute] = @mute = value == "1"
+ in Response::ScalerBypass
+ self[:scaler_active] = value != "no"
+ in Response::Mode
+ self[:output_res] = value
+ in Response::InputRes
+ self[:input_res] = value
+ in Nil
+ raise "Unexpected response: #{prop}"
+ end
+
+ task.try(&.success)
+ end
+
+ def do_send(command : Command, *args, **options)
+ arguments = [command.mapped_value]
+
+ unless (splat = args.to_a).is_a? Array(NoReturn)
+ arguments += splat
+ end
+
+ request = "#{arguments.join(':')}#{DELIMITER}"
+ send(request, **options)
+ end
+end
diff --git a/drivers/amx/svsi/n_series_encoder.cr b/drivers/amx/svsi/n_series_encoder.cr
new file mode 100644
index 00000000000..17a54988fef
--- /dev/null
+++ b/drivers/amx/svsi/n_series_encoder.cr
@@ -0,0 +1,123 @@
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+require "inactive-support/mapped_enum"
+require "./models"
+
+# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf
+
+class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver
+ include Interface::Muteable
+ include Amx::Transmitter
+
+ enum Input
+ Hdmionly
+ Vgaonly
+ Hdmivga
+ Vgahdmi
+ end
+
+ include Interface::InputSelection(Input)
+
+ tcp_port 50002
+ descriptive_name "AMX SVSI N-Series Encoder"
+ generic_name :Encoder
+
+ private DELIMITER = "\r"
+
+ mapped_enum Command do
+ GetStatus = "getStatus"
+ VideoSource = "vidsrc"
+ Live = "live"
+ Local = "local"
+ Disable = "txdisable"
+ Mute = "mute"
+ Unmute = "unmute"
+ end
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ end
+
+ def connected
+ schedule.every(50.seconds, true) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def do_poll
+ do_send(Command::GetStatus, priority: 0)
+ end
+
+ def switch_to(input : Input, **options)
+ do_send(Command::VideoSource, input, **options)
+ end
+
+ Modes = (1..8).map &.to_s
+
+ def media_source(mode : String)
+ if mode == "live"
+ do_send(Command::Live)
+ elsif Modes.includes?(mode)
+ do_send(Command::Local, mode)
+ else
+ raise("invalid mode #{mode}")
+ end
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo,
+ )
+ if state
+ do_send(Command::Disable) if layer.audio_video? || layer.video?
+ do_send(Command::Mute) if layer.audio_video? || layer.audio?
+ else
+ do_send(Command::Disable) if layer.audio_video? || layer.video?
+ do_send(Command::Unmute) if layer.audio_video? || layer.audio?
+ end
+ end
+
+ enum Response
+ Name
+ Stream
+ Playmode
+ Mute
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Received: #{data}" }
+
+ prop, value = data.split(':')
+
+ case Response.parse? prop
+ in Response::Name
+ self[:device_name] = value
+ in Response::Stream
+ self[:stream_id] = value.to_i
+ in Response::Playmode
+ self[:mute] = value == "off"
+ in Response::Mute
+ self[:audio_mute] = value == "1"
+ in Nil
+ raise "Invalid response: #{prop}"
+ end
+
+ task.try(&.success)
+ end
+
+ def do_send(command : Command, *args, **options)
+ arguments = [command.mapped_value]
+
+ unless (splat = args.to_a).is_a? Array(NoReturn)
+ arguments += splat
+ end
+
+ request = "#{arguments.join(':')}#{DELIMITER}"
+ send(request, **options)
+ end
+end
diff --git a/drivers/amx/svsi/n_series_switcher.cr b/drivers/amx/svsi/n_series_switcher.cr
new file mode 100644
index 00000000000..88ff69d7d3d
--- /dev/null
+++ b/drivers/amx/svsi/n_series_switcher.cr
@@ -0,0 +1,228 @@
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+
+# require "placeos-driver/interface/switchable"
+
+# Documentation: https://aca.im/driver_docs/AMX/N8000SeriesAPICommandListRev1.1.pdf
+
+class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver
+ include Interface::Muteable
+
+ tcp_port 50002
+ descriptive_name "AMX SVSI N-Series Switcher"
+ generic_name :Switcher
+
+ alias InOut = String | Int32
+
+ @inputs : Hash(String, String) = {} of String => String
+ @outputs : Hash(String, String) = {} of String => String
+ @encoders = [] of String
+ @decoders = [] of String
+ @lookup : Hash(String, String) = {} of String => String
+ @list = [] of String
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("")
+ on_update
+ end
+
+ def on_update
+ @inputs = setting?(Hash(String, String), :inputs) || {} of String => String
+ @outputs = setting?(Hash(String, String), :outputs) || {} of String => String
+
+ @encoders = @inputs.keys
+ @decoders = @outputs.keys
+
+ @lookup = @inputs.merge(@outputs)
+ @list = @encoders + @decoders
+ end
+
+ def connected
+ @lookup.each_key do |ip_address|
+ monitor(ip_address, priority: 0)
+ monitornotify(ip_address, priority: 0)
+ end
+
+ schedule.every(50.seconds) {
+ logger.debug { "-- Maintaining Connection --" }
+ monitornotify(@list.first, priority: 0)
+ }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ CommonCommands = [
+ :monitor, :monitornotify,
+ :live, :local, :serial, :readresponse, :sendir, :sendirraw, :audioon, :audiooff,
+ :enablehdmiaudio, :disablehdmiaudio, :autohdmiaudio,
+ # recorder commands
+ :record, :dsrecord, :dvrswitch1, :dvrswitch2, :mpeg, :mpegall, :deletempegfile,
+ :play, :stop, :pause, :unpause, :fastforward, :rewind, :deletefile, :stepforward,
+ :stepreverse, :stoprecord, :recordhold, :recordrelease, :playhold, :playrelease,
+ :deleteallplaylist, :deleteallmpegs, :remotecopy,
+ # window processor commands
+ :wpswitch, :wpaudioin, :wpactive, :wpinactive, :wpaudioon, :wpaudiooff, :wpmodeon,
+ :wpmodeoff, :wparrange, :wpbackground, :wpcrop, :wppriority, :wpbordon, :wpbordoff,
+ :wppreset,
+ # audio transceiver commands
+ :atrswitch, :atrmute, :atrunmute, :atrtxmute, :atrtxunmute, :atrhpvol, :atrlovol,
+ :atrlovolup, :atrlovoldown, :atrhpvolup, :atrhpvoldown, :openrelay, :closerelay,
+ # video wall commands
+ :videowall,
+ # miscellaneous commands
+ :script, :goto, :tcpclient, :udpclient, :reboot, :gc_serial, :gc_openrelay,
+ :gc_closerelay, :gc_ir,
+ ]
+
+ {% for name in CommonCommands %}
+ def {{name.id}}(ip_address : String, *args, **options)
+ do_send({{name.id.stringify}}, ip_address, *args, **options)
+ end
+ {% end %}
+
+ def serialhex(ip_address : String, wait_time : Int32 = 1, *data, **options)
+ do_send("serialhex", wait_time, ip_address, *data, **options)
+ end
+
+ # Encoder Commands
+ {% for name in [:modeoff, :enablecc, :disablecc, :autocc, :uncompressedoff] %}
+ def {{name.id}}(input : InOut, *args, **options)
+ do_send({{name.id.stringify}}, get_input(input), *args, **options)
+ end
+ {% end %}
+
+ # Decoder Commands
+ {% for name in [:audiofollow, :volume, :dvion, :dvioff, :cropref, :getStatus] %}
+ def {{name.id}}(output : InOut, *args, **options)
+ do_send({{name.id.stringify}}, get_output(output), *args, **options)
+ end
+ {% end %}
+
+ def switch(inouts : Hash(Int32, InOut | Array(InOut)), **options)
+ inouts.each do |input, output|
+ outputs = output.is_a?(InOut) ? [output] : output
+ if input != 0
+ # 'in_ip' => ['ip1', 'ip2'] etc
+ input_actual = get_input(input)
+ outputs.each do |o|
+ output_actual = get_output(o)
+
+ dvion(output_actual, **options)
+ audioon(output_actual, **options)
+ audiofollow(output_actual, **options)
+
+ self["video#{output_actual}"] = input_actual
+ self["audio#{output_actual}"] = input_actual
+ do_send(:switch, output_actual, input_actual, **options)
+ end
+ else
+ # nil => ['ip1', 'ip2'] etc
+ outputs.each do |o|
+ output_actual = get_output(o)
+ dvioff(output_actual, **options)
+ audiooff(output_actual, **options)
+ end
+ end
+ end
+ end
+
+ def switch_audio(inouts : Hash(Int32, InOut | Array(InOut)), **options)
+ inouts.each do |input, output|
+ outputs = output.is_a?(InOut) ? [output] : output
+ if input != 0
+ # 'in_ip' => ['ip1', 'ip2'] etc
+ input_actual = get_input(input)
+ outputs.each do |o|
+ output_actual = get_output(o)
+
+ audioon(input_actual, **options)
+ audioon(output_actual, **options)
+
+ self["audio#{output_actual}"] = input_actual
+ do_send(:switchaudio, output_actual, input_actual, **options)
+ end
+ else
+ # nil => ['ip1', 'ip2'] etc
+ outputs.each do |o|
+ audiooff(get_output(o), **options)
+ end
+ end
+ end
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ address = index.is_a?(Int32) && (val = @encoders[index]? || @decoders[index]?) ? val : index.as(String)
+ if state
+ dvioff(address) if layer.audio_video? || layer.video?
+ audiooff(address) if layer.audio_video? || layer.audio?
+ else
+ dvion(address) if layer.audio_video? || layer.video?
+ audioon(address) if layer.audio_video? || layer.audio?
+ end
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Received: #{data}" }
+
+ resp = data.split(':')
+
+ case resp.size
+ when 13 # Encoder or decoder status
+ self[resp[0]] = {
+ communications: resp[1] == "1",
+ dvioff: resp[2] == "1",
+ scaler: resp[3] == "1",
+ source_detected: resp[4] == "1",
+ mode: resp[5],
+ audio_enabled: resp[6] == "1",
+ video_stream: resp[7].to_i,
+ audio_stream: resp[8] == "follow video" ? resp[8] : resp[8].to_i,
+ playlist: resp[9],
+ colorspace: resp[10],
+ hdmiaudio: resp[11],
+ resolution: resp[12],
+ }
+ when 10 # Audio Transceiver or window processor status
+ self[resp[0]] = resp
+ else
+ logger.warn { "unknown response type: #{resp}" }
+ end
+
+ task.try(&.success)
+ end
+
+ def do_send(*args, **options)
+ cmd = args.join(' ')
+ logger.debug { "sending #{cmd}" }
+ send("#{cmd}\r\n", **options)
+ end
+
+ private def get_input(address : InOut) : String
+ if address.is_a?(String) && @inputs[address]?
+ address
+ elsif address.is_a?(Int32) && (input = @encoders[address]?)
+ input
+ else
+ logger.warn { "unknown address #{address}" }
+ address.to_s
+ end
+ end
+
+ private def get_output(address : InOut) : String
+ if address.is_a?(String) && @outputs[address]?
+ address
+ elsif address.is_a?(Int32) && (output = @decoders[address]?)
+ output
+ else
+ logger.warn { "unknown address #{address}" }
+ address.to_s
+ end
+ end
+end
diff --git a/drivers/amx/svsi/virtual_switcher.cr b/drivers/amx/svsi/virtual_switcher.cr
new file mode 100644
index 00000000000..23b3cacf6a1
--- /dev/null
+++ b/drivers/amx/svsi/virtual_switcher.cr
@@ -0,0 +1,69 @@
+require "placeos-driver"
+require "placeos-driver/interface/switchable"
+require "./models"
+
+# This driver provides an abstraction layer for systems using SVSI based signal
+# distribution. In place of referencing specific receivers and stream id's,
+# this may be used to enable all endpoints associated with a system to be
+# grouped as a virtual matrix switcher and a familiar switcher API used.
+
+class Amx::Svsi::VirtualSwitcher < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::Switchable(Int32, Int32)
+
+ descriptive_name "AMX SVSI Virtual Switcher"
+ generic_name :Switcher
+
+ private def transmitters
+ system.implementing(Amx::Transmitter)
+ end
+
+ private def receivers
+ system.implementing(Amx::Receiver)
+ end
+
+ def switch_to(input : Int32)
+ receivers.each(&.switch_to(input))
+ end
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ layer ||= SwitchLayer::All
+ connect(map) do |mod, stream|
+ mod.switch_audio(stream) if layer.all? || layer.audio?
+ mod.switch_video(stream) if layer.all? || layer.video?
+ end
+ end
+
+ def encoder_count
+ transmitters.size
+ end
+
+ def decoder_count
+ receivers.size
+ end
+
+ private def connect(inouts : Hash(Input, Array(Output)), &)
+ inouts.each do |input, outputs|
+ if input == 0
+ stream = 0 # disconnected
+ else
+ # Subtract one as Encoder_1 on the system would be encoder[0] here
+ if encoder = transmitters[input - 1]?
+ stream = encoder[:stream_id]
+ else
+ logger.warn { "could not find Encoder_#{input}" }
+ break
+ end
+ end
+
+ outputs = outputs.is_a?(Array) ? outputs : [outputs]
+ outputs.each do |output|
+ # Subtract one as Decoder_1 on the system would be decoder[0] here
+ if decoder = receivers[output - 1]?
+ yield(decoder, stream)
+ else
+ logger.warn { "could not find Decoder_#{output}" }
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/ashrae/bacnet.cr b/drivers/ashrae/bacnet.cr
new file mode 100644
index 00000000000..89717628ed7
--- /dev/null
+++ b/drivers/ashrae/bacnet.cr
@@ -0,0 +1,634 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "socket"
+require "./bacnet_models"
+
+class Ashrae::BACnet < PlaceOS::Driver
+ include Interface::Sensor
+
+ generic_name :BACnet
+ descriptive_name "BACnet Connector"
+ description %(makes BACnet data available to other drivers in PlaceOS)
+
+ # Hookup dispatch to the BACnet BBMD device
+ uri_base "ws://dispatch/api/dispatch/v1/udp_dispatch?port=47808&accept=192.168.0.1"
+
+ default_settings({
+ dispatcher_key: "secret",
+ bbmd_ip: "192.168.0.1",
+ known_devices: [{
+ ip: "192.168.86.25",
+ id: 389999,
+ net: 0x0F0F,
+ addr: "0A",
+ }],
+ verbose_debug: false,
+ poll_period: 3,
+ })
+
+ def websocket_headers
+ dispatcher_key = setting?(String, :dispatcher_key)
+ HTTP::Headers{
+ "Authorization" => "Bearer #{dispatcher_key}",
+ "X-Module-ID" => module_id,
+ }
+ end
+
+ protected getter! udp_server : UDPSocket
+ protected getter! bacnet_client : ::BACnet::Client::IPv4
+ protected getter! device_registry : ::BACnet::Client::DeviceRegistry
+
+ alias DeviceInfo = ::BACnet::Client::DeviceRegistry::DeviceInfo
+
+ @packets_processed : UInt64 = 0_u64
+ @verbose_debug : Bool = false
+ @bbmd_ip : Socket::IPAddress = Socket::IPAddress.new("127.0.0.1", 0xBAC0)
+ @devices : Hash(UInt32, DeviceInfo) = {} of UInt32 => DeviceInfo
+ @mutex : Mutex = Mutex.new(:reentrant)
+ @bbmd_forwarding : Array(UInt8) = [] of UInt8
+ @seen_devices : Hash(UInt32, DeviceAddress) = {} of UInt32 => DeviceAddress
+
+ protected def get_device(device_id : UInt32)
+ @mutex.synchronize { @devices[device_id]? }
+ end
+
+ def on_load
+ # We only use dispatcher for broadcast messages, a local port for primary comms
+ server = UDPSocket.new
+ server.bind "0.0.0.0", 0xBAC0
+ server.write_timeout = 200.milliseconds
+ @udp_server = server
+
+ queue.timeout = 2.seconds
+
+ # Hook up the client to the transport
+ client = ::BACnet::Client::IPv4.new(0, 2.seconds)
+ client.on_transmit do |message, address|
+ if address.address == Socket::IPAddress::BROADCAST
+ if @bbmd_forwarding.size == 4
+ message.data_link.request_type = ::BACnet::Message::IPv4::Request::ForwardedNPDU
+ message.data_link.address.ip1 = @bbmd_forwarding[0]
+ message.data_link.address.ip2 = @bbmd_forwarding[1]
+ message.data_link.address.ip3 = @bbmd_forwarding[2]
+ message.data_link.address.ip4 = @bbmd_forwarding[3]
+ message.data_link.address.port = 47808_u16
+ end
+
+ logger.debug { "sending broadcase message #{message.inspect}" }
+
+ # send to the known devices (in case BBMD does not forward message)
+ devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress
+ devices.each do |dev|
+ begin
+ server.send message, to: dev.address
+ rescue error
+ logger.warn(exception: error) { "error sending message to #{dev.address}" }
+ end
+ end
+
+ # Send this message to the BBMD
+ message.data_link.request_type = ::BACnet::Message::IPv4::Request::DistributeBroadcastToNetwork
+ payload = DispatchProtocol.new
+ payload.message = DispatchProtocol::MessageType::WRITE
+ payload.ip_address = @bbmd_ip.address
+ payload.id_or_port = @bbmd_ip.port.to_u64
+ payload.data = message.to_slice
+ transport.send payload.to_slice
+ else
+ server.send message, to: address
+ end
+ end
+ @bacnet_client = client
+
+ # Track the discovery of devices
+ registry = ::BACnet::Client::DeviceRegistry.new(client, logger)
+ registry.on_new_device { |device| new_device_found(device) }
+ @device_registry = registry
+
+ spawn { process_data(server, client) }
+ on_update
+ end
+
+ # This is our input read loop, grabs the incoming data and pumps it to our client
+ protected def process_data(server, client)
+ loop do
+ break if server.closed?
+ bytes, client_addr = server.receive
+
+ begin
+ message = IO::Memory.new(bytes).read_bytes(::BACnet::Message::IPv4)
+ client.received message, client_addr
+ @packets_processed += 1_u64
+ rescue error
+ logger.warn(exception: error) { "error parsing BACnet packet from #{client_addr}: #{bytes.to_slice.hexstring}" }
+ end
+ end
+ end
+
+ def on_unload
+ udp_server.close
+ end
+
+ def on_update
+ bbmd_ip = setting?(String, :bbmd_ip) || ""
+ bbmd_forwarding = setting?(String, :bbmd_forwarding) || ""
+
+ @bbmd_forwarding = bbmd_forwarding.strip.split(".").select(&.presence).map(&.to_u8)
+ @bbmd_ip = Socket::IPAddress.new(bbmd_ip, 0xBAC0) if bbmd_ip.presence
+ @verbose_debug = setting?(Bool, :verbose_debug) || false
+
+ schedule.clear
+ schedule.in(5.seconds) { query_known_devices }
+
+ poll_period = setting?(UInt32, :poll_period) || 3
+ schedule.every(poll_period.minutes) do
+ logger.debug { "--- Polling all known bacnet devices" }
+ keys = @mutex.synchronize { @devices.keys }
+ keys.each { |device_id| poll_device(device_id) }
+ end
+
+ perform_discovery if bbmd_ip.presence
+ end
+
+ def packets_processed
+ @packets_processed
+ end
+
+ def connected
+ bbmd_ip = setting?(String, :bbmd_ip)
+ perform_discovery if bbmd_ip.presence
+ end
+
+ protected def object_value(obj)
+ val = obj.value.try &.value
+ case val
+ in ::BACnet::Time, ::BACnet::Date
+ val.value
+ in ::BACnet::BitString, BinData
+ nil
+ in ::BACnet::PropertyIdentifier
+ val.property_type
+ in ::BACnet::ObjectIdentifier
+ {val.object_type, val.instance_number}
+ in Nil, Bool, UInt64, Int64, Float32, Float64, String
+ val
+ end
+ rescue
+ nil
+ end
+
+ protected def device_details(device)
+ {
+ name: device.name,
+ model_name: device.model_name,
+ vendor_name: device.vendor_name,
+
+ ip_address: device.ip_address.to_s,
+ network: device.network,
+ address: device.address,
+ id: device.object_ptr.instance_number,
+
+ objects: device.objects.map { |obj|
+ {
+ name: obj.name,
+ type: obj.object_type,
+ id: obj.instance_id,
+
+ unit: obj.unit,
+ value: object_value(obj),
+ seen: obj.changed,
+ }
+ },
+ }
+ end
+
+ def device(device_id : UInt32)
+ device_details get_device(device_id).not_nil!
+ end
+
+ def devices
+ device_registry.devices.map { |device| device_details device }
+ end
+
+ def query_known_devices
+ sent = [] of UInt32
+ @seen_devices.each_value do |info|
+ sent << info.id.not_nil!
+ logger.debug { "inspecting #{info.address} - #{info.id}" }
+ device_registry.inspect_device(info.address, info.identifier, info.net, info.addr)
+ end
+ devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress
+ devices.each do |info|
+ if id = info.id
+ next if id.in? sent
+ sent << id
+ logger.debug { "inspecting #{info.address} - #{info.id}" }
+ device_registry.inspect_device(info.address, info.identifier, info.net, info.addr)
+ end
+ end
+ "inspected #{sent.size} devices"
+ end
+
+ def poll_device(device_id : UInt32)
+ device = get_device(device_id)
+ return false unless device
+
+ client = bacnet_client
+ objects = @mutex.synchronize { device.objects.dup }
+ objects.each do |obj|
+ next unless obj.object_type.in?(::BACnet::Client::DeviceRegistry::OBJECTS_WITH_VALUES)
+ name = object_binding(device_id, obj)
+ queue(name: name, priority: 0, timeout: 500.milliseconds) do |task|
+ spawn_action(task) do
+ obj.sync_value(client)
+ self[name] = object_value(obj)
+ end
+ end
+ Fiber.yield
+ end
+ true
+ end
+
+ protected def spawn_action(task, &block : -> Nil)
+ spawn { task.success block.call }
+ Fiber.yield
+ end
+
+ # Performs a WhoIs discovery against the BACnet network
+ def perform_discovery : Nil
+ bacnet_client.who_is
+ end
+
+ alias ObjectType = ::BACnet::ObjectIdentifier::ObjectType
+
+ def update_value(device_id : UInt32, instance_id : UInt32, object_type : ObjectType)
+ obj = get_object_details(device_id, instance_id, object_type)
+ name = object_binding(device_id, obj)
+
+ queue(name: name, priority: 50) do |task|
+ spawn_action(task) do
+ obj.sync_value(bacnet_client)
+ self[name] = object_value(obj)
+ end
+ end
+ end
+
+ protected def get_object_details(device_id : UInt32, instance_id : UInt32, object_type : ObjectType)
+ device = get_device(device_id).not_nil!
+ device.objects.find { |obj| obj.object_ptr.object_type == object_type && obj.object_ptr.instance_number == instance_id }.not_nil!
+ end
+
+ def write_real(device_id : UInt32, instance_id : UInt32, value : Float32, object_type : ObjectType = ObjectType::AnalogValue)
+ object = get_object_details(device_id, instance_id, object_type)
+
+ queue(priority: 99) do |task|
+ spawn_action(task) do
+ bacnet_client.write_property(
+ object.ip_address,
+ ::BACnet::ObjectIdentifier.new(object_type, instance_id),
+ ::BACnet::PropertyType::PresentValue,
+ ::BACnet::Object.new.set_value(value),
+ network: object.network,
+ address: object.address
+ )
+ end
+ end
+ value
+ end
+
+ def write_double(device_id : UInt32, instance_id : UInt32, value : Float64, object_type : ObjectType = ObjectType::LargeAnalogValue)
+ object = get_object_details(device_id, instance_id, object_type)
+
+ queue(priority: 99) do |task|
+ spawn_action(task) do
+ bacnet_client.write_property(
+ object.ip_address,
+ ::BACnet::ObjectIdentifier.new(object_type, instance_id),
+ ::BACnet::PropertyType::PresentValue,
+ ::BACnet::Object.new.set_value(value),
+ network: object.network,
+ address: object.address
+ )
+ end
+ end
+ value
+ end
+
+ def write_unsigned_int(device_id : UInt32, instance_id : UInt32, value : UInt64, object_type : ObjectType = ObjectType::PositiveIntegerValue)
+ object = get_object_details(device_id, instance_id, object_type)
+
+ queue(priority: 99) do |task|
+ spawn_action(task) do
+ bacnet_client.write_property(
+ object.ip_address,
+ ::BACnet::ObjectIdentifier.new(object_type, instance_id),
+ ::BACnet::PropertyType::PresentValue,
+ ::BACnet::Object.new.set_value(value),
+ network: object.network,
+ address: object.address
+ )
+ end
+ end
+ value
+ end
+
+ def write_signed_int(device_id : UInt32, instance_id : UInt32, value : Int64, object_type : ObjectType = ObjectType::IntegerValue)
+ object = get_object_details(device_id, instance_id, object_type)
+
+ queue(priority: 99) do |task|
+ spawn_action(task) do
+ bacnet_client.write_property(
+ object.ip_address,
+ ::BACnet::ObjectIdentifier.new(object_type, instance_id),
+ ::BACnet::PropertyType::PresentValue,
+ ::BACnet::Object.new.set_value(value),
+ network: object.network,
+ address: object.address
+ )
+ end
+ end
+ value
+ end
+
+ def write_string(device_id : UInt32, instance_id : UInt32, value : String, object_type : ObjectType = ObjectType::CharacterStringValue)
+ object = get_object_details(device_id, instance_id, object_type)
+
+ queue(priority: 99) do |task|
+ spawn_action(task) do
+ bacnet_client.write_property(
+ object.ip_address,
+ ::BACnet::ObjectIdentifier.new(object_type, instance_id),
+ ::BACnet::PropertyType::PresentValue,
+ ::BACnet::Object.new.set_value(value),
+ network: object.network,
+ address: object.address
+ )
+ end
+ end
+ value
+ end
+
+ def write_binary(device_id : UInt32, instance_id : UInt32, value : Bool, object_type : ObjectType = ObjectType::BinaryValue)
+ val = value ? 1 : 0
+ object = get_object_details(device_id, instance_id, object_type)
+ val = ::BACnet::Object.new.set_value(val)
+ val.short_tag = 9_u8
+
+ queue(priority: 99) do |task|
+ spawn_action(task) do
+ bacnet_client.write_property(
+ object.ip_address,
+ ::BACnet::ObjectIdentifier.new(object_type, instance_id),
+ ::BACnet::PropertyType::PresentValue,
+ val,
+ network: object.network,
+ address: object.address
+ )
+ end
+ end
+ value
+ end
+
+ protected def new_device_found(device)
+ logger.debug { "new device found: #{device.name}, #{device.model_name} (#{device.vendor_name}) with #{device.objects.size} objects" }
+ logger.debug { device.inspect } if @verbose_debug
+
+ @mutex.synchronize { @devices[device.object_ptr.instance_number] = device }
+
+ device_id = device.object_ptr.instance_number
+ device.objects.each { |obj| self[object_binding(device_id, obj)] = object_value(obj) }
+ end
+
+ protected def object_binding(device_id, obj)
+ "#{device_id}.#{obj.object_type}[#{obj.instance_id}]"
+ end
+
+ def received(data, task)
+ # we should only be receiving broadcasted messages here
+ protocol = IO::Memory.new(data).read_bytes(DispatchProtocol)
+
+ logger.debug { "received message: #{protocol.message} #{protocol.ip_address}:#{protocol.id_or_port} (size #{protocol.data_size})" }
+
+ if protocol.message.received?
+ message = IO::Memory.new(protocol.data).read_bytes(::BACnet::Message::IPv4)
+ logger.debug { "dispatch sent:\n#{message.inspect}" } if @verbose_debug
+ bacnet_client.received message, @bbmd_ip
+
+ app = message.application
+
+ is_iam = false
+ is_cov = case app
+ when ::BACnet::ConfirmedRequest
+ app.service.cov_notification?
+ when ::BACnet::UnconfirmedRequest
+ is_iam = app.service.i_am?
+ app.service.cov_notification?
+ else
+ false
+ end
+ network = message.network
+
+ if network && is_cov
+ ip = if message.data_link.request_type.forwarded_npdu?
+ ip_add = message.data_link.address
+ "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}"
+ else
+ protocol.ip_address
+ end
+ if network.source_specifier
+ addr = network.source_address
+ net = network.source.network
+ end
+ device = message.objects.find { |obj| obj.tag == 1 }.not_nil!.to_object_id.instance_number
+ # prop = message.objects.find { |obj| obj.tag == 2 }
+ @seen_devices[device] = DeviceAddress.new(ip, device, net, addr)
+ end
+
+ if network && is_iam
+ ip = if message.data_link.request_type.forwarded_npdu?
+ ip_add = message.data_link.address
+ "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}"
+ else
+ protocol.ip_address
+ end
+ details = ::BACnet::Client::Message::IAm.parse(message)
+ device = details[:object_id].instance_number
+ @seen_devices[device] = DeviceAddress.new(ip, device, details[:network], details[:address])
+ end
+ end
+
+ task.try &.success
+ end
+
+ def seen_devices
+ @seen_devices
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ protected def to_sensor(device_id, device, object, filter_type = nil) : Interface::Sensor::Detail?
+ sensor_type = case object.unit
+ when Nil
+ # required for case statement to work
+ if object.name.includes? "count"
+ SensorType::Counter
+ end
+ when .degrees_fahrenheit?, .degrees_celsius?, .degrees_kelvin?
+ SensorType::Temperature
+ when .percent_relative_humidity?
+ SensorType::Humidity
+ when .pounds_force_per_square_inch?
+ SensorType::Pressure
+ # when
+ # SensorType::Presence
+ when .volts?, .millivolts?, .kilovolts?, .megavolts?
+ SensorType::Voltage
+ when .milliamperes?, .amperes?
+ SensorType::Current
+ when .millimeters_of_water?, .centimeters_of_water?, .inches_of_water?, .cubic_feet?, .cubic_meters?, .imperial_gallons?, .milliliters?, .liters?, .us_gallons?
+ SensorType::Volume
+ when .milliwatts?, .watts?, .kilowatts?, .megawatts?, .watt_hours?, .kilowatt_hours?, .megawatt_hours?
+ SensorType::Power
+ when .hertz?, .kilohertz?, .megahertz?
+ SensorType::Frequency
+ when .cubic_feet_per_second?, .cubic_feet_per_minute?, .cubic_feet_per_hour?, .cubic_meters_per_second?, .cubic_meters_per_minute?, .cubic_meters_per_hour?, .imperial_gallons_per_minute?, .milliliters_per_second?, .liters_per_second?, .liters_per_minute?, .liters_per_hour?, .us_gallons_per_minute?, .us_gallons_per_hour?
+ SensorType::Flow
+ when .percent?
+ SensorType::Level
+ when .no_units?
+ if object.name.includes? "count"
+ SensorType::Counter
+ end
+ end
+ return nil unless sensor_type
+ return nil if filter_type && sensor_type != filter_type
+
+ unit = case object.unit
+ when Nil
+ when .degrees_fahrenheit? then "[degF]"
+ when .degrees_celsius? then "Cel"
+ when .degrees_kelvin? then "K"
+ when .pounds_force_per_square_inch? then "[psi]"
+ when .volts? then "V"
+ when .millivolts? then "mV"
+ when .kilovolts? then "kV"
+ when .megavolts? then "MV"
+ when .milliamperes? then "mA"
+ when .amperes? then "A"
+ when .cubic_feet? then "[cft_i]"
+ when .cubic_meters? then "m3"
+ when .imperial_gallons? then "[gal_br]"
+ when .milliliters? then "ml"
+ when .liters? then "l"
+ when .us_gallons? then "[gal_us]"
+ when .milliwatts? then "mW"
+ when .watts? then "W"
+ when .kilowatts? then "kW"
+ when .megawatts? then "MW"
+ when .watt_hours? then "Wh"
+ when .kilowatt_hours? then "kWh"
+ when .megawatt_hours? then "MWh"
+ when .hertz? then "Hz"
+ when .kilohertz? then "kHz"
+ when .megahertz? then "MHz"
+ when .cubic_feet_per_second? then "[cft_i]/s"
+ when .cubic_feet_per_minute? then "[cft_i]/min"
+ when .cubic_feet_per_hour? then "[cft_i]/h"
+ when .cubic_meters_per_second? then "m3/s"
+ when .cubic_meters_per_minute? then "m3/min"
+ when .cubic_meters_per_hour? then "m3/h"
+ when .imperial_gallons_per_minute? then "[gal_br]/min"
+ when .milliliters_per_second? then "ml/s"
+ when .liters_per_second? then "l/s"
+ when .liters_per_minute? then "l/min"
+ when .liters_per_hour? then "l/h"
+ when .us_gallons_per_minute? then "[gal_us]/min"
+ when .us_gallons_per_hour? then "[gal_us]/h"
+ end
+
+ obj_value = object_value(object)
+ value = case obj_value
+ in String, Nil, ::Time, ::BACnet::PropertyIdentifier::PropertyType, Tuple(ObjectType, UInt32)
+ nil
+ in Bool
+ obj_value ? 1.0 : 0.0
+ in UInt64, Int64, Float32, Float64
+ obj_value.to_f64
+ end
+ return nil if value.nil?
+
+ Interface::Sensor::Detail.new(
+ type: sensor_type,
+ value: value,
+ last_seen: object.changed.to_unix,
+ mac: device_id.to_s,
+ id: "#{object.object_type}[#{object.instance_id}]",
+ name: "#{device.name}: #{object.name}",
+ module_id: module_id,
+ binding: object_binding(device_id, object),
+ unit: unit
+ )
+ end
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ filter = type ? Interface::Sensor::SensorType.parse?(type) : nil
+
+ if mac
+ device_id = mac.to_u32?
+ return NO_MATCH unless device_id
+ device = get_device device_id
+ return NO_MATCH unless device
+ return device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) }
+ end
+
+ matches = @mutex.synchronize do
+ @devices.map do |(device_id, device)|
+ device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) }
+ end
+ end
+ matches.flatten
+ rescue error
+ logger.warn(exception: error) { "searching for sensors" }
+ NO_MATCH
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+ device_id = mac.to_u32?
+ return nil unless device_id
+ device = get_device device_id
+ return nil unless device
+
+ # id should be in the format "object_type[instance_id]"
+ obj_type_string, instance_id_string = id.split('[', 2)
+ instance_id = instance_id_string.rchop.to_u32?
+ return nil unless instance_id
+
+ object_type = ObjectType.parse?(obj_type_string)
+ return nil unless object_type
+
+ object = get_object_details(device_id, instance_id, object_type)
+
+ if object.changed < 1.minutes.ago
+ begin
+ object.sync_value(bacnet_client)
+ rescue error
+ logger.warn(exception: error) { "failed to obtain latest value for sensor at #{mac}.#{id}" }
+ end
+ end
+
+ to_sensor(device_id, device, object)
+ end
+
+ @[Security(Level::Support)]
+ def save_seen_devices
+ define_setting(:known_devices, @seen_devices.values)
+ end
+end
diff --git a/drivers/ashrae/bacnet_datapoints.cr b/drivers/ashrae/bacnet_datapoints.cr
new file mode 100644
index 00000000000..9a09ae9b658
--- /dev/null
+++ b/drivers/ashrae/bacnet_datapoints.cr
@@ -0,0 +1,26 @@
+require "placeos-driver"
+require "json"
+
+class Ashrae::BACnetDataPoints < PlaceOS::Driver
+ descriptive_name "BACnet Data Points"
+ generic_name :DataPoints
+
+ default_settings({
+ points: {
+ "power" => "101003.AnalogValue[45]",
+ "humidity" => "101005.AnalogValue[4]",
+ },
+ })
+
+ accessor bacnet : BACnet_1
+
+ def on_update
+ subscriptions.clear
+ points = setting(Hash(String, String), :points)
+ points.each do |(key, status)|
+ bacnet.subscribe(status) do |_sub, payload|
+ self[key] = JSON.parse(payload)
+ end
+ end
+ end
+end
diff --git a/drivers/ashrae/bacnet_datapoints_spec.cr b/drivers/ashrae/bacnet_datapoints_spec.cr
new file mode 100644
index 00000000000..05559525362
--- /dev/null
+++ b/drivers/ashrae/bacnet_datapoints_spec.cr
@@ -0,0 +1,20 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Qbic::TouchPanel" do
+ system({
+ BACnet: {BACnetMock},
+ })
+
+ sleep 0.2
+
+ status["power"].should eq true
+ status["humidity"].should eq 34.4
+end
+
+# :nodoc:
+class BACnetMock < DriverSpecs::MockDriver
+ def on_load
+ self["101003.AnalogValue[45]"] = true
+ self["101005.AnalogValue[4]"] = 34.4
+ end
+end
diff --git a/drivers/ashrae/bacnet_models.cr b/drivers/ashrae/bacnet_models.cr
new file mode 100644
index 00000000000..dc0d0a1d672
--- /dev/null
+++ b/drivers/ashrae/bacnet_models.cr
@@ -0,0 +1,42 @@
+require "bacnet"
+require "json"
+
+module Ashrae
+ class DeviceAddress
+ include JSON::Serializable
+
+ def initialize(@ip, @id, @net, @addr)
+ end
+
+ getter ip : String
+ getter id : UInt32?
+ getter net : UInt16?
+ getter addr : String?
+
+ def address
+ Socket::IPAddress.new(@ip, 0xBAC0)
+ end
+
+ def identifier
+ ::BACnet::ObjectIdentifier.new :device, @id.not_nil!
+ end
+ end
+
+ class DispatchProtocol < BinData
+ endian big
+
+ enum MessageType
+ OPENED
+ CLOSED
+ RECEIVED
+ WRITE
+ CLOSE
+ end
+
+ enum_field UInt8, message : MessageType = MessageType::RECEIVED
+ string :ip_address
+ uint64 :id_or_port
+ uint32 :data_size, value: ->{ data.size }
+ bytes :data, length: ->{ data_size }, default: Bytes.new(0)
+ end
+end
diff --git a/drivers/ashrae/bacnet_spec.cr b/drivers/ashrae/bacnet_spec.cr
new file mode 100644
index 00000000000..e6d768eca87
--- /dev/null
+++ b/drivers/ashrae/bacnet_spec.cr
@@ -0,0 +1,8 @@
+require "placeos-driver/spec"
+
+# NOTE:: this spec only works if there is a BACnet network configured locally
+# such as https://github.com/chipkin/BACnetServerExampleCPP/releases
+DriverSpecs.mock_driver "Ashrae::BACnet" do
+ exec(:query_known_devices).get
+ (exec(:devices).get.not_nil!.size > 0).should be_true
+end
diff --git a/drivers/aver/cam520_pro.cr b/drivers/aver/cam520_pro.cr
new file mode 100644
index 00000000000..01f566da0da
--- /dev/null
+++ b/drivers/aver/cam520_pro.cr
@@ -0,0 +1,353 @@
+require "placeos-driver"
+require "placeos-driver/interface/camera"
+require "placeos-driver/interface/powerable"
+require "./cam520_pro_models"
+
+class Aver::Cam520Pro < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Camera
+
+ # Discovery Information.
+ generic_name :Camera
+ descriptive_name "Aver 520 Pro Camera"
+
+ # note: wss port is 9188
+ uri_base "ws://10.110.144.40:9187/ws"
+
+ default_settings({
+ username: "spec",
+ password: "Aver",
+
+ zoom_max: 28448,
+ invert_controls: false,
+ })
+
+ protected getter bearer_token : String = ""
+ @username : String = ""
+ @zoom_max : Int32 = 28448
+ @invert : Bool = false
+
+ def on_load
+ queue.wait = false
+ transport.before_request do |request|
+ logger.debug { "performing request: #{request.method} #{request.path}\n#{String.new(request.body.as(IO::Memory).to_slice)}" }
+ if request.path != "/login_name"
+ bearer = bearer_token.presence || authenticate
+ request.headers["Authorization"] = "Bearer #{bearer}"
+ end
+ end
+ on_update
+ end
+
+ def on_update
+ @username = setting(String, :username)
+ if @username != "spec"
+ device_host = URI.parse(config.uri.not_nil!)
+ device_host.port = nil
+ transport.http_uri_override = device_host
+ end
+
+ @zoom_max = setting(Int32, :zoom_max)
+ @presets = setting?(Presets, :camera_presets) || @presets
+ self[:presets] = @presets.keys
+ self[:inverted] = @invert = setting?(Bool, :invert_controls) || false
+ end
+
+ def connected
+ send "token:#{authenticate}"
+ schedule.clear
+ schedule.every(10.minutes) { authenticate }
+ schedule.every(1.minutes) { keep_alive }
+
+ pan?
+ tilt?
+ zoom?
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ protected def check_success(response) : Bool
+ logger.debug { "http response #{response.status_code}: #{response.body}" }
+ return true if response.success?
+ @bearer_token = "" if response.status_code == 401
+ details = HttpResponse(Nil?).from_json(response.body.not_nil!)
+ raise "unexpected response #{details.code} - #{details.msg}"
+ end
+
+ macro parse(response, klass = Nil?)
+ check_success({{response}})
+ HttpResponse({{klass}}).from_json({{response}}.body.not_nil!).data
+ end
+
+ protected def authenticate
+ logger.debug { "Authenticating" }
+
+ response = post("/login_name", body: {
+ name: setting(String, :username),
+ password: setting(String, :password),
+ }.to_json)
+
+ @bearer_token = parse(response, Auth).token
+ end
+
+ def keep_alive
+ send("alive")
+ end
+
+ getter pan_pos : Int32 = 0
+ getter tilt_pos : Int32 = 0
+ getter zoom_pos : Int32 = 0
+
+ def received(data, task) : Nil
+ data = String.new(data)
+ logger.debug { "Camera sent: #{data}" }
+
+ payload = Event.from_json(data).data
+ case payload
+ in Option
+ value = payload.value.to_i
+ case payload.option
+ in .ptz_ps?
+ @pan_pos = value
+ in .ptz_ts?
+ @tilt_pos = value
+ in .ptz_zs?
+ @zoom_pos = value
+ self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f)
+ end
+ in Event
+ raise "not possible"
+ end
+ ensure
+ task.try &.success
+ end
+
+ # ====== Camera Interface ======
+
+ def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0)
+ tilt_speed = -tilt_speed if @invert
+
+ if pan_speed.abs >= tilt_speed.abs
+ axis = AxisSelect::Pan
+ stop = AxisSelect::Tilt
+ dir = pan_speed >= 0.0 ? 0 : 1
+ cmd = pan_speed.zero? ? 2 : 1
+ else
+ stop = AxisSelect::Pan
+ axis = AxisSelect::Tilt
+ dir = tilt_speed >= 0.0 ? 0 : 1
+ cmd = tilt_speed.zero? ? 2 : 1
+ end
+
+ # stop any previous move
+ spawn do
+ post("/camera_move", body: {
+ method: "SetPtzf",
+ axis: stop.to_i,
+ dir: dir,
+ cmd: 2,
+ }.to_json)
+ end
+
+ Fiber.yield
+
+ # start moving in the desired direction
+ response = post("/camera_move", body: {
+ method: "SetPtzf",
+ axis: axis.to_i,
+ dir: dir,
+ cmd: cmd,
+ }.to_json)
+
+ parse(response, Nil)
+ end
+
+ alias Presets = Hash(String, Tuple(Int32, Int32, Int32))
+ @presets : Presets = {} of String => Tuple(Int32, Int32, Int32)
+
+ def recall(position : String, index : Int32 | String = 0)
+ if pos = @presets[position]?
+ pan_pos, tilt_pos, zoom_pos = pos
+ zoom_native(zoom_pos)
+ pan_direct(pan_pos)
+ tilt_direct(tilt_pos)
+ else
+ raise "unknown preset #{position}"
+ end
+ end
+
+ def save_position(name : String, index : Int32 | String = 0)
+ pan?
+ tilt?
+ zoom?
+ @presets[name] = {@pan_pos, @tilt_pos, @zoom_pos}
+ save_presets
+ end
+
+ def remove_position(name : String, index : Int32 | String = 0)
+ @presets.delete(name)
+ save_presets
+ end
+
+ protected def save_presets
+ define_setting(:camera_presets, @presets)
+ self[:presets] = @presets.keys
+ end
+
+ def pan_direct(position : Int32)
+ response = post("/set_option", body: {
+ method: "Set",
+ option: "ptz_p",
+ value: position,
+ }.to_json)
+
+ parse(response, Nil) || position
+ end
+
+ def tilt_direct(position : Int32)
+ response = post("/set_option", body: {
+ method: "Set",
+ option: "ptz_t",
+ value: position,
+ }.to_json)
+
+ parse(response, Nil) || position
+ end
+
+ def pan?
+ response = post("/get_option", body: {
+ method: "Get",
+ option: "ptz_p_s",
+ }.to_json)
+
+ @pan_pos = parse(response, Int32)
+ end
+
+ def tilt?
+ response = post("/get_option", body: {
+ method: "Get",
+ option: "ptz_t_s",
+ }.to_json)
+
+ @tilt_pos = parse(response, Int32)
+ end
+
+ # ====== Zoomable Interface ======
+
+ # Zooms to an absolute position
+ def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0)
+ position = position.clamp(0.0, 100.0)
+ percentage = position / 100.0
+ zoom_native (percentage * @zoom_max.to_f).to_i
+ end
+
+ def zoom(direction : ZoomDirection, index : Int32 | String = 0)
+ case direction
+ in .stop?
+ dir = 0
+ cmd = 2
+ in .out?
+ dir = 1
+ cmd = 1
+ in .in?
+ dir = 0
+ cmd = 1
+ end
+
+ response = post("/camera_move", body: {
+ method: "SetPtzf",
+ axis: AxisSelect::Zoom.to_i,
+ dir: dir,
+ cmd: cmd,
+ }.to_json)
+
+ parse(response, Nil)
+ end
+
+ def zoom_native(position : Int32)
+ response = post("/set_option", body: {
+ method: "Set",
+ option: "ptz_z",
+ value: position,
+ }.to_json)
+
+ parse(response, Nil) || position
+ end
+
+ def zoom?
+ response = post("/get_option", body: {
+ method: "Get",
+ option: "ptz_z_s",
+ }.to_json)
+
+ @zoom_pos = value = parse(response, Int32)
+ self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f)
+ end
+
+ # ====== Moveable Interface ======
+
+ # moves at 50% of max speed
+ def move(position : MoveablePosition, index : Int32 | String = 0)
+ case position
+ in .up?
+ joystick(pan_speed: 0.0, tilt_speed: 50.0)
+ in .down?
+ joystick(pan_speed: 0.0, tilt_speed: -50.0)
+ in .left?
+ joystick(pan_speed: -50.0, tilt_speed: 0.0)
+ in .right?
+ joystick(pan_speed: 50.0, tilt_speed: 0.0)
+ in .in?
+ zoom(:in)
+ in .out?
+ zoom(:out)
+ in .open?, .close?
+ # not supported
+ end
+ end
+
+ # ====== Stoppable Interface ======
+
+ def stop(index : Int32 | String = 0, emergency : Bool = false)
+ # tilt
+ spawn do
+ post("/camera_move", body: {
+ method: "SetPtzf",
+ axis: AxisSelect::Tilt.to_i,
+ dir: 0,
+ cmd: 2,
+ }.to_json)
+ end
+
+ # pan
+ spawn do
+ post("/camera_move", body: {
+ method: "SetPtzf",
+ axis: AxisSelect::Pan.to_i,
+ dir: 0,
+ cmd: 2,
+ }.to_json)
+ end
+
+ Fiber.yield
+
+ # zoom
+ response = post("/camera_move", body: {
+ method: "SetPtzf",
+ axis: AxisSelect::Zoom.to_i,
+ dir: 0,
+ cmd: 2,
+ }.to_json)
+
+ parse(response, Nil)
+ end
+
+ # ====== Powerable Interface ======
+
+ # dummy interface as no power command, camera is always on
+ def power(state : Bool)
+ state
+ end
+end
diff --git a/drivers/aver/cam520_pro_models.cr b/drivers/aver/cam520_pro_models.cr
new file mode 100644
index 00000000000..473cdce22fa
--- /dev/null
+++ b/drivers/aver/cam520_pro_models.cr
@@ -0,0 +1,53 @@
+require "json"
+
+module Aver
+ enum AxisSelect
+ Pan = 0
+ Tilt
+ Zoom
+ Focus
+ end
+
+ struct Auth
+ include JSON::Serializable
+
+ getter token : String
+ end
+
+ struct HttpResponse(Data)
+ include JSON::Serializable
+
+ getter code : Int32
+ getter msg : String
+ getter data : Data
+ end
+
+ abstract struct Event
+ include JSON::Serializable
+
+ getter event : String
+
+ use_json_discriminator "event", {
+ "option" => EventOption,
+ }
+ end
+
+ enum OptionType
+ PtzPS
+ PtzTS
+ PtzZS
+ end
+
+ struct Option
+ include JSON::Serializable
+
+ getter option : OptionType
+ getter value : String
+ end
+
+ struct EventOption < Event
+ include JSON::Serializable
+
+ getter data : Option
+ end
+end
diff --git a/drivers/aver/cam520_pro_spec.cr b/drivers/aver/cam520_pro_spec.cr
new file mode 100644
index 00000000000..f3272673bb0
--- /dev/null
+++ b/drivers/aver/cam520_pro_spec.cr
@@ -0,0 +1,173 @@
+require "placeos-driver/spec"
+require "./cam520_pro_models"
+
+DriverSpecs.mock_driver "Aver::Cam520Pro" do
+ # ====================
+ # should send an authentication request
+ # ====================
+ token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2Njc3ODE0OTR9.blGZUSAekKJVi4VoOAEg9fARCOhyIMNiu_37L3Jv070"
+ expect_http_request do |request, response|
+ io = request.body
+ if io
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request["name"] == "spec" && request["password"] == "Aver"
+ response.status_code = 200
+ response << {
+ code: 200,
+ msg: "ok",
+ data: {
+ token: token,
+ },
+ }.to_json
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include dialing details #{request.inspect}"
+ end
+ end
+
+ should_send "token:#{token}"
+
+ # ======================
+ # query state on connect
+ # ======================
+
+ # query pan?
+ expect_http_request do |request, response|
+ data = request.body.not_nil!.gets_to_end
+ request = JSON.parse(data)
+ if request["method"] == "Get" && request["option"] == "ptz_p_s"
+ response.status_code = 200
+ response << {
+ code: 200,
+ msg: "ok",
+ data: 200,
+ }.to_json
+ else
+ response.status_code = 400
+ end
+ end
+
+ # query tilt?
+ expect_http_request do |request, response|
+ data = request.body.not_nil!.gets_to_end
+ request = JSON.parse(data)
+ if request["method"] == "Get" && request["option"] == "ptz_t_s"
+ response.status_code = 200
+ response << {
+ code: 200,
+ msg: "ok",
+ data: 100,
+ }.to_json
+ else
+ response.status_code = 400
+ end
+ end
+
+ # query zoom?
+ expect_http_request do |request, response|
+ data = request.body.not_nil!.gets_to_end
+ request = JSON.parse(data)
+ if request["method"] == "Get" && request["option"] == "ptz_z_s"
+ response.status_code = 200
+ response << {
+ code: 200,
+ msg: "ok",
+ data: 0,
+ }.to_json
+ else
+ response.status_code = 400
+ end
+ end
+
+ sleep 0.2
+
+ status[:zoom].should eq(0.0)
+ exec(:pan_pos).get.should eq(200)
+ exec(:tilt_pos).get.should eq(100)
+
+ # ====================
+ # test zoom value parsing
+ # ====================
+ transmit({
+ event: "option",
+ data: {
+ option: "ptz_z_s",
+ value: "28448",
+ },
+ }.to_json)
+
+ sleep 0.2
+
+ status[:zoom].should eq(100.0)
+
+ # ====================
+ # check zoom interface
+ # ====================
+ resp = exec(:zoom_to, 0.0)
+ expect_http_request do |request, response|
+ data = request.body.not_nil!.gets_to_end
+ request = JSON.parse(data)
+ if request["option"] == "ptz_z" && request["value"] == 0
+ response.status_code = 200
+ response << {
+ code: 200,
+ msg: "ok",
+ data: nil,
+ }.to_json
+ else
+ response.status_code = 400
+ end
+ end
+ resp.get
+
+ transmit({
+ event: "option",
+ data: {
+ option: "ptz_z_s",
+ value: "0",
+ },
+ }.to_json)
+
+ sleep 0.2
+
+ status[:zoom].should eq(0.0)
+
+ # ======================
+ # check camera interface
+ # ======================
+ resp = exec(:joystick, 80.0, 10.0)
+ # Stop tilt
+ expect_http_request do |request, response|
+ data = request.body.not_nil!.gets_to_end
+ request = JSON.parse(data)
+ if request["axis"] == 1 && request["cmd"] == 2
+ response.status_code = 200
+ response << {
+ code: 200,
+ msg: "ok",
+ data: nil,
+ }.to_json
+ else
+ raise "stop move failed in joystick request"
+ end
+ end
+ # Move pan
+ expect_http_request do |request, response|
+ data = request.body.not_nil!.gets_to_end
+ request = JSON.parse(data)
+ if request["axis"] == 0 && request["cmd"] == 1
+ response.status_code = 200
+ response << {
+ code: 200,
+ msg: "ok",
+ data: nil,
+ }.to_json
+ else
+ response.status_code = 400
+ end
+ end
+ resp.get
+end
diff --git a/drivers/aws/sns_sms.cr b/drivers/aws/sns_sms.cr
new file mode 100644
index 00000000000..d303aa444a1
--- /dev/null
+++ b/drivers/aws/sns_sms.cr
@@ -0,0 +1,74 @@
+require "placeos-driver"
+require "placeos-driver/interface/sms"
+require "awscr-signer"
+require "uri/params"
+
+# Documentation: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html
+
+class AWS::SnsSms < PlaceOS::Driver
+ include Interface::SMS
+
+ # Discovery Information
+ generic_name :SMS
+ descriptive_name "Amazon SNS - SMS service"
+ uri_base "https://sns.us-west-2.amazonaws.com"
+
+ default_settings({
+ aws_access_key: "12345",
+ aws_secret: "random",
+ })
+
+ getter! signer : Awscr::Signer::Signers::V4
+
+ def on_update
+ access_key = setting(String, :aws_access_key)
+ secret = setting(String, :aws_secret)
+
+ # grab the bits required for the signer
+ uri_parts = URI.parse(config.uri.not_nil!).host.not_nil!.split('.')
+ service = uri_parts[0]
+ region = uri_parts[1]
+
+ @signer = Awscr::Signer::Signers::V4.new(service, region, access_key, secret)
+ transport.before_request { |request| signer.sign(request) }
+ end
+
+ def send_sms(
+ phone_numbers : String | Array(String),
+ message : String,
+ format : String? = "SMS",
+ source : String? = nil
+ )
+ phone_numbers = [phone_numbers] unless phone_numbers.is_a?(Array)
+
+ responses = phone_numbers.map do |number|
+ params = URI::Params.build do |form|
+ form.add "Action", "Publish"
+ form.add "PhoneNumber", number
+ form.add "Message", message
+
+ if source
+ if source =~ /^\+?\d{5,14}$/
+ form.add "MessageAttributes.entry.1.Name", "AWS.MM.SMS.OriginationNumber"
+ form.add "MessageAttributes.entry.1.Value.DataType", "String"
+ form.add "MessageAttributes.entry.1.Value.StringValue", source
+ else
+ form.add "MessageAttributes.entry.1.Name", "AWS.SNS.SMS.SenderID"
+ form.add "MessageAttributes.entry.1.Value.DataType", "String"
+ form.add "MessageAttributes.entry.1.Value.StringValue", source.gsub(' ', '-')
+ end
+ end
+ end
+
+ post("/?#{params}", headers: HTTP::Headers{
+ "Accept" => "application/json",
+ })
+ end
+
+ responses.each do |response|
+ raise "request failed with #{response.status_code}: #{response.body}" unless response.success?
+ end
+
+ nil
+ end
+end
diff --git a/drivers/aws/sns_sms_spec.cr b/drivers/aws/sns_sms_spec.cr
new file mode 100644
index 00000000000..932a6227789
--- /dev/null
+++ b/drivers/aws/sns_sms_spec.cr
@@ -0,0 +1,24 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "AWS::SnsSms" do
+ # Send the request
+ retval = exec(:send_sms,
+ phone_numbers: "61418419954",
+ message: "hello steve"
+ )
+
+ # sms should send a HTTP request
+ expect_http_request do |request, response|
+ params = request.query_params
+ if {params["Action"], params["PhoneNumber"], params["Message"]} == {"Publish", "61418419954", "hello steve"}
+ response.status_code = 200
+ response << "{\"PublishResponse\":{\"PublishResult\":{\"MessageId\":\"0b486c18-fa23-5f82-a5a0-35200c5f3d96\",\"SequenceNumber\":null},\"ResponseMetadata\":{\"RequestId\":\"6710f384-1a8a-56e3-8b63-aabcecf664f7\"}}}"
+ else
+ response.status_code = 400
+ response << "{}"
+ end
+ end
+
+ # What the sms function should return
+ retval.get.should eq(nil)
+end
diff --git a/drivers/biamp/nexia.cr b/drivers/biamp/nexia.cr
new file mode 100644
index 00000000000..bd76ef17c1a
--- /dev/null
+++ b/drivers/biamp/nexia.cr
@@ -0,0 +1,154 @@
+require "placeos-driver"
+require "inactive-support/mapped_enum"
+require "./ntp"
+
+class Biamp::Nexia < PlaceOS::Driver
+ include Biamp::NTP
+
+ tcp_port 23
+ descriptive_name "Biamp Nexia/Audia"
+ generic_name :Mixer
+
+ protected property device_id = 0
+
+ def on_load
+ queue.delay = 30.milliseconds
+ transport.tokenizer = Tokenizer.new("\r\n", "\xFF\xFE\x01")
+ end
+
+ def connected
+ send Bytes[0xFF, 0xFE, 0x01], wait: false # Echo off
+ schedule.every(60.seconds, true) do
+ query_device_id
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def query_device_id
+ send Command[:GETD, 0, "DEVID"]
+ end
+
+ def preset(number : Int32)
+ send Command[:RECALL, 0, "PRESET", number], name: "preset_#{number}"
+ end
+
+ mapped_enum Mixer do
+ Matrix = "MMMUTEXP"
+ Standard = "SMMUTEXP"
+ Auto = "AMMUTEXP"
+ end
+
+ def mixer(id : Int32, inouts : Hash(Int32, Array(Int32)) | Array(Int32), mute : Bool = false, type : Mixer = Mixer::Matrix)
+ value = mute ? 0 : 1
+
+ if inouts.is_a? Hash
+ inouts.each do |input, outputs|
+ outputs.each do |output|
+ send Command[:SET, device_id, type.mapped_value, id, input, output, value]
+ end
+ end
+ else
+ inouts.each do |input|
+ send Command[:SET, device_id, Mixer::Auto.mapped_value, id, input, value]
+ end
+ end
+ end
+
+ mapped_enum Faders do
+ Fader = "FDRLVL"
+ MatrixIn = "MMLVLIN"
+ MatrixOut = "MMLVLOUT"
+ MatrixCrosspoint = "MMLVLXP"
+ StdmatrixIn = "SMLVLIN"
+ StdmatrixOut = "SMLVLOUT"
+ AutoIn = "AMLVLIN"
+ AutoOut = "AMLVLOUT"
+ IoIn = "INPLVL"
+ IoOut = "OUTLVL"
+ end
+
+ protected def get_range(type : Faders)
+ return -100..0 if type.matrix_crosspoint?
+ -100..12
+ end
+
+ def fader(id : Int32, level : Float64 | Int32, index : Int32 = 1, type : Faders = Faders::Fader)
+ level = level.to_f.clamp(0.0, 100.0)
+ percentage = level / 100.0
+ range = get_range type
+
+ # adjust into range
+ level_actual = percentage * (range.size - 1).to_f
+ level_actual = level_actual + range.begin.to_f
+
+ send Command[:SETD, device_id, type.mapped_value, id, index, level_actual], name: "fader_#{id}"
+ end
+
+ def query_fader(id : Int32, index : Int32 = 1, type : Faders = Faders::Fader)
+ send Command[:GETD, device_id, type.mapped_value, id, index]
+ end
+
+ mapped_enum Mutes do
+ Fader = "FDRMUTE"
+ MatrixIn = "MMMUTEIN"
+ MatrixOut = "MMMUTEOUT"
+ AutoIn = "AMMUTEIN"
+ AutoOut = "AMMUTEOUT"
+ StdmatrixIn = "SMMUTEIN"
+ StdmatrixOut = "SMOUTMUTE"
+ IoIn = "INPMUTE"
+ IoOut = "OUTMUTE"
+ end
+
+ def mute(id : Int32, state : Bool = true, index : Int32 = 1, type : Mutes = Mutes::Fader)
+ value = state ? 1 : 0
+ send Command[:SETD, device_id, type.mapped_value, id, index, value], name: "mute_#{id}"
+ end
+
+ def unmute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader)
+ mute(id, false, index, type)
+ end
+
+ def query_mute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader)
+ send Command[:GETD, device_id, type.mapped_value, id, index]
+ end
+
+ def received(data, task)
+ case response = Response.parse data
+ in Response::FullPath
+ logger.debug { "Device responded #{response.message}" }
+ result = process_full_path_response response
+ task.try &.success result
+ in Response::OK
+ logger.info { "OK" }
+ task.try &.success
+ in Response::Error
+ logger.warn { "Device error: #{data}" }
+ task.try &.abort(response.message)
+ in Response::Invalid
+ logger.error { "Invalid response structure" }
+ task.try &.abort(response.data)
+ end
+ end
+
+ protected def process_full_path_response(response)
+ case response.attribute
+ when "DEVID"
+ self["device_id"] = self.device_id = response.value.to_i
+ else
+ if mute = Mutes.from_mapped_value? response.attribute
+ id, index = response.params
+ self["#{mute.to_s.underscore}#{id}_#{index}_mute"] = response.value == "1"
+ elsif fader = Faders.from_mapped_value? response.attribute
+ range = get_range fader
+ vol_percent = ((response.value.to_f - range.begin.to_f) / (range.size - 1).to_f) * 100.0
+
+ id, index = response.params
+ self["#{fader.to_s.underscore}#{id}_#{index}"] = vol_percent
+ end
+ end
+ end
+end
diff --git a/drivers/biamp/nexia_spec.cr b/drivers/biamp/nexia_spec.cr
new file mode 100644
index 00000000000..c690a28c6b4
--- /dev/null
+++ b/drivers/biamp/nexia_spec.cr
@@ -0,0 +1,48 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Biamp::Nexia" do
+ should_send "\xFF\xFE\x01"
+
+ should_send("GETD 0 DEVID\n")
+ responds("#GETD 0 DEVID 1\r\n")
+ status["device_id"].should eq(1)
+
+ exec(:preset, 1001)
+ should_send("RECALL 0 PRESET 1001\n")
+ responds("#RECALL 0 PRESET 1001 +OK\r\n")
+
+ exec(:fader, 1, 0.0)
+ should_send("SETD 1 FDRLVL 1 1 -100.0\n")
+ responds("#SETD 1 FDRLVL 1 1 -100.0 +OK\r\n")
+ status["fader1_1"].should eq(0.0)
+
+ exec(:fader, 1, 100.0, 2, "matrix_in")
+ should_send("SETD 1 MMLVLIN 1 2 12.0\n")
+ responds("#SETD 1 MMLVLIN 1 2 12.0 +OK\r\n")
+ status["matrix_in1_2"].should eq(100.0)
+
+ exec(:mute, 1234, false, 3)
+ should_send("SETD 1 FDRMUTE 1234 3 0\n")
+ responds("#SETD 1 FDRMUTE 1234 3 0 +OK\r\n")
+ status["fader1234_3_mute"].should eq(false)
+
+ exec(:mute, 1234, true, 5, "auto_in")
+ should_send("SETD 1 AMMUTEIN 1234 5 1\n")
+ responds("#SETD 1 AMMUTEIN 1234 5 1 +OK\r\n")
+ status["auto_in1234_5_mute"].should eq(true)
+
+ exec(:unmute, 111)
+ should_send("SETD 1 FDRMUTE 111 1 0\n")
+ responds("#SETD 1 FDRMUTE 111 1 0 +OK\r\n")
+ status["fader111_1_mute"].should eq(false)
+
+ exec(:query_fader, 133)
+ should_send("GETD 1 FDRLVL 133 1\n")
+ responds("#GETD 1 FDRLVL 133 1 -100.0\r\n")
+ status["fader133_1"].should eq(0.0)
+
+ exec(:query_mute, 155)
+ should_send("GETD 1 FDRMUTE 155 1\n")
+ responds("#GETD 1 FDRMUTE 155 1 0\r\n")
+ status["fader155_1_mute"].should eq(false)
+end
diff --git a/drivers/biamp/ntp.cr b/drivers/biamp/ntp.cr
new file mode 100644
index 00000000000..236772fdfec
--- /dev/null
+++ b/drivers/biamp/ntp.cr
@@ -0,0 +1,80 @@
+# Biamp ATP/NTP protocol utilities.
+# https://support.biamp.com/Audia-Nexia/Control/Audia-Nexia_Text_Protocol
+module Biamp::NTP
+ record Command,
+ type : Type,
+ device : Int32,
+ attribute : String,
+ instance : Int32? = nil,
+ index_1 : Int32? = nil,
+ index_2 : Int32? = nil,
+ value : String | Int32 | Float64 | Nil = nil do
+ macro [](type, *params)
+ {% if type == :GET || type == :GETD %}
+ {{@type.name}}.new({{type}}, {{params.splat}})
+ {% else %}
+ {{@type.name}}.new({{type}}, {{params[0...-1].splat}}, value: {{params[-1]}})
+ {% end %}
+ end
+
+ enum Type
+ SET
+ SETD
+ GET
+ GETD
+ INC
+ INCD
+ DEC
+ DECD
+ RECALL
+ DIAL
+ end
+
+ def to_io(io : IO, format = nil)
+ io << type
+ {device, attribute, instance, index_1, index_2, value}.each do |field|
+ next if field.nil?
+ io << ' ' << field
+ end
+ io << '\n'
+ end
+ end
+
+ module Response
+ record FullPath,
+ message : String,
+ type : Command::Type,
+ device : Int32,
+ attribute : String,
+ params : Array(String),
+ value : String
+ record OK
+ record Error, message : String
+ record Invalid, data : Bytes
+
+ def self.parse(data : Bytes)
+ case data[0]
+ when '#'
+ response = String.new data
+ if response.includes? " -ERR"
+ Error.new response
+ else
+ fields = response[1..].split
+ type = Command::Type.parse fields[0]
+ device = fields[1].to_i
+ attribute = fields[2]
+ params = fields[3..]
+ # All responses except GETD provide an "+OK" in the last field
+ value = type.getd? ? fields[-1] : fields[-2]
+ FullPath.new response, type, device, attribute, params, value
+ end
+ when '+'
+ OK.new
+ when '-'
+ Error.new String.new data
+ else
+ Invalid.new data
+ end
+ end
+ end
+end
diff --git a/drivers/biamp/tesira.cr b/drivers/biamp/tesira.cr
new file mode 100644
index 00000000000..da5dc03d9ea
--- /dev/null
+++ b/drivers/biamp/tesira.cr
@@ -0,0 +1,222 @@
+require "placeos-driver"
+require "telnet"
+
+module Biamp; end
+
+class Biamp::Tesira < PlaceOS::Driver
+ # Discovery Information
+ tcp_port 23 # Telnet
+ descriptive_name "Biamp Tesira"
+ generic_name :Mixer
+
+ default_settings({
+ no_password: true,
+ username: "default",
+ password: "default",
+ })
+
+ alias Num = Int32 | Float64
+ alias Ids = String | Array(String)
+
+ def on_load
+ # Nexia requires some breathing room
+ queue.wait = false
+ queue.delay = 30.milliseconds
+ end
+
+ def connected
+ @telnet = telnet = Telnet.new do |telnet_response|
+ transport.send telnet_response
+ end
+ transport.pre_processor { |bytes| telnet.buffer(bytes) }
+
+ if setting(Bool, :no_password)
+ do_send setting(String, :username) || "admin", wait: false, delay: 200.milliseconds, priority: 98
+ do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97
+ end
+ do_send "SESSION set verbose false", priority: 96
+
+ schedule.clear
+ schedule.every(60.seconds) do
+ do_send "DEVICE get serialNumber", priority: 95
+ end
+ end
+
+ def disconnected
+ transport.tokenizer = nil
+ schedule.clear
+ end
+
+ def preset(number_or_name : String | Int32)
+ if number_or_name.is_a? Int32
+ do_send "DEVICE recallPreset #{number_or_name}", priority: 30, name: "preset_#{number_or_name}"
+ else
+ do_send build(:DEVICE, :recallPresetByName, number_or_name), priority: 30, name: "preset_#{number_or_name}"
+ end
+ end
+
+ def start_audio
+ do_send "DEVICE startAudio"
+ end
+
+ def reboot
+ do_send "DEVICE reboot"
+ end
+
+ def get_aliases
+ do_send "SESSION get aliases"
+ end
+
+ MIXERS = {
+ "matrix" => "crosspointLevelState",
+ "mixer" => "crosspoint",
+ }
+
+ def mixer(id : String, inouts : Hash(Int32, Int32 | Array(Int32)) | Array(Int32), mute : Bool = false, type : String = "matrix")
+ mixer_type = MIXERS[type] || type
+
+ if inouts.is_a? Hash
+ inouts.each do |input, outs|
+ outputs = ensure_array(outs)
+ outputs.each do |output|
+ do_send build(id, :set, mixer_type, input, output, mute), priority: 30, name: "mixmute_#{input}_#{output}"
+ end
+ end
+ else # assume array (auto-mixer)
+ inouts.each do |input|
+ do_send build(id, :set, mixer_type, input, mute), priority: 30, name: "mixmute_#{input}"
+ end
+ end
+ end
+
+ FADERS = {
+ "fader" => "level",
+ "matrix_in" => "inputLevel",
+ "matrix_out" => "outputLevel",
+ "matrix_crosspoint" => "crosspointLevel",
+ "level" => "fader",
+ "inputLevel" => "matrix_in",
+ "outputLevel" => "matrix_out",
+ "crosspointLevel" => "matrix_crosspoint",
+ }
+
+ def fader(fader_id : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ # value range: -100 ~ 12
+ fader_type = FADERS[type] || type
+
+ fader_ids = ensure_array(fader_id)
+ indicies = ensure_array(index)
+ fader_ids.each do |fad|
+ indicies.each do |i|
+ do_send build(fad, :set, fader_type, i, level), priority: 30, name: "fade_#{fad}_#{i}"
+ self["#{fader_type}_#{fad}_#{i}"] = level
+ end
+ end
+ end
+
+ # Named params version
+ def faders(ids : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ fader(ids, level, index, type)
+ end
+
+ MUTES = {
+ "fader" => "mute",
+ "matrix_in" => "inputMute",
+ "matrix_out" => "outputMute",
+ "mute" => "fader",
+ "inputMute" => "matrix_in",
+ "outputMute" => "matrix_out",
+ }
+
+ def mute(fader_id : Ids, value : Bool = true, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ mute_type = MUTES[type] || type
+
+ fader_ids = ensure_array(fader_id)
+ indicies = ensure_array(index)
+ fader_ids.each do |fad|
+ indicies.each do |i|
+ do_send build(fad, :set, mute_type, i, value), priority: 30, name: "mute_#{fad}_#{i}"
+ self["#{mute_type}_#{fad}_#{i}_mute"] = value
+ end
+ end
+ end
+
+ # Named params version
+ def mutes(ids : Ids, muted : Bool, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ mute(ids, muted, index, type)
+ end
+
+ def unmute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ mute(fader_id, false, index, type)
+ end
+
+ def query_fader(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ fad_type = FADERS[type] || type
+ fader_id = ensure_array(fader_id)[0]
+ index = ensure_array(index)[0]
+
+ do_send build(fader_id, :get, fad_type, index)
+ end
+
+ # Named params version
+ def query_faders(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ query_fader(ids, index, type)
+ end
+
+ def query_mute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ mute_type = MUTES[type] || type
+ fader_id = ensure_array(fader_id)[0]
+ index = ensure_array(index)[0]
+
+ do_send build(fader_id, :get, mute_type, index)
+ end
+
+ # Named params version
+ def query_mutes(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader")
+ query_mute(ids, index, type)
+ end
+
+ def received(data, task)
+ data = String.new(data).strip
+
+ logger.debug { "Tesira responded -> data: #{data}" }
+ result = data.split(" ")
+
+ if result[0] == "-"
+ task.try(&.abort)
+ end
+
+ if data =~ /login:|server/i
+ transport.tokenizer = Tokenizer.new "\r\n"
+ end
+
+ task.try(&.success)
+ end
+
+ private def build(*args)
+ cmd = ""
+ args.each do |arg|
+ data = arg.to_s
+ next if data.blank?
+ cmd = cmd + " " if cmd.size > 0
+
+ if data.includes? " "
+ cmd = cmd + "\""
+ cmd = cmd + data
+ cmd = cmd + "\""
+ else
+ cmd = cmd + data
+ end
+ end
+ cmd
+ end
+
+ private def do_send(command, **options)
+ logger.debug { "requesting #{command}" }
+ send @telnet.not_nil!.prepare(command), **options
+ end
+
+ private def ensure_array(object)
+ object.is_a?(Array) ? object : [object]
+ end
+end
diff --git a/drivers/biamp/tesira_spec.cr b/drivers/biamp/tesira_spec.cr
new file mode 100644
index 00000000000..52a6afd2d03
--- /dev/null
+++ b/drivers/biamp/tesira_spec.cr
@@ -0,0 +1,39 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Biamp::Tesira" do
+ transmit "login: "
+ should_send "default\r\n"
+ should_send "default\r\n"
+ should_send "SESSION set verbose false\r\n"
+
+ exec(:preset, 1001)
+ should_send "DEVICE recallPreset 1001"
+
+ exec(:preset, "1001-test")
+ should_send "DEVICE recallPresetByName 1001-test"
+
+ exec(:start_audio)
+ should_send "DEVICE startAudio"
+
+ exec(:reboot)
+ should_send "DEVICE reboot"
+
+ exec(:get_aliases)
+ should_send "SESSION get aliases"
+
+ exec(:mixer, "123", [1])
+ should_send "123 set crosspointLevelState 1 false"
+
+ exec(:fader, "Fader123", 11)
+ should_send "Fader123 set level 1 11"
+ responds("+OK\r\n")
+ status["level_Fader123_1"] = 11
+
+ exec(:mute, "Fader123")
+ should_send "Fader123 set mute 1 true"
+ responds("+OK\r\n")
+ status["level_Fader123_1_mute"] = true
+
+ exec(:query_fader, "Fader123")
+ should_send "Fader123 get level 1"
+end
diff --git a/drivers/bose/control_space_serial.cr b/drivers/bose/control_space_serial.cr
new file mode 100644
index 00000000000..2044a9712bd
--- /dev/null
+++ b/drivers/bose/control_space_serial.cr
@@ -0,0 +1,58 @@
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/Bose/Bose-ControlSpace-SerialProtocol-v5.pdf
+
+class Bose::ControlSpaceSerial < PlaceOS::Driver
+ # Discovery Information
+ tcp_port 10055
+ descriptive_name "Bose ControlSpace Serial Protocol"
+ generic_name :Mixer
+
+ def on_load
+ # 0x0D ( carriage return \r)
+ transport.tokenizer = Tokenizer.new(Bytes[0x0D])
+ on_update
+ end
+
+ def on_update
+ end
+
+ def connected
+ schedule.every(60.seconds) do
+ logger.debug { "-- maintaining connection" }
+ do_send "GS", priority: 99
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ private def do_send(data, **options)
+ logger.debug { "requesting: #{data}" }
+ send "#{data}\x0D", **options
+ end
+
+ def set_parameter_group(id : UInt8)
+ do_send("SS #{id.to_s(16).upcase}", wait: false, name: "set_pgroup").get
+ self[:parameter_group] = id
+ end
+
+ def get_parameter_group
+ do_send "GS"
+ end
+
+ def received(data, task)
+ # Ignore the framing bytes
+ data = String.new(data).rchop
+ logger.debug { "ControlSpace sent: #{data}" }
+
+ parts = data.split(" ")
+ case parts[0]
+ when "S"
+ self[:parameter_group] = parts[1].to_i(16)
+ end
+
+ task.try &.success
+ end
+end
diff --git a/drivers/bose/control_space_serial_spec.cr b/drivers/bose/control_space_serial_spec.cr
new file mode 100644
index 00000000000..939b6d6848e
--- /dev/null
+++ b/drivers/bose/control_space_serial_spec.cr
@@ -0,0 +1,12 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Bose::ControlSpaceSerial" do
+ exec(:set_parameter_group, 12)
+ should_send("SS C\r")
+ status[:parameter_group].should eq(12)
+
+ exec(:get_parameter_group)
+ should_send("GS\r")
+ responds("S FF\r")
+ status[:parameter_group].should eq(255)
+end
diff --git a/drivers/build.cr b/drivers/build.cr
new file mode 100644
index 00000000000..84cf25664f1
--- /dev/null
+++ b/drivers/build.cr
@@ -0,0 +1,10 @@
+{% if env("COMPILE_DRIVER") %}
+ {% if env("COMPILE_DRIVER").ends_with?("_spec.cr") %}
+ require "placeos-driver/spec"
+ {% else %}
+ require "placeos-driver"
+ {% end %}
+
+ # Dynamically require the desired driver
+ {{ ("require \"../" + env("COMPILE_DRIVER") + "\"").id }}
+{% end %}
diff --git a/drivers/cisco/booking_panel_led_sync.cr b/drivers/cisco/booking_panel_led_sync.cr
new file mode 100644
index 00000000000..08e13bc53cb
--- /dev/null
+++ b/drivers/cisco/booking_panel_led_sync.cr
@@ -0,0 +1,49 @@
+require "placeos-driver"
+
+class Cisco::BookingPanelLedSync < PlaceOS::Driver
+ descriptive_name "Cisco Webex Navigator Panel LED Sync"
+ generic_name :Navigator_LED_Sync
+ description "Sync Cisco Webex Navigator Panel LED to Bookings.in_use status"
+
+ default_settings({
+ led_color_when_room_booked: "Red",
+ led_color_when_room_available: "Green",
+ webex_panel_device_id: "Ensure this is set in each System's settings"
+ })
+
+ accessor webex_xapi : CloudXAPI
+ accessor room_bookings : Bookings
+
+ @led_color_when_room_booked : String = "Red"
+ @led_color_when_room_available : String = "Green"
+ @webex_panel_device_id : String = "Ensure this is set in each System's settings"
+
+ def on_load
+ on_update
+ sync_led_color_now
+ end
+
+ def on_update
+ subscriptions.clear
+ @led_color_when_room_booked = setting(String, :led_color_when_room_booked) || "Red"
+ @led_color_when_room_available = setting(String, :led_color_when_room_available) || "Green"
+ @webex_panel_device_id = setting(String, :webex_panel_device_id) || "Ensure this is set in each System's settings"
+
+ system.subscribe("Bookings_1", "in_use") do |_sub, value|
+ next unless ["true", "false"].includes?(value)
+ self[:room_in_use] = room_in_use = value == "true"
+ set_led_color(room_in_use)
+ end
+ end
+
+ def sync_led_color_now
+ return unless ["true", "false"].includes?(room_in_use = system[:Bookings].status?(Bool, :in_use))
+ set_led_color(room_in_use) unless room_in_use.nil?
+ end
+
+ private def set_led_color(room_in_use : Bool)
+ new_led_color = room_in_use ? @led_color_when_room_booked : @led_color_when_room_available
+ webex_xapi.led_colour(@webex_panel_device_id, new_led_color)
+ self[:led_color] = new_led_color
+ end
+end
diff --git a/drivers/cisco/booking_panel_led_sync_spec.cr b/drivers/cisco/booking_panel_led_sync_spec.cr
new file mode 100644
index 00000000000..ba2f8729286
--- /dev/null
+++ b/drivers/cisco/booking_panel_led_sync_spec.cr
@@ -0,0 +1,5 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::BookingPanelLedSync" do
+
+end
diff --git a/drivers/cisco/collaboration_endpoint.cr b/drivers/cisco/collaboration_endpoint.cr
new file mode 100644
index 00000000000..840797cbdf9
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint.cr
@@ -0,0 +1,505 @@
+require "placeos-driver"
+require "promise"
+require "uuid"
+
+module Cisco::CollaborationEndpoint
+ macro included
+ @@status_mappings = {} of Symbol => String
+
+ def self.map_status(**opts)
+ @@status_mappings.merge! opts.to_h
+ end
+ end
+
+ # used by many of the commands
+ enum Toogle
+ On
+ Off
+ end
+
+ getter peripheral_id : String do
+ uuid = generate_request_uuid
+ @ignore_update = true
+ define_setting(:peripheral_id, uuid)
+ uuid
+ end
+
+ protected getter feedback : Feedback = Feedback.new
+ @ready : Bool = false
+ @init_called : Bool = false
+
+ # Camera idx => Preset name => Preset id
+ alias Presets = Hash(Int32, Hash(String, Int32))
+ @presets : Presets = {} of Int32 => Hash(String, Int32)
+ getter feedback_paths : Array(String) = [] of String
+
+ def on_load
+ # NOTE:: on_load doesn't call on_update as on_update disconnects
+ queue.delay = 80.milliseconds
+ queue.timeout = 3.seconds
+ @peripheral_id = setting?(String, :peripheral_id)
+ @presets = setting?(Presets, :camera_presets) || @presets
+ self[:camera_presets] = @presets.transform_values { |val| val.keys }
+ driver = self
+ driver.load_settings if driver.responds_to?(:load_settings)
+ end
+
+ # used when saving settings from the driver
+ # this prevents needless disconnects
+ @ignore_update : Bool = false
+
+ def on_update
+ if @ignore_update
+ @ignore_update = false
+ return
+ end
+ @presets = setting?(Presets, :camera_presets) || @presets
+ self[:camera_presets] = @presets.transform_values { |val| val.keys }
+ driver = self
+ driver.load_settings if driver.responds_to?(:load_settings)
+
+ # Force a reconnect and event resubscribe following module updates.
+ disconnect
+ end
+
+ @last_received : Int64 = 0_i64
+
+ protected def reset_connection_flags
+ @ready = false
+ @init_called = false
+ @feedback_paths = [] of String
+ transport.tokenizer = nil
+ end
+
+ def connected
+ reset_connection_flags
+ schedule.every(2.minutes) { ensure_feedback_registered }
+ schedule.every(30.seconds) do
+ if @last_received > 40.seconds.ago.to_unix
+ heartbeat timeout: 35
+ else
+ disconnect
+ end
+ end
+ schedule.in(10.seconds) do
+ init_connection unless @ready || @init_called
+ schedule.in(15.seconds) { disconnect if !@ready || self["configuration"]?.nil? }
+ end
+ begin
+ transport.send "xPreferences OutputMode JSON\n"
+ rescue
+ end
+ queue.clear abort_current: true
+ end
+
+ def disconnected
+ schedule.clear
+ reset_connection_flags
+ clear_feedback_subscriptions(false)
+ queue.clear abort_current: true
+ self[:ready] = false
+ end
+
+ def generate_request_uuid
+ UUID.random.to_s
+ end
+
+ def ensure_feedback_registered
+ send "xPreferences OutputMode JSON\n", priority: 0, wait: false, name: "output_json"
+ results = @feedback_paths.map do |path|
+ request = XAPI.xfeedback :register, path
+ # Always returns an empty response, nothing special to handle
+ do_send(request, priority: 0, name: path)
+ end
+ spawn do
+ success = 0
+ results.each do |task|
+ begin
+ success += 1 if task.get.state.success?
+ rescue
+ end
+ end
+ logger.debug { "FEEDBACK REGISTERED #{success}" }
+ disconnect unless success > 0
+ end
+ @feedback_paths.size
+ end
+
+ # ------------------------------
+ # Exec methods
+
+ alias JSONBasic = Enumerable::JSONBasic
+ alias Config = Hash(String, Hash(String, JSONBasic))
+
+ # Push a configuration settings to the device.
+ def xconfigurations(config : Config)
+ config.each { |path, settings| xconfiguration(path, settings) }
+ end
+
+ # Execute an xCommand on the device.
+ def xcommand(
+ command : String,
+ multiline_body : String? = nil,
+ hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type,
+ **kwargs
+ )
+ request = XAPI.xcommand(command, **kwargs.merge({hash_args: hash_args}))
+ name = if kwargs.empty?
+ command
+ elsif kwargs.size == 1
+ "#{command} #{kwargs.keys.to_a.first}"
+ end
+
+ # use default queue priority is not specified
+ priority = kwargs[:priority]? || queue.priority
+
+ do_send request, multiline_body, name: name, priority: priority do |response|
+ # The result keys are a little odd: they're a concatenation of the
+ # last two command elements and 'Result', unless the command
+ # failed in which case it's just 'Result'.
+ # For example:
+ # xCommand Video Input SetMainVideoSource ...
+ # becomes:
+ # InputSetMainVideoSourceResult
+ result_key = command.split(' ').last(2).join("") + "Result"
+ command_result = response["CommandResponse/#{result_key}/status"]?
+ failure_result = response["CommandResponse/Result/Reason"]?
+
+ result = command_result || failure_result
+
+ if result
+ if result == "OK"
+ result
+ else
+ failure_result ||= response["CommandResponse/#{result_key}/Reason"]?
+ logger.error { failure_result.inspect }
+ :abort
+ end
+ else
+ logger.warn { "Unexpected response format" }
+ :abort
+ end
+ end
+ end
+
+ # Apply a single configuration on the device.
+ def xconfiguration(
+ path : String,
+ hash_args : Hash(String, JSONBasic) = {} of String => JSONBasic,
+ **kwargs
+ )
+ promises = hash_args.map do |setting, value|
+ apply_configuration(path, setting, value)
+ end
+ kwargs.each do |setting, value|
+ promise = apply_configuration(path, setting, value)
+ promises << promise
+ end
+ Promise.all(promises).get.first
+ end
+
+ protected def apply_configuration(path : String, setting : String, value : JSONBasic)
+ request = XAPI.xconfiguration(path, setting, value)
+ promise = Promise.new(Bool)
+
+ task = do_send request, name: "#{path} #{setting}" do |response|
+ result = response["CommandResponse/Configuration/status"]?
+
+ if result == "Error"
+ reason = response["CommandResponse/Configuration/Reason"]?
+ xpath = response["CommandResponse/Configuration/XPath"]?
+
+ error_msg = "#{reason} (#{xpath})"
+ promise.reject(RuntimeError.new error_msg)
+ logger.error { error_msg }
+ :abort
+ else
+ promise.resolve true
+ true
+ end
+ end
+
+ spawn do
+ task.get
+ promise.reject(RuntimeError.new "failed to set configuration: #{path} #{setting}: #{value}") if task.state == :abort
+ end
+ promise
+ end
+
+ def xstatus(path : String)
+ request = XAPI.xstatus path
+ promise = Promise.new(Hash(String, Enumerable::JSONComplex))
+
+ task = do_send request do |response|
+ prefix = "Status/#{XAPI.tokenize(path).join('/')}"
+ results = {} of String => Enumerable::JSONComplex
+ response.each do |key, value|
+ results[key] = value if key.starts_with?(prefix)
+ end
+
+ if !results.empty?
+ promise.resolve results
+ results
+ elsif error = response["Status/status"]? || response["CommandResponse/Status/status"]?
+ reason = response["Status/Reason"]? || response["CommandResponse/Status/Reason"]?
+ xpath = response["Status/XPath"]? || response["CommandResponse/Status/XPath"]?
+ error_msg = "#{reason} (#{xpath})"
+ promise.reject(RuntimeError.new error_msg)
+ logger.error { error_msg }
+ :abort
+ else
+ results[prefix] = nil
+ promise.resolve results
+ results
+ end
+ end
+
+ spawn do
+ task.get
+ promise.reject(RuntimeError.new "failed to obtain status: #{path}") if task.state == :abort
+ end
+ promise.get
+ end
+
+ # ------------------------------
+ # Base comms
+
+ protected def init_connection
+ @init_called = true
+ transport.tokenizer = Tokenizer.new do |io|
+ raw = io.gets_to_end
+ data = raw.lstrip
+ index = if data.starts_with?("{")
+ count = 0
+ pos = 0
+ data.each_char_with_index do |char, i|
+ pos = i
+ count += 1 if char == '{'
+ count -= 1 if char == '}'
+ break if count.zero?
+ end
+ pos if count.zero?
+ else
+ data =~ XAPI::COMMAND_RESPONSE
+ end
+
+ if index
+ message = data[0..index]
+ index += raw.byte_index_to_char_index(raw.byte_index(message).not_nil!).not_nil!
+ index = raw.char_index_to_byte_index(index + 1)
+ end
+
+ index || -1
+ end
+
+ raise "failed to register control system" unless register_control_system.get.state.success?
+ self[:ready] = @ready = true
+
+ push_config
+ sync_config
+ @@status_mappings.each do |key, path|
+ begin
+ bind_status(path, key.to_s)
+ rescue error
+ logger.warn(exception: error) { "failed to bind status #{path} (#{key})" }
+ end
+ end
+
+ driver = self
+ driver.connection_ready if driver.responds_to?(:connection_ready)
+ rescue error
+ @init_called = false
+ logger.warn(exception: error) { "error configuring xapi transport" }
+ end
+
+ protected def do_send(command, multiline_body = nil, **options)
+ do_send(command, multiline_body, **options) { true }
+ end
+
+ protected def do_send(command, multiline_body = nil, **options, &callback : ::PlaceOS::Driver::Task::ResponseCallback)
+ request_id = generate_request_uuid
+ request = "#{command} | resultId=\"#{request_id}\"\n"
+
+ logger.debug { "-> #{request}" }
+ request = "#{request}#{multiline_body}\n.\n" if multiline_body
+
+ task = send request, **options
+ task.xapi_request_id = request_id
+ task.xapi_callback = callback
+ task
+ end
+
+ def received(data, task)
+ @last_received = Time.utc.to_unix
+ payload = String.new(data)
+ logger.debug { "<- #{payload}" }
+
+ if transport.tokenizer.nil? && payload =~ XAPI::LOGIN_COMPLETE
+ queue.clear abort_current: true
+ sleep 500.milliseconds
+ transport.send "xPreferences OutputMode JSON\n"
+ logger.info { "initializing connection" }
+ spawn { init_connection }
+ return
+ end
+
+ response = XAPI.parse payload
+
+ return feedback.notify(response) if task.nil?
+
+ if task.xapi_request_id == response["ResultId"]?
+ command_result = task.xapi_callback.try &.call(response)
+
+ feedback.notify(response) if command_result.nil?
+ command_result == :abort ? task.abort : task.success(command_result)
+ else
+ feedback.notify(response)
+ end
+ rescue error : JSON::ParseException
+ payload = String.new(data).strip
+ case payload
+ when "OK"
+ task.try &.success payload
+ when "Command not recognized."
+ logger.error { "Command not recognized: `#{task.try &.request_payload}`" }
+ task.try &.abort payload
+ else
+ logger.debug { "Malformed device response: #{error}\n#{payload}" }
+ task.try &.abort "Malformed device response: #{error}"
+ end
+ end
+
+ # ------------------------------
+ # Event subscription
+
+ # Subscribe to feedback from the device.
+ def register_feedback(path : String, &update_handler : Proc(String, Enumerable::JSONComplex, Nil))
+ if !@ready
+ unless feedback.contains? path
+ @feedback_paths << path
+ @feedback_paths.uniq!
+ feedback.insert(path, &update_handler)
+ end
+ return true
+ end
+
+ logger.debug { "Subscribing to device feedback for #{path}" }
+
+ unless feedback.contains? path
+ @feedback_paths << path
+ @feedback_paths.uniq!
+ request = XAPI.xfeedback :register, path
+ # Always returns an empty response, nothing special to handle
+ result = do_send request, name: path
+ end
+
+ feedback.insert path, &update_handler
+
+ result.try(&.get) || true
+ end
+
+ def unregister_feedback(path : String)
+ return clear_feedback_subscriptions if path == "/"
+ logger.debug { "Unsubscribing feedback for #{path}" }
+ feedback.remove path
+ @feedback_paths.delete path
+ do_send XAPI.xfeedback(:deregister, path)
+ end
+
+ def clear_feedback_subscriptions(connected : Bool = true)
+ logger.debug { "Unsubscribing all feedback" }
+ @status_keys.clear
+ feedback.clear
+ @feedback_paths.clear
+ do_send XAPI.xfeedback(:deregister_all) if connected
+ end
+
+ # ------------------------------
+ # Module status
+
+ @status_keys = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key|
+ hash[key] = {} of String => Enumerable::JSONComplex
+ end
+
+ # Bind arbitary device feedback to a status variable.
+ def bind_feedback(path : String, status_key : String)
+ register_feedback path do |value_path, value|
+ if value_path == path
+ self[status_key] = value
+ else
+ key_path = value_path.sub(path, "")
+ hash = @status_keys[status_key]
+ hash[key_path] = value
+ self[status_key] = hash
+ end
+ end
+ end
+
+ # Bind device status to a module status variable.
+ def bind_status(path : String, status_key : String)
+ bind_path = "Status/#{path.tr " ", "/"}"
+ bind_feedback "/#{bind_path}", status_key
+ payload = xstatus(path)
+
+ # single value?
+ if payload.size == 1 && payload.has_key?(bind_path)
+ self[status_key] = payload[bind_path]
+ else
+ self[status_key] = @status_keys[status_key] = payload.transform_keys do |key|
+ key.sub(path, "")
+ end
+ end
+ payload
+ end
+
+ def push_config
+ if config = setting?(Config, :configuration)
+ xconfigurations config
+ end
+ end
+
+ def sync_config
+ bind_feedback "/Configuration", "configuration"
+ send "xConfiguration *\n", wait: false
+ end
+
+ # ------------------------------
+ # External feedback subscriptions
+
+ # Subscribe another module to async device events.
+ # Callback methods must be of arity 1 and public.
+ def on_event(path : String, mod_id : String, channel : String)
+ logger.debug { "Registering callback for #{path} to #{mod_id}/#{channel}" }
+ register_feedback path do |event_path, value|
+ event_json = {event_path => value}.to_json
+ logger.debug { "Publishing #{path} event to #{mod_id}/#{channel} with payload #{event_json}" }
+ publish("#{mod_id}/#{channel}", event_json)
+ end
+ end
+
+ # Clear external event subscribtions for a specific device path.
+ def clear_event(path : String)
+ logger.debug { "Clearing event subscription for #{path}" }
+ unregister_feedback path
+ end
+
+ # ------------------------------
+ # Connectivity management
+
+ protected def register_control_system
+ xcommand "Peripherals Connect",
+ hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id},
+ name: "PlaceOS",
+ type: :ControlSystem
+ end
+
+ protected def heartbeat(timeout : Int32)
+ # high priority as otherwise the VC will indicate we've disconnected
+ xcommand "Peripherals HeartBeat",
+ hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id},
+ timeout: timeout,
+ priority: 99
+ end
+end
+
+require "./collaboration_endpoint/xapi"
diff --git a/drivers/cisco/collaboration_endpoint/cameras.cr b/drivers/cisco/collaboration_endpoint/cameras.cr
new file mode 100644
index 00000000000..a8ae810b212
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint/cameras.cr
@@ -0,0 +1,193 @@
+require "placeos-driver/interface/camera"
+require "./xapi"
+
+module Cisco::CollaborationEndpoint::Cameras
+ include PlaceOS::Driver::Interface::Camera
+ include Cisco::CollaborationEndpoint::XAPI
+
+ alias Interface = PlaceOS::Driver::Interface
+
+ protected def save_presets
+ @ignore_update = true
+ define_setting(:camera_presets, @presets)
+ self[:camera_presets] = @presets.transform_values { |val| val.keys }
+ end
+
+ command({"Camera Preset Activate" => :camera_preset},
+ preset_id: 1..35)
+ command({"Camera Preset Store" => :camera_store_preset},
+ camera_id: 1..2,
+ preset_id: 1..35, # Optional - codec will auto-assign if omitted
+ name_: String,
+ take_snapshot_: Bool,
+ default_position_: Bool)
+ command({"Camera Preset Remove" => :camera_remove_preset},
+ preset_id: 1..35)
+
+ enum CameraAxis
+ All
+ Focus
+ PanTilt
+ Zoom
+ end
+
+ enum FocusDirection
+ Far
+ Near
+ Stop
+ end
+
+ command({"Camera PositionReset" => :camera_position_reset},
+ camera_id: 1..2,
+ axis_: CameraAxis)
+ command({"Camera Ramp" => :camera_move},
+ camera_id: 1..2,
+ pan_: Interface::Camera::PanDirection,
+ pan_speed_: 1..15,
+ tilt_: Interface::Camera::TiltDirection,
+ tilt_speed_: 1..15,
+ zoom_: Interface::Zoomable::ZoomDirection,
+ zoom_speed_: 1..15,
+ focus_: FocusDirection)
+
+ # Camera Interface
+ # ================
+
+ def stop(index : Int32 | String = 0, emergency : Bool = false)
+ cam = index.to_i
+ cam = 1 if cam.zero?
+
+ camera_move(
+ camera_id: cam,
+ pan: PanDirection::Stop,
+ tilt: TiltDirection::Stop,
+ zoom: ZoomDirection::Stop
+ )
+ end
+
+ def move(position : MoveablePosition, index : Int32 | String = 0)
+ cam = index.to_i
+ cam = 1 if cam.zero?
+
+ case position
+ in .open?, .close?
+ # iris not supported
+ in .down?, .up?
+ joystick(
+ pan_speed: 0.0,
+ tilt_speed: position.down? ? -50.0 : 50.0,
+ index: cam
+ )
+ in .left?, .right?
+ joystick(
+ pan_speed: position.left? ? -50.0 : 50.0,
+ tilt_speed: 0.0,
+ index: cam
+ )
+ in .in?, .out?
+ zoom(position.in? ? ZoomDirection::In : ZoomDirection::Out, cam)
+ end
+ end
+
+ def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0)
+ raise "direct zoom unsupported on this camera"
+ end
+
+ def zoom(direction : ZoomDirection, index : Int32 | String = 0)
+ cam = index.to_i
+ cam = 1 if cam.zero?
+
+ camera_move(
+ camera_id: cam,
+ zoom: direction,
+ zoom_speed: 6
+ )
+ end
+
+ def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0)
+ pan_speed = pan_speed.clamp(-100.0, 100.0)
+ tilt_speed = tilt_speed.clamp(-100.0, 100.0)
+
+ pan = if pan_speed.zero?
+ pan_speed = nil
+ PanDirection::Stop
+ else
+ pan_speed.negative? ? PanDirection::Left : PanDirection::Right
+ end
+
+ tilt = if tilt_speed.zero?
+ tilt_speed = nil
+ TiltDirection::Stop
+ else
+ tilt_speed.negative? ? TiltDirection::Down : TiltDirection::Up
+ end
+
+ cam = index.to_i
+ cam = 1 if cam.zero?
+
+ if pan_speed
+ percentage = pan_speed.abs / 100.0
+ pan_speed_actual = (percentage * 15.0).round.to_i
+ end
+
+ if tilt_speed
+ percentage = tilt_speed.abs / 100.0
+ tilt_speed_actual = (percentage * 15.0).round.to_i
+ end
+
+ camera_move(
+ camera_id: cam,
+ pan: pan,
+ pan_speed: pan_speed_actual,
+ tilt: tilt,
+ tilt_speed: tilt_speed_actual,
+ zoom: ZoomDirection::Stop
+ )
+ end
+
+ def recall(position : String, index : Int32 | String = 0)
+ cam = index.to_i
+ cam = 1 if cam.zero?
+
+ presets = @presets[cam]? || {} of String => Int32
+ preset = presets[position]?
+ raise "preset '#{position}' not found on camera #{index}" unless preset
+
+ camera_preset(preset_id: preset)
+ end
+
+ def save_position(name : String, index : Int32 | String = 0)
+ cam = index.to_i
+ cam = 1 if cam.zero?
+
+ presets = @presets[cam]? || {} of String => Int32
+ in_use = @presets.values.flat_map(&.values)
+ next_available = ((1..35).to_a - in_use).first
+ presets[name] = next_available
+
+ camera_store_preset(
+ camera_id: cam,
+ preset_id: next_available, # Optional - codec will auto-assign if omitted
+ name: name
+ ).get
+
+ @presets[cam] = presets
+ save_presets
+ true
+ end
+
+ def remove_position(name : String, index : Int32 | String = 0)
+ cam = index.to_i
+ cam = 1 if cam.zero?
+
+ presets = @presets[cam]? || {} of String => Int32
+ presets.delete(name)
+ if presets.empty?
+ @presets.delete(cam)
+ else
+ @presets[cam] = presets
+ end
+ save_presets
+ true
+ end
+end
diff --git a/drivers/cisco/collaboration_endpoint/feedback.cr b/drivers/cisco/collaboration_endpoint/feedback.cr
new file mode 100644
index 00000000000..0cf25f4333e
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint/feedback.cr
@@ -0,0 +1,49 @@
+class Cisco::CollaborationEndpoint::Feedback
+ def initialize
+ @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k|
+ h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil)
+ end
+ end
+
+ # Nuke a subtree below the path
+ def remove(path : String)
+ remove = [] of String
+ @callbacks.each_key { |key| remove << key if key.starts_with?(path) }
+ remove.each { |key| @callbacks.delete(key) }
+ self
+ end
+
+ # Insert a response handler block to be notified of updates effecting the
+ # specified feedback path.
+ def insert(path : String, &handler : Proc(String, Enumerable::JSONComplex, Nil))
+ @callbacks[path] << handler
+ self
+ end
+
+ def contains?(path : String)
+ found = false
+ @callbacks.each_key do |key|
+ if path.starts_with? key
+ found = true
+ break
+ end
+ end
+ found
+ end
+
+ def notify(path : String, value : Enumerable::JSONComplex)
+ @callbacks.each do |key, callbacks|
+ callbacks.each &.call(path, value) if path.starts_with? key
+ end
+ end
+
+ def notify(payload : Hash(String, Enumerable::JSONComplex))
+ payload.each { |key, value| notify("/#{key}", value) }
+ end
+
+ def clear
+ @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k|
+ h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil)
+ end
+ end
+end
diff --git a/drivers/cisco/collaboration_endpoint/powerable.cr b/drivers/cisco/collaboration_endpoint/powerable.cr
new file mode 100644
index 00000000000..18962590cad
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint/powerable.cr
@@ -0,0 +1,40 @@
+require "placeos-driver/interface/powerable"
+require "./xapi"
+
+module Cisco::CollaborationEndpoint::Powerable
+ include PlaceOS::Driver::Interface::Powerable
+ include Cisco::CollaborationEndpoint::XAPI
+
+ alias Interface = PlaceOS::Driver::Interface
+
+ # Powerable Interface:
+ # ====================
+
+ command({"Standby Deactivate" => :powerup})
+ command({"Standby HalfWake" => :half_wake})
+ command({"Standby Activate" => :standby})
+ command({"Standby ResetTimer" => :reset_standby_timer}, delay: 1..480)
+
+ def power(state : Bool)
+ state ? powerup : half_wake
+ self[:power] = state
+ end
+
+ def power_state(state : Interface::Powerable::PowerState)
+ case state
+ in .on?
+ power true
+ in .off?
+ power false
+ in .full_off?
+ standby
+ self[:power] = false
+ end
+ self[:power_state] = state
+ end
+
+ enum PowerOff
+ Restart
+ Shutdown
+ end
+end
diff --git a/drivers/cisco/collaboration_endpoint/presentation.cr b/drivers/cisco/collaboration_endpoint/presentation.cr
new file mode 100644
index 00000000000..15e6ea535a7
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint/presentation.cr
@@ -0,0 +1,62 @@
+require "placeos-driver/interface/switchable"
+require "./xapi"
+
+module Cisco::CollaborationEndpoint::Presentation
+ enum PresentationInputs
+ None
+ Input1
+ Input2
+ Input3
+ Input4
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(PresentationInputs)
+ include Cisco::CollaborationEndpoint::XAPI
+
+ enum SendingMode
+ LocalRemote
+ LocalOnly
+ end
+
+ @sending_mode : SendingMode = SendingMode::LocalRemote
+ @presenting_input : Int32? = nil
+
+ command({"Presentation Start" => :presentation_start},
+ presentation_source_: 1..2,
+ sending_mode_: SendingMode,
+ connector_id_: 1..2,
+ instance_: 1..6) # TODO:: support "New"
+ command({"Presentation Stop" => :presentation_stop},
+ instance_: 1..6,
+ presentation_source_: 1..4)
+
+ # Provide compatabilty with the router module for activating presentation.
+ def switch_to(input : PresentationInputs)
+ if input.none?
+ @presenting_input = nil
+ presentation_stop
+ else
+ source = input.to_s[5..-1].to_i
+ @presenting_input = source
+
+ presentation_start(
+ presentation_source: source,
+ sending_mode: @sending_mode
+ )
+ end
+
+ self[:presenting_input] = @presenting_input
+ end
+
+ def send_presentation_to(remote : Bool)
+ @sending_mode = remote ? SendingMode::LocalRemote : SendingMode::LocalOnly
+ self[:present_to_remote] = remote
+
+ if input = @presenting_input
+ presentation_start(
+ presentation_source: input,
+ sending_mode: @sending_mode
+ )
+ end
+ end
+end
diff --git a/drivers/cisco/collaboration_endpoint/response.cr b/drivers/cisco/collaboration_endpoint/response.cr
new file mode 100644
index 00000000000..46b73e4dcb9
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint/response.cr
@@ -0,0 +1,81 @@
+require "json"
+
+module Cisco::CollaborationEndpoint::XAPI
+ TRUTHY = {"true", "available", "standby", "on", "active"}
+ FALSEY = {"false", "unavailable", "off", "inactive"}
+ BOOLEAN = ->(val : String) { TRUTHY.includes?(val.downcase) }
+ BOOL_OR = ->(term : String) { ->(val : String) { val == term ? term : BOOLEAN.call(val) } }
+ PARSERS = {
+ "TTPAR_OnOff" => BOOLEAN,
+ "TTPAR_OnOffAuto" => BOOL_OR.call("Auto"),
+ "TTPAR_OnOffCurrent" => BOOL_OR.call("Current"),
+ "TTPAR_MuteEnabled" => BOOLEAN,
+ }
+
+ def self.value_convert(value : String, valuespace : String? = nil)
+ parser = PARSERS[valuespace]?
+ return value.to_i64 unless parser
+ parser.call(value)
+ rescue
+ check = value.downcase
+ # probably wasn't an integer
+ if check.in? TRUTHY
+ true
+ elsif check.in? FALSEY
+ false
+ else
+ value
+ end
+ end
+
+ def self.parse(data : String)
+ JSON.parse(data).as_h.flatten_xapi_json
+ end
+end
+
+module Enumerable
+ alias JSONBasic = Bool | Float64 | Int64 | String | Nil
+ alias JSONComplex = JSONBasic | Hash(String, JSONComplex)
+
+ def flatten_xapi_json(parent_prefix : String? = nil, delimiter : String = "/")
+ res = {} of String => JSONComplex
+
+ self.each_with_index do |elem, i|
+ if elem.is_a?(Tuple)
+ k, v = elem
+ else
+ # this is an Array
+ k, v = i, elem
+
+ # check if there is an ID element in the child
+ if id = v.as_h?.try &.delete("id")
+ k = id
+ end
+ end
+
+ # assign key name for result hash
+ key = parent_prefix ? "#{parent_prefix}#{delimiter}#{k}" : k.to_s
+ raw = v.raw
+
+ case raw
+ in Array(JSON::Any)
+ # recursive call to flatten child elements
+ res.merge!(raw.flatten_xapi_json(key, delimiter))
+ in Hash(String, JSON::Any)
+ value = raw["Value"]?
+ if value && value.as_h?.nil?
+ valuespaceref = raw["valueSpaceRef"]?.try &.as_s.split('/').last
+ res[key] = Cisco::CollaborationEndpoint::XAPI.value_convert(value.as_s, valuespaceref)
+ elsif id
+ res[key] = raw.flatten_xapi_json(delimiter: delimiter)
+ else
+ res.merge!(raw.flatten_xapi_json(key, delimiter))
+ end
+ in JSONBasic
+ res[key] = raw
+ end
+ end
+
+ res
+ end
+end
diff --git a/drivers/cisco/collaboration_endpoint/ui_extensions.cr b/drivers/cisco/collaboration_endpoint/ui_extensions.cr
new file mode 100644
index 00000000000..972923e91d8
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint/ui_extensions.cr
@@ -0,0 +1,76 @@
+require "./xapi"
+
+module Cisco::CollaborationEndpoint::UIExtensions
+ include Cisco::CollaborationEndpoint::XAPI
+
+ command({"UserInterface Message Alert Clear" => :msg_alert_clear})
+ command({"UserInterface Message Alert Display" => :msg_alert},
+ text: String,
+ title_: String,
+ duration_: 0..3600)
+
+ command({"UserInterface Message Prompt Clear" => :msg_prompt_clear})
+
+ def msg_prompt(text : String, options : Array(JSON::Any::Type), title : String? = nil, feedback_id : String? = nil, duration : Int64? = nil)
+ # TODO: return a promise, then prepend a async traffic monitor so it
+ # can be resolved with the response, or rejected after the timeout.
+ option_map = {} of String => JSON::Any::Type
+ ("Option.1".."Option.5").each_with_index do |key, i|
+ break if i >= options.size
+ option_map[key] = options[i]
+ end
+
+ xcommand "UserInterface Message Prompt Display",
+ hash_args: Hash(String, JSON::Any::Type){
+ "text" => text,
+ "title" => title,
+ "feedback_id" => feedback_id,
+ "duration" => duration,
+ }.merge(option_map)
+ end
+
+ enum TextInputType
+ SingleLine
+ Numeric
+ Password
+ PIN
+ end
+
+ enum TextKeyboardState
+ Open
+ Closed
+ end
+
+ command({"UserInterface Message TextInput Clear" => :msg_text_clear})
+ command({"UserInterface Message TextInput Display" => :msg_text},
+ text: String,
+ feedback_id: String,
+ title_: String,
+ duration_: 0..3600,
+ input_type_: TextInputType,
+ keyboard_state_: TextKeyboardState,
+ place_holder_: String,
+ submit_text_: String)
+
+ def ui_set_value(widget : String, value : JSON::Any::Type? = nil)
+ if value.nil?
+ xcommand "UserInterface Extensions Widget UnsetValue",
+ widget_id: widget
+ else
+ xcommand "UserInterface Extensions Widget SetValue",
+ value: value, widget_id: widget
+ end
+ end
+
+ def ui_extensions_deploy(id : String, xml_def : String)
+ xcommand "UserInterface Extensions Set", xml_def, config_id: id
+ end
+
+ def ui_extensions_list
+ xcommand "UserInterface Extensions List"
+ end
+
+ def ui_extensions_clear
+ xcommand "UserInterface Extensions Clear"
+ end
+end
diff --git a/drivers/cisco/collaboration_endpoint/xapi.cr b/drivers/cisco/collaboration_endpoint/xapi.cr
new file mode 100644
index 00000000000..5189c9164ef
--- /dev/null
+++ b/drivers/cisco/collaboration_endpoint/xapi.cr
@@ -0,0 +1,150 @@
+require "json"
+require "./response"
+require "./feedback"
+
+# monkey patching task is how we attach custom data
+# request_payload is set by send if it's defined
+class ::PlaceOS::Driver::Task
+ getter request_payload : String? = nil
+
+ def request_payload=(payload : String)
+ @request_payload = payload.split("\n")[0]
+ end
+
+ alias ResponseCallback = Proc(Hash(String, Enumerable::JSONComplex), Hash(String, Enumerable::JSONComplex) | Enumerable::JSONComplex | Symbol)
+ property xapi_request_id : String? = nil
+ property xapi_callback : ResponseCallback? = nil
+end
+
+module Cisco::CollaborationEndpoint::XAPI
+ # Regexp's for tokenizing the xAPI command and response structure.
+ INVALID_COMMAND = /(?<=Command not recognized\.)[\r\n]+/
+
+ SUCCESS = /(?<=OK)[\r\n]+/
+
+ COMMAND_RESPONSE = Regex.union(INVALID_COMMAND, SUCCESS)
+
+ LOGIN_COMPLETE = /Login successful/
+
+ enum ActionType
+ XConfiguration
+ XCommand
+ XStatus
+ XFeedback
+ XPreferences
+ end
+
+ enum FeedbackAction
+ Register
+ Deregister
+ DeregisterAll
+ List
+ end
+
+ # Serialize an xAPI action into transmittable command.
+ def self.create_action(
+ __action__ : ActionType,
+ *args,
+ hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type,
+ priority : Int32? = nil, # we want to ignore this param, hence we specified it here
+ **kwargs
+ )
+ [
+ __action__.to_s.camelcase(lower: true),
+ args.compact_map(&.to_s),
+ hash_args.map { |key, value|
+ if value
+ value = "\"#{value}\"" if value.is_a? String
+ "#{key.to_s.camelcase}: #{value}"
+ end
+ },
+ kwargs.map { |key, value|
+ if value
+ value = "\"#{value}\"" if value.is_a? String
+ "#{key.to_s.camelcase}: #{value}"
+ end
+ }.to_a.compact!,
+ ].flatten.join " "
+ end
+
+ # Serialize an xCommand into transmittable command.
+ def self.xcommand(
+ path : String,
+ hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type,
+ **kwargs
+ )
+ create_action ActionType::XCommand, path, **kwargs.merge({hash_args: hash_args})
+ end
+
+ # Serialize an xConfiguration action into a transmittable command.
+ def self.xconfiguration(path : String, setting : String, value : JSON::Any::Type)
+ create_action ActionType::XConfiguration, path, hash_args: {
+ setting => value,
+ }
+ end
+
+ # Serialize an xStatus request into transmittable command.
+ def self.xstatus(path : String)
+ create_action ActionType::XStatus, path
+ end
+
+ # Serialize a xFeedback subscription request.
+ def self.xfeedback(action : FeedbackAction, path : String? = nil)
+ if path
+ xpath = tokenize path
+ create_action ActionType::XFeedback, action, "/#{xpath.join('/')}"
+ else
+ create_action ActionType::XFeedback, action
+ end
+ end
+
+ def self.tokenize(path : String)
+ # Allow space or slash seperated paths
+ path.split(/[\s\/\\]/).reject(&.empty?)
+ end
+
+ macro command(cmd_name, **params)
+ {% for cmd, name in cmd_name %}
+ def {{name.id}}(
+ {% for param, klass in params %}
+ {% optional = false %}
+ {% if param.stringify.ends_with?("_") %}
+ {% optional = true %}
+ {% param = param.stringify[0..-2] %}
+ {% end %}
+
+ {% if klass.is_a?(RangeLiteral) %}
+ {{param.id}} : Int32{% if optional %}? = nil{% end %},
+ {% else %}
+ {{param.id}} : {{klass}}{% if optional %}? = nil{% end %},
+ {% end %}
+ {% end %}
+ )
+ {% for param, klass in params %}
+ {% if klass.is_a?(RangeLiteral) %}
+ {% optional = false %}
+ {% if param.stringify.ends_with?("_") %}
+ {% optional = true %}
+ {% param = param.stringify[0..-2] %}
+ {% end %}
+ {% if optional %} if {{param.id}}{% end %}
+ raise ArgumentError.new("#{ {{param.stringify}} } must be within #{ {{klass}} }, was #{ {{param.id}} }") unless ({{klass}}).includes?({{param.id}})
+ {% if optional %}end{% end %}
+ {% end %}
+ {% end %}
+
+ # send the command
+ xcommand(
+ {{cmd}},
+ {% for param, klass in params %}
+ {% if param.stringify.ends_with?("_") %}
+ {% param = param.stringify[0..-2] %}
+ {% end %}
+
+ {{param.id}}: {{param.id}},
+ {% end %}
+ )
+ end
+ {% end %}
+ end
+end
diff --git a/drivers/cisco/dna_spaces.cr b/drivers/cisco/dna_spaces.cr
new file mode 100644
index 00000000000..482a59756c4
--- /dev/null
+++ b/drivers/cisco/dna_spaces.cr
@@ -0,0 +1,677 @@
+require "placeos-driver"
+require "set"
+require "jwt"
+require "s2_cells"
+require "simple_retry"
+require "placeos-driver/interface/sensor"
+require "placeos-driver/interface/locatable"
+
+class Cisco::DNASpaces < PlaceOS::Driver
+ include Interface::Locatable
+ include Interface::Sensor
+
+ # Discovery Information
+ descriptive_name "Cisco Spaces"
+ generic_name :Cisco_Spaces
+ uri_base "https://partners.dnaspaces.io"
+
+ default_settings({
+ _dna_spaces_activation_key: "provide this and the API / tenant ids will be generated automatically",
+ _dna_spaces_api_key: "X-API-KEY",
+ _tenant_id: "sfdsfsdgg",
+ verify_activation_key: false,
+
+ # Time before a user location is considered probably too old (in minutes)
+ # we have a large time here as DNA spaces only updates when a user moves
+ # device exit is used to signal when a device has left the building
+ max_location_age: 300,
+
+ floorplan_mappings: {
+ location_a4cb0: {
+ "level_name" => "optional name",
+ "building" => "zone-GAsXV0nc",
+ "level" => "zone-GAsmleH",
+ "offset_x" => 12.4,
+ "offset_y" => 5.2,
+ "map_width" => 50.3,
+ "map_height" => 100.9,
+ },
+ },
+
+ debug_stream: false,
+ })
+
+ @streaming = false
+ @last_received = 0_i64
+ @stream_active = false
+
+ def on_unload
+ @channel.close
+ @stream_active = false
+ update_monitoring_status(running: false)
+ end
+
+ @activation_token : String = ""
+ @verify_activation_key : Bool = false
+ @api_key : String = ""
+ @tenant_id : String = ""
+ @channel : Channel(String) = Channel(String).new
+ @max_location_age : Time::Span = 300.minutes
+ @s2_level : Int32 = 21
+ @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new
+ @debug_stream : Bool = false
+ @events_received : UInt64 = 0_u64
+
+ def on_update
+ @max_location_age = (setting?(UInt32, :max_location_age) || 10).minutes
+ @s2_level = setting?(Int32, :s2_level) || 21
+ @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings
+ @debug_stream = setting?(Bool, :debug_stream) || false
+ @verify_activation_key = setting?(Bool, :verify_activation_key) || false
+
+ schedule.clear
+ schedule.every(30.minutes) { cleanup_caches }
+ schedule.every(5.minutes) { update_monitoring_status }
+ schedule.in(5.seconds) { update_monitoring_status }
+
+ @activation_token = setting?(String, :dna_spaces_activation_key) || ""
+ if @activation_token.empty?
+ @api_key = setting(String, :dna_spaces_api_key)
+ @tenant_id = setting(String | Int64, :tenant_id).to_s
+ else
+ @api_key = setting?(String, :dna_spaces_api_key) || ""
+ @tenant_id = setting?(String | Int64, :tenant_id).try(&.to_s) || ""
+
+ # Activate the API key using the activation_token
+ schedule.in(5.seconds) { activate } if @api_key.empty?
+ end
+
+ @description_lock.synchronize do
+ if !@streaming && !@api_key.empty?
+ @streaming = true
+ spawn { start_streaming_events }
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def activate
+ return if @activation_token.empty?
+
+ response = get("/client/v1/partner/partnerPublicKey/")
+ raise "failed to obtain partner public key, code #{response.status_code}" unless response.success?
+
+ logger.debug { "public key requested: #{response.body}" }
+
+ payload = NamedTuple(
+ status: Bool,
+ message: String,
+ data: Array(ActivactionPublicKey)).from_json(response.body.not_nil!)
+
+ raise "unexpected failure obtaining partner public key: #{payload[:message]}" unless payload[:status]
+
+ public_key = payload[:data][0].public_key
+ payload, header = JWT.decode(@activation_token, public_key, JWT::Algorithm::RS256, @verify_activation_key)
+ app_id = payload["appId"].as_s
+ ref_id = payload["activationRefId"].as_s
+ tenant_id = payload["tenantId"].as_i64.to_s
+
+ response = post("/client/v1/partner/activateOnPremiseApp", headers: {
+ "Content-Type" => "application/json",
+ "Authorization" => "Bearer #{@activation_token}",
+ }, body: {
+ appId: app_id,
+ activationRefId: ref_id,
+ }.to_json)
+ raise "failed to obtain API key, code #{response.status_code}\n#{response.body}" unless response.success?
+
+ logger.debug { "application activated: #{response.body}" }
+
+ payload = NamedTuple(
+ status: Bool,
+ message: String,
+ data: NamedTuple(apiKey: String)).from_json(response.body.not_nil!)
+
+ raise "unexpected failure obtaining API key: #{payload[:message]}" unless payload[:status]
+
+ api_key = payload[:data][:apiKey]
+ logger.debug { "saving API key: #{tenant_id}, #{api_key}" }
+
+ define_setting(:tenant_id, tenant_id)
+ define_setting(:dna_spaces_api_key, api_key)
+ define_setting(:dna_spaces_activation_key, "")
+
+ logger.debug { "settings saved! Starting stream" }
+ @api_key = api_key
+ @tenant_id = tenant_id
+
+ @description_lock.synchronize do
+ if !@streaming
+ @streaming = true
+ spawn { start_streaming_events }
+ end
+ end
+ end
+
+ class LocationInfo
+ include JSON::Serializable
+
+ getter location : Location
+
+ @[JSON::Field(key: "locationDetails")]
+ getter details : LocationDetails
+ end
+
+ def get_location_info(location_id : String)
+ response = get("/api/partners/v1/locations/#{location_id}?partnerTenantId=#{@tenant_id}", headers: {
+ "X-API-KEY" => @api_key,
+ })
+
+ raise "failed to obtain location id #{location_id}, code #{response.status_code}" unless response.success?
+ LocationInfo.from_json(response.body.not_nil!)
+ end
+
+ @description_lock : Mutex = Mutex.new
+ @location_descriptions : Hash(String, String) = {} of String => String
+
+ def seen_locations
+ @description_lock.synchronize { @location_descriptions.dup }
+ end
+
+ # MAC Address => Location (including user)
+ @locations : Hash(String, DeviceLocationUpdate | IotTelemetry | WebexTelemetryUpdate) = {} of String => DeviceLocationUpdate | IotTelemetry | WebexTelemetryUpdate
+ @loc_lock : Mutex = Mutex.new
+ @devices : Hash(String, IotTelemetry | WebexTelemetryUpdate) = {} of String => IotTelemetry | WebexTelemetryUpdate
+ @dev_lock : Mutex = Mutex.new
+
+ def locations
+ @loc_lock.synchronize { yield @locations }
+ end
+
+ def devices
+ @dev_lock.synchronize { yield @devices }
+ end
+
+ @user_lookup : Hash(String, Set(String)) = {} of String => Set(String)
+ @user_loc : Mutex = Mutex.new
+
+ def user_lookup
+ @user_loc.synchronize { yield @user_lookup }
+ end
+
+ def user_lookup(user_id : String)
+ formatted_user = format_username(user_id)
+ user_lookup { |lookup| lookup[formatted_user]? }
+ end
+
+ def locate_mac(address : String)
+ formatted_address = format_mac(address)
+ locations { |locs| locs[formatted_address]? }
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def inspect_state
+ logger.debug {
+ "MAC Locations: #{locations &.keys}"
+ }
+ {tracking: locations &.size, events_received: @events_received}
+ end
+
+ @map_details : Hash(String, Dimension) = {} of String => Dimension
+ @map_lock : Mutex = Mutex.new
+
+ def get_map_details(map_id : String)
+ map = @map_lock.synchronize { @map_details[map_id]? }
+ if !map
+ response = get("/api/partners/v1/maps/#{map_id}?partnerTenantId=#{@tenant_id}", headers: {
+ "X-API-KEY" => @api_key,
+ })
+ if !response.success?
+ message = "failed to obtain map id #{map_id}, code #{response.status_code}"
+ logger.warn { message }
+ return nil
+ end
+ map = MapInfo.from_json(response.body.not_nil!).dimension
+ @map_lock.synchronize { @map_details[map_id] = map }
+ end
+ map
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def cleanup_caches : Nil
+ logger.debug { "removing location data that is over 30 minutes old" }
+
+ old = 30.minutes.ago.to_unix
+ remove_keys = [] of String
+ locations do |locs|
+ locs.each { |mac, location| remove_keys << mac if location.last_seen < old }
+ remove_keys.each { |mac| locs.delete(mac) }
+ end
+
+ logger.debug { "removed #{remove_keys.size} MACs" }
+ nil
+ end
+
+ # we want to stream events until driver is terminated
+ protected def start_streaming_events
+ @streaming = true
+ SimpleRetry.try_to(
+ base_interval: 2.seconds,
+ max_interval: 10.seconds
+ ) do
+ logger.info { "connecting to event stream" }
+ stream_events unless terminated?
+ end
+ ensure
+ @streaming = false
+ end
+
+ # as sometimes the map id is missing, but in the same location
+ # location id => map id
+ @location_id_maps = {} of String => String
+
+ # Processes events as they come in, forces a disconnect if no events are sent
+ # for a period of time as the remote should be sending them periodically
+ protected def process_events(client)
+ loop do
+ select
+ when data = @channel.receive
+ logger.debug { "received push #{data}" } if @debug_stream
+ @events_received = @events_received &+ 1_u64
+ begin
+ event = Cisco::DNASpaces::Events.from_json(data)
+ payload = event.payload
+ case payload
+ when DeviceExit
+ device_mac = format_mac(payload.device.mac_address)
+ locations &.delete(device_mac)
+ when DeviceEntry
+ # This is used entirely for
+ @description_lock.synchronize { payload.location.descriptions(@location_descriptions) }
+ when DeviceLocationUpdate, IotTelemetry, WebexTelemetryUpdate
+ device_mac = format_mac(payload.device.mac_address)
+
+ # we want timestamps in seconds
+ payload.last_seen = payload.last_seen // 1000
+
+ case payload
+ when IotTelemetry
+ self[device_mac] = payload
+ devices { |dev| dev[device_mac] = payload }
+
+ next unless payload.has_position?
+ when WebexTelemetryUpdate
+ if webex_obj = devices { |dev| dev[device_mac]? }
+ webex_obj = webex_obj.as(WebexTelemetryUpdate)
+ webex_obj.device = payload.device
+ webex_obj.location = payload.location
+ webex_obj.last_seen = payload.last_seen
+ webex_obj.telemetries = payload.telemetries
+ payload = webex_obj
+ else
+ @description_lock.synchronize { payload.location.descriptions(@location_descriptions) }
+ devices { |dev| dev[device_mac] = payload }
+ end
+ payload.update_telemetry
+ self[device_mac] = payload
+ end
+
+ # Keep track of device location
+ existing = nil
+
+ # ignore locations where we don't have enough details to put the device on a map
+ if payload.map_id.presence
+ @location_id_maps[payload.location.location_id] = payload.map_id
+ else
+ locations = payload.location_mappings.values
+ level_id = locations.find { |loc_id| @floorplan_mappings[loc_id]? }
+
+ if level_id && (level_data = @floorplan_mappings[level_id]) && level_data["map_width"]? && level_data["map_height"]?
+ # we don't need the map ID as the x, y coordinates are defined by us
+ # we do need the map_id for grouping results, so we assign it the level id
+ payload.map_id = level_id
+ else
+ found = false
+ payload.location_mappings.values.each do |loc_id|
+ if map_id = @location_id_maps[loc_id]?
+ payload.map_id = map_id
+ found = true
+ break
+ end
+ end
+
+ if !found
+ logger.debug { "ignoring device #{device_mac} location as map_id is empty, location id #{payload.location.location_id}, visit #{payload.visit_id}" }
+ next
+ end
+ end
+ end
+
+ locations do |loc|
+ existing = loc[device_mac]?
+ loc[device_mac] = payload
+ end
+
+ # Maintain user lookup
+ if payload.raw_user_id.presence
+ user_id = format_username(payload.raw_user_id)
+
+ if existing && payload.raw_user_id != existing.raw_user_id
+ old_user_id = format_username(existing.raw_user_id)
+
+ user_lookup do |lookup|
+ lookup[old_user_id]?.try &.delete(device_mac)
+ devices = lookup[old_user_id]? || Set(String).new
+ devices.delete(device_mac)
+ lookup.delete(old_user_id) if devices.empty?
+
+ devices = lookup[user_id]? || Set(String).new
+ devices << device_mac
+ lookup[user_id] = devices
+ end
+ else
+ user_lookup do |lookup|
+ devices = lookup[user_id]? || Set(String).new
+ devices << device_mac
+ lookup[user_id] = devices
+ end
+ end
+ end
+
+ # payload.location_mappings => { "ZONE" => loc_id, "FLOOR" => loc_id, "BUILDING" => loc_id, "CAMPUS" => loc_id }
+ else
+ logger.debug { "ignoring event: #{payload ? payload.class : event.class}" }
+ end
+ rescue error
+ logger.error(exception: error) { "parsing DNA Spaces event: #{data}" }
+ end
+ when timeout(20.seconds)
+ logger.debug { "no events received for 20 seconds, expected heartbeat at 15 seconds" }
+ @channel.close
+ break
+ end
+ end
+ ensure
+ client.close
+ end
+
+ protected def stream_events
+ client = HTTP::Client.new URI.parse(config.uri.not_nil!)
+ client.get("/api/partners/v1/firehose/events", HTTP::Headers{
+ "X-API-KEY" => @api_key,
+ }) do |response|
+ if !response.success?
+ @stream_active = false
+ logger.warn { "failed to connect to firehose api #{response.status_code}" }
+ raise "failed to connect to firehose api #{response.status_code}"
+ end
+
+ @stream_active = true
+
+ # We use a channel for event processing so we can make use of timeouts
+ @channel = Channel(String).new
+ spawn { process_events(client) }
+
+ begin
+ loop do
+ if response.body_io.closed?
+ @channel.close
+ break
+ end
+
+ if data = response.body_io.gets
+ @last_received = Time.utc.to_unix_ms
+ @channel.send data
+ else
+ @channel.close
+ break
+ end
+ end
+ rescue IO::Error
+ @channel.close
+ end
+ end
+
+ # Trigger the retry behaviour
+ @stream_active = false
+ raise "stream closed"
+ end
+
+ # =============================
+ # Locatable interface
+ # =============================
+ def locate_user(email : String? = nil, username : String? = nil)
+ if macs = user_lookup(username.presence || email.presence.not_nil!)
+ location_max_age = @max_location_age.ago.to_unix
+
+ macs.compact_map { |mac|
+ if location = locate_mac(mac)
+ next if location.is_a?(WebexTelemetryUpdate)
+ if location.last_seen > location_max_age
+ # we update the mac_address to a formatted version
+ location.device.mac_address = mac
+ location
+ end
+ end
+ }.sort! { |a, b|
+ b.last_seen <=> a.last_seen
+ }.map { |location|
+ lat = location.latitude
+ lon = location.longitude
+
+ loc = {
+ "location" => "wireless",
+ "coordinates_from" => "top-left",
+ "x" => location.x_pos,
+ "y" => location.y_pos,
+ "lon" => lon,
+ "lat" => lat,
+ "s2_cell_id" => S2Cells.at(lat, lon).parent(@s2_level).to_token,
+ "mac" => location.device.mac_address,
+ "variance" => location.unc,
+ "last_seen" => location.last_seen,
+ "dna_floor_id" => location.map_id,
+ "ssid" => location.ssid,
+ "manufacturer" => location.device.manufacturer,
+ "os" => location.device.os,
+ }
+
+ map_width = 0.0
+ map_height = 0.0
+ offset_x = 0.0
+ offset_y = 0.0
+
+ # Add our zone IDs to the response
+ location.location_mappings.each_value do |location_id|
+ if level_data = @floorplan_mappings[location_id]?
+ level_data.each do |key, value|
+ case key
+ when "offset_x"
+ offset_x = value.as(Float64)
+ loc["x"] = location.x_pos - offset_x
+ when "offset_y"
+ offset_y = value.as(Float64)
+ loc["y"] = location.y_pos - offset_y
+ when "map_width"
+ map_width = value.as(Float64)
+ when "map_height"
+ map_height = value.as(Float64)
+ else
+ loc[key] = value
+ end
+ end
+ break
+ end
+ end
+
+ # Add map information to the response
+ if map_width > 0.0 && map_height > 0.0
+ loc["map_width"] = map_width
+ loc["map_height"] = map_height
+ elsif map_size = get_map_details(location.map_id)
+ loc["map_width"] = map_width > 0.0 ? map_width : (map_size.length - offset_x)
+ loc["map_height"] = map_height > 0.0 ? map_height : (map_size.width - offset_y)
+ end
+
+ loc
+ }
+ else
+ [] of Nil
+ end
+ end
+
+ # Will return an array of MAC address strings
+ # lowercase with no seperation characters abcdeffd1234 etc
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ user_lookup(username.presence || email.presence.not_nil!).try(&.to_a) || [] of String
+ end
+
+ # Will return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}`
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ if location = locate_mac(mac_address)
+ {
+ location: "wireless",
+ assigned_to: format_username(location.raw_user_id),
+ mac_address: format_mac(mac_address),
+ }
+ end
+ end
+
+ # Will return an array of devices and their x, y coordinates
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "looking up device locations in #{zone_id}" }
+ return [] of Nil if location.presence && location != "wireless"
+
+ # Find the floors associated with the provided zone id
+ floors = [] of String
+ adjustments = {} of String => Tuple(Float64, Float64, Float64, Float64)
+ @floorplan_mappings.each do |floor_id, data|
+ if data.values.includes?(zone_id)
+ floors << floor_id
+ offset_x = (data["offset_x"]? || 0.0).as(Float64)
+ offset_y = (data["offset_y"]? || 0.0).as(Float64)
+ map_width = (data["map_width"]? || -1.0).as(Float64)
+ map_height = (data["map_height"]? || -1.0).as(Float64)
+ adjustments[floor_id] = {offset_x, offset_y, map_width, map_height}
+ end
+ end
+ logger.debug { "found matching meraki floors: #{floors}" }
+ return [] of Nil if floors.empty?
+
+ checking_count = @locations.size
+ wrong_floor = 0
+ too_old = 0
+
+ # Find the devices that are on the matching floors
+ oldest_location = @max_location_age.ago.to_unix
+
+ matching = locations(&.compact_map { |mac, loc|
+ if loc.last_seen < oldest_location
+ too_old += 1
+ next
+ end
+ if (floors & loc.location_mappings.values).empty?
+ wrong_floor += 1
+ next
+ end
+
+ # ensure the formatted mac is being used
+ loc.device.mac_address = mac
+ loc
+ })
+
+ logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" }
+
+ matching.group_by(&.map_id).flat_map { |map_id, locations|
+ map_width = -1.0
+ map_height = -1.0
+ offset_x = 0.0
+ offset_y = 0.0
+
+ # any adjustments required for these locations?
+ locations.first.location_mappings.each_value do |location_id|
+ if level_data = adjustments[location_id]?
+ offset_x, offset_y, map_width, map_height = level_data
+ break
+ end
+ end
+
+ if map_width == -1.0 || map_height == -1.0
+ if map_size = get_map_details(map_id)
+ map_width = map_width > -1.0 ? map_width : (map_size.length - offset_x)
+ map_height = map_height > -1.0 ? map_height : (map_size.width - offset_y)
+ end
+ end
+
+ locations.compact_map do |loc|
+ next if loc.is_a?(WebexTelemetryUpdate)
+ lat = loc.latitude
+ lon = loc.longitude
+
+ {
+ location: :wireless,
+ coordinates_from: "top-left",
+ x: loc.x_pos - offset_x,
+ y: loc.y_pos - offset_y,
+ lon: lon,
+ lat: lat,
+ s2_cell_id: S2Cells.at(lat, lon).parent(@s2_level).to_token,
+ mac: loc.device.mac_address,
+ variance: loc.unc,
+ last_seen: loc.last_seen,
+ map_width: map_width,
+ map_height: map_height,
+ ssid: loc.ssid,
+ manufacturer: loc.device.manufacturer,
+ os: loc.device.os,
+ }
+ end
+ }
+ end
+
+ def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+
+ def format_username(user : String)
+ if user.includes? "@"
+ user = user.split("@")[0]
+ elsif user.includes? "\\"
+ user = user.split("\\")[1]
+ end
+ user.downcase
+ end
+
+ # This provides the DNA Spaces dashboard with stream consumption status
+ @[Security(PlaceOS::Driver::Level::Administrator)]
+ def update_monitoring_status(running : Bool = true) : Nil
+ response = put("/api/partners/v1/monitoring/status", headers: {
+ "Content-Type" => "application/json",
+ "X-API-KEY" => @api_key,
+ }, body: {
+ data: {
+ overallStatus: {
+ status: running ? "up" : "down",
+ notices: [] of Nil,
+ },
+ instanceDetails: {
+ ipAddress: "",
+ instanceId: module_id,
+ },
+ cloudFirehose: {
+ status: @stream_active ? "connected" : "disconnected",
+ lastReceived: @last_received,
+ },
+ localFirehose: {
+ status: "disconnected",
+ lastReceived: 0,
+ },
+ subsystems: [] of Nil,
+ },
+ }.to_json)
+ raise "failed to update status, code #{response.status_code}\n#{response.body}" unless response.success?
+ end
+end
+
+require "./dna_spaces/events"
+require "./dna_spaces/sensor_interface"
diff --git a/drivers/cisco/dna_spaces/activation_publickey.cr b/drivers/cisco/dna_spaces/activation_publickey.cr
new file mode 100644
index 00000000000..eb30ec06dd3
--- /dev/null
+++ b/drivers/cisco/dna_spaces/activation_publickey.cr
@@ -0,0 +1,14 @@
+require "./events"
+
+class Cisco::DNASpaces::ActivactionPublicKey
+ include JSON::Serializable
+
+ getter version : String
+
+ @[JSON::Field(key: "publicKey")]
+ getter public_key : String
+
+ def public_key
+ "-----BEGIN PUBLIC KEY-----\n#{@public_key}\n-----END PUBLIC KEY-----\n"
+ end
+end
diff --git a/drivers/cisco/dna_spaces/app_activaction.cr b/drivers/cisco/dna_spaces/app_activaction.cr
new file mode 100644
index 00000000000..36202b21424
--- /dev/null
+++ b/drivers/cisco/dna_spaces/app_activaction.cr
@@ -0,0 +1,21 @@
+require "./events"
+
+class Cisco::DNASpaces::AppActivaction
+ include JSON::Serializable
+
+ @[JSON::Field(key: "spacesTenantName")]
+ getter spaces_tenant_name : String
+
+ @[JSON::Field(key: "spacesTenantId")]
+ getter spaces_tenant_id : String
+
+ @[JSON::Field(key: "partnerTenantId")]
+ getter partner_tenant_id : String
+ getter name : String
+
+ @[JSON::Field(key: "referenceId")]
+ getter reference_id : String
+
+ @[JSON::Field(key: "instanceName")]
+ getter instance_name : String
+end
diff --git a/drivers/cisco/dna_spaces/ble_rssi_update.cr b/drivers/cisco/dna_spaces/ble_rssi_update.cr
new file mode 100644
index 00000000000..b9f25eac962
--- /dev/null
+++ b/drivers/cisco/dna_spaces/ble_rssi_update.cr
@@ -0,0 +1,49 @@
+require "./events"
+require "./location"
+
+class Cisco::DNASpaces::BlePayload
+ include JSON::Serializable
+
+ property timestamp : Int64
+ property data : String
+end
+
+class Cisco::DNASpaces::RssiMeasurement
+ include JSON::Serializable
+
+ @[JSON::Field(key: "apMacAddress")]
+ property access_point_mac : String
+
+ @[JSON::Field(key: "ifSlotId")]
+ property if_slot_id : Int32
+
+ @[JSON::Field(key: "bandId")]
+ property band_id : Int32
+
+ @[JSON::Field(key: "antennaId")]
+ property antenna_id : Int32
+
+ property rssi : Int32
+ property timestamp : Int64
+end
+
+class Cisco::DNASpaces::RssiNotification
+ include JSON::Serializable
+
+ @[JSON::Field(key: "macAddress")]
+ property mac_address : String
+
+ @[JSON::Field(key: "apRssiMeasurements")]
+ property measurements : Array(RssiMeasurement)
+
+ @[JSON::Field(key: "blePayload")]
+ property payload : BlePayload
+end
+
+class Cisco::DNASpaces::BleRssiUpdate
+ include JSON::Serializable
+
+ @[JSON::Field(key: "rssiNotification")]
+ getter notification : RssiNotification
+ getter location : Location
+end
diff --git a/drivers/cisco/dna_spaces/device.cr b/drivers/cisco/dna_spaces/device.cr
new file mode 100644
index 00000000000..cb917bcda95
--- /dev/null
+++ b/drivers/cisco/dna_spaces/device.cr
@@ -0,0 +1,48 @@
+require "./events"
+
+class Cisco::DNASpaces::Device
+ include JSON::Serializable
+
+ @[JSON::Field(key: "deviceId")]
+ getter device_id : String
+
+ @[JSON::Field(key: "userId")]
+ getter user_id : String
+
+ getter tags : Array(String) = [] of String
+ getter mobile : String?
+ getter email : String?
+
+ def email
+ @email.try &.downcase
+ end
+
+ def email_raw
+ @email
+ end
+
+ getter gender : String?
+
+ @[JSON::Field(key: "firstName")]
+ getter first_name : String?
+
+ @[JSON::Field(key: "lastName")]
+ getter last_name : String?
+
+ @[JSON::Field(key: "postalCode")]
+ getter postal_code : String?
+
+ # optIns
+ # otherFields
+ # socialNetworkInfo
+
+ # We make this editable so we can store the formatted version here
+ @[JSON::Field(key: "macAddress")]
+ property mac_address : String
+ getter manufacturer : String?
+ getter os : String?
+
+ @[JSON::Field(key: "osVersion")]
+ getter os_version : String?
+ getter type : String
+end
diff --git a/drivers/cisco/dna_spaces/device_count.cr b/drivers/cisco/dna_spaces/device_count.cr
new file mode 100644
index 00000000000..c799d9b8286
--- /dev/null
+++ b/drivers/cisco/dna_spaces/device_count.cr
@@ -0,0 +1,22 @@
+require "./events"
+
+class Cisco::DNASpaces::DeviceCount
+ include JSON::Serializable
+
+ getter location : Location
+
+ @[JSON::Field(key: "associatedCount")]
+ getter associated_count : Int32
+
+ @[JSON::Field(key: "estimatedProbingCount")]
+ getter estimated_probing_count : Int32
+
+ @[JSON::Field(key: "probingRandomizedPercentage")]
+ getter probing_randomized_percentage : Float64
+
+ @[JSON::Field(key: "estimatedDensity")]
+ getter estimated_density : Float64
+
+ @[JSON::Field(key: "estimatedCapacityPercentage")]
+ getter estimated_capacity_percentage : Float64
+end
diff --git a/drivers/cisco/dna_spaces/device_entry.cr b/drivers/cisco/dna_spaces/device_entry.cr
new file mode 100644
index 00000000000..befd6dd137e
--- /dev/null
+++ b/drivers/cisco/dna_spaces/device_entry.cr
@@ -0,0 +1,26 @@
+require "./events"
+
+class Cisco::DNASpaces::DeviceEntry
+ include JSON::Serializable
+
+ getter device : Device
+ getter location : Location
+
+ @[JSON::Field(key: "visitId")]
+ getter visit_id : String
+
+ @[JSON::Field(key: "entryTimestamp")]
+ getter entry_timestamp : Int64
+
+ @[JSON::Field(key: "entryDateTime")]
+ getter entry_datetime : String
+
+ @[JSON::Field(key: "timeZone")]
+ getter time_zone : String
+
+ @[JSON::Field(key: "deviceClassification")]
+ getter device_classification : String
+
+ @[JSON::Field(key: "daysSinceLastVisit")]
+ getter days_sinc_last_visit : Int32
+end
diff --git a/drivers/cisco/dna_spaces/device_exit.cr b/drivers/cisco/dna_spaces/device_exit.cr
new file mode 100644
index 00000000000..151ef54cd5f
--- /dev/null
+++ b/drivers/cisco/dna_spaces/device_exit.cr
@@ -0,0 +1,38 @@
+require "./events"
+
+class Cisco::DNASpaces::DeviceExit
+ include JSON::Serializable
+
+ getter device : Device
+ getter location : Location
+
+ @[JSON::Field(key: "visitId")]
+ getter visit_id : String
+
+ @[JSON::Field(key: "visitDurationMinutes")]
+ getter visit_duration_minutes : Int32
+
+ @[JSON::Field(key: "visitDurationMinutes")]
+ getter visit_duration_minutes : Int32
+
+ @[JSON::Field(key: "entryTimestamp")]
+ getter entry_timestamp : Int64
+
+ @[JSON::Field(key: "entryDateTime")]
+ getter entry_datetime : String
+
+ @[JSON::Field(key: "exitTimestamp")]
+ getter exit_timestamp : Int64
+
+ @[JSON::Field(key: "exitDateTime")]
+ getter exit_datetime : String
+
+ @[JSON::Field(key: "timeZone")]
+ getter time_zone : String
+
+ @[JSON::Field(key: "deviceClassification")]
+ getter device_classification : String
+
+ @[JSON::Field(key: "visitClassification")]
+ getter visit_classification : String
+end
diff --git a/drivers/cisco/dna_spaces/device_location_update.cr b/drivers/cisco/dna_spaces/device_location_update.cr
new file mode 100644
index 00000000000..8be08d405f7
--- /dev/null
+++ b/drivers/cisco/dna_spaces/device_location_update.cr
@@ -0,0 +1,55 @@
+require "./events"
+
+class Cisco::DNASpaces::DeviceLocationUpdate
+ include JSON::Serializable
+
+ getter device : Device
+ getter location : Location
+
+ getter ssid : String
+
+ @[JSON::Field(key: "rawUserId")]
+ getter raw_user_id : String
+
+ @[JSON::Field(key: "visitId")]
+ getter visit_id : String
+
+ @[JSON::Field(key: "lastSeen")]
+ property last_seen : Int64
+
+ @[JSON::Field(key: "deviceClassification")]
+ getter device_classification : String
+
+ @[JSON::Field(key: "mapId")]
+ property map_id : String
+
+ @[JSON::Field(key: "xPos")]
+ getter x_pos : Float64
+
+ @[JSON::Field(key: "yPos")]
+ getter y_pos : Float64
+
+ @[JSON::Field(key: "confidenceFactor")]
+ getter confidence_factor : Float64
+ getter latitude : Float64
+ getter longitude : Float64
+ getter unc : Float64
+
+ def has_position?
+ true
+ end
+
+ @[JSON::Field(ignore: true)]
+ @location_mappings : Hash(String, String)? = nil
+
+ # Ensure we only process these once
+ def location_mappings : Hash(String, String)
+ if mappings = @location_mappings
+ mappings
+ else
+ mappings = location.details
+ @location_mappings = mappings
+ mappings
+ end
+ end
+end
diff --git a/drivers/cisco/dna_spaces/device_presence.cr b/drivers/cisco/dna_spaces/device_presence.cr
new file mode 100644
index 00000000000..2c3973a4035
--- /dev/null
+++ b/drivers/cisco/dna_spaces/device_presence.cr
@@ -0,0 +1,54 @@
+require "./events"
+
+class Cisco::DNASpaces::DevicePresence
+ include JSON::Serializable
+
+ @[JSON::Field(key: "presenceEventType")]
+ getter presence_event_type : String
+
+ @[JSON::Field(key: "wasInActive")]
+ getter was_in_active : Bool
+ getter device : Device
+ getter location : Location
+
+ getter ssid : String
+
+ @[JSON::Field(key: "rawUserId")]
+ getter raw_user_id : String
+
+ @[JSON::Field(key: "visitId")]
+ getter visit_id : String
+
+ @[JSON::Field(key: "daysSinceLastVisit")]
+ getter days_since_last_visit : Int32
+
+ @[JSON::Field(key: "entryTimestamp")]
+ getter entry_timestamp : Int64
+
+ @[JSON::Field(key: "entryDateTime")]
+ getter entry_datetime : String
+
+ @[JSON::Field(key: "exitTimestamp")]
+ getter exit_timestamp : Int64
+
+ @[JSON::Field(key: "exitDateTime")]
+ getter exit_date_time : String
+
+ @[JSON::Field(key: "visitDurationMinutes")]
+ getter visit_duration_minutes : Int32
+
+ @[JSON::Field(key: "timeZone")]
+ getter time_zone : String
+
+ @[JSON::Field(key: "deviceClassification")]
+ getter device_classification : String
+
+ @[JSON::Field(key: "visitClassification")]
+ getter visit_classification : String
+
+ @[JSON::Field(key: "activeDevicesCount")]
+ getter active_devices_count : Int32
+
+ @[JSON::Field(key: "inActiveDevicesCount")]
+ getter inactive_devices_count : Int32
+end
diff --git a/drivers/cisco/dna_spaces/events.cr b/drivers/cisco/dna_spaces/events.cr
new file mode 100644
index 00000000000..2b0371a39a8
--- /dev/null
+++ b/drivers/cisco/dna_spaces/events.cr
@@ -0,0 +1,142 @@
+require "json"
+require "../dna_spaces"
+require "./location"
+require "./device"
+require "./*"
+
+# This is used to map the various events into a simpler data structure
+abstract class Cisco::DNASpaces::Events
+ include JSON::Serializable
+
+ # event type hint
+ use_json_discriminator "eventType", {
+ "KEEP_ALIVE" => KeepAlive,
+ "DEVICE_ENTRY" => DeviceEntryWrapper,
+ "DEVICE_EXIT" => DeviceExitWrapper,
+ "PROFILE_UPDATE" => ProfileUpdateWrapper,
+ "LOCATION_CHANGE" => LocationChangeWrapper,
+ "DEVICE_LOCATION_UPDATE" => DeviceLocationUpdateWrapper,
+ "TP_PEOPLE_COUNT_UPDATE" => PeopleCountUpdateWrapper,
+ "DEVICE_PRESENCE" => DevicePresenceWrapper,
+ "USER_PRESENCE" => UserPresenceWrapper,
+ "APP_ACTIVATION" => AppActivactionWrapper,
+ "DEVICE_COUNT" => DeviceCountWrapper,
+ "BLE_RSSI_UPDATE" => BleRssiUpdateWrapper,
+ "IOT_TELEMETRY" => IotTelemetryWrapper,
+ "WEBEX_TELEMETRY" => WebexTelemetryUpdateWrapper,
+ }
+
+ @[JSON::Field(key: "recordUid")]
+ getter record_uid : String
+
+ @[JSON::Field(key: "recordTimestamp")]
+ getter record_timestamp : Int64
+
+ @[JSON::Field(key: "spacesTenantId")]
+ getter spaces_tenant_id : String
+
+ @[JSON::Field(key: "spacesTenantName")]
+ getter spaces_tenant_name : String
+
+ @[JSON::Field(key: "partnerTenantId")]
+ getter partner_tenant_id : String
+end
+
+class Cisco::DNASpaces::KeepAlive < Cisco::DNASpaces::Events
+ getter eventType : String = "KEEP_ALIVE"
+
+ def payload
+ nil
+ end
+end
+
+class Cisco::DNASpaces::DeviceEntryWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "DEVICE_ENTRY"
+
+ @[JSON::Field(key: "deviceEntry")]
+ getter payload : DeviceEntry
+end
+
+class Cisco::DNASpaces::DeviceExitWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "DEVICE_EXIT"
+
+ @[JSON::Field(key: "deviceExit")]
+ getter payload : DeviceExit
+end
+
+class Cisco::DNASpaces::ProfileUpdateWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "PROFILE_UPDATE"
+
+ @[JSON::Field(key: "deviceProfileUpdate")]
+ getter payload : Device
+end
+
+class Cisco::DNASpaces::LocationChangeWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "LOCATION_CHANGE"
+
+ @[JSON::Field(key: "locationHierarchyChange")]
+ getter payload : LocationChange
+end
+
+class Cisco::DNASpaces::DeviceLocationUpdateWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "DEVICE_LOCATION_UPDATE"
+
+ @[JSON::Field(key: "deviceLocationUpdate")]
+ getter payload : DeviceLocationUpdate
+end
+
+class Cisco::DNASpaces::PeopleCountUpdateWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "TP_PEOPLE_COUNT_UPDATE"
+
+ @[JSON::Field(key: "tpPeopleCountUpdate")]
+ getter payload : PeopleCountUpdate
+end
+
+class Cisco::DNASpaces::DevicePresenceWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "DEVICE_PRESENCE"
+
+ @[JSON::Field(key: "devicePresence")]
+ getter payload : DevicePresence
+end
+
+class Cisco::DNASpaces::UserPresenceWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "USER_PRESENCE"
+
+ @[JSON::Field(key: "userPresence")]
+ getter payload : UserPresence
+end
+
+class Cisco::DNASpaces::AppActivactionWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "APP_ACTIVATION"
+
+ @[JSON::Field(key: "appActivation")]
+ getter payload : AppActivaction
+end
+
+class Cisco::DNASpaces::DeviceCountWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "DEVICE_COUNT"
+
+ @[JSON::Field(key: "deviceCounts")]
+ getter payload : DeviceCount
+end
+
+class Cisco::DNASpaces::BleRssiUpdateWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "BLE_RSSI_UPDATE"
+
+ @[JSON::Field(key: "bleRssiUpdate")]
+ getter payload : BleRssiUpdate
+end
+
+class Cisco::DNASpaces::IotTelemetryWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "IOT_TELEMETRY"
+
+ @[JSON::Field(key: "iotTelemetry")]
+ getter payload : IotTelemetry
+end
+
+class Cisco::DNASpaces::WebexTelemetryUpdateWrapper < Cisco::DNASpaces::Events
+ getter eventType : String = "WEBEX_TELEMETRY"
+
+ @[JSON::Field(key: "webexTelemetryUpdate")]
+ getter payload : WebexTelemetryUpdate
+end
diff --git a/drivers/cisco/dna_spaces/iot_telemetry.cr b/drivers/cisco/dna_spaces/iot_telemetry.cr
new file mode 100644
index 00000000000..10084a8752a
--- /dev/null
+++ b/drivers/cisco/dna_spaces/iot_telemetry.cr
@@ -0,0 +1,254 @@
+require "./events"
+require "./location"
+
+class Cisco::DNASpaces::IotDeviceInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "deviceType")]
+ property type : String
+
+ @[JSON::Field(key: "deviceId")]
+ property id : String
+
+ @[JSON::Field(key: "deviceMacAddress")]
+ property mac_address : String
+
+ @[JSON::Field(key: "deviceName")]
+ property device_name : String
+
+ @[JSON::Field(key: "firmwareVersion")]
+ property firmware_version : String
+
+ @[JSON::Field(key: "rawDeviceId")]
+ property raw_id : String
+ property manufacturer : String
+
+ def os
+ type
+ end
+end
+
+class Cisco::DNASpaces::IotPosition
+ include JSON::Serializable
+
+ @[JSON::Field(key: "mapId")]
+ property map_id : String
+
+ @[JSON::Field(key: "xPos")]
+ getter x_pos : Float64
+
+ @[JSON::Field(key: "yPos")]
+ getter y_pos : Float64
+
+ @[JSON::Field(key: "confidenceFactor")]
+ getter confidence_factor : Float64
+ getter latitude : Float64
+ getter longitude : Float64
+
+ @[JSON::Field(key: "locationId")]
+ property location_id : String
+
+ @[JSON::Field(key: "lastLocatedTime")]
+ property time_located : Int64
+end
+
+class Cisco::DNASpaces::TpData
+ include JSON::Serializable
+
+ @[JSON::Field(key: "peopleCount")]
+ property people_count : Int32
+
+ @[JSON::Field(key: "standbyState")]
+ property standby_state : Int32
+
+ @[JSON::Field(key: "ambientNoise")]
+ property ambient_noise : Int32
+
+ @[JSON::Field(key: "drynessScore")]
+ property dryness_score : Int32
+
+ @[JSON::Field(key: "activeCalls")]
+ property active_calls : Int32
+
+ @[JSON::Field(key: "presentationState")]
+ property presentation_state : Int32
+
+ @[JSON::Field(key: "timeStamp")]
+ property time_stamp : Int64
+
+ @[JSON::Field(key: "airQualityIndex")]
+ property air_quality_index : Float64
+
+ @[JSON::Field(key: "temperatureInCelsius")]
+ property temperature_in_celsius : Float64
+
+ @[JSON::Field(key: "humidityInPercentage")]
+ property humidity_in_percentage : Float64
+
+ getter presence : Bool
+end
+
+class Cisco::DNASpaces::IotTelemetry
+ include JSON::Serializable
+
+ @[JSON::Field(key: "deviceInfo")]
+ getter device : IotDeviceInfo
+
+ @[JSON::Field(key: "detectedPosition")]
+ getter detected_position : IotPosition?
+
+ @[JSON::Field(key: "placedPosition")]
+ getter placed_position : IotPosition?
+
+ getter location : Location
+
+ @[JSON::Field(key: "deviceRtcTime")]
+ getter device_rtc : Int64
+
+ @[JSON::Field(key: "rawHeader")]
+ getter raw_header : Int64
+
+ @[JSON::Field(key: "rawPayload")]
+ getter raw_payload : String
+
+ @[JSON::Field(key: "sequenceNum")]
+ getter sequence_num : Int64
+
+ @[JSON::Field(key: "airQuality")]
+ getter air_quality_index : NamedTuple(airQualityIndex: Float64)?
+
+ @[JSON::Field(key: "temperature")]
+ getter temperature_celsius : NamedTuple(temperatureInCelsius: Float64)?
+
+ @[JSON::Field(key: "humidity")]
+ getter humidity_percent : NamedTuple(humidityInPercentage: Float64)?
+
+ @[JSON::Field(key: "airPressure")]
+ getter air_pressure_actual : NamedTuple(pressure: Float64)?
+
+ @[JSON::Field(key: "pirTrigger")]
+ getter pir_trigger : NamedTuple(timestamp: Int64)?
+
+ @[JSON::Field(key: "tpData")]
+ getter tele_presence_data : TpData?
+
+ def people_count
+ tele_presence_data.try &.people_count
+ end
+
+ def presence
+ tele_presence_data.try &.presence
+ end
+
+ def ambient_noise
+ tele_presence_data.try &.ambient_noise
+ end
+
+ def air_quality
+ if index = @air_quality_index
+ index[:airQualityIndex]
+ end
+ end
+
+ def temperature
+ if temp = @temperature_celsius
+ temp[:temperatureInCelsius]
+ end
+ end
+
+ def humidity
+ if humidity = @humidity_percent
+ humidity[:humidityInPercentage]
+ end
+ end
+
+ def air_pressure
+ if pressure = @air_pressure_actual
+ pressure[:pressure]
+ end
+ end
+
+ def pir_triggered
+ if pir_trigger = @pir_trigger
+ pir_trigger[:timestamp]
+ end
+ end
+
+ def binding(type : SensorType, mac : String)
+ case type
+ when .humidity?
+ "#{mac}->humidity->humidityInPercentage"
+ when .air_quality?
+ "#{mac}->airQuality->airQualityIndex"
+ when .people_count?
+ "#{mac}->tpData->peopleCount"
+ when .temperature?
+ "#{mac}->temperature->temperatureInCelsius"
+ end
+ end
+
+ @[JSON::Field(ignore: true)]
+ @location_mappings : Hash(String, String)? = nil
+
+ # Ensure we only process these once
+ def location_mappings : Hash(String, String)
+ if mappings = @location_mappings
+ mappings
+ else
+ mappings = location.details
+ @location_mappings = mappings
+ mappings
+ end
+ end
+
+ def has_position?
+ !!(@detected_position || @placed_position)
+ end
+
+ def position : IotPosition
+ (@detected_position || @placed_position).not_nil!
+ end
+
+ # make this class quack like a wifi DeviceLocationUpdate
+ delegate latitude, to: position
+ delegate longitude, to: position
+ delegate confidence_factor, to: position
+ delegate x_pos, to: position
+ delegate y_pos, to: position
+ delegate map_id, to: position
+
+ def map_id=(id)
+ position.map_id = id
+ end
+
+ def visit_id
+ "unknown for IoT"
+ end
+
+ def last_seen
+ tele_presence_data.try(&.time_stamp) || (has_position? ? position.time_located : device_rtc)
+ end
+
+ def last_seen=(time)
+ if tele_data = tele_presence_data
+ tele_data.time_stamp = time
+ elsif has_position?
+ position.time_located = time
+ else
+ @device_rtc = time
+ end
+ time
+ end
+
+ def raw_user_id
+ ""
+ end
+
+ def unc : Float64
+ 3.0
+ end
+
+ def ssid
+ "IoT"
+ end
+end
diff --git a/drivers/cisco/dna_spaces/location.cr b/drivers/cisco/dna_spaces/location.cr
new file mode 100644
index 00000000000..26cb978eef1
--- /dev/null
+++ b/drivers/cisco/dna_spaces/location.cr
@@ -0,0 +1,30 @@
+require "./events"
+
+class Cisco::DNASpaces::Location
+ include JSON::Serializable
+
+ @[JSON::Field(key: "locationId")]
+ getter location_id : String
+ getter name : String
+
+ # TODO:: this might be better as an enum
+ # if there are only limited types
+ @[JSON::Field(key: "inferredLocationTypes")]
+ getter tags : Array(String) = [] of String
+
+ getter parent : Location?
+
+ # Maps tag names to location_ids
+ def details(mappings = {} of String => String)
+ parent.try &.details(mappings)
+ tags.each { |tag| mappings[tag] = location_id }
+ mappings
+ end
+
+ # Maps location_ids to location names
+ def descriptions(mappings = {} of String => String)
+ parent.try &.descriptions(mappings)
+ mappings[location_id] = name
+ mappings
+ end
+end
diff --git a/drivers/cisco/dna_spaces/location_change.cr b/drivers/cisco/dna_spaces/location_change.cr
new file mode 100644
index 00000000000..6043e172f14
--- /dev/null
+++ b/drivers/cisco/dna_spaces/location_change.cr
@@ -0,0 +1,32 @@
+require "./events"
+
+class Cisco::DNASpaces::LocationChange
+ include JSON::Serializable
+
+ @[JSON::Field(key: "changeType")]
+ getter change_type : String
+ getter location : Location
+
+ class Metadata
+ include JSON::Serializable
+
+ getter key : String
+ getter values : Array(String)
+ end
+
+ class LocationDetails
+ include JSON::Serializable
+
+ @[JSON::Field(key: "timeZone")]
+ getter time_zone : String
+ getter city : String
+ getter state : String
+ getter country : String
+ getter category : String
+
+ getter latitude : Float64
+ getter longitude : Float64
+
+ getter metadata : Array(Metadata)
+ end
+end
diff --git a/drivers/cisco/dna_spaces/location_details.cr b/drivers/cisco/dna_spaces/location_details.cr
new file mode 100644
index 00000000000..c69048af78f
--- /dev/null
+++ b/drivers/cisco/dna_spaces/location_details.cr
@@ -0,0 +1,16 @@
+require "./events"
+
+class Cisco::DNASpaces::LocationDetails
+ include JSON::Serializable
+
+ @[JSON::Field(key: "timeZone")]
+ getter time_zone : String
+
+ getter city : String
+ getter state : String
+ getter country : String
+ getter category : String
+
+ getter latitude : Float64
+ getter longitude : Float64
+end
diff --git a/drivers/cisco/dna_spaces/map_info.cr b/drivers/cisco/dna_spaces/map_info.cr
new file mode 100644
index 00000000000..31be13841d7
--- /dev/null
+++ b/drivers/cisco/dna_spaces/map_info.cr
@@ -0,0 +1,30 @@
+require "./events"
+
+class Cisco::DNASpaces::Dimension
+ include JSON::Serializable
+
+ getter length : Float64
+ getter width : Float64
+ getter height : Float64
+
+ @[JSON::Field(key: "offsetX")]
+ getter offset_x : Float64
+
+ @[JSON::Field(key: "offsetY")]
+ getter offset_y : Float64
+end
+
+class Cisco::DNASpaces::MapInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "mapId")]
+ getter id : String
+
+ @[JSON::Field(key: "imageWidth")]
+ getter image_width : Float64
+
+ @[JSON::Field(key: "imageHeight")]
+ getter image_height : Float64
+
+ getter dimension : Cisco::DNASpaces::Dimension
+end
diff --git a/drivers/cisco/dna_spaces/people_count_update.cr b/drivers/cisco/dna_spaces/people_count_update.cr
new file mode 100644
index 00000000000..99d8f35cd22
--- /dev/null
+++ b/drivers/cisco/dna_spaces/people_count_update.cr
@@ -0,0 +1,32 @@
+require "./events"
+
+# This is triggered from telepresence devices
+class Cisco::DNASpaces::PeopleCountUpdate
+ include JSON::Serializable
+
+ @[JSON::Field(key: "tpDeviceId")]
+ getter tp_device_id : String
+ getter location : Location
+ getter presence : Bool
+
+ @[JSON::Field(key: "peopleCount")]
+ getter people_count : Int32
+
+ @[JSON::Field(key: "standbyState")]
+ getter standby_state : Int32
+
+ @[JSON::Field(key: "ambientNoise")]
+ getter ambient_noise : Int32
+
+ @[JSON::Field(key: "drynessScore")]
+ getter dryness_score : Int32
+
+ @[JSON::Field(key: "activeCalls")]
+ getter active_calls : Int32
+
+ @[JSON::Field(key: "presentationState")]
+ getter presentation_state : Int32
+
+ @[JSON::Field(key: "timeStamp")]
+ getter timestamp : Int64
+end
diff --git a/drivers/cisco/dna_spaces/sensor_interface.cr b/drivers/cisco/dna_spaces/sensor_interface.cr
new file mode 100644
index 00000000000..9825f9af11a
--- /dev/null
+++ b/drivers/cisco/dna_spaces/sensor_interface.cr
@@ -0,0 +1,129 @@
+class Cisco::DNASpaces
+ IOT_SENSORS = {
+ SensorType::Presence, SensorType::PeopleCount, SensorType::Humidity,
+ SensorType::AirQuality, SensorType::SoundPressure, SensorType::Temperature,
+ }
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ protected def to_sensors(zone_id, filter, device : IotTelemetry | WebexTelemetryUpdate)
+ if level_loc = device.location_mappings["FLOOR"]?
+ if floorplan = @floorplan_mappings[level_loc]?
+ building = floorplan["building"]?.as(String?)
+ level = floorplan["level"]?.as(String?)
+ end
+ end
+
+ sensors = [] of Interface::Sensor::Detail
+ return sensors if zone_id && (building || level) && !zone_id.in?({building, level})
+
+ formatted_mac = format_mac(device.device.mac_address)
+ time = device.last_seen
+ device_name = device.device.device_name.presence || device.device.id
+
+ IOT_SENSORS.each do |type|
+ next if filter && filter != type
+ # next if device.is_a?(WebexTelemetryUpdate) && !filter.in?({SensorType::PeopleCount, SensorType::Presence})
+
+ unit = nil
+ value = nil
+ binding = device.binding(type, formatted_mac)
+
+ case type
+ when SensorType::Presence
+ if !(presence = device.presence).nil?
+ value = presence ? 1.0 : 0.0
+ end
+ when SensorType::Humidity
+ if humidity = device.humidity
+ value = humidity
+ unit = "%"
+ end
+ when SensorType::AirQuality
+ if air_quality = device.air_quality
+ value = air_quality
+ end
+ when SensorType::PeopleCount
+ if count = device.people_count
+ value = count.to_f
+ end
+ when SensorType::Temperature
+ if temp = device.temperature
+ value = temp
+ unit = "Cel"
+ end
+ when SensorType::SoundPressure
+ if noise = device.ambient_noise
+ value = noise.to_f
+ unit = "dB[SPL]" # NOTE:: this is a guess
+ end
+ else
+ next
+ end
+
+ next unless value
+
+ sensor = Interface::Sensor::Detail.new(
+ type: type,
+ value: value,
+ last_seen: time,
+ mac: formatted_mac,
+ id: type.to_s,
+ name: "#{device_name} #{device.device.type} (#{type})",
+ module_id: module_id,
+ binding: binding,
+ unit: unit
+ )
+
+ sensor.building = building
+ sensor.level = level
+ sensors << sensor
+ end
+
+ sensors
+ end
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ filter = type ? SensorType.parse(type) : nil
+ return NO_MATCH if filter && !filter.in?(IOT_SENSORS)
+
+ if mac
+ mac = format_mac(mac)
+ device = devices { |dev| dev[mac]? }
+ return NO_MATCH unless device
+ return case device
+ in IotTelemetry, WebexTelemetryUpdate
+ to_sensors(zone_id, filter, device)
+ in DeviceLocationUpdate
+ NO_MATCH
+ end
+ end
+
+ device_values = devices &.values
+ device_values.flat_map do |device|
+ case device
+ in IotTelemetry, WebexTelemetryUpdate
+ to_sensors(zone_id, filter, device)
+ in DeviceLocationUpdate
+ NO_MATCH
+ end
+ end
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+
+ return nil unless id
+ mac = format_mac(mac)
+ device = devices { |dev| dev[mac]? }
+ return nil unless device
+
+ filter = SensorType.parse(id)
+ case device
+ in IotTelemetry, WebexTelemetryUpdate
+ to_sensors(nil, filter, device).first?
+ in DeviceLocationUpdate
+ end
+ end
+end
diff --git a/drivers/cisco/dna_spaces/user_presence.cr b/drivers/cisco/dna_spaces/user_presence.cr
new file mode 100644
index 00000000000..762c06de514
--- /dev/null
+++ b/drivers/cisco/dna_spaces/user_presence.cr
@@ -0,0 +1,83 @@
+require "./events"
+
+class Cisco::DNASpaces::UserPresence
+ include JSON::Serializable
+
+ class User
+ include JSON::Serializable
+
+ @[JSON::Field(key: "userId")]
+ getter user_id : String
+
+ @[JSON::Field(key: "deviceIds")]
+ getter device_ids : Array(String)
+ getter tags : Array(String) = [] of String
+ getter mobile : String?
+ getter email : String?
+ getter gender : String?
+
+ @[JSON::Field(key: "firstName")]
+ getter first_name : String?
+
+ @[JSON::Field(key: "lastName")]
+ getter last_name : String?
+
+ @[JSON::Field(key: "postalCode")]
+ getter postal_code : String?
+
+ # otherFields
+ # socialNetworkInfo
+ end
+
+ class UserCount
+ include JSON::Serializable
+
+ @[JSON::Field(key: "usersWithUserId")]
+ getter users_with_user_id : Int64
+
+ @[JSON::Field(key: "usersWithoutUserId")]
+ getter users_without_user_id : Int64
+
+ @[JSON::Field(key: "totalUsers")]
+ getter total_users : Int64
+ end
+
+ @[JSON::Field(key: "presenceEventType")]
+ getter presence_event_type : String
+
+ @[JSON::Field(key: "wasInActive")]
+ getter was_in_active : Bool
+
+ getter user : User
+ getter location : Location
+
+ @[JSON::Field(key: "rawUserId")]
+ getter raw_user_id : String
+
+ @[JSON::Field(key: "visitId")]
+ getter visit_id : String
+
+ @[JSON::Field(key: "entryTimestamp")]
+ getter entry_timestamp : Int64
+
+ @[JSON::Field(key: "entryDateTime")]
+ getter entry_datetime : String
+
+ @[JSON::Field(key: "exitTimestamp")]
+ getter exit_timestamp : Int64
+
+ @[JSON::Field(key: "exitDateTime")]
+ getter exit_datetime : String
+
+ @[JSON::Field(key: "visitDurationMinutes")]
+ getter visit_duration_minutes : Int32
+
+ @[JSON::Field(key: "timeZone")]
+ getter time_zone : String
+
+ @[JSON::Field(key: "activeUsersCount")]
+ getter active_users_count : UserCount
+
+ @[JSON::Field(key: "inActiveUsersCount")]
+ getter inactive_users_count : UserCount
+end
diff --git a/drivers/cisco/dna_spaces/webex_telemetry.cr b/drivers/cisco/dna_spaces/webex_telemetry.cr
new file mode 100644
index 00000000000..fef7ac2c336
--- /dev/null
+++ b/drivers/cisco/dna_spaces/webex_telemetry.cr
@@ -0,0 +1,174 @@
+require "./events"
+require "./location"
+
+# https://partners.dnaspaces.io/docs/v1/basic/c-dnas-firehose-api-references.html#!c-firehose-proto-buf-doc.html
+class Cisco::DNASpaces::WebexDeviceInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "deviceId")]
+ getter id : String
+
+ @[JSON::Field(key: "macAddress")]
+ property mac_address : String
+
+ @[JSON::Field(key: "ipAddress")]
+ getter ip_address : String
+
+ # these fields are named to be compatible with the IoT field names
+ @[JSON::Field(key: "product")]
+ getter type : String
+
+ @[JSON::Field(key: "displayName")]
+ getter device_name : String
+
+ @[JSON::Field(key: "serialNumber")]
+ getter serial_number : String
+
+ @[JSON::Field(key: "softwareVersion")]
+ getter software_version : String
+
+ @[JSON::Field(key: "workspaceId")]
+ getter workspace_id : String
+
+ @[JSON::Field(key: "orgId")]
+ getter org_id : String
+end
+
+struct Cisco::DNASpaces::WebexTelemetry
+ include JSON::Serializable
+
+ getter presence : Bool?
+
+ @[JSON::Field(key: "peopleCount")]
+ getter count : Int32?
+
+ @[JSON::Field(key: "soundLevel")]
+ getter sound_level : Float64?
+
+ @[JSON::Field(key: "airQuality")]
+ getter air_quality : Float64?
+
+ @[JSON::Field(key: "ambientTemp")]
+ getter ambient_temp : Float64?
+
+ @[JSON::Field(key: "ambientNoise")]
+ getter ambient_noise : Float64?
+
+ @[JSON::Field(key: "relativeHumidity")]
+ getter relative_humidity : Float64?
+end
+
+class Cisco::DNASpaces::WebexTelemetryUpdate
+ include JSON::Serializable
+
+ @[JSON::Field(key: "deviceInfo")]
+ property device : WebexDeviceInfo
+ property location : Location
+
+ @[JSON::Field(ignore_serialize: true)]
+ property telemetries : Array(WebexTelemetry) { [] of WebexTelemetry }
+
+ getter people_count : Int32 do
+ telemetries.compact_map(&.count).first? || 0
+ end
+
+ getter presence : Bool do
+ telemetries.compact_map(&.presence).first? || (people_count > 0)
+ end
+
+ getter humidity : Float64? do
+ telemetries.compact_map(&.relative_humidity).first?
+ end
+
+ getter air_quality : Float64? do
+ telemetries.compact_map(&.air_quality).first?
+ end
+
+ getter temperature : Float64? do
+ telemetries.compact_map(&.ambient_temp).first?
+ end
+
+ getter ambient_noise : Float64? do
+ telemetries.compact_map(&.ambient_noise).first?
+ end
+
+ def update_telemetry
+ telemetries.each do |telemetry|
+ if !telemetry.presence.nil?
+ @presence = telemetry.presence
+ next
+ end
+
+ if count = telemetry.count
+ @people_count = count
+ next
+ end
+
+ if float = telemetry.relative_humidity
+ @humidity = float
+ next
+ end
+
+ if float = telemetry.air_quality
+ @air_quality = float
+ next
+ end
+
+ if float = telemetry.ambient_temp
+ @temperature = float
+ next
+ end
+
+ if float = telemetry.ambient_noise
+ @ambient_noise = float
+ end
+ end
+ end
+
+ def binding(type : SensorType, mac : String)
+ case type
+ when .presence?
+ "#{mac}->presence"
+ when .humidity?
+ "#{mac}->humidity"
+ when .air_quality?
+ "#{mac}->air_quality"
+ when .people_count?
+ "#{mac}->people_count"
+ when .temperature?
+ "#{mac}->temperature"
+ when .sound_pressure?
+ "#{mac}->ambient_noise"
+ end
+ end
+
+ @[JSON::Field(ignore: true)]
+ property last_seen : Int64 do
+ Time.utc.to_unix_ms
+ end
+
+ @[JSON::Field(ignore: true)]
+ property map_id : String = ""
+
+ def visit_id
+ nil
+ end
+
+ def raw_user_id : String
+ ""
+ end
+
+ @[JSON::Field(ignore: true)]
+ @location_mappings : Hash(String, String)? = nil
+
+ # Ensure we only process these once
+ def location_mappings : Hash(String, String)
+ if mappings = @location_mappings
+ mappings
+ else
+ mappings = location.details
+ @location_mappings = mappings
+ mappings
+ end
+ end
+end
diff --git a/drivers/cisco/dna_spaces_spec.cr b/drivers/cisco/dna_spaces_spec.cr
new file mode 100644
index 00000000000..87e6ec0656d
--- /dev/null
+++ b/drivers/cisco/dna_spaces_spec.cr
@@ -0,0 +1,38 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::DNASpaces" do
+ settings({
+ dna_spaces_activation_key: "provide this and the API / tenant ids will be generated automatically",
+ dna_spaces_api_key: "X-API-KEY",
+ tenant_id: "sfdsfsdgg",
+ verify_activation_key: false,
+ max_location_age: 300,
+ floorplan_mappings: {
+ location_a4cb0: {
+ "level_name" => "optional name",
+ "building" => "zone-GAsXV0nc",
+ "level" => "zone-GAsmleH",
+ "offset_x" => 12.4,
+ "offset_y" => 5.2,
+ "map_width" => 50.3,
+ "map_height" => 100.9,
+ },
+ },
+ debug_stream: false,
+ })
+
+ # The dashboard should request the streaming API
+ expect_http_request do |request, response|
+ headers = request.headers
+ if headers["X-API-KEY"]? == "X-API-KEY"
+ response.headers["Transfer-Encoding"] = "chunked"
+ response.status_code = 200
+ response << %({"recordUid":"event-85b84f15","recordTimestamp":1605502585236,"spacesTenantId":"","spacesTenantName":"","partnerTenantId":"","eventType":"KEEP_ALIVE"})
+ else
+ response.status_code = 401
+ end
+ end
+
+ # Should standardise the format of MAC addresses
+ exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b)
+end
diff --git a/drivers/cisco/ise/guest_users.cr b/drivers/cisco/ise/guest_users.cr
new file mode 100644
index 00000000000..b79e2b81cec
--- /dev/null
+++ b/drivers/cisco/ise/guest_users.cr
@@ -0,0 +1,194 @@
+require "placeos-driver"
+require "xml"
+
+# Tested with Cisco ISE API v2.2
+# NOTE:: DO NOT USE, HERE FOR COMPATIBILITY REASONS
+# https://developer.cisco.com/docs/identity-services-engine/3.0/#!guest-user/resource-definition
+# However, should work and conform to v1.4 requirements
+# https://www.cisco.com/c/en/us/td/docs/security/ise/1-4/api_ref_guide/api_ref_book/ise_api_ref_guest.html#79039
+
+class Cisco::Ise::Guests < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Cisco ISE Guest Control"
+ generic_name :Guests
+ uri_base "https://ise-pan:9060/ers/config"
+
+ default_settings({
+ username: "user",
+ password: "pass",
+ portal_id: "Required, ask cisco ISE admins",
+ timezone: "Australia/Sydney",
+ guest_type: "Required, ask cisco ISE admins for valid subset of values", # e.g. Contractor
+ location: "Required for ISE v2.2, ask cisco ISE admins for valid value. Else, remove for ISE v1.4", # e.g. New York
+ custom_data: {} of String => JSON::Any::Type,
+ })
+
+ @basic_auth : String = ""
+ @portal_id : String = ""
+ @sms_service_provider : String? = nil
+ @guest_type : String = "default_guest_type"
+ @timezone : Time::Location = Time::Location.load("Australia/Sydney")
+ @location : String? = nil
+ @custom_data = {} of String => JSON::Any::Type
+
+ TYPE_HEADER = "application/vnd.com.cisco.ise.identity.guestuser.2.0+xml"
+ TIME_FORMAT = "%m/%d/%Y %H:%M"
+
+ def on_update
+ @basic_auth = "Basic #{Base64.strict_encode("#{setting?(String, :username)}:#{setting?(String, :password)}")}"
+ @portal_id = setting?(String, :portal_id) || "portal101"
+ @guest_type = setting?(String, :guest_type) || "default_guest_type"
+ @location = setting?(String, :location)
+ @sms_service_provider = setting?(String, :sms_service_provider)
+
+ time_zone = setting?(String, :timezone).presence
+ @timezone = Time::Location.load(time_zone) if time_zone
+ @custom_data = setting?(Hash(String, JSON::Any::Type), :custom_data) || {} of String => JSON::Any::Type
+ end
+
+ def create_guest(
+ event_start : Int64,
+ attendee_email : String,
+ attendee_name : String,
+ company_name : String? = nil, # Mandatory but driver will extract from email if not passed
+ phone_number : String = "0123456789", # Mandatory, use a fake value as default
+ sms_service_provider : String? = nil, # Use this param to override the setting
+ guest_type : String? = nil, # Mandatory but use this param to override the setting
+ portal_id : String? = nil # Mandatory but use this param to override the setting
+ )
+ # Determine the name of the attendee for ISE
+ guest_names = attendee_name.split
+ first_name_index_end = guest_names.size > 1 ? -2 : -1
+ first_name = guest_names[0..first_name_index_end].join(' ')
+ last_name = guest_names[-1]
+ username = genererate_username(first_name, last_name)
+
+ return {"username" => username, "password" => UUID.random.to_s[0..3]}.merge(@custom_data) if setting?(Bool, :test)
+
+ sms_service_provider ||= @sms_service_provider
+ guest_type ||= @guest_type
+ portal_id ||= @portal_id
+
+ time_object = Time.unix(event_start).in(@timezone)
+ from_date = time_object.at_beginning_of_day.to_s(TIME_FORMAT)
+ to_date = time_object.at_end_of_day.to_s(TIME_FORMAT)
+
+ # If company_name isn't passed
+ # Hackily grab a company name from the attendee's email (we may be able to grab this from the signal if possible)
+ company_name ||= attendee_email.split('@')[1].split('.')[0].capitalize
+
+ # Now generate our XML body
+ xml_string = %(
+ )
+
+ # customFields is required for ISE API v2.2
+ # since location is also required for 2.2, we can check if location is present
+ xml_string += %(
+ ) if @location
+
+ xml_string += %(
+
+ #{from_date} )
+
+ xml_string += %(
+ #{@location} ) if @location
+
+ xml_string += %(
+ #{to_date}
+ 1
+
+
+ #{company_name}
+ #{attendee_email}
+ #{first_name}
+ #{last_name}
+ English
+ #{phone_number} )
+
+ xml_string += %(
+ #{sms_service_provider} ) if sms_service_provider
+
+ xml_string += %(
+ #{username}
+
+ #{guest_type}
+ #{portal_id}
+ )
+
+ response = post("/guestuser/", body: xml_string, headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success?
+
+ guest_id = response.headers["Location"].split('/').last
+ guest_crendentials(guest_id).merge(@custom_data)
+ end
+
+ # Will be 9 characters in length until 2081-08-05 10:16:46.208000000 UTC
+ # when it will increase to 10
+ private def genererate_username(firstname, lastname)
+ "#{firstname[0].downcase}#{lastname[0].downcase}#{Time.utc.to_unix_ms.to_s(62)}"
+ end
+
+ def guest_crendentials(id : String)
+ response = get("/guestuser/#{id}", headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+ parsed_body = XML.parse(response.body)
+ guest_user = parsed_body.first_element_child.not_nil!
+ guest_info = guest_user.children.find { |c| c.name == "guestInfo" }.not_nil!
+ {
+ "username" => guest_info.children.find { |c| c.name == "userName" }.not_nil!.content,
+ "password" => guest_info.children.find { |c| c.name == "password" }.not_nil!.content,
+ }
+ end
+
+ def test_xml(xml_string : String)
+ response = post("/guestuser/", body: XML.parse(xml_string).to_s, headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+ raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success?
+ end
+
+ def test2
+ xml_string = %(
+
+
+08/06/2014 23:22
+08/07/2014 23:22
+1
+
+
+New Company
+john@example.com
+John
+Doe
+English
+9999998877
+Global Default
+autoguestuser1
+
+Daily
+sponsor
+portal101
+interview
+ )
+ test_xml(xml_string)
+ end
+
+ def test_json(json : String)
+ response = post("/guestuser/", body: json, headers: {
+ "Accept" => "application/json",
+ "Content-Type" => "application/json",
+ "Authorization" => @basic_auth,
+ })
+ raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success?
+ end
+end
diff --git a/drivers/cisco/ise/guest_users_spec.cr b/drivers/cisco/ise/guest_users_spec.cr
new file mode 100644
index 00000000000..ac109bd570e
--- /dev/null
+++ b/drivers/cisco/ise/guest_users_spec.cr
@@ -0,0 +1,62 @@
+require "placeos-driver/spec"
+require "xml"
+
+TIME_FORMAT = "%m/%d/%Y %H:%M"
+
+DriverSpecs.mock_driver "Cisco::Ise::Guests" do
+ portal = "portal101"
+ phone = "0123456789"
+ type = "Contractor"
+ lo = "New York"
+
+ settings({
+ portal_id: portal,
+ guest_type: type,
+ location: lo,
+ })
+
+ start_time = Time.local(Time::Location.load("Australia/Sydney"))
+ start_date = start_time.at_beginning_of_day.to_s(TIME_FORMAT)
+ end_date = start_time.at_end_of_day.to_s(TIME_FORMAT)
+ attendee_email = "attendee@test.com"
+ company_name = "PlaceOS"
+
+ sms = "Global Default"
+ exec(:create_guest, start_time.to_unix, attendee_email, "First Last", company_name, phone, sms, "Daily")
+
+ # POST to /guestuser/
+ expect_http_request do |request, response|
+ parsed_body = XML.parse(request.body.not_nil!)
+ guest_user = parsed_body.first_element_child.not_nil!
+
+ guest_access_info = guest_user.children.find { |c| c.name == "guestAccessInfo" }.not_nil!
+ from_date = guest_access_info.children.find { |c| c.name == "fromDate" }.not_nil!.content
+ from_date.should eq start_date
+ to_date = guest_access_info.children.find { |c| c.name == "toDate" }.not_nil!.content
+ to_date.should eq end_date
+
+ guest_info = guest_user.children.find { |c| c.name == "guestInfo" }.not_nil!
+ company = guest_info.children.find { |c| c.name == "company" }.not_nil!.content
+ company.should eq company_name
+ email_address = guest_info.children.find { |c| c.name == "emailAddress" }.not_nil!.content
+ email_address.should eq attendee_email
+ first_name = guest_info.children.find { |c| c.name == "firstName" }.not_nil!.content
+ first_name.should eq "First"
+ last_name = guest_info.children.find { |c| c.name == "lastName" }.not_nil!.content
+ last_name.should eq "Last"
+ phone_number = guest_info.children.find { |c| c.name == "phoneNumber" }.not_nil!.content
+ phone_number.should eq phone
+ sms_service_provider = guest_info.children.find { |c| c.name == "smsServiceProvider" }.not_nil!.content
+ sms_service_provider.should eq sms
+
+ portal_id = guest_user.children.find { |c| c.name == "portalId" }.not_nil!.content
+ portal_id.should eq portal
+
+ guest_type = guest_user.children.find { |c| c.name == "guestType" }.not_nil!.content
+ guest_type.should eq "Daily"
+
+ response.status_code = 201
+ response.headers["Location"] = "https://ise-pan:9060/ers/config/guestuser/e1bb8290-6ccb-11e3-8cdf-000c29c56fc7"
+ response.headers["Content-Type"] = "application/xml"
+ end
+end
diff --git a/drivers/cisco/ise/models/internal_user.cr b/drivers/cisco/ise/models/internal_user.cr
new file mode 100644
index 00000000000..332c4364a70
--- /dev/null
+++ b/drivers/cisco/ise/models/internal_user.cr
@@ -0,0 +1,41 @@
+require "json"
+
+class Cisco::Ise::Models::InternalUser
+ include JSON::Serializable
+
+ @[JSON::Field(key: "name")]
+ property name : String
+
+ @[JSON::Field(key: "id")]
+ property id : String?
+
+ @[JSON::Field(key: "identityGroups")]
+ property identity_groups : String?
+
+ @[JSON::Field(key: "description")]
+ property description : String?
+
+ @[JSON::Field(key: "changePassword")]
+ property change_password : Bool = false
+
+ @[JSON::Field(key: "email")]
+ property email : String?
+
+ @[JSON::Field(key: "enabled")]
+ property enabled : Bool = true
+
+ @[JSON::Field(key: "customAttributes")]
+ property custom_attributes : Hash(String, String) = {} of String => String
+
+ @[JSON::Field(key: "firstName")]
+ property first_name : String?
+
+ @[JSON::Field(key: "lastName")]
+ property last_name : String?
+
+ @[JSON::Field(key: "password")]
+ property password : String?
+
+ @[JSON::Field(key: "passwordIDStore")]
+ property password_store : String = "Internal Users"
+end
diff --git a/drivers/cisco/ise/network_access.cr b/drivers/cisco/ise/network_access.cr
new file mode 100644
index 00000000000..9200c63f747
--- /dev/null
+++ b/drivers/cisco/ise/network_access.cr
@@ -0,0 +1,293 @@
+require "placeos-driver"
+require "./models/internal_user"
+require "uuid"
+
+require "../../place/password_generator_helper"
+
+# Tested with Cisco ISE API v3.1
+# https://developer.cisco.com/docs/identity-services-engine/v1/#!internaluser
+
+class Cisco::Ise::NetworkAccess < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Cisco ISE REST API"
+ generic_name :NetworkAccess
+ uri_base "https://ise-pan:9060/ers/config"
+
+ default_settings({
+ username: "user",
+ password: "pass",
+ portal_id: "Required for Guest Users, ask cisco ISE admins",
+ timezone: "UTC",
+ guest_type: "Required for Guest Users, ask cisco ISE admins for valid subset of values", # e.g. Contractor
+ custom_data: {} of String => String,
+ password_length: DEFAULT_PASSWORD_LENGTH,
+ password_exclude: DEFAULT_PASSWORD_EXCLUDE,
+ password_minimum_lowercase: DEFAULT_PASSWORD_MINIMUM_LOWERCASE,
+ password_minimum_uppercase: DEFAULT_PASSWORD_MINIMUM_UPPERCASE,
+ password_minimum_numbers: DEFAULT_PASSWORD_MINIMUM_NUMBERS,
+ password_minimum_symbols: DEFAULT_PASSWORD_MINIMUM_SYMBOLS,
+ debug: false,
+ test_mode: false,
+ })
+
+ @basic_auth : String = ""
+ @portal_id : String = ""
+ @sms_service_provider : String? = nil
+ @guest_type : String = "default_guest_type"
+ @password_length : Int32 = DEFAULT_PASSWORD_LENGTH
+ @password_exclude : String = DEFAULT_PASSWORD_EXCLUDE
+ @password_minimum_lowercase : Int32 = DEFAULT_PASSWORD_MINIMUM_LOWERCASE
+ @password_minimum_uppercase : Int32 = DEFAULT_PASSWORD_MINIMUM_UPPERCASE
+ @password_minimum_numbers : Int32 = DEFAULT_PASSWORD_MINIMUM_NUMBERS
+ @password_minimum_symbols : Int32 = DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+ @timezone : Time::Location = Time::Location.load("Australia/Sydney")
+ @custom_data = {} of String => String
+
+ TYPE_HEADER = "application/json"
+ TIME_FORMAT = "%m/%d/%Y %H:%M"
+
+ def on_update
+ username = setting?(String, :username)
+ password = setting?(String, :password)
+
+ @basic_auth = ["Basic", Base64.strict_encode([username, password].join(":"))].join(" ")
+
+ @debug = setting?(Bool, :debug) || false
+ @test_mode = setting?(Bool, :test) || false
+
+ @portal_id = setting?(String, :portal_id) || "portal101"
+ @guest_type = setting?(String, :guest_type) || "default_guest_type"
+ @sms_service_provider = setting?(String, :sms_service_provider)
+ @password_length = setting?(Int32, :password_length) || DEFAULT_PASSWORD_LENGTH
+ @password_exclude = setting?(String, :password_exclude) || DEFAULT_PASSWORD_EXCLUDE
+ @password_minimum_lowercase = setting?(Int32, :password_minimum_lowercase) || DEFAULT_PASSWORD_MINIMUM_LOWERCASE
+ @password_minimum_uppercase = setting?(Int32, :password_minimum_uppercase) || DEFAULT_PASSWORD_MINIMUM_UPPERCASE
+ @password_minimum_numbers = setting?(Int32, :password_minimum_numbers) || DEFAULT_PASSWORD_MINIMUM_NUMBERS
+ @password_minimum_symbols = setting?(Int32, :password_minimum_symbols) || DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+
+ time_zone = setting?(String, :timezone).presence
+ @timezone = Time::Location.load(time_zone) if time_zone
+ @custom_data = setting?(Hash(String, String), :custom_data) || {} of String => String
+
+ logger.debug { "Basic auth details: #{@basic_auth}" } if @debug
+ end
+
+ def create_internal_user(
+ email : String,
+ name : String? = nil,
+ first_name : String? = nil,
+ last_name : String? = nil,
+ description : String? = nil,
+ password : String? = nil,
+ identity_groups : Array(String) = [] of String
+ )
+ name ||= email
+ password ||= generate_password(
+ length: @password_length,
+ exclude: @password_exclude,
+ minimum_lowercase: @password_minimum_lowercase,
+ minimum_uppercase: @password_minimum_uppercase,
+ minimum_numbers: @password_minimum_numbers,
+ minimum_symbols: @password_minimum_symbols
+ )
+
+ internal_user = Models::InternalUser.from_json(
+ {
+ name: name,
+ email: email,
+ password: password,
+ firstName: first_name,
+ lastName: last_name,
+ description: description, # custom_attributes: custom_attributes
+ identityGroups: identity_groups.join(","),
+ }.to_json)
+
+ logger.debug { "Creating Internal User: #{internal_user.to_json}" } if @debug
+
+ response = post("/internaluser/", body: {"InternalUser" => internal_user}.to_json, headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug
+
+ raise "Failed to create internal user, code #{response.status_code}\n#{response.body}" unless response.success?
+
+ user = get_internal_user_by_name(name)
+ user.password = password
+ user
+ end
+
+ def get_internal_user_by_id(id : String)
+ response = get("/internaluser/#{id}", headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug
+
+ raise "failed to get internal user by id, code #{response.status_code}\n#{response.body}" unless response.success?
+
+ parsed_body = JSON.parse(response.body)
+ internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json)
+
+ internal_user
+ end
+
+ def get_internal_user_by_name(name : String)
+ response = get("/internaluser/name/#{name}", headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug
+
+ raise "failed to get internal user by name, code #{response.status_code}\n#{response.body}" unless response.success?
+
+ parsed_body = JSON.parse(response.body)
+ internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json)
+
+ internal_user
+ end
+
+ def get_internal_user_by_email(email : String)
+ response = get("/internaluser/?filter=email.CONTAINS.#{email}", headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug
+
+ raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success?
+
+ parsed_body = JSON.parse(response.body)
+
+ resources = parsed_body["SearchResult"].as_h.["resources"].as_a
+
+ raise "returned body has no resources" if resources.empty?
+
+ get_internal_user_by_id(resources.first.as_h.["id"].to_s)
+ end
+
+ def update_internal_user_password_by_id(id : String, password : String? = nil)
+ password ||= generate_password(
+ length: @password_length,
+ exclude: @password_exclude,
+ minimum_lowercase: @password_minimum_lowercase,
+ minimum_uppercase: @password_minimum_uppercase,
+ minimum_numbers: @password_minimum_numbers,
+ minimum_symbols: @password_minimum_symbols
+ )
+
+ response = put("/internaluser/#{id}", body: {"InternalUser" => {"password" => password}}.to_json, headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ raise "failed: #{response.status_code}: #{response.body}" unless response.success?
+
+ JSON.parse(response.body)
+ end
+
+ def update_internal_user_password_by_name(name : String, password : String? = nil)
+ password ||= generate_password(
+ length: @password_length,
+ exclude: @password_exclude,
+ minimum_lowercase: @password_minimum_lowercase,
+ minimum_uppercase: @password_minimum_uppercase,
+ minimum_numbers: @password_minimum_numbers,
+ minimum_symbols: @password_minimum_symbols
+ )
+
+ response = put("/internaluser/name/#{name}", body: {"InternalUser" => {"password" => password}}.to_json, headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ raise "failed: #{response.status_code}: #{response.body}" unless response.success?
+
+ JSON.parse(response.body)
+ end
+
+ def update_internal_user_password_by_email(email : String, password : String? = nil)
+ password ||= generate_password(
+ length: @password_length,
+ exclude: @password_exclude,
+ minimum_lowercase: @password_minimum_lowercase,
+ minimum_uppercase: @password_minimum_uppercase,
+ minimum_numbers: @password_minimum_numbers,
+ minimum_symbols: @password_minimum_symbols
+ )
+ internal_user = get_internal_user_by_email(email)
+
+ update_internal_user_password_by_id(internal_user.id.to_s, password)
+ end
+
+ def update_internal_user_identity_groups_by_id(id : String, identity_groups : Array(String))
+ internal_user = get_internal_user_by_id(id)
+
+ response = put("/internaluser/#{internal_user.id}", body: {"InternalUser" => {"identityGroups" => identity_groups.join(",")}}.to_json, headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success?
+
+ JSON.parse(response.body)
+ end
+
+ def update_internal_user_identity_groups_by_name(name : String, identity_groups : Array(String))
+ response = put("/internaluser/name/#{name}", body: {"InternalUser" => {"identityGroups" => identity_groups.join(",")}}.to_json, headers: {
+ "Accept" => TYPE_HEADER,
+ "Content-Type" => TYPE_HEADER,
+ "Authorization" => @basic_auth,
+ })
+
+ raise "failed: #{response.status_code}: #{response.body}" unless response.success?
+
+ JSON.parse(response.body)
+ end
+
+ def update_internal_user_identity_groups_by_email(email : String, identity_groups : Array(String))
+ internal_user = get_internal_user_by_email(email)
+
+ update_internal_user_identity_groups_by_id(internal_user.id.to_s, identity_groups)
+ end
+
+ # Todo, when ISE doesn't return 401 for Guest related api calls
+ # def create_guest (...)
+ # # sms_service_provider ||= @sms_service_provider
+ # # guest_type ||= @guest_type
+ # # portal_id ||= @portal_id
+
+ # # time_object = Time.unix(event_start).in(@timezone)
+ # # from_date = time_object.at_beginning_of_day.to_s(TIME_FORMAT)
+ # # to_date = time_object.at_end_of_day.to_s(TIME_FORMAT)
+
+ # # If company_name isn't passed
+ # # Hackily grab a company name from the attendee's email (we may be able to grab this from the signal if possible)
+ # # company_name ||= attendee_email.split('@')[1].split('.')[0].capitalize
+
+ # These custom attributes and any custom attribute needs to be predefined
+ # in the ISE GUI.
+ # custom_attributes = {
+ # "fromDate" => from_date,
+ # "toDate" => to_date,
+ # "location" => @location.to_s,
+ # "companyName" => company_name,
+ # "phoneNumber" => phone_number,
+ # "smsServiceProvider" => sms_service_provider.to_s,
+ # "guestType" => guest_type,
+ # "portalId" => portal_id,
+ # } of String => String
+
+ # custom_attributes.merge!(@custom_data)
+ # end
+end
diff --git a/drivers/cisco/ise/network_access_spec.cr b/drivers/cisco/ise/network_access_spec.cr
new file mode 100644
index 00000000000..5f0b76aa78d
--- /dev/null
+++ b/drivers/cisco/ise/network_access_spec.cr
@@ -0,0 +1,39 @@
+require "placeos-driver"
+require "./network_access"
+require "./models/internal_user"
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::Ise::NetworkAccess" do
+ # Mock data for GUEST Users only
+ # portal = "portal101"
+ # phone = "0123456789"
+ # type = "Contractor"
+ # lo = "New York"
+ # settings({
+ # portal_id: portal,
+ # guest_type: type,
+ # location: lo,
+ # })
+ # start_time = Time.local(Time::Location.load("Australia/Sydney"))
+ # start_date = start_time.at_beginning_of_day.to_s(Cisco::Ise::NetworkAccess::TIME_FORMAT)
+ # end_date = start_time.at_end_of_day.to_s(Cisco::Ise::NetworkAccess::TIME_FORMAT)
+
+ # Test INTERNAL User creation
+ attendee_email = "attendee@test.com"
+ exec(:create_internal, email: attendee_email, name: attendee_email) # The attendee name must be unique, and in most real-world use cases, the clients prefer that to be the email address
+ # POST to /internaluser/
+ expect_http_request do |request, response|
+ parsed_body = JSON.parse(request.body.not_nil!)
+ internal_user = Cisco::Ise::Models::InternalUser.from_json(parsed_body["InternalUser"].to_json)
+
+ email_address = internal_user.email
+ email_address.should eq attendee_email
+
+ name = internal_user.name
+ name.should eq attendee_email
+
+ response.status_code = 201
+ response.headers["Location"] = "https://ise-pan:9060/ers/config/internaluser/e1bb8290-6ccb-11e3-8cdf-000c29c56fc7"
+ response.headers["Content-Type"] = "application/xml"
+ end
+end
diff --git a/drivers/cisco/meraki/captive_portal.cr b/drivers/cisco/meraki/captive_portal.cr
new file mode 100644
index 00000000000..acf9d316f7f
--- /dev/null
+++ b/drivers/cisco/meraki/captive_portal.cr
@@ -0,0 +1,138 @@
+require "json"
+require "openssl"
+require "placeos-driver"
+
+class Cisco::Meraki::CaptivePortal < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Cisco Meraki Captive Portal"
+ generic_name :CaptivePortal
+ description %(
+ for more information visit: https://meraki.cisco.com/lib/pdf/meraki_whitepaper_captive_portal.pdf
+ )
+
+ default_settings({
+ wifi_secret: "anything really",
+ default_timezone: "Australia/Sydney",
+ date_format: "%Y%m%d",
+ # duration of access in hours
+ access_duration: 12,
+ # Length of the clients wifi code
+ code_length: 4,
+ success_url: "https://company.com/welcome",
+ })
+
+ @wifi_secret : String = ""
+ @date_format : String = "%Y%m%d"
+ @success_url : String = "https://place.technology/"
+ @default_timezone : Time::Location = Time::Location.load("Australia/Sydney")
+ @access_duration : Time::Span = 12.hours
+ @code_length : Int32 = 4
+
+ @denied : UInt64 = 0_u64
+ @granted : UInt64 = 0_u64
+ @errors : UInt64 = 0_u64
+
+ @guests : Hash(String, ChallengePayload) = {} of String => ChallengePayload
+
+ def on_update
+ @wifi_secret = setting?(String, :wifi_secret) || "anything really"
+ @date_format = setting?(String, :date_format) || "%Y%m%d"
+ @success_url = setting?(String, :success_url) || "https://place.technology/"
+ @access_duration = (setting?(Int32, :access_duration) || 12).hours
+ @code_length = setting?(Int32, :code_length) || 4
+
+ time_zone = setting?(String, :default_timezone).presence
+ @default_timezone = Time::Location.load(time_zone) if time_zone
+ end
+
+ @[Security(Level::Support)]
+ def guests
+ @guests
+ end
+
+ @[Security(Level::Support)]
+ def lookup(mac : String)
+ @guests[format_mac(mac)]
+ end
+
+ def generate_guest_data(email : String, time : Int64, time_zone : String? = nil)
+ time_zone = time_zone.presence ? Time::Location.load(time_zone.not_nil!) : @default_timezone
+ date = Time.unix(time).in(time_zone).to_s(@date_format)
+ guest_string = "#{email.downcase}-#{date}-#{@wifi_secret}"
+
+ OpenSSL::Digest.new("SHA256").update(guest_string).final.hexstring
+ end
+
+ # Splits the SHA256 into code length and then randomly selects one
+ def generate_guest_token(email : String, time : Int64, time_zone : String? = nil)
+ generate_guest_data(email, time, time_zone).scan(/.{#{@code_length}}/).sample(1)[0][0]
+ end
+
+ class ChallengePayload
+ include JSON::Serializable
+
+ property ap_mac : String
+ property client_ip : String
+ property client_mac : String
+ property base_grant_url : String
+ property user_continue : String?
+
+ # key they were provided in their invite email
+ property code : String
+ property email : String
+ property timezone : String?
+
+ property expires : Time? = nil
+ end
+
+ EMPTY_HEADERS = {} of String => String
+ JSON_HEADERS = {
+ "Content-Type" => "application/json",
+ }
+
+ # Webhook for providing guest access
+ def challenge(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "guest access attempt: #{method},\nheaders #{headers},\nbody #{body}" }
+
+ challenge = ChallengePayload.from_json(body)
+
+ check_code = challenge.code
+ guest_codes = generate_guest_data(challenge.email, Time.utc.to_unix, challenge.timezone)
+ matched = guest_codes.scan(/.{#{@code_length}}/).count { |code| code[0] == check_code } > 0
+
+ if matched
+ challenge.expires = @access_duration.from_now
+ @guests[format_mac(challenge.client_mac)] = challenge
+ @granted += 1_u64
+ self[:granted_access] = @granted
+
+ redirect_url = "#{challenge.base_grant_url}?duration=#{@access_duration.to_i}&continue_url=#{challenge.user_continue || @success_url}"
+ response = {
+ redirect_to: redirect_url,
+ }.to_json
+
+ logger.debug { "successful joined network #{challenge.inspect}" }
+
+ # Redirect to the success URL
+ {HTTP::Status::OK, JSON_HEADERS, response}
+ else
+ @denied += 1_u64
+ self[:denied_access] = @denied
+
+ logger.debug { "failed wifi access attempt by #{challenge.inspect}" }
+
+ {HTTP::Status::NOT_ACCEPTABLE, JSON_HEADERS, "{}"}
+ end
+ rescue error
+ @errors += 1_u64
+ self[:errors] = @errors
+ last_error = error.inspect_with_backtrace
+ self[:last_error] = last_error
+ logger.error { "failed to parse wifi challenge payload\n#{error}" }
+ {HTTP::Status::INTERNAL_SERVER_ERROR, EMPTY_HEADERS, nil}
+ end
+
+ protected def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+end
diff --git a/drivers/cisco/meraki/captive_portal_spec.cr b/drivers/cisco/meraki/captive_portal_spec.cr
new file mode 100644
index 00000000000..11b01cf020b
--- /dev/null
+++ b/drivers/cisco/meraki/captive_portal_spec.cr
@@ -0,0 +1,16 @@
+require "openssl"
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::Meraki::CaptivePortal" do
+ date = Time.unix(1599477274).in(Time::Location.load("Australia/Sydney")).to_s("%Y%m%d")
+ hexdigest = OpenSSL::Digest.new("SHA256").update("guest@email.com-#{date}-anything really").final.hexstring
+
+ # Check the hex codes match
+ retval = exec(:generate_guest_data, "guest@email.com", 1599477274, "Australia/Sydney")
+ retval.get.should eq hexdigest
+
+ # check it matches on of the codes
+ codes = hexdigest.scan(/.{4}/).map { |code| code[0] }
+ retval = exec(:generate_guest_token, "guest@email.com", 1599477274, "Australia/Sydney")
+ codes.includes?(retval.get.not_nil!.as_s).should eq true
+end
diff --git a/drivers/cisco/meraki/dashboard.cr b/drivers/cisco/meraki/dashboard.cr
new file mode 100644
index 00000000000..9e5c8b2edf7
--- /dev/null
+++ b/drivers/cisco/meraki/dashboard.cr
@@ -0,0 +1,252 @@
+require "uri"
+require "json"
+require "link-header"
+require "placeos-driver"
+require "./scanning_api"
+
+class Cisco::Meraki::Dashboard < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Cisco Meraki Dashboard"
+ generic_name :Dashboard
+ uri_base "https://api.meraki.com"
+ description %(
+ for more information visit:
+ * Dashboard API: https://documentation.meraki.com/zGeneral_Administration/Other_Topics/The_Cisco_Meraki_Dashboard_API
+ * Scanning API: https://developer.cisco.com/meraki/scanning-api/#!introduction/scanning-api
+
+ NOTE:: API Call volume is rate limited to 5 calls per second per organization.
+ )
+
+ default_settings({
+ meraki_validator: "configure if scanning API is enabled",
+ meraki_secret: "configure if scanning API is enabled",
+ meraki_api_key: "configure for the dashboard API",
+
+ # Max requests a second made to the dashboard
+ rate_limit: 4,
+ debug_payload: false,
+
+ # filter message type
+ scanning_api_filter: "WiFi",
+ })
+
+ def on_load
+ spawn { rate_limiter }
+ on_update
+ end
+
+ def on_unload
+ @channel.close
+ end
+
+ @scanning_validator : String = ""
+ @scanning_secret : String = ""
+ @api_key : String = ""
+ @scanning_api_filter : MessageType = MessageType::WiFi
+
+ @rate_limit : Int32 = 4
+ @channel : Channel(Nil) = Channel(Nil).new(1)
+ @queue_lock : Mutex = Mutex.new
+ @queue_size = 0
+ @wait_time : Time::Span = 300.milliseconds
+
+ @debug_payload : Bool = false
+
+ def on_update
+ @scanning_validator = setting?(String, :meraki_validator) || ""
+ @scanning_secret = setting?(String, :meraki_secret) || ""
+ @api_key = setting?(String, :meraki_api_key) || ""
+ @scanning_api_filter = setting?(MessageType, :scanning_api_filter) || MessageType::WiFi
+
+ @rate_limit = setting?(Int32, :rate_limit) || 4
+ @wait_time = 1.second / @rate_limit
+
+ @debug_payload = setting?(Bool, :debug_payload) || false
+ end
+
+ # Perform fetch with the required API request limits in place
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def fetch(location : String)
+ req(location, &.body)
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def fetch_all(location : String)
+ responses = [] of String
+ req_all_pages(location) { |response| responses << response.body }
+ responses
+ end
+
+ protected def req(location : String)
+ if (@wait_time * @queue_size) > 10.seconds
+ raise "wait time would be exceeded for API request, #{@queue_size} requests already queued"
+ end
+
+ @queue_lock.synchronize { @queue_size += 1 }
+ @channel.receive
+ @queue_lock.synchronize { @queue_size -= 1 }
+
+ headers = HTTP::Headers{
+ "X-Cisco-Meraki-API-Key" => @api_key,
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ "User-Agent" => "PlaceOS/2.0 PlaceTechnology",
+ }
+
+ uri = URI.parse(location)
+ response = if uri.host.nil?
+ get(location, headers: headers)
+ else
+ HTTP::Client.get(location, headers: headers)
+ end
+
+ if response.success?
+ yield response
+ elsif response.status.found?
+ # Meraki might return a `302` on GET requests
+ response = HTTP::Client.get(response.headers["Location"], headers: headers)
+ if response.success?
+ yield response
+ else
+ raise "request #{location} failed with status: #{response.status_code}"
+ end
+ else
+ raise "request #{location} failed with status: #{response.status_code}"
+ end
+ end
+
+ protected def req_all_pages(location : String) : Nil
+ next_page = location
+
+ loop do
+ break unless next_page
+
+ next_page = req(next_page) do |response|
+ yield response
+ LinkHeader.new(response)["next"]?
+ end
+ end
+ end
+
+ EMPTY_HEADERS = {} of String => String
+ SUCCESS_RESPONSE = {HTTP::Status::OK.to_i, EMPTY_HEADERS, nil}
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def organizations
+ req("/api/v1/organizations?perPage=1000") do |response|
+ Array(Organization).from_json(response.body)
+ end
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def networks(organization_id : String)
+ nets = [] of Network
+ req_all_pages("/api/v1/organizations/#{organization_id}/networks?perPage=1000") do |response|
+ nets.concat Array(Network).from_json(response.body)
+ end
+ nets
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def poll_clients(
+ network_id : String? = nil,
+ timespan : UInt32 = 900_u32,
+ connection : ConnectionType? = nil,
+ device_serial : String? = nil,
+ statuses : String = "Online"
+ )
+ params = URI::Params.build do |form|
+ form.add "perPage", "1000"
+ form.add "timespan", timespan.to_s
+ form.add "statuses[]", statuses
+ form.add "recentDeviceConnections[]", connection.to_s if connection
+ end
+
+ clients = [] of Client
+ req_all_pages "/api/v1/networks/#{network_id}/clients?#{params}" do |response|
+ clients.concat Array(Client).from_json(response.body)
+ end
+
+ if device_serial
+ clients.select! { |client| client.recent_device_serial == device_serial }.sort! { |a, b| b.last_seen <=> a.last_seen }
+ else
+ clients.sort! { |a, b| b.last_seen <=> a.last_seen }
+ end
+ end
+
+ def ports_statuses(device_serial : String)
+ req("/api/v1/devices/#{device_serial}/switch/ports/statuses") do |response|
+ Array(PortStatusResponse).from_json(response.body)
+ end
+ end
+
+ def get_zones(camera_serial : String)
+ req("/api/v1/devices/#{camera_serial}/camera/analytics/zones") do |response|
+ Array(CameraZone).from_json(response.body)
+ end
+ end
+
+ # Webhook endpoint for scanning API, expects version 3
+ def scanning_api(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "scanning API received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+ logger.debug { body } if @debug_payload
+
+ # Return the scanning API validator code on a GET request
+ return {HTTP::Status::OK.to_i, EMPTY_HEADERS, @scanning_validator} if method == "GET"
+
+ # Check the version matches
+ if !body.starts_with?(%({"version":"3.0"))
+ logger.warn { "unknown scanning API message received:\n#{body[0..96]}" }
+ return SUCCESS_RESPONSE
+ end
+
+ # Parse the data posted
+ begin
+ seen = DevicesSeen.from_json(body)
+ logger.debug { "parsed meraki payload" }
+
+ # filter out observations we're not interested in
+ if !@scanning_api_filter.none? && seen.message_type != @scanning_api_filter
+ logger.debug { "ignoring message type: #{seen.message_type}" }
+ return SUCCESS_RESPONSE
+ end
+
+ # Check the secret matches
+ raise "secret mismatch, sent: #{seen.secret}" unless seen.secret == @scanning_secret
+
+ self[seen.data.network_id] = seen.data.observations
+ rescue e
+ logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" }
+ logger.debug { "failed payload body was\n#{body}" }
+ end
+
+ # Return a 200 response
+ SUCCESS_RESPONSE
+ end
+
+ # a webhook for obtaining changes in port status
+ def port_status(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "Webhook Alert received: #{method},\nheaders #{headers},\nbody #{body}" }
+
+ self[:port_update] = WebhookAlert.from_json(body)
+
+ # Return a 200 response
+ SUCCESS_RESPONSE
+ end
+
+ protected def rate_limiter
+ loop do
+ break if @channel.closed?
+ begin
+ @channel.send(nil)
+ rescue error
+ logger.error(exception: error) { "issue with rate limiter" }
+ ensure
+ sleep @wait_time
+ end
+ end
+ rescue
+ # Possible error with logging exception, restart rate limiter silently
+ spawn { rate_limiter } unless @channel.closed?
+ end
+end
diff --git a/drivers/cisco/meraki/dashboard_spec.cr b/drivers/cisco/meraki/dashboard_spec.cr
new file mode 100644
index 00000000000..b5ca78a6486
--- /dev/null
+++ b/drivers/cisco/meraki/dashboard_spec.cr
@@ -0,0 +1,21 @@
+require "./scanning_api"
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::Meraki::Dashboard" do
+ # Send the request
+ retval = exec(:fetch, "/api/v0/organizations")
+
+ # The dashboard should send a HTTP request with the API key
+ expect_http_request do |request, response|
+ headers = request.headers
+ if headers["X-Cisco-Meraki-API-Key"]? == "configure for the dashboard API"
+ response.status_code = 202
+ response << %([{"id":"org id","name":"place tech"}])
+ else
+ response.status_code = 401
+ end
+ end
+
+ # Should return the payload
+ retval.get.should eq %([{"id":"org id","name":"place tech"}])
+end
diff --git a/drivers/cisco/meraki/geo.cr b/drivers/cisco/meraki/geo.cr
new file mode 100644
index 00000000000..cf2c419721c
--- /dev/null
+++ b/drivers/cisco/meraki/geo.cr
@@ -0,0 +1,73 @@
+require "math"
+require "json"
+
+module Cisco; end
+
+module Cisco::Meraki; end
+
+module Cisco::Meraki::Geo
+ struct Point
+ include JSON::Serializable
+
+ def initialize(@lat, @lng)
+ end
+
+ property lat : Float64
+ property lng : Float64
+ end
+
+ struct Distance
+ include JSON::Serializable
+
+ def initialize(@x, @y)
+ end
+
+ property x : Float64
+ property y : Float64
+ end
+
+ def self.calculate_xy(top_left : Point, bottom_left : Point, bottom_right : Point, position, distance : Distance)
+ y_base = geo_distance(top_left, bottom_left)
+ a = geo_distance(top_left, position)
+ c = geo_distance(bottom_left, position)
+ x_raw = triangle_height(a, y_base, c)
+
+ x_base = geo_distance(bottom_left, bottom_right)
+ a = geo_distance(bottom_left, position)
+ c = geo_distance(bottom_right, position)
+ y_raw = triangle_height(a, x_base, c)
+
+ # find the percentage distance from the origin
+ percentage_height = y_raw / y_base
+ percentage_width = x_raw / x_base
+
+ # adjust into range provided by the original distances
+ Distance.new(distance.x * percentage_width, distance.y * percentage_height)
+ end
+
+ # radius in meters, approx as we're using a perfect sphere the same volume as the earth
+ EarthRadiusApprox = 6371000.7900_f64
+ Radians = Math::PI / 180_f64
+
+ # https://www.movable-type.co.uk/scripts/latlong.html
+ # returns the distance in meters
+ def self.geo_distance(start : Point, ending)
+ lat_diff = (ending.lat - start.lat) * Radians
+ lng_diff = (ending.lng - start.lng) * Radians
+ start_lat_radian = start.lat * Radians
+ end_lng_radian = ending.lng * Radians
+
+ a = Math.sin(lat_diff / 2_f64) * Math.sin(lat_diff / 2_f64) +
+ Math.cos(start_lat_radian) * Math.cos(end_lng_radian) *
+ Math.sin(lng_diff / 2_f64) * Math.sin(lng_diff / 2_f64)
+
+ c = 2_f64 * Math.atan2(Math.sqrt(a), Math.sqrt(1_f64 - a))
+
+ EarthRadiusApprox * c
+ end
+
+ # https://www.omnicalculator.com/math/triangle-height
+ def self.triangle_height(a : Float64, base : Float64, c : Float64)
+ 0.5_f64 * Math.sqrt((a + base + c) * (base + c - a) * (a - base + c) * (a + base - c)) / base
+ end
+end
diff --git a/drivers/cisco/meraki/meraki_locations.cr b/drivers/cisco/meraki/meraki_locations.cr
new file mode 100644
index 00000000000..11347efa2a0
--- /dev/null
+++ b/drivers/cisco/meraki/meraki_locations.cr
@@ -0,0 +1,1337 @@
+require "placeos-driver"
+require "json"
+require "s2_cells"
+require "./mqtt_models"
+require "./scanning_api"
+require "../../place/area_polygon"
+require "placeos-driver/interface/sensor"
+require "placeos-driver/interface/locatable"
+
+class Cisco::Meraki::Locations < PlaceOS::Driver
+ include Interface::Locatable
+ include Interface::Sensor
+
+ # Discovery Information
+ descriptive_name "Meraki Location Service"
+ generic_name :MerakiLocations
+
+ description %(requires meraki dashboard driver for API calls)
+
+ accessor dashboard : Dashboard_1
+ accessor staff_api : StaffAPI_1
+ accessor area_manager : AreaManagement_1
+
+ default_settings({
+ # We will always accept a reading with a confidence lower than this
+ acceptable_confidence: 5.0,
+
+ # Max Uncertainty in meters - we don't accept positions that are less certain
+ maximum_uncertainty: 25.0,
+
+ # For confident yet inaccurate location data/maps. If a location's variance is below this threshold, increase it to this value.
+ # 0.0 disables the override
+ override_min_variance: 0.0,
+
+ # Optionally only store locations for devices whose "os" property matches this regex string.
+ regex_filter_device_os: nil,
+
+ # can we use the meraki dashboard API for user lookups
+ default_network_id: "network_id",
+
+ # Area index each point on a floor lands on
+ # 21 == ~4 meters squared, which given wifi variance is good enough for tracing
+ # S2 cell levels: https://s2geometry.io/resources/s2cell_statistics.html
+ s2_level: 21,
+ debug_payload: false,
+ debug_webhook: false,
+
+ # Level mappings, level name for human readability
+ floorplan_mappings: {
+ "g_727894289773756672" => {
+ "building": "zone-12345",
+ "level": "zone-123456",
+ "level_name": "BUILDING - L1",
+ },
+ },
+
+ # Time before a user location is considered probably too old
+ max_location_age: 10,
+
+ # Ignore certain usernames from the dashboard
+ ignore_usernames: ["host/"],
+
+ # Enable / Disable dashboard username lookup completely
+ disable_username_lookup: false,
+
+ # Where desks have no occupancy
+ return_empty_spaces: true,
+
+ # wired desks mappings
+ wired_desks: [
+ {
+ serial: "switch-serial-number",
+ level_id: "zone-1234",
+ ports: {
+ 11 => "desk-1234",
+ 12 => "desk-5678",
+ },
+ },
+ ],
+ })
+
+ alias WiredDesks = Hash(String, Hash(Int32, String))
+
+ def on_load
+ # We want to store our user => mac_address mappings in redis
+ @user_mac_mappings = PlaceOS::Driver::RedisStorage.new(module_id, "user_macs")
+ on_update
+ end
+
+ @acceptable_confidence : Float64 = 5.0
+ @maximum_uncertainty : Float64 = 25.0
+ @override_min_variance : Float64 = 0.0
+ @regex_filter_device_os : String? = nil
+ getter building_zone : String = "searching..."
+
+ @time_multiplier : Float64 = 0.0
+ @confidence_multiplier : Float64 = 0.0
+ @max_location_age : Time::Span = 6.minutes
+ @drift_location_age : Time::Span = 4.minutes
+ @confidence_time : Time::Span = 2.minutes
+
+ @storage_lock : Mutex = Mutex.new
+ @user_mac_mappings : PlaceOS::Driver::RedisStorage? = nil
+ @default_network : String = ""
+ @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new
+ @floorplan_sizes = {} of String => FloorPlan
+ @network_devices = {} of String => NetworkDevice
+
+ @s2_level : Int32 = 21
+ @ignore_usernames : Array(String) = [] of String
+ @return_empty_spaces : Bool = true
+
+ @debug_payload : Bool = false
+ @debug_webhook : Bool = false
+
+ def on_update
+ @default_network = setting?(String, :default_network_id) || ""
+ @return_empty_spaces = setting?(Bool, :return_empty_spaces) || false
+
+ @acceptable_confidence = setting?(Float64, :acceptable_confidence) || 5.0
+ @maximum_uncertainty = setting?(Float64, :maximum_uncertainty) || 25.0
+ @override_min_variance = setting?(Float64, :override_min_variance) || 0.0
+ @regex_filter_device_os = setting?(String, :regex_filter_device_os)
+
+ @max_location_age = (setting?(UInt32, :max_location_age) || 6).minutes
+ # Age we keep a confident value (without drifting towards less confidence)
+ @confidence_time = @max_location_age / 3
+ # Age at which we discard a drifting value (accepting a less confident value)
+ @drift_location_age = @max_location_age - @confidence_time
+
+ # How much confidence do we have in this new value, relative to an old confident value
+ @time_multiplier = 1.0_f64 / (@drift_location_age.to_i - @confidence_time.to_i).to_f64
+ @confidence_multiplier = 1.0_f64 / (@maximum_uncertainty.to_i - @acceptable_confidence.to_i).to_f64
+
+ @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings
+
+ @s2_level = setting?(Int32, :s2_level) || 21
+ @debug_payload = setting?(Bool, :debug_payload) || false
+ @debug_webhook = setting?(Bool, :debug_webhook) || false
+ @ignore_usernames = setting?(Array(String), :ignore_usernames) || [] of String
+ disable_username_lookup = setting?(Bool, :disable_username_lookup) || false
+
+ schedule.clear
+
+ # Wired desk data
+ init_wired_port_mappings
+
+ if @default_network.presence
+ schedule.every(59.seconds) { update_sensor_cache }
+ schedule.every(2.minutes) { map_users_to_macs } unless disable_username_lookup
+ schedule.every(29.minutes) { sync_floorplan_sizes }
+
+ schedule.in(30.milliseconds) do
+ sync_floorplan_sizes
+ update_sensor_cache
+ end
+ end
+ schedule.every(30.minutes) { cleanup_caches }
+
+ subscriptions.clear
+ if @default_network.presence
+ dashboard.subscribe(@default_network) do |_subscription, new_value|
+ # values are always raw JSON strings
+ parse_new_locations(new_value)
+ end
+ end
+
+ zones = config.control_system.not_nil!.zones
+ spawn { find_building(zones) }
+
+ # Grab desk data from the MQTT connection
+ if system.exists? :MerakiMQTT
+ mqtt_module = system[:MerakiMQTT]
+ mqtt_module.subscribe(:floor_lookup) do |_sub, new_value|
+ next if new_value.nil? || new_value == "null"
+ @floor_lookup = Hash(String, FloorMapping).from_json(new_value)
+ update_desk_mappings unless @zone_lookup.empty?
+ end
+ mqtt_module.subscribe(:zone_lookup) do |_sub, new_value|
+ next if new_value.nil? || new_value == "null"
+ @zone_lookup = Hash(String, Array(String)).from_json(new_value)
+ update_desk_mappings unless @floor_lookup.empty?
+ end
+ schedule.every(10.minutes) { update_desk_mappings }
+ mqtt_module.subscribe(:camera_updated) do |_sub, new_value|
+ next if new_value.nil? || new_value == "null"
+ _time, camera_serial = Tuple(Int64, String).from_json(new_value)
+
+ if @desk_mappings.has_key? camera_serial
+ check_camera_status(mqtt_module, camera_serial)
+ end
+ end
+ end
+ end
+
+ # grab building zone from the current system
+ protected def find_building(zones : Array(String)) : Nil
+ zones.each do |zone_id|
+ zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json
+ if zone.tags.includes?("building")
+ @building_zone = zone.id
+ break
+ end
+ end
+ raise "no building zone found in System" unless @building_zone
+ rescue error
+ logger.warn(exception: error) { "error looking up building zone" }
+ schedule.in(5.seconds) { find_building(zones) }
+ end
+
+ protected def check_camera_status(mqtt_module, camera_serial)
+ detected_desks = mqtt_module.status(DetectedDesks, "camera_#{camera_serial}_desks")
+ desk_details[camera_serial] = detected_desks
+ average_results(camera_serial, detected_desks)
+ if lux_level = mqtt_module.status?(Float64, "camera_#{camera_serial}_lux")
+ lux[camera_serial] = lux_level
+ end
+ end
+
+ # serial => desks detected
+ getter desk_details : Hash(String, DetectedDesks) = {} of String => DetectedDesks
+
+ # serial => lux
+ getter lux : Hash(String, Float64) = {} of String => Float64
+
+ protected def user_mac_mappings
+ @storage_lock.synchronize {
+ yield @user_mac_mappings.not_nil!
+ }
+ end
+
+ protected def req(location : String)
+ response = dashboard.fetch(location).get.as_s
+ begin
+ yield response
+ rescue error
+ logger.debug(exception: error) { "processing failed for #{location} with response: #{response}" }
+ raise error
+ end
+ end
+
+ protected def req_all(location : String)
+ dashboard.fetch_all(location).get.as_a.each { |resp| yield resp.as_s }
+ end
+
+ struct Lookup
+ include JSON::Serializable
+
+ property time : Time
+ property mac : String
+
+ def initialize(@time, @mac)
+ end
+ end
+
+ # MAC Address => Location
+ @locations : Hash(String, DeviceLocation) = {} of String => DeviceLocation
+ @ip_lookup : Hash(String, Lookup) = {} of String => Lookup
+
+ def lookup_ip(address : String)
+ @ip_lookup[address.downcase]?
+ end
+
+ def locate_mac(address : String)
+ @locations[format_mac(address)]?
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def inspect_foorplans
+ @floorplan_sizes
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def inspect_network_devices
+ @network_devices
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def inspect_state
+ logger.debug {
+ "IP Mappings: #{@ip_lookup.keys}\n\nMAC Locations: #{@locations.keys}\n\nClient Details: #{@client_details.keys}"
+ }
+ {ip_mappings: @ip_lookup.size, tracking: @locations.size, client_details: @client_details.size}
+ end
+
+ # Returns the list of users who can be located
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def locateable
+ too_old = @max_location_age.ago
+ @client_details.compact_map do |mac, client|
+ location = @locations[mac]?
+ client.user if location && ((location.time > too_old) || (client.time_added > too_old))
+ end
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def poll_clients(
+ network_id : String? = nil,
+ timespan : UInt32 = 900_u32,
+ connection : ConnectionType? = nil,
+ device_serial : String? = nil
+ )
+ network_id = network_id.presence || @default_network
+ Array(Client).from_json dashboard.poll_clients(network_id, timespan, connection, device_serial).get.to_json
+ end
+
+ @client_details : Hash(String, Client) = {} of String => Client
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def map_users_to_macs(network_id : String? = nil)
+ network_id = network_id.presence || @default_network
+
+ logger.debug { "mapping users to device MACs" }
+ clients = poll_clients(network_id)
+
+ new_devices = 0
+ updated_dev = 0
+ now = Time.utc
+
+ logger.debug { "mapping found #{clients.size} devices" }
+
+ user_mac_mappings do |storage|
+ clients.each do |client|
+ # So we can merge additional details into device location responses
+ user_mac = format_mac(client.mac)
+ client.time_added = now
+
+ # Store the mac to hostname lookups to help with learning
+ if hostname = client.description
+ @mac_hostnames[user_mac] = hostname
+ end
+
+ user_id = client.user
+
+ if user_id
+ @ignore_usernames.each do |name|
+ if user_id.starts_with?(name)
+ client.user = user_id = nil
+ break
+ end
+ end
+ end
+
+ # Attempt to lookup username via learning
+ if user_id.nil?
+ if known_id = storage[user_mac]?
+ client.user = known_id
+ end
+ end
+
+ @client_details[user_mac] = client
+ next unless user_id
+
+ was_update, was_new = map_user_mac(user_mac, user_id, storage)
+ updated_dev += 1 if was_update
+ new_devices += 1 if was_new
+ end
+ end
+
+ logger.debug { "mapping assigned #{new_devices} new devices, #{updated_dev} user updated" }
+ nil
+ end
+
+ protected def map_user_mac(user_mac, user_id, storage)
+ updated_dev = false
+ new_devices = false
+ user_id = format_username(user_id)
+
+ # Check if mac mapping already exists
+ existing_user = storage[user_mac]?
+ return {false, false} if existing_user == user_id
+
+ # Remove any pervious mappings
+ if existing_user
+ updated_dev = true
+ if user_macs = storage[existing_user]?
+ macs = Array(String).from_json(user_macs)
+ macs.delete(user_mac)
+ storage[existing_user] = macs.to_json
+ end
+ else
+ new_devices = true
+ end
+
+ # Update the user mappings
+ storage[user_mac] = user_id
+ macs = if user_macs = storage[user_id]?
+ tmp_macs = Array(String).from_json(user_macs)
+ tmp_macs.unshift(user_mac)
+ tmp_macs.uniq!
+ tmp_macs[0...9]
+ else
+ [user_mac]
+ end
+ storage[user_id] = macs
+
+ {updated_dev, new_devices}
+ end
+
+ def format_username(user : String)
+ if user.includes? "@"
+ user = user.split("@")[0]
+ elsif user.includes? "\\"
+ user = user.split("\\")[1]
+ end
+ user.downcase
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ username = format_username(username.presence || email.presence.not_nil!)
+ if macs = user_mac_mappings(&.[username]?)
+ Array(String).from_json(macs)
+ else
+ [] of String
+ end
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ lookup = format_mac(mac_address)
+ if user = user_mac_mappings(&.[lookup]?)
+ {
+ location: "wireless",
+ assigned_to: user,
+ mac_address: lookup,
+ }
+ end
+ end
+
+ # returns locations based on most recently seen
+ # versus most accurate location
+ def locate_user(email : String? = nil, username : String? = nil)
+ username = format_username(username.presence || email.presence.not_nil!)
+
+ if macs = user_mac_mappings(&.[username]?)
+ location_max_age = @max_location_age.ago
+
+ Array(String).from_json(macs).compact_map { |mac|
+ if location = locate_mac(mac)
+ client = @client_details[mac]?
+
+ # If a filter is set, then ignore this device unless it matches
+ if @regex_filter_device_os
+ if client && client.os
+ unless /#{@regex_filter_device_os}/.match(client.os.not_nil!)
+ logger.debug { "[#{username}] IGNORING #{mac} as OS does not match regex filter" }
+ next
+ end
+ else
+ logger.debug { "[#{username}] IGNORING #{mac} as OS is UNKNOWN" }
+ next
+ end
+ end
+
+ # We set these here to speed up processing
+ location.client = client
+ location.mac = mac
+
+ if client && client.time_added > location_max_age
+ location
+ elsif location.time > location_max_age
+ location
+ end
+ end
+ }.sort! { |a, b|
+ b.time <=> a.time
+ }.map { |location|
+ lat = location.lat
+ lon = location.lng
+
+ loc = {
+ "location" => "wireless",
+ "coordinates_from" => "bottom-left",
+ "x" => location.x,
+ "y" => location.y,
+ "lon" => lon,
+ "lat" => lat,
+ "s2_cell_id" => lat ? S2Cells.at(lat.not_nil!, lon.not_nil!).parent(@s2_level).to_token : nil,
+ "mac" => location.mac,
+ "variance" => location.variance,
+ "last_seen" => location.time.to_unix,
+ "meraki_floor_id" => location.floor_plan_id,
+ "meraki_floor_name" => location.floor_plan_name,
+ }
+
+ # Add our zone IDs to the response
+ if level_data = @floorplan_mappings[location.floor_plan_id]?
+ level_data.each { |k, v| loc[k] = v }
+ end
+
+ # Add meraki map information to the response
+ if map_size = @floorplan_sizes[location.floor_plan_id]?
+ loc["map_width"] = map_size.width
+ loc["map_height"] = map_size.height
+ end
+
+ # Add additional client information if it's available
+ if client = location.client
+ loc["manufacturer"] = client.manufacturer if client.manufacturer
+ loc["os"] = client.os if client.os
+ loc["ssid"] = client.ssid if client.ssid
+ end
+
+ loc
+ }
+ else
+ [] of Nil
+ end
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "looking up device locations in #{zone_id}" }
+ case location.presence
+ when "wireless"
+ wireless_locations(zone_id)
+ when "desk"
+ desk_locs = wired_desk_locations(zone_id)
+ cam_locs = desk_locations(zone_id)
+ combind = Array(typeof(cam_locs[0]) | typeof(desk_locs[0])).new(cam_locs.size + desk_locs.size)
+ combind.concat(desk_locs)
+ combind.concat(cam_locs)
+ when nil
+ wireless_locs = wireless_locations(zone_id)
+ desk_locs = wired_desk_locations(zone_id)
+ cam_locs = desk_locations(zone_id)
+ combind = Array(typeof(wireless_locs[0]) | typeof(cam_locs[0]) | typeof(desk_locs[0])).new(wireless_locs.size + cam_locs.size + desk_locs.size)
+ combind.concat(wireless_locs)
+ combind.concat(desk_locs)
+ combind.concat(cam_locs)
+ else
+ [] of String
+ end
+ end
+
+ def wireless_locations(zone_id : String)
+ # Find the floors associated with the provided zone id
+ floors = [] of String
+ @floorplan_mappings.each do |floor_id, data|
+ floors << floor_id if data.values.includes?(zone_id)
+ end
+ logger.debug { "found matching meraki floors: #{floors}" }
+ return [] of String if floors.empty?
+
+ checking_count = @locations.size
+ wrong_floor = 0
+ too_old = 0
+
+ # Find the devices that are on the matching floors
+ oldest_location = @max_location_age.ago
+ matching = @locations.compact_map do |mac, loc|
+ # We set this here to speed up processing
+ client = @client_details[mac]?
+ loc.client = client
+
+ if loc.time < oldest_location
+ if client
+ if client.time_added < oldest_location
+ too_old += 1
+ next
+ end
+ else
+ too_old += 1
+ next
+ end
+ end
+ if !floors.includes?(loc.floor_plan_id)
+ wrong_floor += 1
+ next
+ end
+ # ensure the formatted mac is being used
+ loc.mac = mac
+ loc
+ end
+
+ logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" }
+
+ # Build the payload on the matching locations
+ matching.group_by(&.floor_plan_id).flat_map { |floor_id, locations|
+ map_width = -1.0
+ map_height = -1.0
+
+ if map_size = @floorplan_sizes[floor_id]?
+ map_width = map_size.width
+ map_height = map_size.height
+ elsif mappings = @floorplan_mappings[floor_id]?
+ map_width = (mappings["width"]? || map_width).as(Float64)
+ map_height = (mappings["height"]? || map_width).as(Float64)
+ end
+
+ locations.compact_map do |loc|
+ lat = loc.lat
+ lon = loc.lng
+
+ # Add additional client information if it's available
+ if client = @client_details[loc.mac]?
+ manufacturer = client.manufacturer
+ os = client.os
+ ssid = client.ssid
+ end
+
+ # Skip payloads with invalid coordinates
+ if (x = loc.x) && (y = loc.y)
+ if x.is_a?(Float64) && y.is_a?(Float64)
+ if loc.x.as(Float64).nan? || loc.y.as(Float64).nan?
+ logger.warn { "ignoring bad location for #{loc.mac}, NaN" }
+ next
+ end
+ else
+ logger.warn { "ignoring bad location for #{loc.mac}, unexpected value #{loc.x.inspect}" }
+ next
+ end
+ else
+ logger.warn { "ignoring bad location for #{loc.mac}, no coordinates provided" }
+ next
+ end
+
+ {
+ location: :wireless,
+ coordinates_from: "bottom-left",
+ x: loc.x,
+ y: loc.y,
+ lon: lon,
+ lat: lat,
+ s2_cell_id: lat ? S2Cells.at(lat.not_nil!, lon.not_nil!).parent(@s2_level).to_token : nil,
+ mac: loc.mac,
+ variance: loc.variance,
+ last_seen: loc.time.to_unix,
+ map_width: map_width,
+ map_height: map_height,
+ manufacturer: manufacturer,
+ os: os,
+ ssid: ssid,
+ }
+ end
+ }
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def cleanup_caches : Nil
+ logger.debug { "removing IP and location data that is over 30 minutes old" }
+
+ # IP => MAC mappings
+ old = 30.minutes.ago
+ remove_keys = [] of String
+ @ip_lookup.each { |ip, lookup| remove_keys << ip if lookup.time < old }
+ remove_keys.each { |ip| @ip_lookup.delete(ip) }
+ logger.debug { "removed #{remove_keys.size} IP => MAC mappings" }
+
+ # IP => Username mappings
+ remove_keys.clear
+ @ip_usernames.each { |ip, lookup| remove_keys << ip if lookup.time < old }
+ remove_keys.each { |ip| @ip_usernames.delete(ip) }
+ logger.debug { "removed #{remove_keys.size} IP => Username mappings" }
+
+ # Client details
+ remove_keys.clear
+ @client_details.each { |mac, client| remove_keys << mac if client.time_added < old }
+ remove_keys.each { |mac| @client_details.delete(mac) }
+ logger.debug { "removed #{remove_keys.size} client details" }
+
+ # MACs
+ remove_keys.clear
+ @locations.each do |mac, location|
+ if location.time < old
+ if client = @client_details[mac]?
+ remove_keys << mac if client.time_added < old
+ else
+ remove_keys << mac
+ end
+ end
+ end
+ remove_keys.each { |mac| @locations.delete(mac) }
+ logger.debug { "removed #{remove_keys.size} MACs" }
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def sync_floorplan_sizes(network_id : String? = nil)
+ network_id = network_id.presence || @default_network
+ logger.debug { "syncing floor plan sizes for network #{network_id}" }
+
+ floor_plans = {} of String => FloorPlan
+
+ req_all("/api/v1/networks/#{network_id}/floorPlans?perPage=1000") { |response|
+ Array(FloorPlan).from_json(response).each do |plan|
+ floor_plans[plan.id] = plan
+ end
+ nil
+ }
+
+ @floorplan_sizes = floor_plans
+
+ # mac address => device location
+ network_devices = {} of String => NetworkDevice
+ cameras = [] of NetworkDevice
+
+ req_all("/api/v1/networks/#{network_id}/devices?perPage=1000") { |response|
+ Array(NetworkDevice).from_json(response).each do |device|
+ cameras << device if device.firmware.starts_with?("cam")
+ next unless device.floor_plan_id
+ network_devices[format_mac(device.mac)] = device
+ end
+ nil
+ }
+
+ @network_devices = network_devices
+ @cameras = cameras
+
+ {floor_plans, network_devices}
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def camera_analytics(serial : String)
+ req("/api/v1/devices/#{serial}/camera/analytics/live") do |response|
+ CameraAnalytics.from_json(response)
+ end
+ end
+
+ alias CamAnalytics = NamedTuple(
+ camera: NetworkDevice,
+ details: CameraAnalytics,
+ building: String?,
+ level: String?)
+
+ @camera_analytics = {} of String => CamAnalytics
+ @cameras = [] of NetworkDevice
+
+ getter cameras
+
+ def update_sensor_cache
+ analytics = {} of String => CamAnalytics
+ cameras.each do |cam|
+ begin
+ mappings = @floorplan_mappings[cam.floor_plan_id]?
+ counts = camera_analytics(cam.serial)
+ mac = format_mac(cam.mac)
+ if mappings
+ analytics[mac] = {
+ camera: cam,
+ details: counts,
+ building: mappings["building"]?.as(String?),
+ level: mappings["level"]?.as(String?),
+ }
+ else
+ analytics[mac] = {
+ camera: cam,
+ details: counts,
+ building: nil.as(String?),
+ level: nil.as(String?),
+ }
+ end
+
+ counts.zones.each do |area_id, count|
+ self["people-#{mac}-#{area_id}"] = count.person
+ self["presence-#{mac}-#{area_id}"] = count.person > 0
+ end
+ rescue error
+ logger.debug(exception: error) { "failed to obtain analytics for #{cam.name} (serial: #{cam.serial})" }
+ end
+ end
+ @camera_analytics = analytics
+ end
+
+ # Webhook endpoint for scanning API, expects version 3
+ def parse_new_locations(payload : String) : Nil
+ logger.debug { payload } if @debug_payload
+
+ locations_updated = 0
+
+ # Parse the data posted
+ begin
+ observations = Array(Observation).from_json(payload)
+ logger.debug { "parsed meraki payload" }
+
+ ignore_older = @max_location_age.ago.in Time::Location::UTC
+ drift_older = @drift_location_age.ago.in Time::Location::UTC
+ current_time = Time.utc
+
+ observations.each do |observation|
+ client_mac = format_mac(observation.client_mac)
+ existing = @locations[client_mac]?
+ logger.debug { "parsing new observation for #{client_mac}" } if @debug_webhook
+
+ # If a filter is set, then ignore this device unless it matches
+ if @regex_filter_device_os
+ # client.os has more accurate data (observation.os is usually nil for iPhones)
+ client = @client_details[format_mac(observation.client_mac)]?
+ if client.nil? || /#{@regex_filter_device_os}/.match(client.os || "").nil?
+ logger.debug { "FILTERED OUT #{client_mac}: OS \"#{observation.os}\" did not match \"#{@regex_filter_device_os}\"" } if @debug_webhook
+ next
+ end
+ end
+ location = parse(existing, ignore_older, drift_older, observation)
+ if location
+ @locations[client_mac] = location
+ locations_updated += 1
+ end
+ update_ipv4(observation.ipv4, client_mac, current_time)
+ update_ipv6(observation.ipv6.try(&.downcase), client_mac, current_time)
+ end
+ rescue e
+ logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" }
+ logger.debug { "failed payload body was\n#{payload}" }
+ end
+
+ logger.debug { "updated #{locations_updated} locations" }
+ end
+
+ protected def parse(existing, ignore_older, drift_older, observation) : DeviceLocation?
+ locations_raw = observation.locations
+
+ # We'll attempt to return a location based on the nearest WAP
+ if locations_raw.empty?
+ last_seen = observation.latest_record
+ if wap_device = @network_devices[format_mac(last_seen.nearest_ap_mac)]?
+ return wap_device.location unless wap_device.location.nil?
+
+ if floor_plan = @floorplan_sizes[wap_device.floor_plan_id.not_nil!]?
+ return wap_device.location = DeviceLocation.calculate_location(floor_plan, wap_device, last_seen.time)
+ end
+ end
+ return nil
+ end
+
+ # existing.time is our ajusted time
+ if existing_time = existing.try &.time
+ existing = nil if existing_time < ignore_older
+ end
+
+ # remove locations that don't have an x,y or very uncertain or very old
+ locations = locations_raw.reject do |loc|
+ loc.get_x.nil? || loc.variance > @maximum_uncertainty
+ end
+
+ if locations.empty?
+ logger.debug {
+ if locations_raw.empty?
+ "ignored as no location data provided"
+ else
+ "ignored as no location in observation met minimum requirements, had coordinates: #{!!locations_raw[0].get_x}, uncertainty: #{locations_raw[0].variance}"
+ end
+ } if @debug_webhook
+ return existing
+ end
+
+ # ensure oldest -> newest (we adjusted these already)
+ locations = locations.sort { |a, b| a.time <=> b.time }
+
+ # estimate the location given the current observations
+ location = existing || locations.shift
+ locations.each do |new_loc|
+ next unless new_loc.time >= location.time
+
+ # If acceptable then this is newer
+ if new_loc.variance < @acceptable_confidence
+ location = new_loc
+ next
+ end
+
+ # if more accurate and newer then we'll take this
+ if new_loc.variance < location.variance
+ location = new_loc
+ location.variance = @override_min_variance if location.variance < @override_min_variance
+ next
+ end
+
+ # should we drift the older location towards a less accurate newer location
+ if location.time < drift_older
+ # has the floor changed, we should probably accept the newer less accurate location
+ if location.floor_plan_id != new_loc.floor_plan_id
+ location = new_loc
+ next
+ end
+
+ new_uncertainty = new_loc.variance
+ old_uncertainty = location.variance
+
+ confidence_factor = 1.0 - (@confidence_multiplier * (new_uncertainty - @acceptable_confidence))
+ confidence_factor = 0.0 if confidence_factor < 0
+
+ time_diff = new_loc.time.to_unix - location.time.to_unix
+ time_factor = @time_multiplier * (time_diff - @confidence_time.to_i).to_f
+ time_factor = 0.0 if time_factor < 0
+
+ # Average of the confidence factors
+ average_multiplier = (confidence_factor + time_factor) / 2.0
+
+ new_x = new_loc.x!
+ new_y = new_loc.y!
+ old_x = location.x!
+ old_y = location.y!
+
+ # 7.5 = 5 + (( 10 - 5 ) * 0.5)
+ new_x = old_x + ((new_x - old_x) * average_multiplier)
+ new_y = old_y + ((new_y - old_y) * average_multiplier)
+ new_uncertainty = old_uncertainty + ((new_uncertainty - old_uncertainty) * average_multiplier)
+
+ new_loc.x = new_x
+ new_loc.y = new_y
+ new_loc.variance = new_uncertainty < @override_min_variance ? @override_min_variance : new_uncertainty
+
+ location = new_loc
+ end
+ end
+
+ location
+ end
+
+ protected def update_ipv4(ipv4, client_mac, current_time)
+ return unless ipv4
+
+ lookup = @ip_lookup[ipv4]? || Lookup.new(current_time, client_mac)
+ lookup.time = current_time
+ lookup.mac = client_mac
+ @ip_lookup[ipv4] = lookup
+
+ if lookup = @ip_usernames[ipv4]?
+ username = lookup.mac
+ user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) }
+ end
+ end
+
+ protected def update_ipv6(ipv6, client_mac, current_time)
+ return unless ipv6
+
+ lookup = @ip_lookup[ipv6]? || Lookup.new(current_time, client_mac)
+ lookup.time = current_time
+ lookup.mac = client_mac
+ @ip_lookup[ipv6] = lookup
+
+ if lookup = @ip_usernames[ipv6]?
+ username = lookup.mac
+ user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) }
+ end
+ end
+
+ def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+
+ # ip => {username, time}
+ @ip_usernames : Hash(String, Lookup) = {} of String => Lookup
+
+ @[Security(PlaceOS::Driver::Level::Administrator)]
+ def ip_username_mappings(ip_map : Array(Tuple(String, String, String, String?))) : Nil
+ now = Time.utc
+ user_mac_mappings do |storage|
+ ip_map.each do |(ip, username, domain, hostname)|
+ username = format_username(username)
+ @ip_usernames[ip] = Lookup.new(now, username)
+
+ if lookup = @ip_lookup[ip]?
+ map_user_mac(lookup.mac, username, storage)
+ end
+ end
+ end
+ end
+
+ @[Security(PlaceOS::Driver::Level::Administrator)]
+ def mac_address_mappings(username : String, macs : Array(String), domain : String = "")
+ username = format_username(username)
+ user_mac_mappings do |storage|
+ macs.each { |mac| map_user_mac(format_mac(mac), username, storage) }
+ end
+ end
+
+ # ==========================
+ # Wired Port Sensing / desks
+ # ==========================
+
+ bind Dashboard_1, :port_update, :port_updated
+
+ # Serial => level + port mappings
+ @wired_desks : Hash(String, DeskMappings) = {} of String => DeskMappings
+
+ # level_id => [switch serial numbers]
+ @level_serials : Hash(String, Array(String)) = {} of String => Array(String)
+
+ # serial => port => status + mac address?
+ @port_status : Hash(String, Hash(Int32, PortStatusResponse)) = {} of String => Hash(Int32, PortStatusResponse)
+
+ # User lookup helpers using device hostnames
+ getter mac_hostnames : Hash(String, String) = {} of String => String
+
+ protected def init_wired_port_mappings
+ @port_status = Hash(String, Hash(Int32, PortStatusResponse)).new { |h, k| h[k] = {} of Int32 => PortStatusResponse }
+
+ wired_desks = setting?(Array(DeskMappings), :wired_desks) || [] of DeskMappings
+ level_serials = Hash(String, Array(String)).new { |h, k| h[k] = [] of String }
+ desk_mappings = {} of String => DeskMappings
+ wired_desks.each do |switch|
+ level_serials[switch.level_id] << switch.serial
+ desk_mappings[switch.serial] = switch
+ end
+ @wired_desks = desk_mappings
+ @level_serials = level_serials
+
+ spawn { get_port_status(@wired_desks.keys) }
+
+ if (serials = @wired_desks.keys) && !serials.empty?
+ schedule.in(5.seconds) { get_port_status(serials) }
+ schedule.every(5.minutes) { get_port_status(serials) }
+ end
+ rescue error
+ logger.warn(exception: error) { "error initializing wired port mappings" }
+ end
+
+ def hostname_ownership(hostname : String, username : String?) : Nil
+ macs = @mac_hostnames.compact_map { |(mac, host)| host == hostname ? mac : nil }
+
+ if username && username.presence
+ user_mac_mappings { |storage| macs.each { |mac| map_user_mac(mac, username, storage) } }
+ else
+ # just in case the client doesn't show up again, we don't need to perform lookups
+ macs.each { |mac| @mac_hostnames.delete mac }
+ end
+ end
+
+ # grab all the client data we have (applies to all ports)
+ # grab the port information
+ # check we have a desk mapping for the port
+ # see if we can find the client information for the port.
+ protected def get_port_status(devices : Iterable(String))
+ all_clients = poll_clients(@default_network, connection: :wired)
+ devices.each do |serial|
+ begin
+ ports = @port_status[serial]
+ mappings = @wired_desks[serial]
+
+ Array(PortStatusResponse).from_json(dashboard.ports_statuses(serial).get.to_json).each do |port|
+ # ensure the port has a desk mapping
+ desk_id = mappings.ports[port.port]?
+ next unless desk_id
+
+ port.desk_id = desk_id
+ port.level_id = mappings.level_id
+ port.switch_serial = serial
+
+ if port.status.connected? && (client = all_clients.find { |c| c.recent_device_serial == serial && c.switch_port == port.port })
+ port.mac = client.mac
+ end
+
+ ports[port.port] = port
+ end
+ rescue error
+ logger.warn(exception: error) { "error querying port statuses for #{serial}" }
+ end
+ end
+ end
+
+ private def port_updated(_subscription, new_value)
+ details = WebhookAlert.from_json(new_value)
+ logger.debug { "switch #{details.device_serial}, port #{details.port_num} = #{details.alert_type}" }
+
+ serial = details.device_serial
+ if mappings = @wired_desks[serial]?
+ # query the switch for the port status
+ get_port_status({serial})
+ area_manager.update_available({mappings.level_id})
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to parse port update\n#{new_value.inspect}" }
+ end
+
+ # grabs the wired desk data for a level
+ def wired_desk_locations(zone_id : String)
+ return_empty_spaces = @return_empty_spaces
+
+ serials = if zone_id == @building_zone
+ @level_serials.values.flatten
+ else
+ @level_serials[zone_id]? || [] of String
+ end
+
+ serials.compact_map { |serial|
+ ports = @port_status[serial]?
+ next unless ports
+
+ ports.map do |(port_num, port)|
+ occupied = port.status.connected? ? 1 : 0
+
+ # Do we want to return empty desks (depends on the frontend)
+ next if !return_empty_spaces && occupied == 0
+
+ {
+ location: "desk",
+ at_location: occupied,
+ map_id: port.desk_id,
+ level: port.level_id,
+ building: @building_zone,
+ capacity: 1,
+ mac: port.mac,
+ port: port_num,
+ switch: port.switch_serial,
+ }
+ end
+ }.flatten
+ end
+
+ # ======================
+ # Sensor interface:
+ # ======================
+
+ protected def to_sensors(zone_id, filter, camera, details, building, level)
+ sensors = [] of Interface::Sensor::Detail
+ return sensors if zone_id && (building || level) && !zone_id.in?({building, level})
+
+ formatted_mac = format_mac(camera.mac)
+
+ {SensorType::PeopleCount, SensorType::Presence}.each do |type|
+ next if filter && filter != type
+
+ time = details.ts.to_unix
+ type_indicator = type.to_s.underscore.split('_', 2)[0]
+
+ details.zones.each do |area_id, count|
+ value = case type
+ when SensorType::PeopleCount
+ count.person.to_f
+ when SensorType::Presence
+ count.person > 0 ? 1.0 : 0.0
+ else
+ # Will never make it here
+ raise "unknown sensor"
+ end
+
+ sensor = Interface::Sensor::Detail.new(
+ type: type,
+ value: value,
+ last_seen: time,
+ mac: camera.mac,
+ id: "#{area_id}-#{type_indicator}",
+ name: "#{camera.name} Presence: #{camera.model} (#{camera.serial})",
+
+ module_id: module_id,
+ binding: "#{type_indicator}-#{formatted_mac}-#{area_id}"
+ )
+
+ sensor.building = building
+ sensor.level = level
+ sensors << sensor
+ end
+ end
+
+ sensors
+ end
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if type && !type.in?({"Presence", "PeopleCount"})
+ filter = type ? SensorType.parse(type) : nil
+
+ if mac
+ cam_state = @camera_analytics[format_mac(mac)]?
+ return NO_MATCH unless cam_state
+ return to_sensors(zone_id, filter, **cam_state)
+ end
+
+ @camera_analytics.values.flat_map { |cam_data| to_sensors(zone_id, filter, **cam_data) }
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+
+ return nil unless id
+ cam_state = @camera_analytics[format_mac(mac)]?
+ return nil unless cam_state
+
+ # https://crystal-lang.org/api/1.1.0/String.html#rpartition(search:Char%7CString):Tuple(String,String,String)-instance-method
+ area_str, _, sensor_type = id.rpartition('-')
+
+ filter = case sensor_type
+ when "people"
+ SensorType::PeopleCount
+ when "presence"
+ SensorType::Presence
+ else
+ return nil
+ end
+
+ area_id = area_str.to_i64?
+ return nil unless area_id
+
+ zone_count = cam_state[:details].zones[area_id]?.try &.person
+ return nil unless zone_count
+
+ to_sensors(nil, filter, **cam_state).find { |sensor| sensor.id == id }
+ end
+
+ # =================
+ # Camera Desk data:
+ # =================
+ # desk_id => [{time, occupied}]
+ getter desk_occupancy : Hash(String, Array(Tuple(Int64, Bool)))
+
+ @desk_occupancy : Hash(String, Array(Tuple(Int64, Bool))) = Hash(String, Array(Tuple(Int64, Bool))).new do |hash, key|
+ hash[key] = Array(Tuple(Int64, Bool)).new(4)
+ end
+
+ protected def average_results(serial, detected)
+ desks = @desk_mappings[serial]?
+ return unless desks
+
+ time = Time.utc.to_unix
+ past = desk_data_expiry_time
+
+ # id => Array({distance, occupied})
+ results = Hash(String, Array(Tuple(Float64, Bool))).new { |h, k| h[k] = [] of Tuple(Float64, Bool) }
+
+ # we store the closest desk point to the line,
+ # as this detected desk might be the occupancy we care about
+ detected.desks.each do |(lx, ly, cx, cy, rx, ry, occupancy)|
+ desks.each { |desk| desk.distance = calculate_distance(lx, ly, cx, cy, rx, ry, desk) }
+ if desk = desks.sort! { |a, b| a.distance <=> b.distance }.first?
+ results[desk.label] << {desk.distance, !occupancy.zero?}
+ end
+ end
+
+ # then for each desk id, we take the closest detected desk and use that
+ # occupancy value
+ results.each do |desk_id, distances|
+ _distance, occupancy = distances.sort! { |a, b| a[0] <=> b[0] }.first
+ desk_occupation = @desk_occupancy[desk_id]
+ desk_occupation << {time, occupancy}
+ cleanup_old_data(desk_occupation, past)
+ end
+ end
+
+ # We want to find the line closest to the offical desk point
+ protected def calculate_distance(lx, ly, cx, cy, rx, ry, desk)
+ desk = Point.new(desk.x, desk.y)
+ {
+ Point.new(lx, ly).distance_to(desk),
+ Point.new(rx, ry).distance_to(desk),
+ Point.new(cx, cy).distance_to(desk),
+ }.sum
+ end
+
+ protected def desk_data_expiry_time
+ 90.seconds.ago.to_unix
+ end
+
+ protected def cleanup_old_data(desk_occupation, expiry_time)
+ desk_occupation.reject! { |(time, _occupancy)| time < expiry_time }
+ end
+
+ # =============================
+ # Camera Desk location service:
+ # =============================
+ # zone_id => array of camera serials
+ @zone_lookup : Hash(String, Array(String)) = {} of String => Array(String)
+
+ # camera serial => level + building
+ @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping
+
+ # Camera serial => [desk location]
+ getter desk_mappings : Hash(String, Array(CameraZone)) = {} of String => Array(CameraZone)
+
+ def desk_locations(zone_id : String)
+ serials = @zone_lookup[zone_id]? || [] of String
+ return_empty_spaces = @return_empty_spaces
+ expiry_time = desk_data_expiry_time
+
+ serials.compact_map { |serial|
+ desks = @desk_mappings[serial]?
+ next unless desks
+
+ # does data exist for the desks?
+ next unless desk_details[serial]?
+
+ floor = @floor_lookup[serial]
+ illumination = lux[serial]?
+
+ desks.compact_map do |desk|
+ desk_id = desk.label
+ occupied = is_occupied?(desk_id, expiry_time)
+
+ # Do we want to return empty desks (depends on the frontend)
+ next if !return_empty_spaces && occupied == 0
+
+ {
+ location: "desk",
+ at_location: occupied,
+ map_id: desk_id,
+ level: floor.level_id,
+ building: floor.building_id,
+ capacity: 1,
+
+ area_lux: illumination,
+ merakimv: serial,
+ }
+ end
+ }.flatten
+ end
+
+ def update_desk_mappings
+ desk_mappings = Hash(String, Array(CameraZone)).new
+ @floor_lookup.keys.each do |serial|
+ begin
+ desk_mappings[serial] = Array(CameraZone).from_json(dashboard.get_zones(serial).get.to_json).reject!(&.id.==("0"))
+ rescue error
+ logger.warn(exception: error) { "fetching zones for camera: #{serial}" }
+ end
+ end
+
+ @desk_mappings = desk_mappings
+
+ mqtt_module = system[:MerakiMQTT]
+ desk_mappings.keys.each { |camera_serial| check_camera_status(mqtt_module, camera_serial) }
+ end
+
+ protected def desk_data_expiry_time
+ 90.seconds.ago.to_unix
+ end
+
+ protected def is_occupied?(desk_id, expiry_time)
+ desk_occupation = @desk_occupancy[desk_id]?
+ return 0 unless desk_occupation
+
+ occupied = 0
+ desk_occupation.reject! do |(time, occupancy)|
+ if time < expiry_time
+ next true
+ elsif occupancy
+ occupied += 1
+ end
+ false
+ end
+
+ size = desk_occupation.size
+ return 0 if size.zero?
+
+ # We care if the desk basically had signs of life
+ (occupied / size) > 0.3 ? 1 : 0
+ end
+end
diff --git a/drivers/cisco/meraki/meraki_locations_spec.cr b/drivers/cisco/meraki/meraki_locations_spec.cr
new file mode 100644
index 00000000000..ada209e876f
--- /dev/null
+++ b/drivers/cisco/meraki/meraki_locations_spec.cr
@@ -0,0 +1,199 @@
+require "./scanning_api"
+require "placeos-driver/spec"
+
+# :nodoc:
+class DashboardMock < DriverSpecs::MockDriver
+ def fetch(location : String)
+ logger.info { "fetching: #{location}" }
+ case location
+ when "/api/v1/networks/network_id/floorPlans"
+ %([{"floorPlanId":"floor-123","name":"Level 1","width":30.5,"height":20,"topLeftCorner":{"lat":0,"lng":0},"bottomLeftCorner":{"lat":0,"lng":0},"bottomRightCorner":{"lat":0,"lng":0}}])
+ when "/api/v1/networks/network_id/devices"
+ %([])
+ when "/api/v1/devices/Q2HV-KAM-ETSG/camera/analytics/live"
+ %({
+ "ts": "2021-08-09T23:56:52.236Z",
+ "zones": {
+ "582653201791058186": {
+ "person": 0
+ },
+ "582653201791058185": {
+ "person": 0
+ },
+ "0": {
+ "person": 0
+ }
+ }
+ })
+ else
+ %([])
+ end
+ end
+
+ def fetch_all(location : String)
+ [fetch(location)]
+ end
+
+ def get_zones(serial : String)
+ logger.info { "ZONE REQ: request made for camera '#{serial}'" }
+ [{
+ zoneId: "ignored",
+ type: "something",
+ label: "desk-1234",
+ regionOfInterest: {
+ x0: "0.44",
+ y0: "0.56",
+ x1: "0.44",
+ y1: "0.56",
+ },
+ }]
+ end
+end
+
+# :nodoc:
+class MQTTMock < DriverSpecs::MockDriver
+ def on_load
+ self["camera_camera_serial_desks"] = {
+ "_v" => 2,
+ "time" => "2022-01-20 02:14:00",
+ "desks" => [
+ [185, 282, 227, 211, 272, 158, 0],
+ [376, 197, 321, 268, 264, 365, 0],
+ [401, 450, 460, 355, 499, 273, 0],
+ [572, 348, 547, 414, 506, 483, 0],
+ [312, 571, 259, 546, 210, 515, 0],
+ [536, 492, 494, 529, 446, 560, 0],
+ [137, 542, 162, 573, 189, 597, 0],
+ ],
+ }
+
+ self["camera_updated"] = {0, "camera_serial"}
+
+ self["floor_lookup"] = {
+ "camera_serial" => {
+ camera_serials: ["camera_serial"],
+ level_id: "zone-123",
+ building_id: "zone-456",
+ },
+ }
+
+ self["zone_lookup"] = {
+ "zone-456" => {"camera_serial"},
+ }
+ end
+
+ def trigger_update
+ self["camera_updated"] = {1, "camera_serial"}
+ end
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def zone(id : String)
+ {
+ id: "zone-building",
+ tags: ["building"],
+ name: "building zone",
+ }
+ end
+end
+
+DriverSpecs.mock_driver "Cisco::Meraki::Locations" do
+ system({
+ Dashboard: {DashboardMock},
+ MerakiMQTT: {MQTTMock},
+ StaffAPI: {StaffAPIMock},
+ })
+
+ sleep 0.5
+
+ # Should standardise the format of MAC addresses
+ exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b)
+
+ floors_raw = %({"g_727894289773756676": {
+ "floorPlanId": "g_727894289773756676",
+ "width": 84.73653902424,
+ "height": 55.321510873304,
+ "topLeftCorner": {
+ "lat": 25.20105494120424,
+ "lng": 55.27527794417147
+ },
+ "bottomLeftCorner": {
+ "lat": 25.20128402691947,
+ "lng": 55.27478983574903
+ },
+ "bottomRightCorner": {
+ "lat": 25.200607564298647,
+ "lng": 55.27440203743774
+ },
+ "name": "BUILDING - L3"
+ },
+ "g_727894289773756679": {
+ "floorPlanId": "g_727894289773756679",
+ "width": 82.037895885132,
+ "height": 48.035263155936,
+ "topLeftCorner": {
+ "lat": 25.201070920997147,
+ "lng": 55.27523029269689
+ },
+ "bottomLeftCorner": {
+ "lat": 25.20126383588677,
+ "lng": 55.274803104166594
+ },
+ "bottomRightCorner": {
+ "lat": 25.200603702563107,
+ "lng": 55.27443896882145
+ },
+ "name": "Building - GF"
+ }})
+ floors = Hash(String, Cisco::Meraki::FloorPlan).from_json(floors_raw)
+
+ macs_raw = %({"683a1e545b0c": {
+ "floorPlanId": "g_727894289773756676",
+ "lat": 25.2011012305148,
+ "lng": 55.2749184519053,
+ "mac": "68:3a:1e:54:5b:0c",
+ "name": "1F-07",
+ "model": "MV22",
+ "firmware": "camera-4-13",
+ "serial": "Q2HV-KAM-ETSG"
+ },
+ "683a1e5474ed": {
+ "floorPlanId": "g_727894289773756679",
+ "lat": 25.2008175846893,
+ "lng": 55.2746475487948,
+ "mac": "68:3a:1e:54:74:ed",
+ "name": "GF-29",
+ "model": "MV22",
+ "firmware": "camera-4-13",
+ "serial": "Q2HV-KAM-ETSG"
+ }})
+ macs = Hash(String, Cisco::Meraki::NetworkDevice).from_json(macs_raw)
+
+ macs.each do |_mac, wap_device|
+ floor_plan = floors[wap_device.floor_plan_id]
+ # do some unit testing
+ loc = Cisco::Meraki::DeviceLocation.calculate_location(floor_plan, wap_device, Time.utc)
+ loc.to_json
+ end
+
+ exec(:camera_analytics, "Q2HV-KAM-ETSG").get.should eq({
+ "ts" => "2021-08-09T23:56:52.236+0000",
+ "zones" => {
+ "582653201791058186" => {"person" => 0},
+ "582653201791058185" => {"person" => 0},
+ "0" => {"person" => 0},
+ },
+ })
+
+ exec(:device_locations, "zone-456").get.should eq([{
+ "location" => "desk",
+ "at_location" => 0,
+ "map_id" => "desk-1234",
+ "level" => "zone-123",
+ "building" => "zone-456",
+ "capacity" => 1,
+ "area_lux" => nil,
+ "merakimv" => "camera_serial",
+ }])
+end
diff --git a/drivers/cisco/meraki/mqtt.cr b/drivers/cisco/meraki/mqtt.cr
new file mode 100644
index 00000000000..0271cfaf737
--- /dev/null
+++ b/drivers/cisco/meraki/mqtt.cr
@@ -0,0 +1,421 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "placeos-driver/interface/locatable"
+require "../../place/mqtt_transport_adaptor"
+require "./mqtt_models"
+
+# documentation: https://developer.cisco.com/meraki/mv-sense/#!mqtt
+# Use https://www.desmos.com/calculator for plotting points (sample code for copy and paste)
+# data = [[1,2,3,4,5,6, 0]]
+# data.each do |d|
+# puts "(#{d[0]}, #{d[1]}),(#{d[2]}, #{d[3]}),(#{d[4]}, #{d[5]})"
+# end
+
+class Cisco::Meraki::MQTT < PlaceOS::Driver
+ include Interface::Sensor
+
+ descriptive_name "Meraki MQTT"
+ generic_name :MerakiMQTT
+
+ tcp_port 1883
+ description %(subscribes to Meraki MV Sense camera data)
+
+ default_settings({
+ username: "user",
+ password: "pass",
+ keep_alive: 60,
+ client_id: "placeos",
+
+ floor_mappings: [
+ {
+ camera_serials: ["1234", "camera_serial"],
+ level_id: "zone-123",
+ building_id: "zone-456",
+ },
+ ],
+
+ line_crossing_combined: {
+ area_name: ["camera_serial1", "camera_serial2"],
+ },
+
+ timezone: "America/New_York",
+ disable_line_crossing_reset: false,
+ })
+
+ SUBS = {
+ # Meraki desk occupancy (coords and occupancy are floats)
+ # {ts: unix_time, desks: [[lx, ly, rx, ry, cx, cy, occupancy], [...]]}
+ "/merakimv/+/net.meraki.detector",
+
+ # lux levels on a camera
+ # {lux: float}
+ "/merakimv/+/light",
+
+ # Number of entrances in the camera’s complete field of view
+ # {ts: unix_time, counts: {person: number, vehicle: number}}
+ "/merakimv/+/0",
+
+ # meraki entry and exist monitoring
+ "/merakimv/+/crossing/+",
+ }
+
+ @keep_alive : Int32 = 60
+ @username : String? = nil
+ @password : String? = nil
+ @client_id : String = "placeos"
+
+ @mqtt : ::MQTT::V3::Client? = nil
+ @subs : Array(String) = [] of String
+ @transport : Place::TransportAdaptor? = nil
+ @sub_proc : Proc(String, Bytes, Nil) = Proc(String, Bytes, Nil).new { |_key, _payload| nil }
+
+ @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping
+
+ # area name => array of serials
+ @line_crossing : Hash(String, Array(String)) = {} of String => Array(String)
+
+ # serial => area name
+ @crossing_lookup : Hash(String, String) = {} of String => String
+
+ def on_load
+ @sub_proc = Proc(String, Bytes, Nil).new { |key, payload| on_message(key, payload) }
+ on_update
+ end
+
+ def on_unload
+ end
+
+ def on_update
+ @username = setting?(String, :username)
+ @password = setting?(String, :password)
+ @keep_alive = setting?(Int32, :keep_alive) || 60
+ @client_id = setting?(String, :client_id) || ::MQTT.generate_client_id("placeos_")
+
+ # zone_id => camera serial
+ zone_lookup = Hash(String, Array(String)).new { |h, k| h[k] = [] of String }
+ # camera serial => level + building
+ floor_lookup = {} of String => FloorMapping
+ floor_mappings = setting?(Array(FloorMapping), :floor_mappings) || [] of FloorMapping
+ floor_mappings.each do |mapping|
+ mapping.camera_serials.each do |serial|
+ zone_lookup[mapping.level_id] << serial
+ zone_lookup[mapping.building_id.not_nil!] << serial if mapping.building_id
+ floor_lookup[serial] = mapping
+ end
+ end
+ self[:floor_lookup] = @floor_lookup = floor_lookup
+ self[:zone_lookup] = zone_lookup
+
+ existing = @subs
+ @subs = SUBS.to_a
+
+ @line_crossing = line_crossing_combined = setting?(Hash(String, Array(String)), :line_crossing_combined) || {} of String => Array(String)
+ line_crossing_mapping = {} of String => String
+ line_crossing_combined.each do |name, serials|
+ serials.each { |serial| line_crossing_mapping[serial] = name }
+ end
+ @crossing_lookup = line_crossing_mapping
+
+ schedule.clear
+ schedule.every((@keep_alive // 3).seconds) { ping }
+
+ if !setting?(Bool, :disable_line_crossing_reset)
+ time_zone = setting?(String, :timezone).presence || "America/New_York"
+ tz = Time::Location.load(time_zone)
+ schedule.cron("30 3 * * *", tz) do
+ crossing_people.each_key { |key| self["camera_mvx-#{key}_person"] = 0 }
+ crossing_people.clear
+ crossing_vehicle.each_key { |key| self["camera_mvx-#{key}_vehicle"] = 0 }
+ crossing_vehicle.clear
+ end
+ end
+
+ if client = @mqtt
+ unsub = existing - @subs
+ newsub = @subs - existing
+
+ unsub.each do |sub|
+ logger.debug { "unsubscribing to #{sub}" }
+ client.unsubscribe(sub)
+ end
+
+ newsub.each do |sub|
+ logger.debug { "subscribing to #{sub}" }
+ client.subscribe(sub, &@sub_proc)
+ end
+ end
+ end
+
+ def connected
+ transp = Place::TransportAdaptor.new(transport, queue)
+ client = ::MQTT::V3::Client.new(transp)
+ @transport = transp
+ @mqtt = client
+
+ logger.debug { "sending connect message" }
+ client.connect(@username, @password, @keep_alive, @client_id)
+ @subs.each do |sub|
+ logger.debug { "subscribing to #{sub}" }
+ client.subscribe(sub, &@sub_proc)
+ end
+ end
+
+ def disconnected
+ @transport = nil
+ @mqtt = nil
+ end
+
+ def ping
+ logger.debug { "sending ping" }
+ @mqtt.not_nil!.ping
+ end
+
+ def received(data, task)
+ logger.debug { "received #{data.size} bytes: 0x#{data.hexstring}" }
+ @transport.try &.process(data)
+ task.try &.success
+ end
+
+ getter people_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do
+ Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key|
+ hash[key] = {} of String => Tuple(Float64, Int64)
+ end
+ end
+
+ getter vehicle_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do
+ Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key|
+ hash[key] = {} of String => Tuple(Float64, Int64)
+ end
+ end
+
+ # Serial => count
+ getter crossing_people : Hash(String, Tuple(Int32, Int64)) do
+ Hash(String, Tuple(Int32, Int64)).new { |hash, key| hash[key] = {0, 0_i64} }
+ end
+
+ getter crossing_vehicle : Hash(String, Tuple(Int32, Int64)) do
+ Hash(String, Tuple(Int32, Int64)).new { |hash, key| hash[key] = {0, 0_i64} }
+ end
+
+ getter lux : Hash(String, Tuple(Float64, Int64)) = {} of String => Tuple(Float64, Int64)
+
+ # this is where we do all of the MQTT message processing
+ protected def on_message(key : String, playload : Bytes) : Nil
+ json_message = String.new(playload)
+ key = key[1..-1] if key.starts_with?("/")
+
+ logger.debug { "new message: #{key} = #{json_message}" }
+ _merakimv, serial_no, status = key.split("/")
+
+ case status
+ when "net.meraki.detector"
+ # we assume version 3 of the API here for sanity reasons
+ detected_desks = DetectedDesks.from_json(json_message)
+ self["camera_#{serial_no}_desks"] = detected_desks
+ self["camera_updated"] = {Time.utc.to_unix, serial_no}
+ when "light"
+ light = LuxLevel.from_json(json_message)
+ lux[serial_no] = {light.lux, light.timestamp}
+ self["camera_#{serial_no}_lux"] = light.lux
+ when "crossing"
+ crossing = Crossing.from_json(json_message)
+ count_hash = crossing.type.person? ? crossing_people : crossing_vehicle
+ lookup_name = @crossing_lookup[serial_no]? || serial_no
+ current_count, _timestamp = count_hash[lookup_name]
+ case crossing.event
+ when .crossing_in?
+ current_count += 1
+ when .crossing_out?
+ current_count -= 1
+ end
+ current_count = 0 if current_count < 0
+ count_hash[lookup_name] = {current_count, crossing.timestamp}
+ self["camera_mvx-#{serial_no}_#{crossing.type.to_s.downcase}"] = current_count
+ else
+ # Everything else is a zone count
+ entry = Entrances.from_json json_message
+ case entry.count_type
+ in CountType::People
+ people_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix}
+ in CountType::Vehicles
+ vehicle_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix}
+ in CountType::Unknown
+ # ignore
+ end
+ self["camera_#{serial_no}_zone#{status}_#{entry.count_type.to_s.downcase}"] = entry.count
+ end
+ end
+
+ # ----------------
+ # Sensor Interface
+ # ----------------
+
+ # return the specified sensor details
+ def sensor(mac : String, id : String? = nil) : Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+
+ if id == "lux"
+ add_lux_values([] of Detail, mac).first?
+ elsif id.starts_with? "zone"
+ zone, count_type = id.split('_', 2)
+ zone = zone[4..-1] # remove the word "zone"
+
+ sensor_type = SensorType::PeopleCount
+ lookup = case count_type
+ when "people"
+ people_counts
+ when "vehicles"
+ sensor_type = SensorType::Counter
+ vehicle_counts
+ end
+
+ if lookup
+ if counts = lookup[mac]?
+ if count = counts[zone]?
+ to_sensor(sensor_type, mac, "zone#{zone}_#{count_type}", count[0], count[1])
+ end
+ end
+ end
+ else
+ nil
+ end
+ end
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+ LUX_ID = "lux"
+
+ # return an array of sensor details
+ # zone_id can be ignored if location is unknown by the sensor provider
+ # mac_address can be used to grab data from a single device (basic grouping)
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ serial_filter = nil
+ if zone_id && !@floor_lookup.empty?
+ serial_filter = [] of String
+ @floor_lookup.each do |serial, floor|
+ serial_filter << serial if {floor.level_id, floor.building_id}.includes?(zone_id)
+ end
+ end
+
+ sensors = [] of Detail
+ filter = type ? Interface::Sensor::SensorType.parse?(type) : nil
+
+ case filter
+ when nil
+ add_lux_values(sensors, mac, serial_filter)
+ add_people_counts(sensors, mac, serial_filter)
+ add_vehicle_counts(sensors, mac, serial_filter)
+ add_people_crossing(sensors, mac, serial_filter)
+ add_vehicle_crossing(sensors, mac, serial_filter)
+ when .people_count?
+ add_people_counts(sensors, mac, serial_filter)
+ add_people_crossing(sensors, mac, serial_filter)
+ when .counter?
+ add_vehicle_counts(sensors, mac, serial_filter)
+ add_vehicle_crossing(sensors, mac, serial_filter)
+ when .illuminance?
+ add_lux_values(sensors, mac, serial_filter)
+ else
+ sensors
+ end
+ rescue error
+ logger.warn(exception: error) { "searching for sensors" }
+ NO_MATCH
+ end
+
+ protected def add_people_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil)
+ if mac
+ return sensors if serial_filter && !serial_filter.includes?(mac)
+ people_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, mac, "zone#{zone_name}_people", count, time) }
+ else
+ people_counts.each do |serial, zones|
+ next if serial_filter && !serial_filter.includes?(serial)
+ zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, serial, "zone#{zone_name}_people", count, time) }
+ end
+ end
+ sensors
+ end
+
+ protected def add_vehicle_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil)
+ if mac
+ return sensors if serial_filter && !serial_filter.includes?(mac)
+ vehicle_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, mac, "zone#{zone_name}_vehicles", count, time) }
+ else
+ vehicle_counts.each do |serial, zones|
+ next if serial_filter && !serial_filter.includes?(serial)
+ zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, serial, "zone#{zone_name}_vehicles", count, time) }
+ end
+ end
+ sensors
+ end
+
+ protected def add_people_crossing(sensors, mac : String? = nil, serial_filter : Array(String)? = nil)
+ if mac
+ return sensors unless mac.starts_with?("mvx-")
+ mac = mac[4..-1]
+
+ if data = crossing_people[mac]?
+ count, time = data
+ sensors << to_sensor(SensorType::PeopleCount, "mvx-#{mac}", "person", count, time)
+ end
+ else
+ crossing_people.each do |mac, (count, time)|
+ serial = @line_crossing[mac]?.try(&.first?) || mac
+ next if serial_filter && !serial_filter.includes?(serial)
+ sensors << to_sensor(SensorType::PeopleCount, "mvx-#{mac}", "person", count, time)
+ end
+ end
+ sensors
+ end
+
+ protected def add_vehicle_crossing(sensors, mac : String? = nil, serial_filter : Array(String)? = nil)
+ if mac
+ return sensors unless mac.starts_with?("mvx-")
+ mac = mac[4..-1]
+
+ if data = crossing_vehicle[mac]?
+ count, time = data
+ sensors << to_sensor(SensorType::Counter, "mvx-#{mac}", "vehicle", count, time)
+ end
+ else
+ crossing_vehicle.each do |mac, (count, time)|
+ serial = @line_crossing[mac]?.try(&.first?) || mac
+ next if serial_filter && !serial_filter.includes?(serial)
+ sensors << to_sensor(SensorType::Counter, "mvx-#{mac}", "vehicle", count, time)
+ end
+ end
+ sensors
+ end
+
+ protected def add_lux_values(sensors, mac : String? = nil, serial_filter : Array(String)? = nil)
+ if mac
+ return sensors if serial_filter && !serial_filter.includes?(mac)
+ if lux_val = lux[mac]?
+ level, time = lux_val
+ sensors << to_sensor(SensorType::Illuminance, mac, LUX_ID, level, time)
+ end
+ else
+ lux.each do |serial, (level, time)|
+ next if serial_filter && !serial_filter.includes?(serial)
+ sensors << to_sensor(SensorType::Illuminance, serial, LUX_ID, level, time)
+ end
+ end
+ sensors
+ end
+
+ protected def to_sensor(sensor_type, serial, id, value, timestamp) : Interface::Sensor::Detail
+ Interface::Sensor::Detail.new(
+ type: sensor_type,
+ value: value,
+ last_seen: timestamp,
+ mac: serial,
+ id: id,
+ name: "Meraki Camera #{serial}: #{id}",
+ module_id: module_id,
+ binding: "camera_#{serial}_#{id}",
+ unit: sensor_type.illuminance? ? "lx" : nil
+ )
+ end
+end
diff --git a/drivers/cisco/meraki/mqtt_models.cr b/drivers/cisco/meraki/mqtt_models.cr
new file mode 100644
index 00000000000..395e322f71f
--- /dev/null
+++ b/drivers/cisco/meraki/mqtt_models.cr
@@ -0,0 +1,98 @@
+require "json"
+
+# Meraki MQTT Data Models
+module Cisco::Meraki
+ class FloorMapping
+ include JSON::Serializable
+
+ getter camera_serials : Array(String)
+ getter level_id : String
+ getter building_id : String?
+ end
+
+ class DetectedDesks
+ include JSON::Serializable
+
+ @[JSON::Field(key: "_v")]
+ getter api_version : Int32
+
+ # Time in milliseconds v3,
+ @[JSON::Field(key: "ts")]
+ getter time_unix : Int64?
+
+ @[JSON::Field(key: "time")]
+ getter time_string : String?
+
+ getter desks : Array(Tuple(Float64, Float64, # left
+Float64, Float64, # center
+Float64, Float64, # right
+Float64 # occupancy
+))
+ end
+
+ class LuxLevel
+ include JSON::Serializable
+
+ # Not actually provided for this message, but here for testing
+ @[JSON::Field(key: "ts")]
+ getter timestamp : Int64 { Time.utc.to_unix }
+
+ getter lux : Float64
+ end
+
+ enum CountType
+ People
+ Vehicles
+ Unknown
+ end
+
+ class Entrances
+ include JSON::Serializable
+
+ @[JSON::Field(key: "ts")]
+ getter timestamp : Int64
+
+ getter counts : NamedTuple(
+ person: Int32?,
+ vehicle: Int32?,
+ )
+
+ @[JSON::Field(ignore: true)]
+ getter count_type : CountType do
+ if counts[:person]
+ CountType::People
+ elsif counts[:vehicle]
+ CountType::Vehicles
+ else
+ CountType::Unknown
+ end
+ end
+
+ @[JSON::Field(ignore: true)]
+ getter count : Int32 { counts[:person] || counts[:vehicle] || 0 }
+ end
+
+ enum CrossingObject
+ Person
+ Vehicle
+ Unknown
+ end
+
+ enum CrossingEvent
+ CrossingIn
+ CrossingOut
+ Expired
+ Appeared
+ end
+
+ struct Crossing
+ include JSON::Serializable
+
+ @[JSON::Field(key: "ts")]
+ getter timestamp : Int64
+ # getter object_id : Int64
+ getter label : String?
+ getter event : CrossingEvent
+ getter type : CrossingObject
+ end
+end
diff --git a/drivers/cisco/meraki/mqtt_spec.cr b/drivers/cisco/meraki/mqtt_spec.cr
new file mode 100644
index 00000000000..0f8bd3d439a
--- /dev/null
+++ b/drivers/cisco/meraki/mqtt_spec.cr
@@ -0,0 +1,179 @@
+require "placeos-driver/spec"
+require "mqtt"
+
+DriverSpecs.mock_driver "Place::MQTT" do
+ # ============================
+ # CONNECTION
+ # ============================
+ puts "===== CONNECTION NEGOTIATION ====="
+ connect = MQTT::V3::Connect.new
+ connect.id = MQTT::RequestType::Connect
+ connect.keep_alive_seconds = 60_u16
+ connect.client_id = "placeos"
+ connect.clean_start = true
+ connect.username = "user"
+ connect.password = "pass"
+ connect.packet_length = connect.calculate_length
+ should_send(connect.to_slice)
+
+ connack = MQTT::V3::Connack.new
+ connack.id = MQTT::RequestType::Connack
+ connack.packet_length = connack.calculate_length
+ responds(connack.to_slice)
+
+ # ============================
+ # SUBSCRIPTIONS
+ # ============================
+ puts "===== CHECKING DESKS SUBSCRIPTION ====="
+ packet = MQTT::V3::Subscribe.new
+ packet.id = MQTT::RequestType::Subscribe
+ packet.qos = MQTT::QoS::BrokerReceived
+ packet.message_id = 2_u16
+ packet.topic = "/merakimv/+/net.meraki.detector"
+ packet.packet_length = packet.calculate_length
+ should_send(packet.to_slice)
+
+ suback = MQTT::V3::Suback.new
+ suback.id = MQTT::RequestType::Suback
+ suback.message_id = 2_u16
+ suback.return_codes = [MQTT::QoS::FireAndForget]
+ suback.packet_length = suback.calculate_length
+ responds(suback.to_slice)
+
+ puts "===== CHECKING LUX SUBSCRIPTION ====="
+ packet = MQTT::V3::Subscribe.new
+ packet.id = MQTT::RequestType::Subscribe
+ packet.qos = MQTT::QoS::BrokerReceived
+ packet.message_id = 4_u16
+ packet.topic = "/merakimv/+/light"
+ packet.packet_length = packet.calculate_length
+ should_send(packet.to_slice)
+
+ suback = MQTT::V3::Suback.new
+ suback.id = MQTT::RequestType::Suback
+ suback.message_id = 4_u16
+ suback.return_codes = [MQTT::QoS::FireAndForget]
+ suback.packet_length = suback.calculate_length
+ responds(suback.to_slice)
+
+ puts "===== CHECKING COUNTS SUBSCRIPTION ====="
+ packet = MQTT::V3::Subscribe.new
+ packet.id = MQTT::RequestType::Subscribe
+ packet.qos = MQTT::QoS::BrokerReceived
+ packet.message_id = 6_u16
+ packet.topic = "/merakimv/+/0"
+ packet.packet_length = packet.calculate_length
+ should_send(packet.to_slice)
+
+ suback = MQTT::V3::Suback.new
+ suback.id = MQTT::RequestType::Suback
+ suback.message_id = 6_u16
+ suback.return_codes = [MQTT::QoS::FireAndForget]
+ suback.packet_length = suback.calculate_length
+ responds(suback.to_slice)
+
+ # ============================
+ # REMOTE PUBLISH
+ # ============================
+ puts "===== REMOTE PUBLISH ====="
+ publish = MQTT::V3::Publish.new
+ publish.id = MQTT::RequestType::Publish
+ publish.message_id = 8_u16
+ publish.topic = "/merakimv/1234/light"
+ publish.payload = %({"lux":33.2,"ts":1642564552})
+ publish.packet_length = publish.calculate_length
+
+ transmit publish.to_slice
+ sleep 0.1 # wait a bit for processing
+ status["camera_1234_lux"].should eq(33.2)
+
+ # ============================
+ # CHECK SENSOR INTERFACE
+ # ============================
+ lux_sensor = {
+ "status" => "normal",
+ "type" => "illuminance",
+ "value" => 33.2,
+ "last_seen" => 1642564552,
+ "mac" => "1234",
+ "id" => "lux",
+ "name" => "Meraki Camera 1234: lux",
+ "module_id" => "spec_runner",
+ "binding" => "camera_1234_lux",
+ "unit" => "lx",
+ "location" => "sensor",
+ }
+ exec(:sensors).get.should eq([lux_sensor])
+ exec(:sensor, "1234", "lux").get.should eq(lux_sensor)
+
+ # ============================
+ # CHECK LOCATABLE INTERFACE
+ # ============================
+ puts "===== CHECKING LOCATABLE INTERFACE ====="
+ publish = MQTT::V3::Publish.new
+ publish.id = MQTT::RequestType::Publish
+ publish.message_id = 8_u16
+ publish.topic = "/merakimv/camera_serial/net.meraki.detector"
+ publish.payload = %({
+ "_v": 2,
+ "time": "2022-01-20 02:14:00",
+ "coords":[],
+ "desks": [
+ [185, 282, 227, 211, 272, 158, 0],
+ [376, 197, 321, 268, 264, 365, 0],
+ [401, 450, 460, 355, 499, 273, 0],
+ [572, 348, 547, 414, 506, 483, 0],
+ [312, 571, 259, 546, 210, 515, 0],
+ [536, 492, 494, 529, 446, 560, 0],
+ [137, 542, 162, 573, 189, 597, 0]
+ ]
+ })
+ publish.packet_length = publish.calculate_length
+
+ transmit publish.to_slice
+ sleep 0.1 # wait a bit for processing
+ status["camera_1234_lux"].should eq(33.2)
+ status["camera_camera_serial_desks"].should eq({
+ "_v" => 2,
+ "time" => "2022-01-20 02:14:00",
+ "desks" => [
+ [185, 282, 227, 211, 272, 158, 0],
+ [376, 197, 321, 268, 264, 365, 0],
+ [401, 450, 460, 355, 499, 273, 0],
+ [572, 348, 547, 414, 506, 483, 0],
+ [312, 571, 259, 546, 210, 515, 0],
+ [536, 492, 494, 529, 446, 560, 0],
+ [137, 542, 162, 573, 189, 597, 0],
+ ],
+ })
+
+ # ============================
+ # Check Line Crossing
+ # ============================
+ puts "===== REMOTE PUBLISH ====="
+ publish = MQTT::V3::Publish.new
+ publish.id = MQTT::RequestType::Publish
+ publish.message_id = 8_u16
+ publish.topic = "/merakimv/56789/crossing/uuid"
+ publish.payload = %({"label":"testing","event":"crossing_in","type":"person","ts":1642564558,"object_id":2})
+ publish.packet_length = publish.calculate_length
+
+ transmit publish.to_slice
+ sleep 0.1 # wait a bit for processing
+ status["camera_mvx-56789_person"].should eq(1)
+
+ exec(:sensors, "people_count", "mvx-56789").get.should eq([
+ {
+ "status" => "normal",
+ "type" => "people_count",
+ "value" => 1.0,
+ "last_seen" => 1642564558,
+ "mac" => "mvx-56789",
+ "id" => "person",
+ "name" => "Meraki Camera mvx-56789: person",
+ "module_id" => "spec_runner",
+ "binding" => "camera_mvx-56789_person",
+ "location" => "sensor",
+ },
+ ])
+end
diff --git a/drivers/cisco/meraki/scanning_api.cr b/drivers/cisco/meraki/scanning_api.cr
new file mode 100644
index 00000000000..63d8ae7a2c3
--- /dev/null
+++ b/drivers/cisco/meraki/scanning_api.cr
@@ -0,0 +1,433 @@
+require "json"
+require "./geo"
+
+module Cisco::Meraki
+ ISO8601 = "%FT%T%z"
+
+ class Organization
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property url : String
+ property api : NamedTuple(enabled: Bool)
+ end
+
+ class Network
+ include JSON::Serializable
+
+ property id : String
+
+ @[JSON::Field(key: "organizationId")]
+ property organization_id : String
+
+ property name : String
+
+ @[JSON::Field(key: "productTypes")]
+ property product_types : Array(String)
+
+ @[JSON::Field(key: "timeZone")]
+ property time_zone : String
+ property tags : Array(String)
+ property url : String
+
+ @[JSON::Field(key: "enrollmentString")]
+ property enrollment_string : String?
+ property notes : String?
+ end
+
+ class CameraAnalytics
+ include JSON::Serializable
+ ISO8601_MS = "%FT%T.%3N%z"
+
+ class PeopleCount
+ include JSON::Serializable
+
+ property person : Int32
+ end
+
+ @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::CameraAnalytics::ISO8601_MS))]
+ property ts : Time
+ property zones : Hash(Int64, PeopleCount)
+ end
+
+ class FloorPlan
+ include JSON::Serializable
+
+ @[JSON::Field(key: "floorPlanId")]
+ property id : String
+ property width : Float64
+ property height : Float64
+
+ @[JSON::Field(key: "topLeftCorner")]
+ property top_left : Geo::Point
+
+ @[JSON::Field(key: "bottomLeftCorner")]
+ property bottom_left : Geo::Point
+
+ @[JSON::Field(key: "bottomRightCorner")]
+ property bottom_right : Geo::Point
+
+ # This is useful for when we have to map meraki IDs to our zones
+ property name : String?
+
+ def to_distance
+ Geo::Distance.new(width, height)
+ end
+ end
+
+ class FloorPlanLocation
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property x : Float64
+ property y : Float64
+ end
+
+ class NetworkDevice
+ include JSON::Serializable
+
+ # Used for caching the location calculated for this device
+ # where an observation doesn't have location values but has a closest WAP
+ @[JSON::Field(ignore: true)]
+ property location : DeviceLocation?
+
+ @[JSON::Field(key: "floorPlanId")]
+ property floor_plan_id : String?
+
+ property lat : Float64
+ property lng : Float64
+ property mac : String
+
+ property serial : String
+ property model : String
+ property firmware : String
+
+ # This is useful for when we have to map meraki IDs to our zones
+ property name : String?
+ end
+
+ class Client
+ include JSON::Serializable
+
+ property id : String
+ property mac : String
+ property description : String?
+
+ property ip : String?
+ property ip6 : String?
+
+ @[JSON::Field(key: "ip6Local")]
+ property ip6_local : String?
+
+ property user : String?
+
+ # 2020-09-29T07:53:08Z
+ @[JSON::Field(key: "firstSeen")]
+ property first_seen : String
+
+ @[JSON::Field(key: "lastSeen")]
+ property last_seen : Time
+
+ property manufacturer : String?
+ property os : String?
+
+ @[JSON::Field(key: "recentDeviceSerial")]
+ property recent_device_serial : String?
+
+ @[JSON::Field(key: "recentDeviceMac")]
+ property recent_device_mac : String?
+ property ssid : String?
+ property vlan : String?
+ property switchport : String?
+ property status : String
+ property notes : String?
+
+ @[JSON::Field(ignore: true)]
+ property! time_added : Time
+
+ @[JSON::Field(ignore: true)]
+ getter switch_port : Int32 { @switchport.as(String).to_i }
+ end
+
+ class RSSI
+ include JSON::Serializable
+
+ @[JSON::Field(key: "apMac")]
+ property access_point_mac : String
+ property rssi : Int32
+ end
+
+ class DeviceLocation
+ include JSON::Serializable
+
+ def initialize(@x, @y, @lng, @lat, @variance, floor_plan_id, floor_plan_name, @time)
+ @wifi_floor_plan_name = floor_plan_name
+ @wifi_floor_plan_id = floor_plan_id
+ @mac = nil
+ @client = nil
+ @rssi_records = [] of RSSI
+ end
+
+ def self.calculate_location(floor : FloorPlan, device : NetworkDevice, time : Time) : DeviceLocation
+ distance = Geo.calculate_xy(floor.top_left, floor.bottom_left, floor.bottom_right, device, floor.to_distance)
+ DeviceLocation.new(distance.x, distance.y, device.lng, device.lat, 25_f64, floor.id, floor.name, time)
+ end
+
+ # NOTE:: This is not part of the location response,
+ # it is here to simplify processing
+ @[JSON::Field(ignore: true)]
+ property mac : String?
+
+ # NOTE:: this is not part of the location response,
+ # it is here to speed up processing
+ @[JSON::Field(ignore: true)]
+ property client : Client? = nil
+
+ # Multiple types as the location when parsed might include javascript `"NaN"`
+ property x : Float64 | String | Nil
+ property y : Float64 | String | Nil
+ property lng : Float64?
+ property lat : Float64?
+ property variance : Float64
+
+ @[JSON::Field(key: "floorPlanId")]
+ property wifi_floor_plan_id : String?
+
+ @[JSON::Field(key: "floorPlanName")]
+ property wifi_floor_plan_name : String?
+
+ @[JSON::Field(key: "floorPlan")]
+ property floor_plan : FloorPlanLocation?
+
+ @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))]
+ property time : Time
+
+ @[JSON::Field(key: "nearestApTags")]
+ property nearest_ap_tags : Array(String) { [] of String }
+
+ @[JSON::Field(key: "rssiRecords")]
+ property rssi_records : Array(RSSI)
+
+ def x!
+ get_x.not_nil!
+ end
+
+ def y!
+ get_y.not_nil!
+ end
+
+ def get_x : Float64?
+ if tmp = x || floor_plan.try(&.x)
+ if tmp.is_a?(Float64)
+ tmp
+ end
+ end
+ end
+
+ def get_y : Float64?
+ if tmp = y || floor_plan.try(&.y)
+ if tmp.is_a?(Float64)
+ tmp
+ end
+ end
+ end
+
+ def floor_plan_id
+ wifi_floor_plan_id || floor_plan.try(&.id)
+ end
+
+ def floor_plan_name
+ wifi_floor_plan_name || floor_plan.try(&.name)
+ end
+ end
+
+ class LatestRecord
+ include JSON::Serializable
+
+ @[JSON::Field(key: "nearestApMac")]
+ property nearest_ap_mac : String
+
+ @[JSON::Field(key: "nearestApRssi")]
+ property nearest_ap_rssi : Int32
+
+ @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))]
+ property time : Time
+ end
+
+ class Observation
+ include JSON::Serializable
+
+ @[JSON::Field(key: "clientMac")]
+ property client_mac : String
+
+ property manufacturer : String?
+ property ipv4 : String?
+ property ipv6 : String?
+ property ssid : String?
+ property os : String?
+
+ @[JSON::Field(key: "latestRecord")]
+ property latest_record : LatestRecord
+ property locations : Array(DeviceLocation)
+ end
+
+ class Data
+ include JSON::Serializable
+
+ @[JSON::Field(key: "networkId")]
+ property network_id : String
+ property observations : Array(Observation)
+ end
+
+ enum MessageType
+ None
+ WiFi
+ Bluetooth
+ end
+
+ class DevicesSeen
+ include JSON::Serializable
+
+ property version : String
+ property secret : String
+
+ @[JSON::Field(key: "type")]
+ property message_type : MessageType
+
+ property data : Data
+ end
+
+ struct CameraZone
+ include JSON::Serializable
+
+ struct Region
+ include JSON::Serializable
+
+ getter x0 : String
+ getter y0 : String
+ getter x1 : String
+ getter y1 : String
+ end
+
+ @[JSON::Field(key: "zoneId")]
+ getter id : String
+ getter type : String
+ getter label : String
+
+ @[JSON::Field(key: "regionOfInterest")]
+ getter region : Region
+
+ @[JSON::Field(ignore: true)]
+ property distance : Float64 = 0.0
+
+ def mid_point
+ mid_x = (region.x0.to_f64 + region.x1.to_f64) / 2.0
+ mid_y = (region.y0.to_f64 + region.y1.to_f64) / 2.0
+ {mid_x, mid_y}
+ end
+
+ getter x : Float64 do
+ xpos, @y = mid_point
+ xpos
+ end
+
+ getter y : Float64 do
+ @x, ypos = mid_point
+ ypos
+ end
+ end
+
+ struct DeskMappings
+ include JSON::Serializable
+
+ getter serial : String
+ getter level_id : String
+
+ # port_id => desk_id
+ getter ports : Hash(Int32, String)
+ end
+
+ struct ZoneDetails
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property tags : Array(String)
+ end
+
+ enum ConnectionType
+ Wired
+ Wireless
+ end
+
+ enum AlertType
+ PortConnected
+ PortDisconnected
+ end
+
+ struct WebhookAlert
+ include JSON::Serializable
+
+ struct PortData
+ include JSON::Serializable
+
+ @[JSON::Field(key: "portNum")]
+ getter port_num : Int32
+ end
+
+ @[JSON::Field(key: "networkId")]
+ getter network_id : String
+
+ @[JSON::Field(key: "alertTypeId")]
+ getter alert_type : AlertType
+
+ @[JSON::Field(key: "alertData")]
+ getter alert_data : PortData
+
+ @[JSON::Field(key: "deviceSerial")]
+ getter device_serial : String
+
+ @[JSON::Field(key: "sharedSecret")]
+ getter shared_secret : String
+
+ def port_num : Int32
+ alert_data.port_num
+ end
+ end
+
+ enum PortState
+ Connected
+ Disconnected
+ Disabled
+ end
+
+ class PortStatusResponse
+ include JSON::Serializable
+
+ @[JSON::Field(key: "portId")]
+ getter port_id : String
+
+ @[JSON::Field(ignore: true)]
+ getter port : Int32 { port_id.to_i }
+
+ getter? enabled : Bool
+ getter status : PortState
+
+ @[JSON::Field(key: "isUplink")]
+ getter? is_uplink : Bool
+
+ @[JSON::Field(ignore: true)]
+ property! switch_serial : String
+
+ @[JSON::Field(ignore: true)]
+ property mac : String? = nil
+
+ @[JSON::Field(ignore: true)]
+ property! desk_id : String
+
+ @[JSON::Field(ignore: true)]
+ property! level_id : String
+ end
+end
diff --git a/drivers/cisco/room_kit.cr b/drivers/cisco/room_kit.cr
new file mode 100644
index 00000000000..d6416d49c29
--- /dev/null
+++ b/drivers/cisco/room_kit.cr
@@ -0,0 +1,385 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "promise"
+require "uuid"
+
+require "./collaboration_endpoint"
+require "./collaboration_endpoint/ui_extensions"
+require "./collaboration_endpoint/presentation"
+require "./collaboration_endpoint/powerable"
+require "./collaboration_endpoint/cameras"
+
+class Cisco::RoomKit < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ descriptive_name "Cisco Room Kit"
+ generic_name :VidConf
+ tcp_port 22
+
+ description <<-DESC
+ Control of Cisco SX20 devices.
+
+ API access requires a local user with the "admin" role to be
+ created on the codec
+ DESC
+
+ default_settings({
+ ssh: {
+ username: :cisco,
+ password: :cisco,
+ },
+ peripheral_id: "uuid",
+ configuration: {
+ "RoomAnalytics" => {
+ "PeopleCountOutOfCall" => "On",
+ "PeoplePresenceDetector" => "On",
+ "WakeupOnMotionDetection" => "On",
+ },
+ },
+ presets: {
+ "Front Lecturn": 1,
+ },
+ })
+
+ include Cisco::CollaborationEndpoint
+ include Cisco::CollaborationEndpoint::UIExtensions
+ include Cisco::CollaborationEndpoint::Presentation
+ include Cisco::CollaborationEndpoint::Powerable
+ include Cisco::CollaborationEndpoint::Cameras
+
+ enum PresentationMode
+ None
+ Local
+ Remote
+ end
+
+ @presentation_mode : PresentationMode = PresentationMode::None
+ @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new
+
+ def connected
+ super
+ schedule.in(40.seconds) { disconnect if self["calls"]?.nil? }
+ end
+
+ protected def connection_ready
+ subscriptions.clear
+ subscribe("presentation") do |_sub, state|
+ if state != "null"
+ # presentation is typically false or "Sending"
+ if state == "false"
+ self[:presentation_mode] = @presentation_mode
+ else
+ self[:presentation_mode] = PresentationMode::Remote
+ end
+ end
+ end
+
+ register_feedback "/Event/PresentationPreviewStarted" do
+ self[:presentation_mode] = PresentationMode::Local
+ end
+ register_feedback "/Event/PresentationPreviewStopped" do
+ @presentation_mode = PresentationMode::None
+ self[:presentation_mode] = @presentation_mode if self[:presentation]? == false
+ end
+
+ @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key|
+ hash[key] = {} of String => Enumerable::JSONComplex
+ end
+ self[:calls] = @calls
+ register_feedback "/Status/Call" do |value_path, value|
+ if value.is_a? Hash(String, Enumerable::JSONComplex)
+ if value["Status"]? == "Idle" || value["ghost"]? == "True"
+ @calls.delete value_path
+ else
+ @calls[value_path].merge! value
+ end
+ self[:calls] = @calls
+ else
+ logger.debug { "unexpected call status value #{value}" }
+ end
+ end
+ end
+
+ map_status mic_mute: "Audio Microphones Mute"
+ map_status volume: "Audio Volume"
+ map_status speaker_track: "Cameras SpeakerTrack"
+ map_status presence_detected: "RoomAnalytics PeoplePresence"
+ map_status people_count: "RoomAnalytics PeopleCount Current"
+ map_status do_not_disturb: "Conference DoNotDisturb"
+ map_status presentation: "Conference Presentation Mode"
+ map_status peripherals: "Peripherals ConnectedDevice"
+ # selfview == camera pip
+ map_status selfview: "Video Selfview Mode"
+ map_status selfview_fullscreen: "Video Selfview FullScreenMode"
+ map_status video_input: "Video Input"
+ map_status video_output: "Video Output"
+ map_status video_layout: "Video Layout LayoutFamily Local"
+ map_status standby: "Standby State"
+
+ command({"Audio Microphones Mute" => :mic_mute_on})
+ command({"Audio Microphones Unmute" => :mic_mute_off})
+ command({"Audio Microphones ToggleMute" => :mic_mute_toggle})
+
+ def mic_mute(state : Bool = true)
+ state ? mic_mute_on : mic_mute_off
+ end
+
+ enum Toogle
+ On
+ Off
+ end
+
+ enum Sound
+ Alert
+ Bump
+ Busy
+ CallDisconnect
+ CallInitiate
+ CallWaiting
+ Dial
+ KeyInput
+ KeyInputDelete
+ KeyTone
+ Nav
+ NavBack
+ Notification
+ OK
+ PresentationConnect
+ Ringing
+ SignIn
+ SpecialInfo
+ TelephoneCall
+ VideoCall
+ VolumeAdjust
+ WakeUp
+ end
+
+ command({"Audio Sound Play" => :play_sound},
+ sound: Sound,
+ loop_: Toogle)
+ command({"Audio Sound Stop" => :stop_sound})
+
+ command({"Bookings List" => :bookings},
+ days_: 1..365,
+ day_offset_: 0..365,
+ limit_: Int32,
+ offset_: Int32)
+
+ command({"Call Accept" => :call_accept}, call_id_: Int32)
+ command({"Call Reject" => :call_reject}, call_id_: Int32)
+ command({"Call Disconnect" => :hangup}, call_id_: Int32)
+ command({"Call Hold" => :call_place_on_hold}, call_id_: Int32)
+ command({"Call Resume" => :call_resume}, call_id_: Int32)
+
+ command({"Call DTMFSend" => :dtmf_send},
+ d_t_m_f_string: String,
+ call_id_: 0..65534)
+
+ enum DialProtocol
+ H320
+ H323
+ Sip
+ Spark
+ end
+
+ enum CallType
+ Audio
+ Video
+ end
+
+ command({"Dial" => :dial},
+ number: String,
+ protocol_: DialProtocol,
+ call_rate_: 64..6000,
+ call_type_: CallType)
+
+ enum VideoLayout
+ Equal
+ PIP
+ end
+
+ command({"Video Input SetMainVideoSource" => :camera_select},
+ connector_id_: 1..3, # Source can either be specified as the
+ layout_: VideoLayout, # physical connector...
+ source_id_: 1..3) # ...or the logical source ID
+
+ enum LayoutFamily
+ Auto
+ Equal
+ Overlay
+ Prominent
+ Single
+ end
+
+ enum LayoutTarget
+ Local
+ Remote
+ end
+
+ command({"Video Layout LayoutFamily Set" => :video_layout},
+ layout_family: LayoutFamily,
+ target_: LayoutTarget)
+
+ enum PiPPosition
+ CenterLeft
+ CenterRight
+ LowerLeft
+ LowerRight
+ UpperCenter
+ UpperLeft
+ UpperRight
+ end
+
+ enum MonitorRole
+ First
+ Second
+ Third
+ Fourth
+ end
+
+ command({"Video Selfview Set" => :selfview},
+ mode_: Toogle,
+ full_screen_mode_: Toogle,
+ p_i_p_position_: PiPPosition,
+ on_monitor_role_: MonitorRole)
+
+ @[Security(Level::Support)]
+ command({"Cameras AutoFocus Diagnostics Start" => :autofocus_diagnostics_start},
+ camera_id: 1..1)
+
+ @[Security(Level::Support)]
+ command({"Cameras AutoFocus Diagnostics Stop" => :autofocus_diagnostics_stop},
+ camera_id: 1..1)
+
+ @[Security(Level::Support)]
+ command({"Cameras SpeakerTrack Diagnostics Start" => :speaker_track_diagnostics_start})
+
+ @[Security(Level::Support)]
+ command({"Cameras SpeakerTrack Diagnostics Stop" => :speaker_track_diagnostics_stop})
+
+ @[Security(Level::Support)]
+ command({"Cameras SpeakerTrack Activate" => :speaker_track_activate})
+
+ @[Security(Level::Support)]
+ command({"Cameras SpeakerTrack Deactivate" => :speaker_track_deactivate})
+
+ def speaker_track(state : Bool = true)
+ state ? speaker_track_activate : speaker_track_deactivate
+ end
+
+ enum PhonebookType
+ Corporate
+ Local
+ end
+
+ command({"Phonebook Search" => :phonebook_search},
+ search_string: String,
+ phonebook_type_: PhonebookType,
+ limit_: Int32,
+ offset_: Int32)
+
+ command({"UserInterface WebView Display" => :webview_display},
+ url: String)
+
+ command({"UserInterface WebView Clear" => :webview_clear})
+
+ @[Security(Level::Support)]
+ command({"SystemUnit Boot" => :reboot}, action_: PowerOff)
+
+ # Helper methods
+ # ==============
+
+ def show_camera_pip(visible : Bool)
+ mode = visible ? Toogle::On : Toogle::Off
+ selfview mode: mode
+ end
+
+ def mic_mute(state : Bool = true)
+ state ? mic_mute_on : mic_mute_off
+ end
+
+ def presentation_mode(value : PresentationMode)
+ case value
+ in .remote?
+ presentation_start sending_mode: :LocalRemote
+ in .local?
+ @presentation_mode = PresentationMode::Local
+ presentation_start sending_mode: :LocalOnly
+ in .none?
+ @presentation_mode = PresentationMode::None
+ presentation_stop
+ end
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if mac && mac != config.ip
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+
+ if sensor_type
+ sensor = build_sensor_details(sensor_type)
+ return NO_MATCH unless sensor
+ [sensor]
+ else
+ space_sensors
+ end
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+ return nil unless mac == config.ip
+
+ case id
+ when "people"
+ build_sensor_details(:people_count)
+ when "presence"
+ build_sensor_details(:presence)
+ end
+ end
+
+ protected def build_sensor_details(sensor : SensorType) : Detail?
+ id = "people_count"
+
+ value = case sensor
+ when .people_count?
+ self[:people_count].as_i.to_f64
+ when .presence?
+ id = "presence_detected"
+ self[:presence_detected] == "No" ? 0.0 : 1.0
+ else
+ raise "sensor type unavailable: #{sensor}"
+ end
+ return nil unless value
+
+ Detail.new(
+ type: sensor,
+ value: value,
+ last_seen: Time.utc.to_unix,
+ mac: config.ip.as(String),
+ id: id,
+ name: "Cisco Room Kit (#{config.ip})",
+ module_id: module_id,
+ binding: id
+ )
+ end
+
+ protected def space_sensors
+ [
+ build_sensor_details(:people_count),
+ build_sensor_details(:presence),
+ ].compact
+ end
+end
diff --git a/drivers/cisco/room_os.cr b/drivers/cisco/room_os.cr
new file mode 100644
index 00000000000..ec3af9fa146
--- /dev/null
+++ b/drivers/cisco/room_os.cr
@@ -0,0 +1,42 @@
+require "placeos-driver"
+require "promise"
+require "uuid"
+
+require "./collaboration_endpoint"
+require "./collaboration_endpoint/ui_extensions"
+
+class Cisco::RoomOS < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Cisco Room OS"
+ generic_name :RoomOS
+ tcp_port 22
+
+ description <<-DESC
+ Low level driver for any Cisco Room OS device. This may be used
+ if direct access is required to the device API, or a required feature
+ is not provided by the device specific implementation.
+
+ Where possible use the implementation for room device in use
+ i.e. SX80, Room Kit etc.
+ DESC
+
+ default_settings({
+ ssh: {
+ username: :cisco,
+ password: :cisco,
+ },
+ peripheral_id: "uuid",
+ configuration: {
+ "Audio Microphones Mute" => {"Enabled" => "False"},
+ "Audio Input Line 1 VideoAssociation" => {
+ "MuteOnInactiveVideo" => "On",
+ "VideoInputSource" => 2,
+ },
+ },
+ })
+
+ include Cisco::CollaborationEndpoint
+ include Cisco::CollaborationEndpoint::UIExtensions
+
+ map_status volume: "Audio Volume"
+end
diff --git a/drivers/cisco/room_os_spec.cr b/drivers/cisco/room_os_spec.cr
new file mode 100644
index 00000000000..9e15843da94
--- /dev/null
+++ b/drivers/cisco/room_os_spec.cr
@@ -0,0 +1,608 @@
+require "placeos-driver/spec"
+require "./collaboration_endpoint/xapi"
+
+DriverSpecs.mock_driver "Cisco::RoomOS" do
+ # Test command generation helpers
+ action = Cisco::CollaborationEndpoint::XAPI.xcommand(
+ "Camera PositionSet",
+ camera_id: 1,
+ lens: "Wide",
+ optional: nil
+ )
+ action.should eq(%(xCommand Camera PositionSet CameraId: 1 Lens: "Wide"))
+
+ action = Cisco::CollaborationEndpoint::XAPI.xcommand(
+ "Audio Volume Decrease"
+ )
+ action.should eq(%(xCommand Audio Volume Decrease))
+
+ # Test the response processing helpers
+ response = JSON.parse(%({
+ "Configuration":{
+ "Audio":{
+ "DefaultVolume":{
+ "valueSpaceRef":"/Valuespace/INT_0_100",
+ "Value":"50"
+ },
+ "Input":{
+ "Line":[
+ {
+ "id":"1",
+ "VideoAssociation":{
+ "MuteOnInactiveVideo":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ },
+ "VideoInputSource":{
+ "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2",
+ "Value":"2"
+ }
+ }
+ }
+ ],
+ "Microphone":[
+ {
+ "id":"AAA",
+ "EchoControl":{
+ "Dereverberation":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"Off"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ },
+ "NoiseReduction":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ },
+ "Level":{
+ "valueSpaceRef":"/Valuespace/INT_0_24",
+ "Value":"14"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ },
+ {
+ "id":"2",
+ "EchoControl":{
+ "Dereverberation":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"Off"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ },
+ "NoiseReduction":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ },
+ "Level":{
+ "valueSpaceRef":"/Valuespace/INT_0_24",
+ "Value":"14"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ }
+ ]
+ },
+ "Microphones":{
+ "Mute":{
+ "Enabled":{
+ "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled",
+ "Value":"True"
+ }
+ }
+ }
+ }
+ }
+ })).as_h.flatten_xapi_json
+ response.should eq({
+ "Configuration/Audio/DefaultVolume" => 50,
+ "Configuration/Audio/Input/Line/1" => {
+ "VideoAssociation/MuteOnInactiveVideo" => true,
+ "VideoAssociation/VideoInputSource" => 2,
+ },
+ "Configuration/Audio/Input/Microphone/AAA" => {
+ "EchoControl/Dereverberation" => false,
+ "EchoControl/Mode" => true,
+ "EchoControl/NoiseReduction" => true,
+ "Level" => 14,
+ "Mode" => true,
+ },
+ "Configuration/Audio/Input/Microphone/2" => {
+ "EchoControl/Dereverberation" => false,
+ "EchoControl/Mode" => true,
+ "EchoControl/NoiseReduction" => true,
+ "Level" => 14,
+ "Mode" => true,
+ },
+ "Configuration/Audio/Microphones/Mute/Enabled" => true,
+ })
+
+ transmit "welcome\n*r Login successful\r\n"
+
+ # ====
+ # Connection setup
+ puts "\nCONNECTION SETUP:\n=============="
+ should_send "xPreferences OutputMode JSON\n"
+ should_send "xPreferences OutputMode JSON\n"
+
+ # ====
+ # System registration
+ puts "\nSYSTEM REGISTRATION:\n=============="
+
+ data = String.new expect_send
+ data.starts_with?(%(xCommand Peripherals Connect ID: "uuid" Name: "PlaceOS" Type: ControlSystem | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "CommandResponse":{
+ "PeripheralsConnectResult":{
+ "status":"OK"
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ # ====
+ # Config push
+ puts "\nCONFIG PUSH:\n=============="
+
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Audio Microphones Mute Enabled: "False" | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation MuteOnInactiveVideo: "On" | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation VideoInputSource: 2 | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ # MAPS Status ====
+ data = String.new expect_send
+ data.starts_with?(%(xFeedback Register /Configuration | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ should_send "xConfiguration *\n"
+ responds %({
+ "Configuration":{
+ "Audio":{
+ "DefaultVolume":{
+ "valueSpaceRef":"/Valuespace/INT_0_100",
+ "Value":"50"
+ },
+ "Input":{
+ "Line":[
+ {
+ "id":"1",
+ "VideoAssociation":{
+ "MuteOnInactiveVideo":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ },
+ "VideoInputSource":{
+ "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2",
+ "Value":"2"
+ }
+ }
+ }
+ ],
+ "Microphone":[
+ {
+ "id":"1",
+ "EchoControl":{
+ "Dereverberation":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"Off"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ },
+ "NoiseReduction":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ },
+ "Level":{
+ "valueSpaceRef":"/Valuespace/INT_0_24",
+ "Value":"14"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ },
+ {
+ "id":"2",
+ "EchoControl":{
+ "Dereverberation":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"Off"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ },
+ "NoiseReduction":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ },
+ "Level":{
+ "valueSpaceRef":"/Valuespace/INT_0_24",
+ "Value":"14"
+ },
+ "Mode":{
+ "valueSpaceRef":"/Valuespace/TTPAR_OnOff",
+ "Value":"On"
+ }
+ }
+ ]
+ },
+ "Microphones":{
+ "Mute":{
+ "Enabled":{
+ "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled",
+ "Value":"True"
+ }
+ }
+ }
+ }
+ }
+ })
+
+ status[:configuration].should eq({
+ "/Audio/DefaultVolume" => 50,
+ "/Audio/Input/Line/1" => {
+ "VideoAssociation/MuteOnInactiveVideo" => true,
+ "VideoAssociation/VideoInputSource" => 2,
+ },
+ "/Audio/Input/Microphone/1" => {
+ "EchoControl/Dereverberation" => false,
+ "EchoControl/Mode" => true,
+ "EchoControl/NoiseReduction" => true,
+ "Level" => 14,
+ "Mode" => true,
+ },
+ "/Audio/Input/Microphone/2" => {
+ "EchoControl/Dereverberation" => false,
+ "EchoControl/Mode" => true,
+ "EchoControl/NoiseReduction" => true,
+ "Level" => 14,
+ "Mode" => true,
+ },
+ "/Audio/Microphones/Mute/Enabled" => true,
+ })
+
+ data = String.new expect_send
+ puts "GOT: #{data}"
+ data.starts_with?(%(xFeedback Register /Status/Audio/Volume | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ data = String.new expect_send
+ puts "GOT: #{data}"
+ data.starts_with?(%(xStatus Audio Volume | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "Status":{
+ "Audio":{
+ "Volume":{
+ "Value":"50"
+ }
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ # Finish mapping status
+ status[:volume].should eq(50)
+
+ # ====
+ # Audio Status
+ resp = exec(:xstatus, "Audio")
+ data = String.new expect_send
+ data.starts_with?(%(xStatus Audio | resultId=")).should be_true
+ id = data.split('"')[-2]
+ responds %({
+ "Status":{
+ "Audio":{
+ "Input":{
+ "Connectors":{
+ "Microphone":[
+ {
+ "id":"1",
+ "ConnectionStatus":{
+ "Value":"Connected"
+ }
+ },
+ {
+ "id":"2",
+ "ConnectionStatus":{
+ "Value":"NotConnected"
+ }
+ }
+ ]
+ }
+ },
+ "Microphones":{
+ "Mute":{
+ "Value":"On"
+ }
+ },
+ "Output":{
+ "Connectors":{
+ "Line":[
+ {
+ "id":"1",
+ "DelayMs":{
+ "Value":"0"
+ }
+ }
+ ]
+ }
+ },
+ "Volume":{
+ "Value":"50"
+ }
+ }
+ },
+ "ResultId": "#{id}"
+ })
+ resp.get.should eq({
+ "Status/Audio/Input/Connectors/Microphone/1" => {
+ "ConnectionStatus" => "Connected",
+ },
+ "Status/Audio/Input/Connectors/Microphone/2" => {
+ "ConnectionStatus" => "NotConnected",
+ },
+ "Status/Audio/Microphones/Mute" => true,
+ "Status/Audio/Output/Connectors/Line/1" => {
+ "DelayMs" => 0,
+ },
+ "Status/Audio/Volume" => 50,
+ })
+
+ # ====
+ # Time Status
+ resp = exec(:xstatus, "Time")
+ data = String.new expect_send
+ data.starts_with?(%(xStatus Time | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "Status":{
+ "Time":{
+ "SystemTime":{
+ "Value":"2017-11-27T15:14:25+1000"
+ }
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ resp.get.should eq({
+ "Status/Time/SystemTime" => "2017-11-27T15:14:25+1000",
+ })
+
+ # ====
+ # Time Status fail
+ resp = exec(:xstatus, "Wrong")
+ data = String.new expect_send
+ data.starts_with?(%(xStatus Wrong | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "Status":{
+ "status":"Error",
+ "Reason":{
+ "Value":"No match on address expression."
+ },
+ "XPath":{
+ "Value":"Status/Wrong"
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ expect_raises(PlaceOS::Driver::RemoteException) { resp.get }
+
+ # Basic command
+ resp = exec(:xcommand, "Standby Deactivate")
+ data = String.new expect_send
+ data.starts_with?(%(xCommand Standby Deactivate | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "CommandResponse":{
+ "StandbyDeactivateResult":{
+ "status":"OK"
+ }
+ },
+ "ResultId": "#{id}"
+ })
+ resp.get.should eq "OK"
+
+ # Command with arguments
+ resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, Layout: :PIP})
+ data = String.new expect_send
+ data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 Layout: "PIP" | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "CommandResponse":{
+ "InputSetMainVideoSourceResult":{
+ "status":"OK"
+ }
+ },
+ "ResultId": "#{id}"
+ })
+ resp.get.should eq "OK"
+
+ # Return device argument errors
+ resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, SourceId: 1})
+ data = String.new expect_send
+ data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 SourceId: 1 | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "CommandResponse":{
+ "InputSetMainVideoSourceResult":{
+ "status":"Error",
+ "Reason":{
+ "Value":"Must supply either SourceId or ConnectorId (but not both.)"
+ }
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ expect_raises(PlaceOS::Driver::RemoteException) { resp.get }
+
+ # Return error from invalid / inaccessable xCommands
+ resp = exec(:xcommand, "Not A Real Command")
+ data = String.new expect_send
+ data.starts_with?(%(xCommand Not A Real Command | resultId=")).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "CommandResponse":{
+ "Result":{
+ "status":"Error",
+ "Reason":{
+ "Value":"Unknown command"
+ }
+ },
+ "XPath":{
+ "Value":"/Not/A/Real/Command"
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ expect_raises(PlaceOS::Driver::RemoteException) { resp.get }
+
+ # Multiline commands
+ resp = exec(:xcommand, "SystemUnit SignInBanner Set", "Hello\nWorld!")
+ data = String.new expect_send
+ data.starts_with?(%(xCommand SystemUnit SignInBanner Set | resultId=")).should be_true
+ data.ends_with?(%(Hello\nWorld!\n.\n)).should be_true
+ id = data.split('"')[-2]
+
+ responds %({
+ "CommandResponse":{
+ "SignInBannerSetResult":{
+ "status":"OK"
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ resp.get.should eq "OK"
+
+ # Multuple settings return a unit :success when all ok
+ resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Name: "Borris", Quality: :Motion})
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true
+ id = data.split('"')[-2]
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Video Input Connector 1 Name: "Borris" | resultId=")).should be_true
+ id = data.split('"')[-2]
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true
+ id = data.split('"')[-2]
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ resp.get.should eq true
+
+ # Multiple settings with failure with return a command failure
+ resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Foo: "Bar", Quality: :Motion})
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true
+ id = data.split('"')[-2]
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Video Input Connector 1 Foo: "Bar" | resultId=")).should be_true
+ id = data.split('"')[-2]
+ responds %({
+ "CommandResponse":{
+ "Configuration":{
+ "status":"Error",
+ "Reason":{
+ "Value":"No match on address expression."
+ },
+ "XPath":{
+ "Value":"Configuration/Video/Input/Connector[1]/Foo"
+ }
+ }
+ },
+ "ResultId": "#{id}"
+ })
+
+ data = String.new expect_send
+ data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true
+ id = data.split('"')[-2]
+ responds %({
+ "ResultId": "#{id}"
+ })
+
+ expect_raises(PlaceOS::Driver::RemoteException) { resp.get }
+
+ # Out of order send
+ responds %({
+ "Status":{
+ "Audio":{
+ "Volume":{
+ "Value":"52"
+ }
+ }
+ }
+ })
+
+ # Finish mapping status
+ status[:volume].should eq(52)
+end
diff --git a/drivers/cisco/spaces_room.cr b/drivers/cisco/spaces_room.cr
new file mode 100644
index 00000000000..8f5fc49873b
--- /dev/null
+++ b/drivers/cisco/spaces_room.cr
@@ -0,0 +1,59 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+
+class Cisco::SpacesRoom < PlaceOS::Driver
+ include Interface::Sensor
+
+ descriptive_name "Cisco Spaces Room Sensors"
+ generic_name :SpacesRoomSensors
+ description "exposes sensor information to the room"
+
+ default_settings({
+ _cisco_spaces_system: "sys-12345",
+ _cisco_spaces_module: "Cisco_Spaces",
+
+ space_room_id: "a410b6d676",
+ })
+
+ getter system_id : String = ""
+ getter module_name : String = ""
+ getter room_id : String = ""
+
+ def on_update
+ @system_id = setting?(String, :cisco_spaces_system).presence || config.control_system.not_nil!.id
+ @module_name = setting?(String, :cisco_spaces_module).presence || "Cisco_Spaces"
+ @room_id = setting(String, :space_room_id)
+ end
+
+ private def cisco_spaces
+ system(system_id)[module_name]
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence, SensorType::Humidity, SensorType::Temperature, SensorType::AirQuality, SensorType::SoundPressure}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if mac && mac != @room_id
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+ return NO_MATCH if zone_id && !system.zones.includes?(zone_id)
+
+ Array(Interface::Sensor::Detail).from_json cisco_spaces.sensors(type, @room_id, zone_id).get.to_json
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+ return nil unless mac == @room_id
+
+ Interface::Sensor::Detail?.from_json(cisco_spaces.sensors(@room_id, id).get.to_json)
+ end
+end
diff --git a/drivers/cisco/spaces_room_spec.cr b/drivers/cisco/spaces_room_spec.cr
new file mode 100644
index 00000000000..88118d8df83
--- /dev/null
+++ b/drivers/cisco/spaces_room_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::SpacesRoom" do
+end
diff --git a/drivers/cisco/switch/snooping_catalyst.cr b/drivers/cisco/switch/snooping_catalyst.cr
new file mode 100644
index 00000000000..9680d0b5ece
--- /dev/null
+++ b/drivers/cisco/switch/snooping_catalyst.cr
@@ -0,0 +1,264 @@
+require "placeos-driver"
+require "set"
+
+class Cisco::Switch::SnoopingCatalyst < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Cisco Catalyst Switch IP Snooping"
+ generic_name :Snooping
+ tcp_port 22
+
+ # Communication settings
+ # tokenize delimiter: /\n|-- /
+
+ default_settings({
+ ssh: {
+ username: :cisco,
+ password: :cisco,
+ },
+ building: "building_code",
+ ignore_macs: {
+ "Cisco Phone Dock" => "7001b5",
+ },
+ })
+
+ # Interfaces that indicate they have a device connected
+ @check_interface = ::Set(String).new
+
+ # MAC, IP, Interface
+ @snooping = [] of Tuple(String, String, String)
+
+ # interface to MAC address mappings
+ @interface_macs = {} of String => String
+ @devices = {} of String => NamedTuple(mac: String, ip: String)
+
+ @hostname : String? = nil
+ @switch_name : String? = nil
+ @ignore_macs = ::Set(String).new
+
+ def on_load
+ # "--More--" is sent without a newline
+ transport.tokenizer = Tokenizer.new("\n", "--More--")
+
+ on_update
+ end
+
+ def on_update
+ @ignore_macs = ::Set.new((setting?(Hash(String, String), :ignore_macs) || {} of String => String).values)
+
+ self[:name] = @switch_name = setting?(String, :switch_name)
+ self[:ip_address] = config.ip.not_nil!.downcase
+ self[:building] = setting?(String, :building)
+ self[:level] = setting?(String, :level)
+ self[:last_successful_query] ||= 0
+ end
+
+ def connected
+ schedule.in(1.second) { query_connected_devices }
+ schedule.every(1.minute) { query_connected_devices }
+ end
+
+ def disconnected
+ schedule.clear
+ queue.clear
+ end
+
+ # Don't want the every day user using this method
+ @[Security(Level::Administrator)]
+ def run(command : String)
+ do_send command
+ end
+
+ def query_interface_status
+ do_send "show interfaces status"
+ end
+
+ def query_mac_addresses
+ @interface_macs.clear
+ do_send "show mac address-table"
+ end
+
+ def query_snooping_bindings
+ @snooping.clear
+ do_send "show ip dhcp snooping binding"
+ end
+
+ @querying_devices : Bool = false
+
+ def query_connected_devices
+ return if @querying_devices
+ @querying_devices = true
+
+ logger.debug { "Querying for connected devices" }
+
+ query_interface_status.get
+ sleep 3.seconds
+
+ query_mac_addresses.get
+ sleep 3.seconds
+
+ query_snooping_bindings.get
+ sleep 2.seconds
+
+ nil
+ ensure
+ @querying_devices = false
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Switch sent: #{data}" }
+
+ # determine the hostname
+ if @hostname.nil?
+ parts = data.split(">")
+ if parts.size == 2
+ self[:hostname] = @hostname = parts[0]
+
+ # Exit early as this line is not a response
+ return task.try &.success
+ end
+ end
+
+ case data
+ when /More/
+ # Detect more data available
+ # ==> --More--
+ send(" ", priority: 99, retries: 0)
+ return task.try &.success
+ when /STATIC|DYNAMIC/
+ # Interface MAC Address detection
+ # 33 e4b9.7aa5.aa7f STATIC Gi3/0/8
+ # 10 f4db.e618.10a4 DYNAMIC Te2/0/40
+ parts = data.split(/\s+/).reject(&.empty?)
+ mac = format(parts[1])
+ interface = normalise(parts[-1])
+
+ @interface_macs[interface] = mac if mac && interface
+
+ return :success
+ when /%LINK/
+ # Interface change detection
+ # 07-Aug-2014 17:28:26 %LINK-I-Up: gi2
+ # 07-Aug-2014 17:28:31 %STP-W-PORTSTATUS: gi2: STP status Forwarding
+ # 07-Aug-2014 17:44:43 %LINK-I-Up: gi2, aggregated (1)
+ # 07-Aug-2014 17:44:47 %STP-W-PORTSTATUS: gi2: STP status Forwarding, aggregated (1)
+ # 07-Aug-2014 17:45:24 %LINK-W-Down: gi2, aggregated (2)
+ interface = normalise(data.split(",")[0].split(/\s/)[-1])
+
+ if data =~ /Up:/
+ logger.debug { "Notify Up: #{interface}" }
+ @check_interface << interface
+
+ # Delay here is to give the PC some time to negotiate an IP address
+ # schedule.in(3000) { query_snooping_bindings }
+ elsif data =~ /Down:/
+ logger.debug { "Notify Down: #{interface}" }
+ # We are no longer interested in this interface
+ @check_interface.delete(interface)
+ end
+
+ self[:interfaces] = @check_interface
+
+ return task.try &.success
+ when .starts_with?("Total number")
+ logger.debug { "Processing #{@snooping.size} bindings" }
+ checked = Set(String).new
+ devices = {} of String => NamedTuple(mac: String, ip: String)
+ state_changed = false
+
+ @snooping.each do |mac, ip, interface|
+ next unless @check_interface.includes?(interface)
+ next unless @interface_macs[interface]? == mac
+ next if checked.includes?(interface)
+
+ checked << interface
+ iface = @devices[interface]? || {mac: "", ip: ""}
+
+ if iface[:ip] != ip || iface[:mac] != mac
+ logger.debug { "New connection on #{interface} with #{ip}: #{mac}" }
+ devices[interface] = {mac: mac, ip: ip}
+ state_changed = true
+ else
+ devices[interface] = iface
+ end
+ end
+
+ # did an interface change state
+ if state_changed
+ @devices = devices
+ self[:devices] = devices
+ end
+
+ # As a link up or down might have modified this list
+ if @check_interface != checked
+ @check_interface = checked
+ self[:interfaces] = checked
+ end
+
+ self[:last_successful_query] = Time.utc.to_unix
+
+ return task.try &.success
+ end
+
+ # Grab the parts of the response
+ entries = data.split(/\s+/).reject(&.empty?)
+
+ # show interfaces status
+ # Port Name Status Vlan Duplex Speed Type
+ # Gi1/1 notconnect 1 auto auto No Gbic
+ # Fa6/1 connected 1 a-full a-100 10/100BaseTX
+ case entries
+ when .includes?("connected")
+ interface = entries[0].downcase
+ unless @check_interface.includes? interface
+ logger.debug { "Interface Up: #{interface}" }
+ @check_interface << interface
+ end
+ when .includes?("notconnect")
+ interface = entries[0].downcase
+ if @check_interface.includes? interface
+ # Delete the lookup records
+ logger.debug { "Interface Down: #{interface}" }
+ @check_interface.delete(interface)
+ end
+ else
+ if entries.size > 2
+ # We are looking for MAC to IP address mappings
+ # =============================================
+ # MacAddress IpAddress Lease(sec) Type VLAN Interface
+ # ------------------ --------------- ---------- ------------- ---- --------------------
+ # 00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/43
+ # Total number of bindings: 3
+ interface = normalise(entries[-1])
+
+ # We only want entries that are currently active
+ if @check_interface.includes? interface
+ # Ensure the data is valid
+ mac = entries[0]
+ if mac =~ /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/
+ mac = format(mac)
+ ip = entries[1]
+
+ @snooping << {mac, ip, interface} unless @ignore_macs.includes?(mac[0..5])
+ end
+ end
+ end
+ end
+
+ task.try &.success
+ end
+
+ protected def do_send(cmd, **options)
+ logger.debug { "requesting: #{cmd}" }
+ send("#{cmd}\n", **options)
+ end
+
+ protected def format(mac)
+ mac.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+
+ protected def normalise(interface)
+ # Port-channel == po
+ interface.downcase.gsub("tengigabitethernet", "te").gsub("twogigabitethernet", "tw").gsub("gigabitethernet", "gi").gsub("fastethernet", "fa")
+ end
+end
diff --git a/drivers/cisco/switch/snooping_catalyst_spec.cr b/drivers/cisco/switch/snooping_catalyst_spec.cr
new file mode 100644
index 00000000000..b3c8cdee782
--- /dev/null
+++ b/drivers/cisco/switch/snooping_catalyst_spec.cr
@@ -0,0 +1,63 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::Switch::SnoopingCatalyst" do
+ transmit "SG-MARWFA61301>"
+ sleep 1.5.seconds
+
+ should_send "show interfaces status\n"
+ transmit "show interfaces status\n"
+ status[:hostname].should eq("SG-MARWFA61301")
+
+ transmit %(Port Name Status Vlan Duplex Speed Type
+Gi1/0/1 notconnect 113 auto auto 10/100/1000BaseTX
+Gi1/0/2 notconnect 113 auto auto 10/100/1000BaseTX
+Gi2/0/11 notconnect 113 auto auto 10/100/1000BaseTX
+Gi2/0/12 notconnect 113 auto auto 10/100/1000BaseTX
+Gi2/0/13 notconnect 113 auto auto 10/100/1000BaseTX
+Gi2/0/14 notconnect 113 auto auto 10/100/1000BaseTX
+Gi2/0/15 notconnect 113 auto auto 10/100/1000BaseTX
+Gi2/0/16 notconnect 113 auto auto 10/100/1000BaseTX
+Gi2/0/17 notconnect 113 auto auto 10/100/1000BaseTX
+Gi3/0/8 connected 33 auto auto 10/100/1000BaseTX
+ --More--)
+
+ should_send " "
+ transmit %(
+Gi4/0/48 notconnect 113 auto auto 10/100/1000BaseTX
+Gi4/1/1 notconnect 1 auto auto unknown
+Gi4/1/2 notconnect 1 auto auto unknown
+Te4/1/4 connected trunk full 10G SFP-10GBase-SR
+Po1 connected trunk a-full a-10G
+)
+
+ sleep 3.1.seconds
+
+ should_send "show mac address-table\n"
+ transmit "show mac address-table\n"
+
+ transmit %(Vlan MAC Type Port
+33 e4b9.7aa5.aa7f STATIC Gi3/0/8
+10 f4db.e618.10a4 DYNAMIC Te2/0/40
+)
+
+ sleep 3.1.seconds
+
+ should_send "show ip dhcp snooping binding\n"
+ transmit %(MacAddress IpAddress Lease(sec) Type VLAN Interface
+------------------ --------------- ---------- ------------- ---- --------------------
+38:C9:86:17:A2:07 192.168.1.15 19868 dhcp-snooping 113 tenGigabitEthernet4/1/4
+E4:B9:7A:A5:AA:7F 10.151.128.150 16532 dhcp-snooping 33 GigabitEthernet3/0/8
+00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/34
+Total number of bindings: 3
+
+)
+
+ status["devices"].should eq({
+ "gi3/0/8" => {
+ "mac" => "e4b97aa5aa7f",
+ "ip" => "10.151.128.150",
+ },
+ })
+
+ status["interfaces"].should eq(["gi3/0/8"])
+end
diff --git a/drivers/cisco/ui_extender.cr b/drivers/cisco/ui_extender.cr
new file mode 100644
index 00000000000..bd7783dd3a9
--- /dev/null
+++ b/drivers/cisco/ui_extender.cr
@@ -0,0 +1,428 @@
+require "promise"
+require "placeos-driver"
+require "./collaboration_endpoint/response"
+
+class Cisco::UIExtender < PlaceOS::Driver
+ descriptive_name "Cisco UI Extender"
+ generic_name :CiscoUI
+ description "Cisco Touch 10 UI extensions"
+
+ default_settings({
+ codec: "VidConf_1",
+ cisco_ui_layout: "XML Config",
+ cisco_ui_bindings: {
+ "id" => "VidConf_1.binding",
+ },
+ })
+
+ @event_handlers : Hash(Tuple(String, String), Proc(JSON::Any, Nil)) = {} of Tuple(String, String) => Proc(JSON::Any, Nil)
+
+ # ------------------------------
+ # Module callbacks
+
+ def on_load
+ on_update(true)
+ end
+
+ def on_unload
+ clear_extensions
+ unbind
+ end
+
+ alias Binding = String | Hash(String, String | Hash(String, String | Hash(String, Array(String))))
+
+ # id => binding
+ alias Bindings = Hash(String, Binding)
+
+ def on_update(loading = false)
+ # we don't want a failure here to prevent loading new settings
+ unless loading
+ begin
+ clear_events
+ rescue
+ end
+ end
+
+ codec_mod = setting?(String, :codec) || "VidConf_1"
+ unless system.exists? codec_mod
+ logger.warn { "could not find codec #{codec_mod}" }
+ return
+ end
+
+ ui_layout = setting?(String, :cisco_ui_layout)
+ bindings = setting?(Bindings, :cisco_ui_bindings) || {} of String => Binding
+
+ bind(codec_mod) do
+ deploy_extensions "PlaceOS", ui_layout if ui_layout
+ bindings.each { |id, config| link_widget id, config }
+ end
+ end
+
+ # ------------------------------
+ # Deployment
+
+ # Push a UI definition build with the in-room control editor to the device.
+ def deploy_extensions(id : String, xml_def : String)
+ codec.xcommand "UserInterface Extensions Set", xml_def, {"config_id" => id}
+ end
+
+ # Retrieve the extensions currently loaded.
+ def list_extensions
+ codec.xcommand "UserInterface Extensions List"
+ end
+
+ # Clear any deployed UI extensions.
+ def clear_extensions
+ codec.xcommand "UserInterface Extensions Clear"
+ end
+
+ # ------------------------------
+ # Panel interaction
+
+ def close_panel
+ codec.xcommand "UserInterface Extensions Panel Close"
+ end
+
+ protected def on_extensions_panel_clicked(event) : Nil
+ id = event["/Event/UserInterface/Extensions/Panel/Clicked/PanelId"]?.try &.as_s
+ return unless id
+ logger.debug { "#{id} opened" }
+ self[:__active_panel] = id
+ end
+
+ # ------------------------------
+ # Element interaction
+
+ protected def set_actual(id : String, value : String)
+ logger.debug { "setting #{id} to #{value}" }
+ update = codec.xcommand "UserInterface Extensions Widget SetValue",
+ hash_args: {WidgetId: id, Value: value}
+
+ # The device does not raise an event when a widget state is changed via
+ # the API. In these cases, ensure locally tracked state remains valid.
+ Promise.defer do
+ update.get
+ self[id] = Cisco::CollaborationEndpoint::XAPI.value_convert(value)
+ value.as(String | Nil)
+ end
+ end
+
+ protected def set_actual(id : String, value : Nil)
+ unset id
+ end
+
+ protected def set_actual(id : String, value : Bool)
+ switch(id, value).catch { highlight(id, value).get }
+ end
+
+ # Set the value of a widget.
+ def set(id : String, value : String | Bool | Nil)
+ set_actual(id, value)
+ end
+
+ # Clear the value associated with a widget.
+ def unset(id : String)
+ logger.debug { "clearing #{id}" }
+
+ update = codec.xcommand "UserInterface Extensions Widget UnsetValue",
+ hash_args: {WidgetId: id}
+
+ Promise.defer do
+ update.get
+ self[id] = nil
+ nil.as(String | Nil)
+ end
+ end
+
+ # Set the state of a switch widget.
+ def switch(id : String, state : Bool? = nil)
+ state = !status?(Bool, id) if state.nil?
+ value = state ? "on" : "off"
+ set id, value
+ end
+
+ # Set the highlight state for a button widget.
+ def highlight(id : String, state : Bool = true, momentary : Bool = false, time : Int32 = 500)
+ value = state ? "active" : "inactive"
+ schedule.in(time.milliseconds) { highlight(id, !state); nil } if momentary
+ set id, value
+ end
+
+ # Set the text label used on text or spinner widget.
+ def label(id : String, value : String | Bool | Nil)
+ set_actual(id, value)
+ end
+
+ # Callback for changes to widget state.
+ @action_merged : Hash(String, JSON::Any) = {} of String => JSON::Any
+
+ def on_extensions_widget_action(event : Hash(String, JSON::Any))
+ logger.debug { "received widget action update #{event}" }
+ current_key = event.keys.first
+ case current_key
+ when "/Event/UserInterface/Extensions/Widget/Action/WidgetId"
+ @action_merged["WidgetId"] = event[current_key]
+ when "/Event/UserInterface/Extensions/Widget/Action", "/Event/UserInterface/Extensions/Widget/Action/Value"
+ @action_merged["Value"] = event[current_key]
+ when "/Event/UserInterface/Extensions/Widget/Action/Type"
+ @action_merged["Type"] = event[current_key]
+ else
+ logger.debug { "ignoring key #{current_key} processing widget_action event" }
+ end
+ logger.debug { "current action state: #{@action_merged}" }
+ return unless @action_merged.size == 3
+ id, value, type = @action_merged.values_at "WidgetId", "Value", "Type"
+ @action_merged = {} of String => JSON::Any
+
+ logger.debug { "#{id} #{type} = #{value}" }
+
+ id = id.as_s
+ type = type.as_s
+
+ # Track values of stateful widgets
+ self[id] = value unless ["", "increment", "decrement"].includes?(value.raw)
+
+ # Trigger any bindings defined for the widget action
+ begin
+ handler = @event_handlers.fetch [id, type], nil
+ handler.try &.call(value)
+ rescue e
+ logger.error(exception: e) { "error in binding for #{id}.#{type}" }
+ end
+
+ # Provide an event stream for other modules to subscribe to
+ self[:__event_stream] = {id: id, type: type, value: value}
+ end
+
+ # ------------------------------
+ # Popup messages
+
+ def alert(text : String, title : String = "", duration : Int32 = 0)
+ codec.xcommand(
+ "UserInterface Message Alert Display",
+ hash_args: {
+ Text: text,
+ Title: title,
+ Duration: duration,
+ }
+ )
+ end
+
+ def clear_alert
+ codec.xcommand "UserInterface Message Alert Clear"
+ end
+
+ # ------------------------------
+ # Internals
+
+ @codec_mod : String = ""
+ @subscriptions : Array(PlaceOS::Driver::Subscriptions::Subscription) = [] of PlaceOS::Driver::Subscriptions::Subscription
+
+ protected def clear_subscriptions
+ logger.debug { "clearing subscriptions!" }
+ @subscriptions.each { |sub| subscriptions.unsubscribe(sub) }
+ @subscriptions.clear
+ end
+
+ # Bind to a Cisco CE device module.
+ protected def bind(mod : String, &bind_cb : Proc(Nil))
+ logger.debug { "binding to #{mod}" }
+
+ @codec_mod = mod
+ subscriptions.clear
+ @subscriptions.clear
+ system.subscribe(@codec_mod, :ready) do |_sub, value|
+ logger.debug { "codec ready: #{value}" }
+ next unless value == "true"
+ clear_subscriptions
+ subscribe_events
+ bind_cb.call
+ sync_widget_state
+ end
+ @codec_mod
+ end
+
+ # Unbind from the device module.
+ protected def unbind
+ logger.debug { "unbinding" }
+ clear_events async: true
+ @codec_mod = ""
+ end
+
+ protected def bound?
+ !@codec_mod.empty?
+ end
+
+ protected def codec
+ raise "not currently bound to a codec module" unless bound?
+ system[@codec_mod]
+ end
+
+ # Push the current module state to the device.
+ def sync_widget_state
+ @__status__.each do |key, value|
+ next if key == "connected"
+
+ # Non-widget related status prefixed with `__`
+ next if key =~ /^__.*/
+ case value
+ when .starts_with?("\"")
+ set key, String.from_json(value)
+ when "true", "false"
+ set key, value == "true"
+ end
+ end
+ end
+
+ # Build a list of device XPath -> callback mappings.
+ protected def event_mappings
+ ui_callbacks.map do |(function_name, callback)|
+ path = "/Event/UserInterface/#{function_name[3..-1].split("_").map(&.capitalize).join("/")}"
+ {path, function_name, callback}
+ end
+ end
+
+ protected def each_mapping(async : Bool)
+ device_mod = codec
+ event_mappings.each { |(path, function, callback)| yield path, function, callback, device_mod }
+ end
+
+ # Perform an action for each event -> callback mapping.
+ protected def each_mapping
+ device_mod = codec
+ interactions = event_mappings.map do |(path, function, callback)|
+ future = yield path, function, callback, device_mod
+ Promise.defer { future.get }
+ end
+ Promise.all(interactions).get
+ end
+
+ protected def subscribe_events(**opts)
+ mod_id = module_id
+ each_mapping(**opts) do |path, function, callback, codec|
+ logger.debug { "monitoring #{mod_id}/#{function}" }
+ @subscriptions << monitor("#{mod_id}/#{function}") do |_sub, event_json|
+ logger.debug { "#{function} received #{event_json}" }
+ spawn do
+ begin
+ callback.call(Hash(String, JSON::Any).from_json(event_json))
+ rescue error
+ logger.error(exception: error) { "processing panel event" }
+ end
+ end
+ end
+ codec.on_event path, mod_id, function
+ end
+ end
+
+ protected def clear_events(**opts)
+ clear_subscriptions
+ each_mapping(**opts) do |path, _function, _callback, _codec|
+ future = codec.clear_event(path)
+ future.get
+ future
+ end
+ end
+
+ # Wire up a widget based on a binding target.
+ def link_widget(id : String, bindings : Binding)
+ logger.debug { "setting up bindings for #{id}" }
+
+ binding = case bindings
+ in String
+ %w(clicked changed status).product([bindings]).to_h
+ in Hash(String, Hash(String, Hash(String, Array(String)) | String) | String)
+ bindings
+ end
+
+ binding.each do |type, target|
+ # Status / feedback state binding
+ if type == "status"
+ # String | Hash(String, String | Hash(String, String))
+ case target
+ in String
+ # "mod.status"
+ mod, state = target.split "."
+ link_feedback id, mod, state
+ in Hash(String, String | Hash(String, Array(String)))
+ # mod => status (provided for compatability with event bindings)
+ mod, state = target.first
+ link_feedback id, mod, state.as(String)
+ end
+
+ # Event binding
+ else
+ handler = build_handler target
+ if handler
+ @event_handlers[{id, type}] = handler
+ else
+ logger.warn { "invalid #{type} binding for #{id}" }
+ end
+ end
+ end
+ end
+
+ # Bind a widget to another modules status var for feedback.
+ protected def link_feedback(id : String, mod : String, state : String)
+ logger.debug { "linking #{id} state to #{mod}.#{state}" }
+
+ system[mod].subscribe(state) do |_sub, value|
+ spawn do
+ begin
+ logger.debug { "#{mod}.#{state} changed to #{value}, updating #{id}" }
+ payload = value.presence ? JSON.parse(value).raw.as(String | Bool | Nil) : nil
+ set id, payload
+ rescue error
+ logger.error(exception: error) { "module status update" }
+ end
+ end
+ end
+ end
+
+ # Given the action for a binding, construct the executable event handler.
+ protected def build_handler(action)
+ case action
+ # Implicit arguments
+ in String
+ # "mod.method"
+ raise "action expected to be in format Module_1.binding not: #{action.inspect}" unless action.includes?(".")
+ mod, method = action.split "."
+ ->(value : JSON::Any) {
+ logger.debug { "proxying event to #{mod}.#{method}" }
+ proxy = system[mod]
+ args = proxy.__metadata__.arity(method).zero? ? nil : {value}
+ proxy.__send__ method, args
+ nil
+ }
+
+ # Explicit / static arguments
+ # mod => { method => [params] }
+ in Hash(String, String | Hash(String, Array(String)))
+ mod, command = action.first
+ method, args = command.as(Hash(String, Array(String))).first
+ ->(_value : JSON::Any) {
+ logger.debug { "proxying event to #{mod}.#{method}" }
+ system[mod].__send__ method, args
+ nil
+ }
+ end
+ end
+
+ # Build a list of all callback methods that have been defined.
+ #
+ # Callback methods are denoted being single arity and beginning with `on_`.
+ IGNORE_METHODS = %w(on_load on_unload on_update)
+
+ {% begin %}
+ protected def ui_callbacks
+ [
+ {% for method in @type.methods %}
+ {% method_name = method.name.stringify %}
+ {% if method.args.size == 1 && !IGNORE_METHODS.includes?(method_name) && method_name[0..2] == "on_" %}
+ { {{method_name}}, ->(event : Hash(String, JSON::Any)) { {{method_name.id}}(event); nil } },
+ {% end %}
+ {% end %}
+ ]
+ end
+ {% end %}
+end
diff --git a/drivers/cisco/ui_extender_spec.cr b/drivers/cisco/ui_extender_spec.cr
new file mode 100644
index 00000000000..5bd779fac6f
--- /dev/null
+++ b/drivers/cisco/ui_extender_spec.cr
@@ -0,0 +1,53 @@
+require "placeos-driver/spec"
+# require "./collaboration_endpoint"
+
+DriverSpecs.mock_driver "Cisco::UIExtender" do
+ system({
+ VidConf: {VidConfMock},
+ })
+ sleep 1
+
+ resp = exec(:set, "something", true).get
+ puts resp.inspect
+ sleep 1
+ status[:something].should eq(true)
+
+ PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", {
+ "/Event/UserInterface/Extensions/Widget/Action/WidgetId" => "something",
+ }.to_json)
+ PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", {
+ "/Event/UserInterface/Extensions/Widget/Action" => false,
+ }.to_json)
+ PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", {
+ "/Event/UserInterface/Extensions/Widget/Action/Type" => "changed",
+ }.to_json)
+ sleep 1
+ status[:something].should eq(false)
+ sleep 1
+end
+
+# :nodoc:
+class VidConfMock < DriverSpecs::MockDriver
+ def on_load
+ spawn {
+ sleep 0.5
+ self[:ready] = self[:connected] = true
+ }
+ end
+
+ def xcommand(
+ command : String,
+ multiline_body : String? = nil,
+ hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type
+ )
+ puts "Running command: #{command} #{hash_args} + body #{multiline_body.try(&.size) || 0}"
+ end
+
+ def on_event(path : String, mod_id : String, channel : String)
+ puts "Registering callback for #{path} to #{mod_id}/#{channel}"
+ end
+
+ def clear_event(path : String)
+ puts "Clearing event subscription for #{path}"
+ end
+end
diff --git a/drivers/cisco/webex/api/messages.cr b/drivers/cisco/webex/api/messages.cr
new file mode 100644
index 00000000000..24bfa2edc2c
--- /dev/null
+++ b/drivers/cisco/webex/api/messages.cr
@@ -0,0 +1,41 @@
+module Cisco
+ module Webex
+ module Api
+ class Messages
+ def initialize(@session : Session)
+ end
+
+ def list(room_id : String, parent_id : String = "", mentioned_people : String = "", before : String = "", before_message : String = "", max : Int32 = 50) : Array(Models::Message)
+ params = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, mentionedPeople: mentioned_people, before: before, beforeMessage: before_message, max: max)
+ response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params)
+ data = JSON.parse(response.body)
+
+ data.["items"].as_a.map do |item|
+ Models::Message.from_json(item.to_json)
+ end
+ end
+
+ def list_direct(person_id : String = "", person_email : String = "", parent_id : String = "") : Array(Models::Message)
+ params = Utils.hash_from_items_with_values(personId: person_id, personEmail: person_email, parentId: parent_id)
+ response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params)
+ data = JSON.parse(response.body)
+
+ data.["items"].as_a.map do |item|
+ Models::Message.from_json(item.to_json)
+ end
+ end
+
+ def create(room_id : String = "", parent_id : String = "", to_person_id : String = "", to_person_email : String = "", text : String = "", markdown : String = "") : Models::Message
+ json = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, toPersonId: to_person_id, toPersonEmail: to_person_email, text: text, markdown: markdown)
+ response = @session.post([Constants::MESSAGES_ENDPOINT, "/"].join(""), json: json)
+ Models::Message.from_json(response.body)
+ end
+
+ def get(message_id : String) : Models::Message
+ response = @session.get([Constants::MESSAGES_ENDPOINT, "/", message_id].join(""))
+ Models::Message.from_json(response.body)
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/api/people.cr b/drivers/cisco/webex/api/people.cr
new file mode 100644
index 00000000000..500cd455c05
--- /dev/null
+++ b/drivers/cisco/webex/api/people.cr
@@ -0,0 +1,15 @@
+module Cisco
+ module Webex
+ module Api
+ class People
+ def initialize(@session : Session)
+ end
+
+ def me : Models::Person
+ response = @session.get([Constants::PEOPLE_ENDPOINT, "/", "me"].join(""))
+ Models::Person.from_json(response.body)
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/api/rooms.cr b/drivers/cisco/webex/api/rooms.cr
new file mode 100644
index 00000000000..bf80133d252
--- /dev/null
+++ b/drivers/cisco/webex/api/rooms.cr
@@ -0,0 +1,41 @@
+module Cisco
+ module Webex
+ module Api
+ class Rooms
+ def initialize(@session : Session)
+ end
+
+ def list(room_id : String, parent_id : String = "", mentioned_people : String = "", before : String = "", before_message : String = "", max : Int32 = 50) : Array(Models::Message)
+ params = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, mentionedPeople: mentioned_people, before: before, beforeMessage: before_message, max: max)
+ response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params)
+ data = JSON.parse(response.body)
+
+ data.["items"].as_a.map do |item|
+ Models::Message.from_json(item.to_json)
+ end
+ end
+
+ def list_direct(person_id : String = "", person_email : String = "", parent_id : String = "") : Array(Models::Message)
+ params = Utils.hash_from_items_with_values(personId: person_id, personEmail: person_email, parentId: parent_id)
+ response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params)
+ data = JSON.parse(response.body)
+
+ data.["items"].as_a.map do |item|
+ Models::Message.from_json(item.to_json)
+ end
+ end
+
+ def create(room_id : String = "", parent_id : String = "", to_person_id : String = "", to_person_email : String = "", text : String = "", markdown : String = "") : Models::Message
+ json = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, toPersonId: to_person_id, toPersonEmail: to_person_email, text: text, markdown: markdown)
+ response = @session.post([Constants::MESSAGES_ENDPOINT, "/"].join(""), json: json)
+ Models::Message.from_json(response.body)
+ end
+
+ def get(message_id : String) : Models::Message
+ response = @session.get([Constants::MESSAGES_ENDPOINT, "/", message_id].join(""))
+ Models::Message.from_json(response.body)
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/client.cr b/drivers/cisco/webex/client.cr
new file mode 100644
index 00000000000..8d5dc1aed32
--- /dev/null
+++ b/drivers/cisco/webex/client.cr
@@ -0,0 +1,153 @@
+module Cisco
+ module Webex
+ class Client
+ Log = ::Log.for(self)
+
+ property id : String
+ property keywords : Hash(String, Command)
+ property socket : HTTP::WebSocket?
+
+ def initialize(@name : String, @access_token : String, @emails : String, @session : Session, @commands : Array(Command))
+ @rooms = Api::Rooms.new(@session)
+ @people = Api::People.new(@session)
+ @messages = Api::Messages.new(@session)
+
+ @keywords =
+ @commands
+ .flat_map { |command| command.keywords.map { |keyword| {"#{keyword}" => command} } }
+ .reduce { |acc, i| acc.try(&.merge(i.not_nil!)) }
+
+ @id = @people.me.id
+ end
+
+ def rooms
+ @rooms
+ end
+
+ def people
+ @people
+ end
+
+ def messages
+ @messages
+ end
+
+ private def device(check_existing : Bool = true) : Models::Device
+ if check_existing
+ response = @session.get([Constants::DEFAULT_DEVICE_URL, "/", "devices"].join(""))
+ data = JSON.parse(response.body)
+
+ devices = data.["devices"].as_a.map do |item|
+ Models::Device.from_json(item.to_json)
+ end
+
+ devices.each do |device|
+ if device.name == nil
+ next
+ end
+
+ if device.name == Constants::DEVICE["name"]
+ return device
+ end
+ end
+ end
+
+ response = @session.post([Constants::DEFAULT_DEVICE_URL, "/", "devices"].join(""), json: Constants::DEVICE)
+ Models::Device.from_json(response.body)
+ end
+
+ private def message_id(activity) : String
+ # In order to geo-locate the correct DC to fetch the message from, you need to use the base64 Id of the message.
+ id = activity.id
+ target_url = activity.target.url
+ target_id = activity.target.id
+
+ verb = activity.verb == "post" ? "messages" : "attachment/actions"
+
+ message_url = target_url.gsub(["conversations", "/", target_id].join(""), [verb, "/", id].join(""))
+ response = Halite.get(message_url, headers: {"Authorization" => ["Bearer", @access_token].join(" ")})
+
+ message = JSON.parse(response.body)
+ message["id"].to_s
+ end
+
+ private def process_incoming_websocket_message(socket, message)
+ peek = Models::Peek.from_json(message)
+ return if peek.data.event_type == "status.start_typing"
+
+ begin
+ event = Models::Event.from_json(message)
+
+ if event.data.event_type == "conversation.activity"
+ activity = event.data.activity
+ Log.debug { "Activity verb is: #{activity.verb}" }
+
+ if activity.verb == "post"
+ id = message_id(activity)
+ message = self.messages.get(id)
+
+ if message.person_id != @id
+ # Ack that this message has been processed. This will prevent the message coming again.
+ socket.send({"type" => "ack", "messageId" => id}.to_json)
+
+ if message.text.starts_with?(@name)
+ message.text = message.text.sub(@name, "").strip
+ end
+
+ return if @emails.none?(activity.actor.email)
+
+ keyword = message.text.split.first.downcase
+
+ if @keywords[keyword]?
+ message.text = message.text.sub(keyword, "").strip
+ message = @keywords[keyword].execute(event, keyword, message)
+
+ room_id = message["id"]? || ""
+ parent_id = message["parent_id"]? || ""
+ to_person_id = message["to_person_id"]? || ""
+ to_person_email = message["to_person_email"]? || ""
+ text = message["text"]? || ""
+ markdown = message["markdown"]? || ""
+
+ self.messages.create(room_id, parent_id, to_person_id, to_person_email, text, markdown)
+ else
+ end
+ end
+ else
+ end
+ end
+ rescue e : Exception
+ Log.debug(exception: e) { }
+ end
+ end
+
+ def run : Void
+ device = device()
+ @socket = socket = HTTP::WebSocket.new(URI.parse(device.websocket_url))
+
+ socket.on_message do |message|
+ process_incoming_websocket_message(socket, message)
+ end
+
+ socket.on_binary do |binary|
+ process_incoming_websocket_message(socket, String.new(binary))
+ end
+
+ message = {
+ "id" => UUID.random.to_s,
+ "type" => "authorization",
+ "trackingId" => ["webex", "-", UUID.random.to_s].join(""),
+ "data" => {
+ "token" => ["Bearer", @access_token].join(" "),
+ },
+ }
+ socket.send(message.to_json)
+ socket.run
+ end
+
+ def stop : Void
+ @socket.close
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/cloud_xapi.cr b/drivers/cisco/webex/cloud_xapi.cr
new file mode 100644
index 00000000000..29997d660e1
--- /dev/null
+++ b/drivers/cisco/webex/cloud_xapi.cr
@@ -0,0 +1,258 @@
+require "placeos-driver"
+require "./cloud_xapi/ui_extensions"
+
+class Cisco::Webex::Cloud < PlaceOS::Driver
+ include CloudXAPI::UIExtensions
+
+ # Discovery Information
+ descriptive_name "Webex Cloud xAPI"
+ generic_name :CloudXAPI
+
+ uri_base "https://webexapis.com"
+
+ default_settings({
+ cisco_client_id: "",
+ cisco_client_secret: "",
+ cisco_target_orgid: "",
+ cisco_app_id: "",
+ cisco_personal_token: "",
+ debug_payload: false,
+ })
+
+ getter! device_token : DeviceToken
+
+ @cisco_client_id : String = ""
+ @cisco_client_secret : String = ""
+ @cisco_target_orgid : String = ""
+ @cisco_app_id : String = ""
+ @cisco_personal_token : String = ""
+ @debug_payload : Bool = false
+
+ def on_load
+ on_update
+ schedule.every(1.minute) { keep_token_refreshed }
+ end
+
+ def on_update
+ @cisco_client_id = setting(String, :cisco_client_id)
+ @cisco_client_secret = setting(String, :cisco_client_secret)
+ @cisco_target_orgid = setting(String, :cisco_target_orgid)
+ @cisco_app_id = setting(String, :cisco_app_id)
+ @cisco_personal_token = setting(String, :cisco_personal_token)
+ @debug_payload = setting?(Bool, :debug_payload) || false
+
+ @device_token = setting?(DeviceToken, :cisco_token_pair) || @device_token
+ end
+
+ def led_mode?(device_id : String)
+ config?(device_id, "UserInterface.LedControl.Mode")
+ end
+
+ def led_mode(device_id : String, value : String)
+ value = value.downcase.capitalize
+ config("UserInterface.LedControl.Mode", device_id, value)
+ end
+
+ def led_colour?(device_id : String)
+ status(device_id, "UserInterface.LedControl.Color")
+ end
+
+ command({"UserInterface LedControl Color Set" => :led_colour}, color: Colour)
+
+ def list_workspaces(org_id : String? = nil, location_id : String? = nil, workspace_location_id : String? = nil, floor_id : String? = nil,
+ display_name : String? = nil, capacity : Int32? = nil, workspace_type : String? = nil, start : Int32? = nil, max : Int32? = nil,
+ calling : String? = nil, supported_devices : String? = nil, calendar : String? = nil, device_hosted_meetings_enabled : Bool? = nil,
+ device_platform : String? = nil, health_level : String? = nil)
+ params = URI::Params.build do |form|
+ form.add("orgId", org_id.to_s) if org_id
+ form.add("locationId", location_id.to_s) if location_id
+ form.add("workspaceLocationId", workspace_location_id.to_s) if workspace_location_id
+ form.add("floorId", floor_id.to_s) if floor_id
+ form.add("displayName", display_name.to_s) if display_name
+ form.add("capacity", capacity.to_s) if capacity
+ form.add("type", workspace_type.to_s) if workspace_type
+ form.add("start", start.to_s) if start
+ form.add("max", max.to_s) if max
+ form.add("calling", calling.to_s) if calling
+ form.add("supportedDevices", supported_devices.to_s) if supported_devices
+ form.add("calendar", calendar.to_s) if calendar
+ form.add("deviceHostedMeetingsEnabled", device_hosted_meetings_enabled.to_s) if device_hosted_meetings_enabled
+ form.add("devicePlatform", device_platform.to_s) if device_platform
+ form.add("healthLevel", health_level.to_s) if health_level
+ end
+
+ query = params.empty? ? nil : params.to_s
+ api_get("/v1/workspaces", query)
+ end
+
+ def workspace_details(workspace_id : String)
+ api_get("/v1/workspaces/#{workspace_id}")
+ end
+
+ def list_devices(max : Int32? = nil, start : Int32? = nil, display_name : String? = nil, person_id : String? = nil, workspace_id : String? = nil,
+ org_id : String? = nil, connection_status : String? = nil, product : String? = nil, device_type : String? = nil, serial : String? = nil,
+ tag : String? = nil, software : String? = nil, upgrade_channel : String? = nil, error_code : String? = nil, capability : String? = nil,
+ permission : String? = nil, location_id : String? = nil, workspace_location_id : String? = nil, mac : String? = nil, device_platform : String? = nil)
+ params = URI::Params.build do |form|
+ form.add("max", max.to_s) if max
+ form.add("start", start.to_s) if start
+ form.add("displayName", display_name.to_s) if display_name
+
+ form.add("personId", person_id.to_s) if person_id
+ form.add("workspaceId", workspace_id.to_s) if workspace_id
+ form.add("orgId", org_id.to_s) if org_id
+ form.add("connectionStatus", connection_status.to_s) if connection_status
+ form.add("product", product.to_s) if product
+ form.add("type", device_type.to_s) if device_type
+ form.add("tag", tag.to_s) if tag
+ form.add("serial", serial.to_s) if serial
+ form.add("software", software.to_s) if software
+ form.add("upgradeChannel", upgrade_channel.to_s) if upgrade_channel
+ form.add("errorCode", error_code.to_s) if error_code
+ form.add("capability", capability.to_s) if capability
+ form.add("permission", permission.to_s) if permission
+ form.add("locationId", location_id.to_s) if location_id
+ form.add("workspaceLocationId", workspace_location_id.to_s) if workspace_location_id
+ form.add("mac", mac.to_s) if mac
+ form.add("devicePlatform", device_platform.to_s) if device_platform
+ end
+ query = params.empty? ? nil : params.to_s
+ api_get("/v1/devices", query)
+ end
+
+ def device_details(device_id : String, org_id : String? = nil)
+ params = URI::Params.build do |form|
+ form.add("orgId", org_id.to_s) if org_id
+ end
+
+ query = params.empty? ? nil : params.to_s
+ api_get("/v1/devices/#{device_id}", query)
+ end
+
+ def status(device_id : String, name : String)
+ query = URI::Params.build do |form|
+ form.add("deviceId", device_id)
+ form.add("name", name)
+ end
+
+ headers = get_headers
+ logger.debug { {msg: "Status HTTP Data:", headers: headers.to_json, query: query.to_s} } if @debug_payload
+
+ response = get("/v1/xapi/status?#{query}", headers: headers)
+ raise "failed to query status for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def command(name : String, payload : String)
+ headers = get_headers
+ logger.debug { {msg: "Command HTTP Data:", headers: headers.to_json, command: name, payload: payload} } if @debug_payload
+
+ response = post("/v1/xapi/command/#{name}", headers: headers, body: payload)
+ raise "failed to execute command #{name}, code #{response.status_code}, body: #{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def api_get(resource : String, query : String? = nil)
+ headers = get_headers
+ logger.debug { {msg: "GET #{resource}:", headers: headers.to_json, query: query.to_s} } if @debug_payload
+ uri = query.presence ? resource + "?#{query}" : resource
+ response = get(uri, headers: headers)
+ raise "failed to get #{resource}, code #{response.status_code}, body: #{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def config?(device_id : String, name : String)
+ query = URI::Params.build do |form|
+ form.add("deviceId", device_id)
+ form.add("key", name)
+ end
+
+ headers = get_headers
+ logger.debug { {msg: "Status HTTP Data:", headers: headers.to_json, query: query.to_s} } if @debug_payload
+
+ response = get("/v1/deviceConfigurations?#{query}", headers: headers)
+ raise "failed to query configuration for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def config(name : String, device_id : String, value : String)
+ body = {
+ "op" => "replace",
+ "path" => "#{name}/sources/configured/value",
+ "value" => value,
+ }
+
+ config(device_id, body.to_json)
+ end
+
+ def config(device_id : String, payload : String)
+ query = URI::Params.build do |form|
+ form.add("deviceId", device_id)
+ end
+
+ headers = get_headers("application/json-patch+json")
+ logger.debug { {msg: "Config HTTP Data:", headers: headers.to_json, query: query, payload: payload} } if @debug_payload
+
+ response = patch("/v1/deviceConfigurations?#{query}", headers: headers, body: payload)
+ raise "failed to patch config on device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ protected def get_access_token
+ if device_token?
+ logger.debug { {msg: "Access Token expiry", expiry: device_token.expiry} } if @debug_payload
+ return device_token.auth_token if 1.minute.from_now <= device_token.expiry
+ logger.debug { {msg: "Access Token expiring, refreshing token", token_expiry: device_token.expiry, refresh_expiry: device_token.refresh_expiry} } if @debug_payload
+ return refresh_token if 1.minute.from_now <= device_token.refresh_expiry
+ end
+
+ body = {
+ "clientId": @cisco_client_id,
+ "clientSecret": @cisco_client_secret,
+ "targetOrgId": @cisco_target_orgid,
+ }.to_json
+
+ headers = HTTP::Headers{
+ "Authorization" => "Bearer #{@cisco_personal_token}",
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ }
+ response = post("/v1/applications/#{@cisco_app_id}/token", headers: headers, body: body)
+ raise "failed to retriee access token for client-id #{@cisco_client_id}, code #{response.status_code}, body #{response.body}" unless response.success?
+ @device_token = DeviceToken.from_json(response.body)
+ define_setting(:cisco_token_pair, device_token)
+ device_token.auth_token
+ end
+
+ protected def refresh_token
+ body = URI::Params.build do |form|
+ form.add("grant_type", "refresh_token")
+ form.add("client_id", @cisco_client_id)
+ form.add("client_secret", @cisco_client_secret)
+ form.add("refresh_token", device_token.refresh_token)
+ end
+
+ headers = HTTP::Headers{
+ "Content-Type" => "application/x-www-form-urlencoded",
+ "Accept" => "application/json",
+ }
+ response = post("/v1/access_token", headers: headers, body: body)
+ raise "failed to refresh access token for client-id #{@cisco_client_id}, code #{response.status_code}, body #{response.body}" unless response.success?
+ @device_token = DeviceToken.from_json(response.body)
+ define_setting(:cisco_token_pair, device_token)
+ device_token.auth_token
+ end
+
+ protected def keep_token_refreshed : Nil
+ return if @device_token.nil?
+ refresh_token if 1.minute.from_now >= device_token.refresh_expiry
+ end
+
+ private def get_headers(content_type : String = "application/json")
+ HTTP::Headers{
+ "Authorization" => get_access_token,
+ "Content-Type" => content_type,
+ "Accept" => "application/json",
+ }
+ end
+end
diff --git a/drivers/cisco/webex/cloud_xapi/models.cr b/drivers/cisco/webex/cloud_xapi/models.cr
new file mode 100644
index 00000000000..7e5f6a53531
--- /dev/null
+++ b/drivers/cisco/webex/cloud_xapi/models.cr
@@ -0,0 +1,105 @@
+module CloudXAPI::Models
+ enum Colour
+ Green
+ Yellow
+ Red
+ Purple
+ Blue
+ Orange
+ Orchid
+ Aquamarine
+ Fuchsia
+ Violet
+ Magenta
+ Scarlet
+ Gold
+ Lime
+ Turquoise
+ Cyan
+ Off
+
+ def to_json(json : JSON::Builder)
+ json.string(to_s)
+ end
+ end
+
+ record DeviceToken, expires_in : Int64, token_type : String, refresh_token : String, refresh_token_expires_in : Int64,
+ access_token : String do
+ include JSON::Serializable
+
+ @[JSON::Field(ignore: true)]
+ getter! expiry : Time
+
+ @[JSON::Field(ignore: true)]
+ getter! refresh_expiry : Time
+
+ def after_initialize
+ @expiry = Time.utc + expires_in.seconds
+ @refresh_expiry = Time.utc + refresh_token_expires_in.seconds
+ end
+
+ def auth_token
+ "#{token_type} #{access_token}"
+ end
+ end
+
+ enum TextInputType
+ SingleLine
+ Numeric
+ Password
+ PIN
+ end
+
+ enum TextKeyboardState
+ Open
+ Closed
+ end
+
+ macro command(cmd_name, **params)
+ {% for cmd, name in cmd_name %}
+ def {{name.id}}(device_id : String,
+ {% for param, klass in params %}
+ {% optional = false %}
+ {% if param.stringify.ends_with?("_") %}
+ {% optional = true %}
+ {% param = param.stringify[0..-2] %}
+ {% end %}
+
+ {% if klass.is_a?(RangeLiteral) %}
+ {{param.id}} : Int32{% if optional %}? = nil{% end %},
+ {% else %}
+ {{param.id}} : {{klass}}{% if optional %}? = nil{% end %},
+ {% end %}
+ {% end %}
+ )
+ {% for param, klass in params %}
+ {% if klass.is_a?(RangeLiteral) %}
+ {% optional = false %}
+ {% if param.stringify.ends_with?("_") %}
+ {% optional = true %}
+ {% param = param.stringify[0..-2] %}
+ {% end %}
+ {% if optional %} if {{param.id}}{% end %}
+ raise ArgumentError.new("#{ {{param.stringify}} } must be within #{ {{klass}} }, was #{ {{param.id}} }") unless ({{klass}}).includes?({{param.id}})
+ {% if optional %}end{% end %}
+ {% end %}
+ {% end %}
+
+ command({{cmd.split(" ").join(".")}},{
+ "deviceId" => JSON::Any.new(device_id),
+ {% if params.size > 0 %}
+ "arguments" => {
+ {% for param, klass in params %}
+ {% if param.stringify.ends_with?("_") %}
+ {% param = param.stringify[0..-2] %}
+ {% end %}
+ "{{param.id.capitalize}}" => JSON.parse({{param.id}}.to_json),
+ {% end %}
+ } of String => JSON::Any
+ {% end %}
+ }.to_json
+ )
+ end
+ {% end %}
+ end
+end
diff --git a/drivers/cisco/webex/cloud_xapi/ui_extensions.cr b/drivers/cisco/webex/cloud_xapi/ui_extensions.cr
new file mode 100644
index 00000000000..57a294a52b3
--- /dev/null
+++ b/drivers/cisco/webex/cloud_xapi/ui_extensions.cr
@@ -0,0 +1,58 @@
+require "./models"
+
+module CloudXAPI::UIExtensions
+ include CloudXAPI::Models
+ command({"UserInterface Message Alert Clear" => :msg_alert_clear})
+ command({"UserInterface Message Alert Display" => :msg_alert},
+ text: String,
+ title_: String,
+ duration_: 0..3600)
+
+ command({"UserInterface Message Prompt Clear" => :msg_prompt_clear})
+
+ def msg_prompt(device_id : String, text : String, options : Array(JSON::Any::Type), title : String? = nil, feedback_id : String? = nil, duration : Int64? = nil)
+ option_map = {} of String => JSON::Any::Type
+ ("Option.1".."Option.5").each_with_index do |key, i|
+ break if i >= options.size
+ option_map[key] = options[i]
+ end
+
+ command "UserInterface.Message.Prompt.Display",
+ {
+ "deviceId" => device_id,
+ "arguments" => {
+ "text" => text,
+ "title" => title,
+ "feedback_id" => feedback_id,
+ "duration" => duration,
+ }.merge(option_map),
+ }.to_json
+ end
+
+ command({"UserInterface Message TextInput Clear" => :msg_text_clear})
+ command({"UserInterface Message TextInput Display" => :msg_text},
+ text: String,
+ feedback_id: String,
+ title_: String,
+ duration_: 0..3600,
+ input_type_: TextInputType,
+ keyboard_state_: TextKeyboardState,
+ place_holder_: String,
+ submit_text_: String)
+
+ def ui_set_value(device_id : String, widget : String, value : JSON::Any::Type? = nil)
+ cmd = (value.nil? ? "UserInterface Extensions Widget UnsetValue" : "UserInterface Extensions Widget SetValue").tap { |v| break v.split(" ").join(".") }
+ payload = {
+ "deviceId" => JSON::Any.new(device_id),
+ "arguments" => JSON::Any.new({
+ "widget_id" => JSON::Any.new(widget),
+ }),
+ } of String => JSON::Any
+ payload["arguments"].as_h["value"] = JSON::Any.new(value) unless value.nil?
+ command(cmd, payload.to_json)
+ end
+
+ command({"UserInterface Extensions Set" => :ui_extensions_deploy}, id: String, xml_def: String)
+ command({"UserInterface Extensions List" => :ui_extensions_list})
+ command({"UserInterface Extensions Clear" => :ui_extensions_clear})
+end
diff --git a/drivers/cisco/webex/cloud_xapi_spec.cr b/drivers/cisco/webex/cloud_xapi_spec.cr
new file mode 100644
index 00000000000..1141a73a1fd
--- /dev/null
+++ b/drivers/cisco/webex/cloud_xapi_spec.cr
@@ -0,0 +1,172 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::Webex::Cloud" do
+ settings({
+ cisco_client_id: "client-id",
+ cisco_client_secret: "client-secret",
+ cisco_target_orgid: "target-org",
+ cisco_app_id: "my-app",
+ cisco_personal_token: "my-personal-token",
+ debug_payload: true,
+ })
+
+ ret_val = exec(:led_colour?, "device1-id")
+
+ expect_http_request(2.seconds) do |request, response|
+ if request.path == "/v1/applications/my-app/token"
+ response.status_code = 200
+ response << device_resp_json.to_json
+ else
+ response.status_code = 401
+ end
+ end
+
+ expect_http_request(2.seconds) do |request, response|
+ if request.headers["Authorization"]? == "Bearer generated-access-token"
+ response.status_code = 200
+ response << color_resp(request.query_params["deviceId"]).to_json
+ else
+ response.status_code = 401
+ end
+ end
+
+ ret_val.get.should eq(color_resp("device1-id"))
+
+ ret_val = exec(:led_colour, "device1-id", :green)
+
+ # invoking another endpoint request should use previously obtained access token
+
+ expect_http_request do |request, response|
+ headers = request.headers
+ io = request.body
+ if io
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request["deviceId"] == "device1-id" && request["arguments"]["Color"] == "Green" && headers["Authorization"] == "Bearer generated-access-token"
+ response.status_code = 202
+ response << color_set_resp.to_json
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include excute command body params #{request.inspect}"
+ end
+ end
+
+ ret_val.get.should eq(color_set_resp)
+
+ ret_val = exec(:led_mode?, "device1-id")
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << %({"mode": "Auto"})
+ end
+
+ ret_val.get.try &.as_h["mode"].should eq "Auto"
+
+ ret_val = exec(:msg_prompt, "device1-id", "text", [JSON::Any.new("one")], "title", "feedback_id", 32)
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << %({"status": "OK"})
+ end
+
+ ret_val.get.try &.as_h["status"].should eq "OK"
+
+ ret_val = exec(:list_workspaces)
+
+ expect_http_request(2.seconds) do |request, response|
+ if request.path == "/v1/workspaces" && request.query_params.empty?
+ response.status_code = 200
+ response << workspace_resp.to_json
+ else
+ response.status_code = 401
+ end
+ end
+
+ ret_val.get.try &.as_h["items"].as_a.size.should eq 1
+
+ ret_val = exec(:workspace_details, "some-workspace-id")
+
+ expect_http_request(2.seconds) do |request, response|
+ if request.path == "/v1/workspaces/some-workspace-id" && request.query_params.empty?
+ response.status_code = 200
+ response << workspace_resp[:items][0].to_json
+ else
+ response.status_code = 401
+ end
+ end
+
+ ret_val.get.try &.as_h["capacity"].as_i.should eq 5
+end
+
+def color_resp(device_id : String)
+ {"deviceId" => device_id, "result" => {"LedControl" => {"Color" => "Green"}}}
+end
+
+def color_set_resp
+ {"deviceId" => "device1-id", "arguments" => {"Color" => "Green"}}
+end
+
+def device_resp_json
+ {
+ "expires_in": 64799,
+ "token_type": "Bearer",
+ "refresh_token": "MjZmMzcyZWUtMzI2MS00MmE4LTgyZWMtYTVlMWIxYzBjZjhiODJmYzViOTItMGFi_PF84_1eb65fdf-9643-417f-9974-ad72cae0e10f",
+ "access_token": "generated-access-token",
+ "refresh_token_expires_in": 7697037,
+ }
+end
+
+def workspace_resp
+ {
+ "items": [
+ {
+ "id": "Y2lzY29zcGFyazovL3VzL1BMQUNFUy81MTAxQjA3Qi00RjhGLTRFRjctQjU2NS1EQjE5QzdCNzIzRjc",
+ "orgId": "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8xZWI2NWZkZi05NjQzLTQxN2YtOTk3NC1hZDcyY2FlMGUxMGY",
+ "locationId": "Y2lzY29...",
+ "workspaceLocationId": "YL34GrT...",
+ "floorId": "Y2lzY29z...",
+ "displayName": "SFO-12 Capanina",
+ "capacity": 5,
+ "type": "notSet",
+ "sipAddress": "test_workspace_1@trialorg.room.ciscospark.com",
+ "created": "2016-04-21T17:00:00.000Z",
+ "calling": {
+ "type": "hybridCalling",
+ "hybridCalling": {
+ "emailAddress": "workspace@example.com",
+ },
+ "webexCalling": {
+ "licenses": [
+ "Y2lzY29g4...",
+ ],
+ },
+ },
+ "notes": "this is a note",
+ "hotdeskingStatus": "on",
+ "supportedDevices": "collaborationDevices",
+ "calendar": {
+ "type": "microsoft",
+ "emailAddress": "workspace@example.com",
+ },
+ "deviceHostedMeetings": {
+ "enabled": true,
+ "siteUrl": "'example.webex.com'",
+ },
+ "devicePlatform": "cisco",
+ "health": {
+ "level": "error",
+ "issues": [
+ {
+ "id": "",
+ "createdAt": "",
+ "title": "",
+ "description": "",
+ "recommendedAction": "",
+ "level": "",
+ },
+ ],
+ },
+ },
+ ],
+ }
+end
diff --git a/drivers/cisco/webex/command.cr b/drivers/cisco/webex/command.cr
new file mode 100644
index 00000000000..4fdac03b1d3
--- /dev/null
+++ b/drivers/cisco/webex/command.cr
@@ -0,0 +1,9 @@
+module Cisco
+ module Webex
+ abstract class Command
+ abstract def keywords : Array(String)
+ abstract def description : String
+ abstract def execute(event, keyword, message)
+ end
+ end
+end
diff --git a/drivers/cisco/webex/commands/echo.cr b/drivers/cisco/webex/commands/echo.cr
new file mode 100644
index 00000000000..64c4bcdad3b
--- /dev/null
+++ b/drivers/cisco/webex/commands/echo.cr
@@ -0,0 +1,19 @@
+module Cisco
+ module Webex
+ module Commands
+ class Echo < Command
+ def keywords : Array(String)
+ ["echo"]
+ end
+
+ def description : String
+ "This command simply replies your message!"
+ end
+
+ def execute(event, keyword, message)
+ {"id" => message.room_id, "text" => message.text}
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/commands/greeting.cr b/drivers/cisco/webex/commands/greeting.cr
new file mode 100644
index 00000000000..dc1a396a1be
--- /dev/null
+++ b/drivers/cisco/webex/commands/greeting.cr
@@ -0,0 +1,19 @@
+module Cisco
+ module Webex
+ module Commands
+ class Greeting < Command
+ def keywords : Array(String)
+ ["hello", "hi"]
+ end
+
+ def description : String
+ "This command simply responds to hello, hi, how are you, etc."
+ end
+
+ def execute(event, keyword, message)
+ {"id" => message.room_id, "text" => "👋"}
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/constants.cr b/drivers/cisco/webex/constants.cr
new file mode 100644
index 00000000000..ef5605e0096
--- /dev/null
+++ b/drivers/cisco/webex/constants.cr
@@ -0,0 +1,45 @@
+module Cisco
+ module Webex
+ module Constants
+ VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify.downcase }}
+
+ STATUS_CODES = {
+ 200 => "Successful request with body content.",
+ 204 => "Successful request without body content.",
+ 400 => "The request was invalid or cannot be otherwise served.",
+ 401 => "Authentication credentials were missing or incorrect.",
+ 403 => "The request is understood, but it has been refused or access is not allowed.",
+ 404 => "The URI requested is invalid or the resource requested, such as a user, does not exist. Also returned when the requested format is not supported by the requested method.",
+ 405 => "The request was made to a resource using an HTTP request method that is not supported.",
+ 409 => "The request could not be processed because it conflicts with some established rule of the system. For example, a person may not be added to a room more than once.",
+ 410 => "The requested resource is no longer available.",
+ 415 => "The request was made to a resource without specifying a media type or used a media type that is not supported.",
+ 423 => "The requested resource is temporarily unavailable. A `Retry-After` header may be present that specifies how many seconds you need to wait before attempting the request again.",
+ 429 => "Too many requests have been sent in a given amount of time and the request has been rate limited. A `Retry-After` header should be present that specifies how many seconds you need to wait before a successful request can be made.",
+ 500 => "Something went wrong on the server. If the issue persists, feel free to contact the Webex Developer Support team (https://developer.webex.com/support).",
+ 502 => "The server received an invalid response from an upstream server while processing the request. Try again later.",
+ 503 => "Server is overloaded with requests. Try again later.",
+ }
+
+ DEFAULT_BASE_URL = "https://webexapis.com/v1/"
+ DEFAULT_DEVICE_URL = "https://wdm-a.wbx2.com/wdm/api/v1/"
+ DEFAULT_SINGLE_REQUEST_TIMEOUT = 60
+ DEFAULT_WAIT_ON_RATE_LIMIT = true
+
+ DEVICE = {
+ "deviceType" => "DESKTOP",
+ "localizedModel" => "crystal",
+ "model" => "crystal",
+ "name" => UUID.random.to_s,
+ "systemName" => "webex-bot-client",
+ "systemVersion" => VERSION,
+ }
+
+ ROOMS_ENDPOINT = "rooms"
+ PEOPLE_ENDPOINT = "people"
+ MESSAGES_ENDPOINT = "messages"
+
+ WEBEX_TEAMS_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
+ end
+ end
+end
diff --git a/drivers/cisco/webex/exceptions/argument.cr b/drivers/cisco/webex/exceptions/argument.cr
new file mode 100644
index 00000000000..4885bbce93f
--- /dev/null
+++ b/drivers/cisco/webex/exceptions/argument.cr
@@ -0,0 +1,8 @@
+module Cisco
+ module Webex
+ module Exceptions
+ class Argument < Exception
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/exceptions/method.cr b/drivers/cisco/webex/exceptions/method.cr
new file mode 100644
index 00000000000..3daec0b5636
--- /dev/null
+++ b/drivers/cisco/webex/exceptions/method.cr
@@ -0,0 +1,8 @@
+module Cisco
+ module Webex
+ module Exceptions
+ class Method < Exception
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/exceptions/rate_limit.cr b/drivers/cisco/webex/exceptions/rate_limit.cr
new file mode 100644
index 00000000000..af22d61182f
--- /dev/null
+++ b/drivers/cisco/webex/exceptions/rate_limit.cr
@@ -0,0 +1,8 @@
+module Cisco
+ module Webex
+ module Exceptions
+ class RateLimit < Exception
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/exceptions/status_code.cr b/drivers/cisco/webex/exceptions/status_code.cr
new file mode 100644
index 00000000000..de113fad804
--- /dev/null
+++ b/drivers/cisco/webex/exceptions/status_code.cr
@@ -0,0 +1,8 @@
+module Cisco
+ module Webex
+ module Exceptions
+ class StatusCode < Exception
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/extensions/chainable.cr b/drivers/cisco/webex/extensions/chainable.cr
new file mode 100644
index 00000000000..2707afeb345
--- /dev/null
+++ b/drivers/cisco/webex/extensions/chainable.cr
@@ -0,0 +1,536 @@
+require "base64"
+
+module Halite
+ module Chainable
+ {% for verb in %w(get head) %}
+ # {{ verb.id.capitalize }} a resource
+ #
+ # ```
+ # Halite.{{ verb.id }}("http://httpbin.org/anything", params: {
+ # first_name: "foo",
+ # last_name: "bar"
+ # })
+ # ```
+ def {{ verb.id }}(uri : String, *,
+ headers : (Hash(String, _) | NamedTuple)? = nil,
+ params : (Hash(String, _) | NamedTuple)? = nil,
+ form : (Hash(String, _) | NamedTuple)? = nil,
+ json : (Hash(String, _) | NamedTuple)? = nil,
+ raw : String? = nil,
+ tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response
+ request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls)
+ end
+
+ # {{ verb.id.capitalize }} a streaming resource
+ #
+ # ```
+ # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response|
+ # puts response.status_code
+ # while line = response.body_io.gets
+ # puts line
+ # end
+ # end
+ # ```
+ def {{ verb.id }}(uri : String, *,
+ headers : (Hash(String, _) | NamedTuple)? = nil,
+ params : (Hash(String, _) | NamedTuple)? = nil,
+ form : (Hash(String, _) | NamedTuple)? = nil,
+ json : (Hash(String, _) | NamedTuple)? = nil,
+ raw : String? = nil,
+ tls : OpenSSL::SSL::Context::Client? = nil,
+ &block : Halite::Response ->)
+ request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls, &block)
+ end
+ {% end %}
+
+ {% for verb in %w(put post patch delete options) %}
+ # {{ verb.id.capitalize }} a resource
+ #
+ # ### Request with form data
+ #
+ # ```
+ # Halite.{{ verb.id }}("http://httpbin.org/anything", form: {
+ # first_name: "foo",
+ # last_name: "bar"
+ # })
+ # ```
+ #
+ # ### Request with json data
+ #
+ # ```
+ # Halite.{{ verb.id }}("http://httpbin.org/anything", json: {
+ # first_name: "foo",
+ # last_name: "bar"
+ # })
+ # ```
+ #
+ # ### Request with raw string
+ #
+ # ```
+ # Halite.{{ verb.id }}("http://httpbin.org/anything", raw: "name=Peter+Lee&address=%23123+Happy+Ave&Language=C%2B%2B")
+ # ```
+ def {{ verb.id }}(uri : String, *,
+ headers : (Hash(String, _) | NamedTuple)? = nil,
+ params : (Hash(String, _) | NamedTuple)? = nil,
+ form : (Hash(String, _) | NamedTuple)? = nil,
+ json : (Hash(String, _) | NamedTuple)? = nil,
+ raw : String? = nil,
+ tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response
+ request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls)
+ end
+
+ # {{ verb.id.capitalize }} a streaming resource
+ #
+ # ```
+ # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response|
+ # puts response.status_code
+ # while line = response.body_io.gets
+ # puts line
+ # end
+ # end
+ # ```
+ def {{ verb.id }}(uri : String, *,
+ headers : (Hash(String, _) | NamedTuple)? = nil,
+ params : (Hash(String, _) | NamedTuple)? = nil,
+ form : (Hash(String, _) | NamedTuple)? = nil,
+ json : (Hash(String, _) | NamedTuple)? = nil,
+ raw : String? = nil,
+ tls : OpenSSL::SSL::Context::Client? = nil,
+ &block : Halite::Response ->)
+ request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls, &block)
+ end
+ {% end %}
+
+ # Adds a endpoint to the request.
+ #
+ #
+ # ```
+ # Halite.endpoint("https://httpbin.org")
+ # .get("/get")
+ # ```
+ def endpoint(endpoint : String | URI) : Halite::Client
+ branch(default_options.with_endpoint(endpoint))
+ end
+
+ # Make a request with the given Basic authorization header
+ #
+ # ```
+ # Halite.basic_auth("icyleaf", "p@ssw0rd")
+ # .get("http://httpbin.org/get")
+ # ```
+ #
+ # See Also: [http://tools.ietf.org/html/rfc2617](http://tools.ietf.org/html/rfc2617)
+ def basic_auth(user : String, pass : String) : Halite::Client
+ auth("Basic " + Base64.strict_encode(user + ":" + pass))
+ end
+
+ # Make a request with the given Authorization header
+ #
+ # ```
+ # Halite.auth("private-token", "6abaef100b77808ceb7fe26a3bcff1d0")
+ # .get("http://httpbin.org/get")
+ # ```
+ def auth(value : String) : Halite::Client
+ headers({"Authorization" => value})
+ end
+
+ # Accept the given MIME type
+ #
+ # ```
+ # Halite.accept("application/json")
+ # .get("http://httpbin.org/get")
+ # ```
+ def accept(value : String) : Halite::Client
+ headers({"Accept" => value})
+ end
+
+ # Set requests user agent
+ #
+ # ```
+ # Halite.user_agent("Custom User Agent")
+ # .get("http://httpbin.org/get")
+ # ```
+ def user_agent(value : String) : Halite::Client
+ headers({"User-Agent" => value})
+ end
+
+ # Make a request with the given headers
+ #
+ # ```
+ # Halite.headers({"Content-Type", "application/json", "Connection": "keep-alive"})
+ # .get("http://httpbin.org/get")
+ # # Or
+ # Halite.headers({content_type: "application/json", connection: "keep-alive"})
+ # .get("http://httpbin.org/get")
+ # ```
+ def headers(headers : Hash(String, _) | NamedTuple) : Halite::Client
+ branch(default_options.with_headers(headers))
+ end
+
+ # Make a request with the given headers
+ #
+ # ```
+ # Halite.headers(content_type: "application/json", connection: "keep-alive")
+ # .get("http://httpbin.org/get")
+ # ```
+ def headers(**kargs) : Halite::Client
+ branch(default_options.with_headers(kargs))
+ end
+
+ # Make a request with the given cookies
+ #
+ # ```
+ # Halite.cookies({"private-token", "6abaef100b77808ceb7fe26a3bcff1d0"})
+ # .get("http://httpbin.org/get")
+ # # Or
+ # Halite.cookies({private-token: "6abaef100b77808ceb7fe26a3bcff1d0"})
+ # .get("http://httpbin.org/get")
+ # ```
+ def cookies(cookies : Hash(String, _) | NamedTuple) : Halite::Client
+ branch(default_options.with_cookies(cookies))
+ end
+
+ # Make a request with the given cookies
+ #
+ # ```
+ # Halite.cookies(name: "icyleaf", "gender": "male")
+ # .get("http://httpbin.org/get")
+ # ```
+ def cookies(**kargs) : Halite::Client
+ branch(default_options.with_cookies(kargs))
+ end
+
+ # Make a request with the given cookies
+ #
+ # ```
+ # cookies = HTTP::Cookies.from_client_headers(headers)
+ # Halite.cookies(cookies)
+ # .get("http://httpbin.org/get")
+ # ```
+ def cookies(cookies : HTTP::Cookies) : Halite::Client
+ branch(default_options.with_cookies(cookies))
+ end
+
+ # Adds a timeout to the request.
+ #
+ # How long to wait for the server to send data before giving up, as a int, float or time span.
+ # The timeout value will be applied to both the connect and the read timeouts.
+ #
+ # Set `nil` to timeout to ignore timeout.
+ #
+ # ```
+ # Halite.timeout(5.5).get("http://httpbin.org/get")
+ # # Or
+ # Halite.timeout(2.minutes)
+ # .post("http://httpbin.org/post", form: {file: "file.txt"})
+ # ```
+ def timeout(timeout : (Int32 | Float64 | Time::Span)?)
+ timeout ? timeout(timeout, timeout, timeout) : branch
+ end
+
+ # Adds a timeout to the request.
+ #
+ # How long to wait for the server to send data before giving up, as a int, float or time span.
+ # The timeout value will be applied to both the connect and the read timeouts.
+ #
+ # ```
+ # Halite.timeout(3, 3.minutes, 5)
+ # .post("http://httpbin.org/post", form: {file: "file.txt"})
+ # # Or
+ # Halite.timeout(3.04, 64, 10.0)
+ # .get("http://httpbin.org/get")
+ # ```
+ def timeout(connect : (Int32 | Float64 | Time::Span)? = nil,
+ read : (Int32 | Float64 | Time::Span)? = nil,
+ write : (Int32 | Float64 | Time::Span)? = nil)
+ branch(default_options.with_timeout(connect, read, write))
+ end
+
+ # Returns `Options` self with automatically following redirects.
+ #
+ # ```
+ # # Automatically following redirects.
+ # Halite.follow
+ # .get("http://httpbin.org/relative-redirect/5")
+ #
+ # # Always redirect with any request methods
+ # Halite.follow(strict: false)
+ # .get("http://httpbin.org/get")
+ # ```
+ def follow(strict = Halite::Options::Follow::STRICT) : Halite::Client
+ branch(default_options.with_follow(strict: strict))
+ end
+
+ # Returns `Options` self with given max hops of redirect times.
+ #
+ # ```
+ # # Max hops 3 times
+ # Halite.follow(3)
+ # .get("http://httpbin.org/relative-redirect/3")
+ #
+ # # Always redirect with any request methods
+ # Halite.follow(4, strict: false)
+ # .get("http://httpbin.org/relative-redirect/4")
+ # ```
+ def follow(hops : Int32, strict = Halite::Options::Follow::STRICT) : Halite::Client
+ branch(default_options.with_follow(hops, strict))
+ end
+
+ # Returns `Options` self with enable or disable logging.
+ #
+ # #### Enable logging
+ #
+ # Same as call `logging` method without any argument.
+ #
+ # ```
+ # Halite.logging.get("http://httpbin.org/get")
+ # ```
+ #
+ # #### Disable logging
+ #
+ # ```
+ # Halite.logging(false).get("http://httpbin.org/get")
+ # ```
+ def logging(enable : Bool = true)
+ options = default_options
+ options.logging = enable
+ branch(options)
+ end
+
+ # Returns `Options` self with given the logging which it integration from `Halite::Logging`.
+ #
+ # #### Simple logging
+ #
+ # ```
+ # Halite.logging
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ #
+ # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post
+ # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json
+ # { ... }
+ # ```
+ #
+ # #### Logger configuration
+ #
+ # By default, Halite will logging all outgoing HTTP requests and their responses(without binary stream) to `STDOUT` on DEBUG level.
+ # You can configuring the following options:
+ #
+ # - `skip_request_body`: By default is `false`.
+ # - `skip_response_body`: By default is `false`.
+ # - `skip_benchmark`: Display elapsed time, by default is `false`.
+ # - `colorize`: Enable colorize in terminal, only apply in `common` format, by default is `true`.
+ #
+ # ```
+ # Halite.logging(skip_request_body: true, skip_response_body: true)
+ # .post("http://httpbin.org/get", form: {image: File.open("halite-logo.png")})
+ #
+ # # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post
+ # # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json
+ # ```
+ #
+ # #### Use custom logging
+ #
+ # Creating the custom logging by integration `Halite::Logging::Abstract` abstract class.
+ # Here has two methods must be implement: `#request` and `#response`.
+ #
+ # ```
+ # class CustomLogger < Halite::Logging::Abstract
+ # def request(request)
+ # @logger.info "| >> | %s | %s %s" % [request.verb, request.uri, request.body]
+ # end
+ #
+ # def response(response)
+ # @logger.info "| << | %s | %s %s" % [response.status_code, response.uri, response.content_type]
+ # end
+ # end
+ #
+ # # Add to adapter list (optional)
+ # Halite::Logging.register_adapter "custom", CustomLogger.new
+ #
+ # Halite.logging(logging: CustomLogger.new)
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ #
+ # # We can also call it use format name if you added it.
+ # Halite.logging(format: "custom")
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ #
+ # # => 2017-12-13 16:40:13 +08:00 | >> | GET | http://httpbin.org/get?name=foobar
+ # # => 2017-12-13 16:40:15 +08:00 | << | 200 | http://httpbin.org/get?name=foobar application/json
+ # ```
+ def logging(logging : Halite::Logging::Abstract = Halite::Logging::Common.new)
+ branch(default_options.with_logging(logging))
+ end
+
+ # Returns `Options` self with given the file with the path.
+ #
+ # #### JSON-formatted logging
+ #
+ # ```
+ # Halite.logging(format: "json")
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ # ```
+ #
+ # #### create a http request and log to file
+ #
+ # ```
+ # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a")))
+ # Halite.logging(for: "halite.file")
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ # ```
+ #
+ # #### Always create new log file and store data to JSON formatted
+ #
+ # ```
+ # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "w"))
+ # Halite.logging(for: "halite.file", format: "json")
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ # ```
+ #
+ # Check the log file content: **/tmp/halite.log**
+ def logging(format : String = "common", *, for : String = "halite",
+ skip_request_body = false, skip_response_body = false,
+ skip_benchmark = false, colorize = true)
+ opts = {
+ for: for,
+ skip_request_body: skip_request_body,
+ skip_response_body: skip_response_body,
+ skip_benchmark: skip_benchmark,
+ colorize: colorize,
+ }
+ branch(default_options.with_logging(format, **opts))
+ end
+
+ # Turn on given features and its options.
+ #
+ # Available features to review all subclasses of `Halite::Feature`.
+ #
+ # #### Use JSON logging
+ #
+ # ```
+ # Halite.use("logging", format: "json")
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ #
+ # # => { ... }
+ # ```
+ #
+ # #### Use common format logging and skip response body
+ # ```
+ # Halite.use("logging", format: "common", skip_response_body: true)
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ #
+ # # => 2018-08-28 14:58:26 +08:00 | request | GET | http://httpbin.org/get
+ # # => 2018-08-28 14:58:27 +08:00 | response | 200 | http://httpbin.org/get | 615.8ms | application/json
+ # ```
+ def use(feature : String, **opts)
+ branch(default_options.with_features(feature, **opts))
+ end
+
+ # Turn on given the name of features.
+ #
+ # Available features to review all subclasses of `Halite::Feature`.
+ #
+ # ```
+ # Halite.use("logging", "your-custom-feature-name")
+ # .get("http://httpbin.org/get", params: {name: "foobar"})
+ # ```
+ def use(*features)
+ branch(default_options.with_features(*features))
+ end
+
+ # Make an HTTP request with the given verb
+ #
+ # ```
+ # Halite.request("get", "http://httpbin.org/get", {
+ # "headers" = { "user_agent" => "halite" },
+ # "params" => { "nickname" => "foo" },
+ # "form" => { "username" => "bar" },
+ # })
+ # ```
+ def request(verb : String, uri : String, *,
+ headers : (Hash(String, _) | NamedTuple)? = nil,
+ params : (Hash(String, _) | NamedTuple)? = nil,
+ form : (Hash(String, _) | NamedTuple)? = nil,
+ json : (Hash(String, _) | NamedTuple)? = nil,
+ raw : String? = nil,
+ tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response
+ request(verb, uri, options_with(headers, params, form, json, raw, tls))
+ end
+
+ # Make an HTTP request with the given verb and options
+ #
+ # > This method will be executed with oneshot request.
+ #
+ # ```
+ # Halite.request("get", "http://httpbin.org/stream/3", headers: {"user-agent" => "halite"}) do |response|
+ # puts response.status_code
+ # while line = response.body_io.gets
+ # puts line
+ # end
+ # end
+ # ```
+ def request(verb : String, uri : String, *,
+ headers : (Hash(String, _) | NamedTuple)? = nil,
+ params : (Hash(String, _) | NamedTuple)? = nil,
+ form : (Hash(String, _) | NamedTuple)? = nil,
+ json : (Hash(String, _) | NamedTuple)? = nil,
+ raw : String? = nil,
+ tls : OpenSSL::SSL::Context::Client? = nil,
+ &block : Halite::Response ->)
+ request(verb, uri, options_with(headers, params, form, json, raw, tls), &block)
+ end
+
+ # Make an HTTP request with the given verb and options
+ #
+ # > This method will be executed with oneshot request.
+ #
+ # ```
+ # Halite.request("get", "http://httpbin.org/get", Halite::Options.new(
+ # "headers" = { "user_agent" => "halite" },
+ # "params" => { "nickname" => "foo" },
+ # "form" => { "username" => "bar" },
+ # )
+ # ```
+ def request(verb : String, uri : String, options : Halite::Options? = nil) : Halite::Response
+ branch(options).request(verb, uri)
+ end
+
+ # Make an HTTP request with the given verb and options
+ #
+ # > This method will be executed with oneshot request.
+ #
+ # ```
+ # Halite.request("get", "http://httpbin.org/stream/3") do |response|
+ # puts response.status_code
+ # while line = response.body_io.gets
+ # puts line
+ # end
+ # end
+ # ```
+ def request(verb : String, uri : String, options : Halite::Options? = nil, &block : Halite::Response ->)
+ branch(options).request(verb, uri, &block)
+ end
+
+ private def branch(options : Halite::Options? = nil) : Halite::Client
+ options ||= default_options
+ Halite::Client.new(options)
+ end
+
+ private def default_options
+ {% if @type.superclass %}
+ @default_options
+ {% else %}
+ DEFAULT_OPTIONS.clear!
+ {% end %}
+ end
+
+ private def options_with(headers : (Hash(String, _) | NamedTuple)? = nil,
+ params : (Hash(String, _) | NamedTuple)? = nil,
+ form : (Hash(String, _) | NamedTuple)? = nil,
+ json : (Hash(String, _) | NamedTuple)? = nil,
+ raw : String? = nil,
+ tls : OpenSSL::SSL::Context::Client? = nil)
+ options = Halite::Options.new(headers: headers, params: params, form: form, json: json, raw: raw, tls: tls)
+ default_options.merge!(options)
+ end
+ end
+end
diff --git a/drivers/cisco/webex/instant_connect.cr b/drivers/cisco/webex/instant_connect.cr
new file mode 100644
index 00000000000..09032909dbb
--- /dev/null
+++ b/drivers/cisco/webex/instant_connect.cr
@@ -0,0 +1,118 @@
+require "placeos-driver"
+require "base64"
+require "jwt"
+
+class Cisco::Webex::InstantConnect < PlaceOS::Driver
+ # Discovery Information
+ generic_name :InstantConnect
+ descriptive_name "Webex InstantConnect"
+ uri_base "https://mtg-broker-a.wbx2.com"
+
+ default_settings({
+ bot_access_token: "token",
+ jwt_audience: "a4d886b0-979f-4e2c-a958-3e8c14605e51",
+ webex_guest_issuer: "a4d886b0",
+ webex_guest_secret: "a958-3e8c14605e51",
+ })
+
+ @jwt_audience : String = "a4d886b0-979f-4e2c-a958-3e8c14605e51"
+ @bot_access_token : String = ""
+ @webex_guest_issuer : String = ""
+ @webex_guest_secret : String = ""
+
+ def on_update
+ @webex_guest_issuer = setting?(String, :webex_guest_issuer) || ""
+ @webex_guest_secret = setting?(String, :webex_guest_secret) || ""
+
+ @audience_setting = setting?(String, :jwt_audience) || "a4d886b0-979f-4e2c-a958-3e8c14605e51"
+ @bot_access_token = setting(String, :bot_access_token)
+ end
+
+ # Cisco docs on the subject:
+ # * Guest JWT: https://developer.webex.com/docs/guest-issuer
+ # * Testing site: https://webexsamples.github.io/browser-sdk-samples/browser-auth-jwt/
+ def create_guest_bearer(user_id : String, display_name : String, expiry : Int64? = nil)
+ expires_at = expiry || 12.hours.from_now.to_unix
+ JWT.encode({
+ "sub": user_id,
+ "name": display_name,
+ "iss": @webex_guest_issuer,
+ "iat": 3.minutes.ago.to_unix,
+ "exp": expires_at,
+ }, Base64.decode_string(@webex_guest_secret), :hs256)
+ end
+
+ def create_meeting(room_id : String)
+ expiry = 24.hours.from_now.to_unix
+ request = {
+ aud: @jwt_audience,
+ provideShortUrls: true,
+ jwt: {
+ # the encounter id, should be unique for each patient encounter
+ sub: room_id,
+ exp: expiry,
+ },
+ }.to_json
+
+ get_meeting_details get_hash(request)
+ end
+
+ protected def get_meeting_details(meeting_keys)
+ host_details = meeting_keys.host.first
+ guest_details = meeting_keys.guest.first
+
+ response = get("/api/v1/space/?int=jose&data=#{host_details.cipher}")
+ logger.debug { "host config returned:\n#{response.body}" }
+ raise "host token request failed with #{response.status_code}" if response_failed?(response)
+ meeting_config = Hash(String, JSON::Any).from_json(response.body)
+
+ response = get("/api/v1/space/?int=jose&data=#{guest_details.cipher}")
+ logger.debug { "guest config returned:\n#{response.body}" }
+ raise "guest token request failed with #{response.status_code}" if response_failed?(response)
+ guest_token = String.from_json(response.body, root: "token")
+
+ {
+ # space_id seems to be an internal id for the meeting room
+ space_id: meeting_config["spaceId"].as_s,
+ host_token: meeting_config["token"].as_s,
+ guest_token: guest_token,
+ host_url: "#{meeting_keys.base_url}#{host_details.short}",
+ guest_url: "#{meeting_keys.base_url}#{guest_details.short}",
+ }
+ end
+
+ struct JoseEncryptResponse
+ include JSON::Serializable
+
+ getter host : Array(MeetingDetails)
+ getter guest : Array(MeetingDetails)
+
+ @[JSON::Field(key: "baseUrl")]
+ getter base_url : String
+ end
+
+ struct MeetingDetails
+ include JSON::Serializable
+
+ getter cipher : String
+ getter short : String
+ end
+
+ protected def get_hash(request : String)
+ response = post("/api/v2/joseencrypt", body: request, headers: HTTP::Headers{
+ "Accept" => "application/json",
+ "Content-Type" => "application/json",
+ "Authorization" => "Bearer #{@bot_access_token}",
+ })
+
+ logger.debug { "get_hash returned:\n#{response.body}" }
+ raise "request failed with #{response.status_code}" if response_failed?(response)
+
+ JoseEncryptResponse.from_json(response.body)
+ end
+
+ protected def response_failed?(response)
+ logger.warn { "instant connect response failure\ncode: #{response.status_code}, status: #{response.status}\nbody:\n#{response.body.inspect}" } unless response.success?
+ response.status_code != 200 || response.body.nil?
+ end
+end
diff --git a/drivers/cisco/webex/instant_connect_spec.cr b/drivers/cisco/webex/instant_connect_spec.cr
new file mode 100644
index 00000000000..f747868df63
--- /dev/null
+++ b/drivers/cisco/webex/instant_connect_spec.cr
@@ -0,0 +1,94 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::Webex::InstantConnect" do
+ # Send the request
+ retval = exec(:create_meeting,
+ room_id: "1"
+ )
+
+ # HTTP request to get host/guest hash
+ expect_http_request do |request, response|
+ headers = request.headers
+ io = request.body
+ if io
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request.to_s.includes?(%("aud" => "a4d886b0-979f-4e2c-a958-3e8c14605e51")) && headers["Authorization"].includes?(%(Bearer))
+ response.status_code = 200
+ response << RAW_HASH_RESPONSE
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include aud & sub details #{request.inspect}"
+ end
+ end
+
+ # HTTP request to get token/spaceId using host JWT
+ expect_http_request do |request, response|
+ headers = request.headers
+ if request.resource.includes?("api/v1/space/?int=jose&data=")
+ response.status_code = 200
+ response << RAW_HOST_RESPONSE
+ else
+ response.status_code = 401
+ end
+ end
+
+ # HTTP request to get token using guest JWT
+ expect_http_request do |request, response|
+ headers = request.headers
+ if request.resource.includes?("api/v1/space/?int=jose&data=")
+ response.status_code = 200
+ response << RAW_GUEST_RESPONSE
+ else
+ response.status_code = 401
+ end
+ end
+
+ retval.get.should eq(JSON.parse(RETVAL))
+end
+
+RAW_HOST_RESPONSE = %({
+ "userIdentifier": "Host",
+ "isLoggedIn": false,
+ "isHost": true,
+ "organizationId": "16917798-5582-49a7-92d0-4410f6964000",
+ "orgName": "PlaceOS",
+ "token": "NmFmZGQwODYtZmIzNi00OTlmLWE3N2QtNzUyNzk2MDk4NDU5MjZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303",
+ "spaceId": "Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1",
+ "visitId": "1",
+ "integrationType": "jose"
+})
+
+RAW_GUEST_RESPONSE = %({
+ "userIdentifier": "Guest",
+ "isLoggedIn": false,
+ "isHost": false,
+ "organizationId": "16917798-5582-49a7-92d0-4410f6964000",
+ "orgName": "PlaceOS",
+ "token": "NmFmZGQwODYtZmIzNi05OTlmLWE3N2QtMzUyNzk2MDk4NDU5MeZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303",
+ "spaceId": "Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1",
+ "visitId": "1",
+ "integrationType": "jose"
+})
+
+RAW_HASH_RESPONSE = %({
+ "host": [{
+ "cipher": "eyJwMnMiOiJCWXpoYmV4W",
+ "short": "abc1234"
+ }],
+ "guest": [{
+ "cipher": "eyJwMnMiOiJaVVJsejNsb1",
+ "short": "def1234"
+ }],
+ "baseUrl": "https://somedomain.com/chat/"
+})
+
+RETVAL = %({
+ "space_id":"Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1",
+ "host_token":"NmFmZGQwODYtZmIzNi00OTlmLWE3N2QtNzUyNzk2MDk4NDU5MjZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303",
+ "guest_token":"NmFmZGQwODYtZmIzNi05OTlmLWE3N2QtMzUyNzk2MDk4NDU5MeZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303",
+ "host_url": "https://somedomain.com/chat/abc1234",
+ "guest_url": "https://somedomain.com/chat/def1234"
+})
diff --git a/drivers/cisco/webex/models/device.cr b/drivers/cisco/webex/models/device.cr
new file mode 100644
index 00000000000..3ccad2ccce3
--- /dev/null
+++ b/drivers/cisco/webex/models/device.cr
@@ -0,0 +1,15 @@
+module Cisco
+ module Webex
+ module Models
+ class Device
+ include JSON::Serializable
+
+ @[JSON::Field(key: "webSocketUrl")]
+ property websocket_url : String
+
+ @[JSON::Field(key: "name")]
+ property name : String?
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/event.cr b/drivers/cisco/webex/models/event.cr
new file mode 100644
index 00000000000..d0ff44cd00b
--- /dev/null
+++ b/drivers/cisco/webex/models/event.cr
@@ -0,0 +1,27 @@
+module Cisco
+ module Webex
+ module Models
+ class Event
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ @[JSON::Field(key: "data")]
+ property data : Events::Data
+
+ @[JSON::Field(key: "timestamp")]
+ property timestamp : Int64
+
+ @[JSON::Field(key: "trackingId")]
+ property tracking_id : String
+
+ @[JSON::Field(key: "sequenceNumber")]
+ property sequence_number : Int64
+
+ @[JSON::Field(key: "filterMessage")]
+ property filter_message : Bool
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/events/activity.cr b/drivers/cisco/webex/models/events/activity.cr
new file mode 100644
index 00000000000..504b2b87c60
--- /dev/null
+++ b/drivers/cisco/webex/models/events/activity.cr
@@ -0,0 +1,35 @@
+module Cisco
+ module Webex
+ module Models
+ module Events
+ class Activity
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ @[JSON::Field(key: "objectType")]
+ property object_type : String
+
+ @[JSON::Field(key: "url")]
+ property url : String
+
+ @[JSON::Field(key: "published")]
+ property published : String
+
+ @[JSON::Field(key: "verb")]
+ property verb : String
+
+ @[JSON::Field(key: "actor")]
+ property actor : Actor
+
+ @[JSON::Field(key: "target")]
+ property target : Target
+
+ @[JSON::Field(key: "clientTempId")]
+ property client_temp_id : String?
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/events/actor.cr b/drivers/cisco/webex/models/events/actor.cr
new file mode 100644
index 00000000000..01c3b9f8007
--- /dev/null
+++ b/drivers/cisco/webex/models/events/actor.cr
@@ -0,0 +1,32 @@
+module Cisco
+ module Webex
+ module Models
+ module Events
+ class Actor
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ @[JSON::Field(key: "objectType")]
+ property object_type : String
+
+ @[JSON::Field(key: "displayName")]
+ property display_name : String
+
+ @[JSON::Field(key: "orgId")]
+ property organisation_id : String
+
+ @[JSON::Field(key: "emailAddress")]
+ property email : String
+
+ @[JSON::Field(key: "entryUUID")]
+ property entry_uuid : String
+
+ @[JSON::Field(key: "type")]
+ property type : String
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/events/data.cr b/drivers/cisco/webex/models/events/data.cr
new file mode 100644
index 00000000000..6cb51913264
--- /dev/null
+++ b/drivers/cisco/webex/models/events/data.cr
@@ -0,0 +1,17 @@
+module Cisco
+ module Webex
+ module Models
+ module Events
+ class Data
+ include JSON::Serializable
+
+ @[JSON::Field(key: "activity")]
+ property activity : Activity
+
+ @[JSON::Field(key: "eventType")]
+ property event_type : String
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/events/target.cr b/drivers/cisco/webex/models/events/target.cr
new file mode 100644
index 00000000000..4c5b77d196c
--- /dev/null
+++ b/drivers/cisco/webex/models/events/target.cr
@@ -0,0 +1,23 @@
+module Cisco
+ module Webex
+ module Models
+ module Events
+ class Target
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ @[JSON::Field(key: "objectType")]
+ property object_type : String
+
+ @[JSON::Field(key: "url")]
+ property url : String
+
+ @[JSON::Field(key: "published")]
+ property published : String
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/events/type.cr b/drivers/cisco/webex/models/events/type.cr
new file mode 100644
index 00000000000..f4a6f84f360
--- /dev/null
+++ b/drivers/cisco/webex/models/events/type.cr
@@ -0,0 +1,14 @@
+module Cisco
+ module Webex
+ module Models
+ module Events
+ class Type
+ include JSON::Serializable
+
+ @[JSON::Field(key: "eventType")]
+ property event_type : String
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/message.cr b/drivers/cisco/webex/models/message.cr
new file mode 100644
index 00000000000..a2136fcc694
--- /dev/null
+++ b/drivers/cisco/webex/models/message.cr
@@ -0,0 +1,77 @@
+module Cisco
+ module Webex
+ module Models
+ class Message
+ include JSON::Serializable
+
+ # The unique identifier for the message.
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ # The unique identifier for the parent message.
+ @[JSON::Field(key: "parentId")]
+ property parent_id : String?
+
+ # The room ID of the message.
+ @[JSON::Field(key: "roomId")]
+ property room_id : String
+
+ # The type of room.
+ @[JSON::Field(key: "roomType")]
+ property room_type : String
+
+ # The person ID of the recipient when sending a 1:1 message.
+ @[JSON::Field(key: "toPersonId")]
+ property to_person_id : String?
+
+ # The email address of the recipient when sending a 1:1 message.
+ @[JSON::Field(key: "toPersonEmail")]
+ property to_person_email : String?
+
+ # The message, in plain text.
+ @[JSON::Field(key: "text")]
+ property text : String
+
+ # The message, in Markdown format.
+ @[JSON::Field(key: "markdown")]
+ property markdown : String?
+
+ # The text content of the message, in HTML format. This read-only property is used by the Webex Teams clients.
+ @[JSON::Field(key: "html")]
+ property html : String?
+
+ # Public URLs for files attached to the message.
+ @[JSON::Field(key: "files")]
+ property files : Array(String)?
+
+ # The person ID of the message author.
+ @[JSON::Field(key: "personId")]
+ property person_id : String
+
+ # The email address of the message author.
+ @[JSON::Field(key: "personEmail")]
+ property person_email : String
+
+ # People IDs for anyone mentioned in the message.
+ @[JSON::Field(key: "mentionedPeople")]
+ property mentioned_people : Array(String)?
+
+ # Group names for the groups mentioned in the message.
+ @[JSON::Field(key: "mentionedGroups")]
+ property mentioned_groups : Array(String)?
+
+ # Message content attachments attached to the message.
+ @[JSON::Field(key: "attachments")]
+ property attachments : Array(String)?
+
+ # The date and time the message was created.
+ @[JSON::Field(key: "created")]
+ property created : String
+
+ # The date and time the message was created.
+ @[JSON::Field(key: "updated")]
+ property updated : String?
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/peek.cr b/drivers/cisco/webex/models/peek.cr
new file mode 100644
index 00000000000..2198be1757f
--- /dev/null
+++ b/drivers/cisco/webex/models/peek.cr
@@ -0,0 +1,15 @@
+module Cisco
+ module Webex
+ module Models
+ class Peek
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ @[JSON::Field(key: "data")]
+ property data : Events::Type
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/person.cr b/drivers/cisco/webex/models/person.cr
new file mode 100644
index 00000000000..0d74284904b
--- /dev/null
+++ b/drivers/cisco/webex/models/person.cr
@@ -0,0 +1,12 @@
+module Cisco
+ module Webex
+ module Models
+ class Person
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/models/room.cr b/drivers/cisco/webex/models/room.cr
new file mode 100644
index 00000000000..d674cda6f9c
--- /dev/null
+++ b/drivers/cisco/webex/models/room.cr
@@ -0,0 +1,45 @@
+module Cisco
+ module Webex
+ module Models
+ class Room
+ include JSON::Serializable
+
+ # A unique identifier for the room.
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ # The name of the room.
+ @[JSON::Field(key: "title")]
+ property title : String
+
+ # The room type.
+ @[JSON::Field(key: "type")]
+ property type : String
+
+ # Whether the room is moderated (locked) or not.
+ @[JSON::Field(key: "isLocked")]
+ property is_locked : Bool
+
+ # The ID for the team with which this room is associated..
+ @[JSON::Field(key: "teamId")]
+ property team_id : String?
+
+ # The date and time of the room"s last activity..
+ @[JSON::Field(key: "lastActivity")]
+ property last_activity : String
+
+ # The ID of the person who created this room.
+ @[JSON::Field(key: "creatorId")]
+ property creator_id : String
+
+ # The date and time the room was created.
+ @[JSON::Field(key: "created")]
+ property created : String
+
+ # The ID of the organization which owns this room.
+ @[JSON::Field(key: "ownerId")]
+ property owner_id : String
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/session.cr b/drivers/cisco/webex/session.cr
new file mode 100644
index 00000000000..f67597a687c
--- /dev/null
+++ b/drivers/cisco/webex/session.cr
@@ -0,0 +1,82 @@
+module Cisco
+ module Webex
+ class Session
+ Log = ::Log.for(self)
+
+ property base_url : String = Constants::DEFAULT_BASE_URL
+ property single_request_timeout : Int32 = Constants::DEFAULT_SINGLE_REQUEST_TIMEOUT
+ property user_agent : String = ["Tepha", Constants::VERSION].join(" ")
+ property wait_on_rate_limit : Bool = Constants::DEFAULT_WAIT_ON_RATE_LIMIT
+
+ private property client : Halite::Client = Halite::Client.new
+
+ def initialize(@access_token : String)
+ end
+
+ def request(method : String, url : String, **kwargs) : Halite::Response
+ # Abstract base method for making requests to the Webex Teams APIs.
+ # This base method:
+ # * Expands the API endpoint URL to an absolute URL
+ # * Makes the actual HTTP request to the API endpoint
+ # * Provides support for Webex Teams rate-limiting
+ # * Inspects response codes and raises exceptions as appropriate
+
+ absolute_url = URI.parse(base_url).resolve(url).to_s
+
+ @client.headers({"Authorization" => ["Bearer", @access_token].join(" ")})
+ @client.headers({"Content-Type" => "application/json;charset=utf-8"})
+ @client.timeout single_request_timeout
+
+ loop do
+ case method
+ when "GET"
+ response = @client.get absolute_url, **kwargs
+ when "POST"
+ response = @client.post absolute_url, **kwargs
+ when "PUT"
+ response = @client.put absolute_url, **kwargs
+ when "DELETE"
+ response = @client.delete absolute_url, **kwargs
+ else
+ raise Exceptions::Method.new("The request-method type is invalid.")
+ end
+
+ begin
+ status_code = StatusCode.new(response.status_code)
+ raise Exceptions::RateLimit.new(status_code.message) if response.status_code == 429
+ raise Exceptions::StatusCode.new(status_code.message) if !status_code.valid?
+
+ return response
+ rescue e : Exceptions::StatusCode
+ Log.error(exception: e) { }
+ rescue e : Exceptions::RateLimit
+ Log.error(exception: e) { }
+
+ retry_after = (response.headers["Retry-After"]? || "15").to_i * 1000
+ sleep(retry_after)
+ end
+ end
+ end
+
+ def get(url : String, **kwargs) : Halite::Response
+ # Sends a GET request.
+ request("GET", url, **kwargs)
+ end
+
+ def post(url : String, **kwargs) : Halite::Response
+ # Sends a POST request.
+ request("POST", url, **kwargs)
+ end
+
+ def put(url : String, **kwargs) : Halite::Response
+ # Sends a PUT request.
+ request("PUT", url, **kwargs)
+ end
+
+ def delete(url : String, **kwargs) : Halite::Response
+ # Sends a DELETE request.
+ request("DELETE", url, **kwargs)
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/status_code.cr b/drivers/cisco/webex/status_code.cr
new file mode 100644
index 00000000000..4e48f8d80ba
--- /dev/null
+++ b/drivers/cisco/webex/status_code.cr
@@ -0,0 +1,23 @@
+module Cisco
+ module Webex
+ class StatusCode
+ private property code : Int32
+
+ def initialize(@code : Int32)
+ end
+
+ def valid? : Bool
+ case @code
+ when 200, 204
+ true
+ else
+ false
+ end
+ end
+
+ def message : String
+ Constants::STATUS_CODES[@code]
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/utils.cr b/drivers/cisco/webex/utils.cr
new file mode 100644
index 00000000000..1dc7c015cc1
--- /dev/null
+++ b/drivers/cisco/webex/utils.cr
@@ -0,0 +1,23 @@
+module Cisco
+ module Webex
+ module Utils
+ def self.hash_from_items_with_values(**kwargs)
+ kwargs = kwargs.map { |k, v|
+ if v != nil && v != ""
+ {"#{k}" => v}
+ end
+ }
+
+ kwargs.reject!(nil)
+ kwargs = kwargs.reduce { |acc, i| acc.try(&.merge(i.not_nil!)) }
+
+ kwargs
+ end
+
+ def self.named_tuple_from_hash(hash)
+ named_tuple = NamedTuple.new(roomId: String, text: String)
+ named_tuple.from(hash)
+ end
+ end
+ end
+end
diff --git a/drivers/clipsal/c_bus.cr b/drivers/clipsal/c_bus.cr
new file mode 100644
index 00000000000..e93e2d92b1f
--- /dev/null
+++ b/drivers/clipsal/c_bus.cr
@@ -0,0 +1,252 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+
+# Documentation: https://aca.im/driver_docs/Clipsal/CBUS-Lighting-Application.pdf
+# and https://aca.im/driver_docs/Clipsal/CBUS-Trigger-Control-Application.pdf
+
+class Clipsal::CBus < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ # Discovery Information
+ descriptive_name "Clipsal CBus Lighting"
+ generic_name :Lighting
+ tcp_port 10001
+
+ default_settings({
+ trigger_groups: [0xCA],
+ })
+
+ @trigger_groups : Array(UInt8) = [0xCA_u8]
+
+ def on_load
+ queue.wait = false
+ queue.delay = 100.milliseconds
+ transport.tokenizer = Tokenizer.new("\r")
+
+ on_update
+ end
+
+ def on_update
+ @trigger_groups = setting?(Array(UInt8), :trigger_groups) || [0xCA_u8]
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def connected
+ # Ensure we are in smart mode
+ send("|||\r", priority: 99)
+
+ # maintain the connection
+ schedule.every(1.minute) do
+ logger.debug { "maintaining connection" }
+ send("|||\r", priority: 0)
+ end
+ end
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ application, group = get_application_group(area, 0xCA)
+ action = scene & 0xFF
+ command = Bytes[0x05, application, 0x00, 0x02, group, action.to_u8]
+
+ self[area] = action
+
+ do_send(command)
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ _application, group = get_application_group(area, 0xCA)
+ self[Area.new(group.to_u32)]?
+ end
+
+ RAMP_RATES = {
+ (...2_000_u32) => 0b0000_u8, # instant
+ (2_000_u32...6_000_u32) => 0b0001_u8, # 4s
+ (6_000_u32...10_000_u32) => 0b0010_u8, # 8s
+ (10_000_u32...15_000_u32) => 0b0011_u8, # 12s
+ (15_000_u32...25_000_u32) => 0b0100_u8, # 20s
+ (25_000_u32...35_000_u32) => 0b0101_u8, # 30s
+ (35_000_u32...50_000_u32) => 0b0110_u8, # 40s
+ (50_000_u32...75_000_u32) => 0b0111_u8, # 1m
+ (75_000_u32...105_000_u32) => 0b1000_u8, # 1m 30s
+ (105_000_u32...150_000_u32) => 0b1001_u8, # 2m
+ (150_000_u32...240_000_u32) => 0b1010_u8, # 3m
+ (240_000_u32...360_000_u32) => 0b1011_u8, # 5m
+ (360_000_u32...510_000_u32) => 0b1100_u8, # 7m
+ (510_000_u32...720_000_u32) => 0b1101_u8, # 10m
+ (720_000_u32...960_000_u32) => 0b1110_u8, # 15m
+ (960_000_u32...) => 0b1111_u8, # 17m
+ }
+
+ def lookup_ramp_rate(fade_time : UInt32) : UInt8
+ range = RAMP_RATES.keys.find(&.includes?(fade_time))
+ rate = RAMP_RATES[range]
+
+ # The command is structured as: 0b0xxxx010 where xxxx == rate
+ ((rate & 0x0F_u8) << 3) | 0b010_u8
+ end
+
+ LEVEL_PERCENTAGE = 0xFF / 100
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ application, group = get_application_group(area, 0x38)
+
+ level = level.clamp(0.0, 100.0)
+ level_byte = (level * LEVEL_PERCENTAGE).to_u8
+ group = (group & 0xFF).to_u8
+ rate = lookup_ramp_rate(fade_time)
+
+ # stop_fading(group)
+ stop_f = cmd_string(Bytes[0x05, 0x38, 0x00, 0x09, group])
+ command = stop_f + cmd_string(Bytes[0x05, application, 0x00, rate, group, level_byte])
+
+ self["#{area}_level"] = level
+
+ send(command, name: "level_#{application}_#{group}")
+ end
+
+ def stop_fading(group : UInt8)
+ do_send(Bytes[0x05, 0x38, 0x00, 0x09, group])
+ end
+
+ # return the current level
+ def lighting_level?(area : Area? = nil)
+ _application, group = get_application_group(area, 0x38)
+ self[Area.new(group.to_u32, append: "level")]?
+ end
+
+ def received(data, task)
+ # extract the hex string encoded bytes
+ payload = String.new(data)
+ logger.debug { "CBus sent: #{payload}" }
+ data = payload[1..-2].hexbytes
+
+ if !check_checksum(data)
+ return task.try(&.abort("CBus checksum failed"))
+ end
+
+ # We are only looking at Point -> MultiPoint commands
+ # 0x03 == Point -> Point -> MultiPoint
+ # 0x06 == Point -> Point
+ if data[0] != 0x05
+ logger.debug { "was not a Point -> MultiPoint response: type 0x#{data[0].to_s(16)}" }
+ return
+ end
+
+ application = data[1] # The application being referenced
+ commands = data[3..-2].to_a # Remove the header + checksum
+
+ while commands.size > 0
+ current = commands.shift
+
+ case application
+ when .in?(@trigger_groups) # Trigger group
+ area = if application == 0xCA_u8
+ Area.new(commands.shift.to_u32)
+ else
+ Area.new(commands.shift.to_u32, channel: application.to_u32)
+ end
+
+ case current
+ when 0x02 # Trigger Event (ex: 0504CA00 020101 29)
+ self[area] = commands.shift # Action selector
+ when 0x01 # Trigger Min
+ self[area] = 0
+ when 0x79 # Trigger Max
+ self[area] = 0xFF
+ when 0x09 # Indicator Kill (ex: 0504CA00 0901 23)
+ logger.debug { "trigger kill request: grp 0x#{commands[0].to_s(16)}" }
+ # Group (turns off indicators of all scenes triggered by this group)
+ else
+ logger.debug { "unknown trigger group request 0x#{current.to_s(16)}" }
+ break # We don't know what data is here
+ end
+ when 0x30..0x5F # Lighting group
+ case current
+ when 0x01 # Group off (ex: 05043800 0101 0102 0103 0104 7905 33)
+ self[Area.new(commands.shift.to_u32, append: "level")] = 0.0
+ when 0x79 # Group on (ex: 05013800 7905 44)
+ self[Area.new(commands.shift.to_u32, append: "level")] = 100.0
+ when 0x02 # Blinds up or stop
+ # Example: 05083000022FFF93
+ group = commands.shift
+ value = commands.shift
+ area = Area.new(group.to_u32, append: "blind")
+
+ if value == 0xFF
+ self[area] = :up
+ elsif value == 5
+ self[area] = :stopped
+ end
+ when 0x1A # Blinds down
+ # Example: 050830001A2F007A
+ group = commands.shift
+ value = commands.shift
+ self[Area.new(group.to_u32, append: "blind")] = :down if value == 0x00
+ when 0x09 # Terminate Ramp
+ logger.debug { "terminate ramp request: grp 0x#{commands[0].to_s(16)}" }
+ commands.shift # Group address
+ else
+ # Ramp to level (ex: 05013800 0205FF BC)
+ # Header cmd cksum
+ if ((current & 0b10000101) == 0) && commands.size > 1
+ logger.debug { "ramp request: grp 0x#{commands[0].to_s(16)} - level 0x#{commands[1].to_s(16)}" }
+ commands.shift(2) # Group address, level
+ else
+ logger.debug { "unknown lighting request 0x#{current.to_s(16)}" }
+ break # We don't know what data is here
+ end
+ end
+ else
+ logger.debug { "unknown application request app 0x#{application.to_s(16)}" }
+ break # We haven't programmed this application
+ end
+ end
+
+ task.try &.success
+ end
+
+ protected def get_application_group(area : Area?, app_default = 0x38)
+ group = area.try &.id
+ raise "area (cbus group) id required" unless group
+ application = (area.try(&.channel) || app_default).to_u8
+
+ {application, group.to_u8 & 0xFF_u8}
+ end
+
+ protected def checksum(data : Bytes) : Bytes
+ check = 0
+ data.each do |byte|
+ check += byte
+ end
+ check = check % 0x100
+ check = ((check ^ 0xFF) + 1) & 0xFF
+ Bytes[check.to_u8]
+ end
+
+ protected def check_checksum(data : Bytes) : Bool
+ check = 0
+ data.each do |byte|
+ check += byte
+ end
+ (check % 0x100) == 0x00
+ end
+
+ protected def cmd_string(command : Bytes) : String
+ String.build do |str|
+ str << "\\"
+ str << command.hexstring.upcase
+ str << checksum(command).hexstring.upcase
+ str << "\r"
+ end
+ end
+
+ protected def do_send(command : Bytes, **options)
+ cmd = cmd_string(command)
+ logger.debug { "Requesting CBus: #{cmd}" }
+ send(cmd, **options)
+ end
+end
diff --git a/drivers/clipsal/c_bus_spec.cr b/drivers/clipsal/c_bus_spec.cr
new file mode 100644
index 00000000000..bf3eb128707
--- /dev/null
+++ b/drivers/clipsal/c_bus_spec.cr
@@ -0,0 +1,12 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Clipsal::CBus" do
+ should_send("|||\r")
+
+ transmit "\\05CA0002250109\r"
+ status["area37"]?.should eq 1
+
+ exec :set_lighting_scene, 2, {id: 37}
+ should_send "\\05CA0002250208\r"
+ status["area37"]?.should eq 2
+end
diff --git a/drivers/comm_box/v3x_v4.cr b/drivers/comm_box/v3x_v4.cr
new file mode 100644
index 00000000000..ba001a56a4b
--- /dev/null
+++ b/drivers/comm_box/v3x_v4.cr
@@ -0,0 +1,172 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+class CommBox::V3X_V4 < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Input
+ Vga = 111 # pc in manual
+ Dvi = 221
+ Hdmi = 211
+ Hdmi2 = 212
+ Hdmi3 = 213
+ Hdmi4 = 214
+ DisplayPort = 231
+ Dtv = 250
+ Media = 310
+ end
+ include Interface::InputSelection(Input)
+
+ # Discovery Information
+ tcp_port 4660
+ descriptive_name "CommBox V3, V3X and V4 Display "
+ generic_name :Display
+
+ DELIMITER = "\r"
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ end
+
+ def connected
+ schedule.every(50.seconds) { power? }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def switch_to(input : Input, **options)
+ send "!200INPT #{input.value}\r", name: "input"
+ end
+
+ def volume(value : Int32 | Float64, **options)
+ data = value.to_f.clamp(0.0, 100.0).round_away.to_i
+ send "!200VOLM #{data}\r", name: "volume"
+ end
+
+ # Mutes both audio/video
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ mute_audio(state) if layer.audio? || layer.audio_video?
+ end
+
+ # Emulate audio mute
+ def mute_audio(state : Bool = true)
+ send "!200MUTE #{state ? 1 : 0}\r", name: "mute"
+ end
+
+ def power?
+ send "!200POWR ?\r"
+ end
+
+ def input?
+ send "!200INPT ?\r"
+ end
+
+ def volume?
+ send "!200VOLM ?\r"
+ end
+
+ def toggle_mute
+ send "!200MUTE 2\r", name: "toggle_mute"
+ end
+
+ def freeze_screen
+ send "!200FREZ 1\r", name: "freeze_on"
+ end
+
+ def toggle_freeze_screen
+ send "!200FREZ 2\r", name: "togge_freeze"
+ end
+
+ def freeze_screen?
+ send "!200FREZ ?\r"
+ end
+
+ def power(state : Bool)
+ if state
+ send "!200POWR 1\r", name: "power"
+ else
+ send "!200POWR 0\r", name: "power"
+ end
+ end
+
+ def age_mode(state : Bool)
+ if state
+ send "!200AGEM 1\r", name: "age_mode"
+ else
+ send "!200AGEMR 0\r", name: "age_mode"
+ end
+ end
+
+ def toggle_age_mode
+ send "!200AGEM 2\r", name: "age_mode"
+ end
+
+ def age_mode?
+ send "!200AGEM ?\r"
+ end
+
+ @[Security(Level::Administrator)]
+ def custom_send(raw : String)
+ send "#{raw}\r"
+ end
+
+ # Command is in format of header, version, ID, command name, separator, parameters, terminator
+ # A common received function for handling responses
+ def received(data, task)
+ data = String.new(data).strip
+ logger.debug { "received data: #{data}" }
+
+ cmd, value = data.split("=", 2)
+ return task.try &.abort if error_check(cmd, value)
+
+ case cmd
+ when "!201VOLM"
+ self[:volume_level] = value.to_i
+ when "!201MUTE"
+ self[:mute] = value == "1"
+ when "!201INPT"
+ self[:input] = Input.from_value(value.to_i)
+ when "!201POWR"
+ self[:power] = value == "1"
+ when "!201FREZ"
+ self[:freeze] = value
+ when "!201AGEM"
+ self[:age_mode] = value
+ else
+ logger.debug { "Unknown Output: #{cmd} with value #{value}" }
+ end
+
+ task.try &.success
+ end
+
+ def error_check(data : String, value : String) : Bool
+ return false unless value.starts_with?("ERR")
+
+ case value
+ when "ERR1"
+ logger.warn { "ERR1 - The command is invalid" }
+ true
+ when "ERR2"
+ logger.warn { "ERR2 - The parameter is out of range or not supported." }
+ true
+ when "ERR3"
+ logger.warn { "ERR3 - The command is unavailable at this time." }
+ true
+ when "ERR4"
+ logger.warn { "ERR4 - General failure - all other errors." }
+ true
+ else
+ logger.warn { "Unknown Error : #{value}" }
+ true
+ end
+ end
+end
diff --git a/drivers/comm_box/v3x_v4_spec.cr b/drivers/comm_box/v3x_v4_spec.cr
new file mode 100644
index 00000000000..27b083d2cbe
--- /dev/null
+++ b/drivers/comm_box/v3x_v4_spec.cr
@@ -0,0 +1,34 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "CommBox::V3X_V4" do
+ exec(:power, true)
+ should_send("!200POWR 1\r")
+ responds("!201POWR=1\r")
+ status[:power].should eq(true)
+
+ exec(:power?)
+ should_send("!200POWR ?\r")
+ responds("!201POWR=1\r")
+
+ exec(:volume, 24)
+ should_send("!200VOLM 24\r")
+ responds("!201VOLM=24\r")
+
+ exec(:volume, 6)
+ should_send("!200VOLM 6\r")
+ responds("!201VOLM=6\r")
+
+ exec(:mute, true)
+ # Audio mute
+ should_send("!200MUTE 1\r")
+ responds("!201MUTE=1\r")
+
+ exec(:mute, false)
+ # Audio mute
+ should_send("!200MUTE 0\r")
+ responds("!201MUTE=0\r")
+
+ exec(:switch_to, "hdmi")
+ should_send("!200INPT 211\r")
+ responds("!201INPT=211\r")
+end
diff --git a/drivers/company_3m/displays/wall_display.cr b/drivers/company_3m/displays/wall_display.cr
new file mode 100644
index 00000000000..5f31f6e37dc
--- /dev/null
+++ b/drivers/company_3m/displays/wall_display.cr
@@ -0,0 +1,314 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+require "bindata"
+
+# Protocol: https://aca.im/driver_docs/X3M/RS-232%20Instructions.pdf
+
+class Company3M::Displays::WallDisplay < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::AudioMuteable
+
+ enum Input
+ VGA = 0
+ DVI = 1
+ HDMI = 2
+ DisplayPort = 3
+ end
+
+ include Interface::InputSelection(Input)
+
+ # Discovery Information
+ descriptive_name "3M Wall Display"
+ generic_name :Display
+ description <<-DESC
+ Display control is via RS-232 only. Ensure IP -> RS-232 converter has
+ been configured to provide comms at 9600,N,8,1.
+ DESC
+
+ # Global Cache Port
+ tcp_port 4999
+
+ default_settings({
+ # 0 == all monitors
+ monitor_id: "all",
+ })
+
+ @monitor_id : MonitorID = MonitorID::All
+ @power_target : Bool? = nil
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("\r")
+ on_update
+ end
+
+ def on_update
+ @monitor_id = setting?(MonitorID, :monitor_id) || MonitorID::All
+ end
+
+ def connected
+ schedule.every(15.seconds) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def do_poll
+ logger.debug { "Polling device for connectivity heartbeat" }
+
+ # The device does not provide any query only methods for interaction.
+ # Re-apply the current known power state to provide a comms heartbeat
+ # if we can do it safely.
+ target = @power_target
+ power(target, priority: 0) unless target.nil?
+ end
+
+ # ===================
+ # Powerable Interface
+ # ===================
+
+ def power(state : Bool, **options)
+ if state != @power_target
+ # Define setting for polling
+ self[:power_target] = @power_target = state
+ end
+ set :power, state, **options
+ end
+
+ # ====================
+ # Audio Mute Interface
+ # ====================
+
+ def mute_audio(state : Bool = true, index : Int32 | String = 0)
+ set :audio_mute, state
+ end
+
+ # =========================
+ # Input selection Interface
+ # =========================
+
+ def switch_to(input : Input)
+ set :input, input
+ end
+
+ # ==============
+ # End Interfaces
+ # ==============
+
+ protected def in_range(level : Int32 | Float64) : Int32
+ level = level.to_f.clamp(0.0, 100.0)
+ level.round.to_i
+ end
+
+ def volume(level : Int32 | Float64)
+ percentage = in_range(level) / 100.0
+ adjusted = (percentage * 30.0).round_away.to_i
+ set :volume, adjusted
+ end
+
+ def brightness(value : Int32 | Float64)
+ value = in_range value
+ set :brightness, value
+ end
+
+ def contrast(value : Int32 | Float64)
+ value = in_range value
+ set :contrast, value
+ end
+
+ def sharpness(value : Int32 | Float64)
+ value = in_range value
+ set :sharpness, value
+ end
+
+ enum ColourTemp
+ K9300 = 0
+ K6500 = 1
+ User = 2
+ end
+
+ def colour_temp(value : ColourTemp)
+ set :colour_temp, value
+ end
+
+ protected def set(command : Command, param, **opts)
+ logger.debug { "Setting #{command} -> #{param}" }
+
+ request = new_request command, param
+ packet = build_packet request
+ send packet, **opts, name: command.to_s
+ end
+
+ def received(bytes, task)
+ response = begin
+ parse_response bytes
+ rescue parse_error
+ logger.warn(exception: parse_error) { "failed to parse 3M packet" }
+ return task.try &.abort
+ end
+
+ unless response.success?
+ logger.warn { "Device error: #{response.inspect}" }
+ return task.try &.abort
+ end
+
+ logger.debug { "Device response received: #{response.inspect}" }
+
+ self[response.command.to_s.underscore] = response.value
+ task.try &.success
+ end
+
+ enum MonitorID : UInt8
+ All = 0x2a
+ A = 0x41
+ B = 0x42
+ C = 0x43
+ D = 0x44
+ E = 0x45
+ F = 0x46
+ G = 0x47
+ H = 0x48
+ I = 0x49
+ end
+
+ enum MessageSender
+ PC = 0x30
+ end
+
+ enum MessageType : UInt8
+ Command = 0x45
+ Reply = 0x46
+ end
+
+ enum Command : UInt16
+ Brightness = 0x0110
+ Contrast = 0x0112
+ Sharpness = 0x018c
+ ColourTemp = 0x0254
+ Volume = 0x0062
+ AudioMute = 0x008d
+ Input = 0x02cb
+ Power = 0x0003
+ end
+
+ enum ResultCode : UInt16
+ Success = 0x3030
+ Unsupported = 0x3031
+ end
+
+ class RequestPacket < BinData
+ endian big
+
+ uint8 :header_start, value: ->{ 0x01_u8 }
+ uint8 :reserved, value: ->{ 0x30_u8 }
+ enum_field UInt8, monitor_id : MonitorID = MonitorID::All
+ enum_field UInt8, sender : MessageSender = MessageSender::PC
+ enum_field UInt8, message_type : MessageType = MessageType::Command
+ string :message_length, value: ->{ 10.to_s(16).upcase.rjust(2, '0') }, length: ->{ 2 }
+
+ uint8 :message_start, value: ->{ 0x02_u8 }
+ string :op_code_page, length: ->{ 2 }
+ string :op_code, length: ->{ 2 }
+ string :set_value, length: ->{ 4 }
+ uint8 :message_end, value: ->{ 0x03_u8 }
+
+ def command=(command : Command)
+ code = command.value.to_s(16).upcase.rjust(4, '0')
+ self.op_code_page = code[0..1]
+ self.op_code = code[2..3]
+ command
+ end
+
+ def value=(val : Int32)
+ self.set_value = val.to_s(16).upcase.rjust(4, '0')
+ end
+ end
+
+ class ResponsePacket < BinData
+ endian big
+
+ uint8 :header_start
+ uint8 :reserved
+ enum_field UInt8, receiver : MessageSender = MessageSender::PC
+ enum_field UInt8, monitor_id : MonitorID = MonitorID::All
+ enum_field UInt8, message_type : MessageType = MessageType::Reply
+ string :message_length, length: ->{ 2 }
+
+ uint8 :message_start, value: ->{ 0x02_u8 }
+ enum_field UInt16, result_code : ResultCode = ResultCode::Success
+ string :op_code_page, length: ->{ 2 }
+ string :op_code, length: ->{ 2 }
+ string :reply_type, length: ->{ 2 }
+ string :max_value, length: ->{ 4 }
+ string :current_value, length: ->{ 4 }
+ uint8 :message_end
+ uint8 :bcc
+ uint8 :delimiter
+
+ getter command : Command do
+ Command.from_value "#{op_code_page}#{op_code}".to_i(16)
+ end
+
+ def success?
+ self.result_code.success?
+ end
+
+ def value
+ raw_val = self.current_value.to_i(16)
+ case self.command
+ in .brightness?, .contrast?, .sharpness?
+ raw_val
+ in .volume?
+ # adjust back into 0-100 range
+ (raw_val / 30.0) * 100.0
+ in .audio_mute?, .power?
+ raw_val == 1
+ in .colour_temp?
+ ColourTemp.from_value raw_val
+ in .input?
+ Input.from_value raw_val
+ end
+ end
+ end
+
+ # Map a symbolic command and parameter value to an [op_code, value]
+ protected def new_request(command : Command, param : Bool | Enum | Int32)
+ value = case param
+ in Bool
+ param ? 1 : 0
+ in Enum
+ param.to_i
+ in Int32
+ param
+ end
+
+ request = RequestPacket.new
+ request.command = command
+ request.value = value
+ request
+ end
+
+ # Build a "set_parameter_command" packet ready for transmission
+ protected def build_packet(request : RequestPacket) : Bytes
+ request.monitor_id = @monitor_id
+ io = IO::Memory.new
+ io.write_bytes(request)
+
+ bytes = io.to_slice
+ io.write_byte(bytes[1..-1].reduce { |acc, i| acc ^ i })
+ io << "\r"
+ io.to_slice
+ end
+
+ protected def parse_response(packet : Bytes) : ResponsePacket
+ io = IO::Memory.new(packet)
+ response = io.read_bytes(ResponsePacket)
+
+ bcc = packet[1..-3].reduce { |acc, i| acc ^ i }
+ raise "invalid checksum" if bcc != response.bcc
+
+ response
+ end
+end
diff --git a/drivers/company_3m/displays/wall_display_spec.cr b/drivers/company_3m/displays/wall_display_spec.cr
new file mode 100644
index 00000000000..9eb2f66a4a3
--- /dev/null
+++ b/drivers/company_3m/displays/wall_display_spec.cr
@@ -0,0 +1,13 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Company3M::Displays::WallDisplay" do
+ exec(:power, true)
+ should_send("\x010*0E0A\x0200030001\x03\x1d\r")
+ responds("\x0100*F12\x020000030000010001\x03\x6d\r")
+ status[:power].should be_true
+
+ exec(:power, false)
+ should_send("\x010*0E0A\x0200030000\x03\x1c\r")
+ responds("\x0100*F12\x020000030000010000\x03\x6c\r")
+ status[:power].should be_false
+end
diff --git a/drivers/crestron/cres_next.cr b/drivers/crestron/cres_next.cr
new file mode 100644
index 00000000000..3c95fa9a39f
--- /dev/null
+++ b/drivers/crestron/cres_next.cr
@@ -0,0 +1,126 @@
+require "placeos-driver"
+require "json"
+require "path"
+require "uri"
+require "./nvx_models"
+require "./cres_next_auth"
+
+# Documentation: https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Prerequisites-Assumptions.htm
+# inspecting request - response packets from the device webui is also useful
+
+# Parent module for Crestron DM NVX devices.
+abstract class Crestron::CresNext < PlaceOS::Driver
+ include Crestron::CresNextAuth
+
+ def websocket_headers
+ authenticate
+
+ headers = HTTP::Headers.new
+ transport.cookies.add_request_headers(headers)
+ headers["CREST-XSRF-TOKEN"] = @xsrf_token unless @xsrf_token.empty?
+ headers["User-Agent"] = "advanced-rest-client"
+ headers
+ end
+
+ def connected
+ schedule.every(10.minutes) { maintain_session }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def tokenize(path : String)
+ path.split('/').reject(&.empty?)
+ end
+
+ # ============================================
+ # websocket for state changes and get requests
+ # ============================================
+ protected def query(path : String, **options, &block : (JSON::Any, ::PlaceOS::Driver::Task) -> Nil)
+ request_path = Path["/Device"].join(path).to_s
+ tokens = tokenize(request_path)
+ parts = tokens.map { |part| %("#{part}":) }
+
+ send(request_path, **options) do |data, task|
+ raw_json = String.new(data)
+ logger.debug { "Crestron sent: #{raw_json}" }
+
+ # check if the response path is included
+ unless parts.map(&.in?(raw_json)).includes?(false)
+ # Just grab the relevant data as the response is deeply nested
+ json = JSON.parse(raw_json)
+ tokens.each { |key| json = json[key] }
+ block.call json, task
+ task.success json
+ end
+ end
+ end
+
+ protected def ws_update(path : String, value, **options)
+ request_path = Path["/Device"].join(path).to_s
+
+ # expands into object that we need to post
+ components = tokenize(request_path).map { |part| %({"#{part}") }
+ payload = %(#{components.join(':')}:#{value.to_json}#{"}" * components.size})
+
+ apply_ws_changes(payload, **options)
+ end
+
+ private def apply_ws_changes(payload : String, **options)
+ send(payload, **options) do |data, task|
+ raw_json = String.new(data)
+ logger.debug { "Crestron sent: #{raw_json}" }
+
+ if raw_json.includes? %("Results":)
+ task.success JSON.parse(raw_json)
+ end
+ end
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def manual_send(payload : JSON::Any)
+ data = payload.to_json
+ logger.debug { "Sending: #{data}" }
+ send data, wait: false
+ end
+
+ def received(data, task)
+ raw_json = String.new data
+ logger.debug { "Crestron sent: #{raw_json}" }
+ end
+
+ # ========================================
+ # HTTP for updates and session maintenance
+ # ========================================
+ def maintain_session
+ response = get("/Device/DeviceInfo")
+ return logout unless response.success?
+ logger.debug { "Maintaining Session:\n#{response.body}" }
+ end
+
+ # payload is expected to be a hash or named tuple
+ protected def update(path : String, value, **options)
+ request_path = Path["/Device"].join(path).to_s
+
+ # expands into object that we need to post
+ components = tokenize(request_path).map { |part| %({"#{part}") }
+ payload = %(#{components.join(':')}:#{value.to_json}#{"}" * components.size})
+
+ apply_http_changes(request_path, payload, **options)
+ end
+
+ private def apply_http_changes(request_path : String, payload : String, **options)
+ queue(**options) do |task|
+ response = post request_path, body: payload, headers: HTTP::Headers{"CREST-XSRF-TOKEN" => @xsrf_token}
+ logger.debug { "updated requested for #{request_path}, response was #{response.body}" }
+
+ # no real need to parse the responses as the changes will be sent down the websocket
+ if response.success?
+ task.success JSON.parse(response.body)
+ else
+ task.abort "crestron failed to apply changes to: #{request_path}\n#{response.body}"
+ end
+ end
+ end
+end
diff --git a/drivers/crestron/cres_next_auth.cr b/drivers/crestron/cres_next_auth.cr
new file mode 100644
index 00000000000..1efc33f0e87
--- /dev/null
+++ b/drivers/crestron/cres_next_auth.cr
@@ -0,0 +1,60 @@
+require "uri"
+
+module Crestron::CresNextAuth
+ protected getter xsrf_token : String = ""
+
+ def authenticate
+ logger.debug { "Authenticating" }
+
+ # some devices require referer and origin to accept the login
+ uri = URI.parse config.uri.not_nil!
+ host = uri.host
+
+ response = post("/userlogin.html", headers: {
+ "Content-Type" => "application/x-www-form-urlencoded",
+ "Referer" => "https://#{host}/userlogin.html",
+ "Origin" => "https://#{host}",
+ }, body: URI::Params.build { |form|
+ form.add("login", setting(String, :username))
+ form.add("passwd", setting(String, :password))
+ })
+
+ case response.status_code
+ when 200, 302
+ auth_cookies = %w(AuthByPasswd iv tag userid userstr)
+ if (auth_cookies - response.cookies.to_h.keys).empty?
+ @xsrf_token = response.headers["CREST-XSRF-TOKEN"]? || ""
+ logger.debug { "Authenticated" }
+ else
+ error = "Device did not return all auth information"
+ end
+ when 403
+ error = "Invalid credentials"
+ else
+ error = "Unexpected response (HTTP #{response.status})"
+ end
+
+ if error
+ logger.error { error }
+ raise error
+ end
+ end
+
+ def logout
+ response = post "/logout"
+
+ case response.status
+ when 302
+ logger.debug { "Logout successful" }
+ true
+ else
+ logger.warn { "Unexpected response (HTTP #{response.status})" }
+ false
+ end
+ ensure
+ @xsrf_token = ""
+ transport.cookies.clear
+ schedule.clear
+ disconnect
+ end
+end
diff --git a/drivers/crestron/fusion.cr b/drivers/crestron/fusion.cr
new file mode 100644
index 00000000000..8d69bb81d64
--- /dev/null
+++ b/drivers/crestron/fusion.cr
@@ -0,0 +1,183 @@
+require "placeos-driver"
+require "xml"
+require "json"
+require "uri"
+
+# TODO: add handling of security level 2
+# TODO: parse returend results into models
+#
+# Documentation: https://sdkcon78221.crestron.com/sdk/Fusion_APIs/Content/Topics/Default.htm
+class Crestron::Fusion < PlaceOS::Driver
+ descriptive_name "Crestron Fusion"
+ generic_name :CrestronFusion
+ description <<-DESC
+ Crestron Fusion
+ DESC
+
+ uri_base "https://fusion.myorg.com/fusion/apiservice/"
+
+ default_settings({
+ # Security level: 0 (No Security), 1 (Clear Text), 2 (Encrypted)
+ security_level: 1,
+
+ user_id: "FUSION_USER_ID",
+
+ # Should be the same as set in the Fusion configuration client
+ api_pass_code: "FUSION_API_PASS_CODE",
+
+ # xml or json
+ content_type: "json",
+
+ # uses old ciphers
+ https_insecure: true,
+ })
+
+ @security_level : Int32 = 1
+ @user_id : String = ""
+ @api_pass_code : String = ""
+ @content_type : String = ""
+
+ def on_update
+ @security_level = setting(Int32, :security_level)
+ @user_id = setting(String, :user_id)
+ @api_pass_code = setting(String, :api_pass_code)
+ @content_type = "application/" + setting(String, :content_type)
+ end
+
+ ###########
+ # Actions #
+ ###########
+
+ def get_actions(name : String?, room_id : String? = nil, page : Int32? = nil)
+ params = URI::Params.new
+ params["search"] = name if name
+ params["room"] = room_id if room_id
+ params["page"] = page.to_s if page
+
+ response = perform_request("GET", "/actions", params)
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def get_action(action_id : String)
+ response = perform_request("GET", "/actions/#{action_id}")
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def send_action(action_id : String?, room_id : String? = nil, node_id : String? = nil)
+ params = URI::Params.new
+ params["room"] = room_id if room_id
+ params["node"] = node_id if node_id
+
+ path = if (id = action_id) && !id.empty?
+ "/actions/#{id}"
+ else
+ "/actions"
+ end
+
+ response = perform_request("POST", path, params)
+ JSON.parse(response.body)
+ end
+
+ ##########
+ # Alerts #
+ ##########
+
+ # Severity should be in the range 1-4
+ def get_alerts(node_ids : Array(String)? = nil, room_ids : Array(String)? = nil, start_time : String? = nil, end_time : String? = nil, severity : Int32? = nil, active_alerts : Bool = true)
+ params = URI::Params.new
+ params["nodes"] = node_ids.join(',') if node_ids
+ params["rooms"] = room_ids.join(',') if room_ids
+ params["start"] = start_time if start_time
+ params["end"] = end_time if end_time
+ params["severity"] = severity.to_s if severity
+ params["activeAlerts"] = active_alerts.to_s if active_alerts
+
+ response = perform_request("GET", "/rooms", params)
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ #########
+ # Rooms #
+ #########
+
+ def post_room(room_xml_or_json : String)
+ response = perform_request("POST", "/rooms", body: room_xml_or_json)
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def get_rooms(name : String?, node_id : String? = nil, page : Int32? = nil)
+ params = URI::Params.new
+ params["search"] = name if name
+ params["node"] = node_id if node_id
+ params["page"] = page.to_s if page
+
+ response = perform_request("GET", "/rooms", params)
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def get_room(room_id : String)
+ response = perform_request("GET", "/rooms/#{room_id}")
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def put_room(room_id : String, room_xml_or_json : String)
+ response = perform_request("PUT", "/rooms/#{room_id}", body: room_xml_or_json)
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def delete_room(room_id : String)
+ response = perform_request("DELETE", "/rooms/#{room_id}")
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ #################
+ # Signal Values #
+ #################
+
+ def get_signal_values(symbol_id : String)
+ response = perform_request("GET", "/signalvalues/#{symbol_id}")
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def get_signal_value(symbol_id : String, attribute_id : String)
+ response = perform_request("GET", "/signalvalues/#{symbol_id}/#{attribute_id}")
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ def put_signal_value(symbol_id : String, attribute_id : String, value : String)
+ params = URI::Params.new
+ params["value"] = value
+
+ response = perform_request("PUT", "/signalvalues/#{symbol_id}/#{attribute_id}", params)
+ @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body)
+ end
+
+ ###########
+ # Helpers #
+ ###########
+
+ private def perform_request(method : String, path : String, params : URI::Params = URI::Params.new, body : String? = nil)
+ if @security_level == 1
+ params["auth"] = "#{@api_pass_code} #{@user_id}"
+ elsif @security_level == 2
+ params["auth"] = encrypted_token
+ end
+
+ headers = HTTP::Headers.new
+ headers["Content-Type"] = @content_type
+ headers["Accept"] = @content_type
+
+ response = http(method, path, body, params, headers)
+ if response.status_code == 200
+ response
+ else
+ raise "Fusion API request failed. Status code: #{response.status_code}"
+ end
+ end
+
+ private def encrypted_token
+ # TODO: encrypt this
+ # "#{Time.utc.to_rfc3339} #{@user_id}"
+ raise "Fusion API security level 2 not supported"
+ end
+end
diff --git a/drivers/crestron/fusion_spec.cr b/drivers/crestron/fusion_spec.cr
new file mode 100644
index 00000000000..230f29a90bb
--- /dev/null
+++ b/drivers/crestron/fusion_spec.cr
@@ -0,0 +1,75 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Crestron::Fusion" do
+ settings({
+ security_level: 0,
+ user_id: "spec-user-id",
+ api_pass_code: "spec-api-pass-code",
+ service_url: "/RoomViewSE/APIService/",
+ content_type: "xml",
+ })
+
+ resp = exec(:get_rooms, "Meeting Room A")
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << rooms.to_json
+ end
+ resp.get
+
+ resp = exec(:get_room, "room-id")
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << room.to_json
+ end
+ resp.get
+
+ resp = exec(:get_signal_value, "symbol-id", "attribute-id")
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << signal_value.to_json
+ end
+ resp.get
+
+ resp = exec(:put_signal_value, "symbol-id", "attribute-id", "start")
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << put_signal_value_response.to_json
+ end
+ resp.get
+end
+
+###########
+# Helpers #
+###########
+
+private def rooms
+ {
+ "rooms" => [
+ room,
+ ],
+ }
+end
+
+private def room
+ {
+ "RoomName" => "Meeting Room A",
+ }
+end
+
+private def signal_value
+ {
+ "API_Signals" => [{
+ "AttributeID" => "attribute-id",
+ "AttributeName" => "PlaceOS_Enabled",
+ "RawValue" => "False",
+ "SymbolID" => "symbol-id",
+ }],
+ "Status" => "Success",
+ }
+end
+
+private def put_signal_value_response
+ {
+ "Status" => "Success",
+ }
+end
diff --git a/drivers/crestron/nvx_address_manager.cr b/drivers/crestron/nvx_address_manager.cr
new file mode 100644
index 00000000000..0a31eb96a7e
--- /dev/null
+++ b/drivers/crestron/nvx_address_manager.cr
@@ -0,0 +1,72 @@
+require "placeos-driver"
+require "./nvx_models"
+
+class Crestron::NvxAddressManager < PlaceOS::Driver
+ descriptive_name "Crestron NVX Address Manager"
+ generic_name :NvxAddressManager
+
+ description <<-DESC
+ Simplified management of NVX encoder multicast addressing.
+
+ Allows a subnet to be assigned with sequential, blocked address
+ allocation to all NVX encoders appearing alongside instances of this
+ module.
+
+ This is intended to be instantiated in systems containing all NVX
+ encoders that share a multicast subnet.
+ DESC
+
+ default_settings({
+ base_address: "239.8.0.2",
+ block_size: 8,
+ })
+
+ # https://github.com/Sija/ipaddress.cr
+ MULTICAST_ADDRESSES = ::IPAddress::IPv4.new("224.0.0.0/4")
+
+ @base_address : UInt32 = 0_u32
+ @block_size : Int32 = 8
+
+ def on_update
+ addr = setting(String, :base_address)
+ base_addr = ::IPAddress::IPv4.new addr
+ @base_address = base_addr.to_u32
+ logger.warn { "#{addr} is not a valid multicast address" } unless MULTICAST_ADDRESSES.includes? base_addr
+ @block_size = setting(Int32, :block_size)
+ end
+
+ def readdress_streams
+ logger.debug { "readdressing devices" }
+
+ address_pairs = encoders.zip(addresses)
+
+ interactions = address_pairs.map_with_index(1) do |(mod, ip_u32), idx|
+ ip = ::IPAddress::IPv4.parse_u32(ip_u32)
+ logger.debug { "setting encoder #{idx} to #{ip}" }
+ mod.multicast_address ip.to_s
+ end
+
+ failed = 0
+ interactions.each do |request|
+ begin
+ request.get
+ rescue error
+ failed += 1
+ logger.warn(exception: error) { "addressing NVX devices" }
+ end
+ end
+
+ raise "#{failed} failed to set stream address" unless failed == 0
+ interactions.size
+ end
+
+ protected def encoders
+ system.implementing(Crestron::Transmitter)
+ end
+
+ # returns an iterator of IPv4 addresses represented as 32bit numbers
+ protected def addresses
+ address_range = (@base_address..MULTICAST_ADDRESSES.last.to_u32)
+ address_range.step by: @block_size
+ end
+end
diff --git a/drivers/crestron/nvx_address_manager_spec.cr b/drivers/crestron/nvx_address_manager_spec.cr
new file mode 100644
index 00000000000..bc92e0b00a7
--- /dev/null
+++ b/drivers/crestron/nvx_address_manager_spec.cr
@@ -0,0 +1,22 @@
+require "placeos-driver/spec"
+require "./nvx_models"
+
+DriverSpecs.mock_driver "Crestron::NvxAddressManager" do
+ system({
+ Encoder: {NvxEncoderMock, NvxEncoderMock},
+ })
+
+ exec(:readdress_streams).get.should eq 2
+
+ system(:Encoder_1)[:address].should eq "239.8.0.2"
+ system(:Encoder_2)[:address].should eq "239.8.0.10"
+end
+
+# :nodoc:
+class NvxEncoderMock < DriverSpecs::MockDriver
+ include Crestron::Transmitter
+
+ def multicast_address(address : String)
+ self[:address] = address
+ end
+end
diff --git a/drivers/crestron/nvx_models.cr b/drivers/crestron/nvx_models.cr
new file mode 100644
index 00000000000..15ba209b7aa
--- /dev/null
+++ b/drivers/crestron/nvx_models.cr
@@ -0,0 +1,61 @@
+require "json"
+
+module Crestron
+ # Interface for enumerating devices
+ module Transmitter
+ end
+
+ module Receiver
+ end
+
+ enum AspectRatio
+ MaintainAspectRatio
+ StretchToFit
+ end
+
+ enum SourceType
+ Audio
+ Video
+ end
+
+ enum Location
+ CenterLeft
+ CenterRight
+ Custom
+ LowerLeft
+ LowerRight
+ UpperLeft
+ UpperRight
+ end
+
+ struct OSD
+ include JSON::Serializable
+
+ @[JSON::Field(key: "IsEnabled")]
+ property is_enabled : Bool?
+
+ @[JSON::Field(key: "Location")]
+ property location : String?
+
+ @[JSON::Field(key: "XPosition")]
+ property x_position : Int32? = 0
+
+ @[JSON::Field(key: "YPosition")]
+ property y_position : Int32? = 0
+
+ @[JSON::Field(key: "Text")]
+ property text : String?
+
+ @[JSON::Field(key: "FontColor")]
+ property font_color : String?
+
+ @[JSON::Field(key: "BackgroundTransparency")]
+ property background_transparency : String?
+
+ @[JSON::Field(key: "Version")]
+ property version : String? = "2.0.0"
+
+ def initialize(@text, @is_enabled, @location, @background_transparency)
+ end
+ end
+end
diff --git a/drivers/crestron/nvx_rx.cr b/drivers/crestron/nvx_rx.cr
new file mode 100644
index 00000000000..e33dcd01dea
--- /dev/null
+++ b/drivers/crestron/nvx_rx.cr
@@ -0,0 +1,281 @@
+require "./cres_next"
+require "placeos-driver/interface/switchable"
+
+class Crestron::NvxRx < Crestron::CresNext # < PlaceOS::Driver
+ alias Input = String
+ alias Output = Int32
+ include Interface::Switchable(Input, Output)
+ include Interface::InputSelection(Input)
+ include Crestron::Receiver
+
+ descriptive_name "Crestron NVX Receiver"
+ generic_name :Decoder
+ description <<-DESC
+ Crestron NVX network media decoder.
+ DESC
+
+ uri_base "wss://192.168.0.5/websockify"
+
+ default_settings({
+ username: "admin",
+ password: "admin",
+ })
+
+ @subscriptions : Hash(String, JSON::Any) = {} of String => JSON::Any
+ @audio_follows_video : Bool = true
+
+ def connected
+ super
+ audio_follows_video = setting?(Bool, :audio_follows_video)
+ @audio_follows_video = audio_follows_video.nil? ? true : audio_follows_video
+
+ # NVX hardware can be confiured a either a RX or TX unit - check this
+ # device is in the correct mode.
+ # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/DeviceSpecific.htm?Highlight=DeviceMode
+ query("/DeviceSpecific/DeviceMode") do |mode|
+ # "DeviceMode":"Transmitter|Receiver",
+ next if mode == "Receiver"
+ logger.warn { "device configured as a #{mode}" }
+ self[:WARN] = "device configured as a #{mode}. Expecting Receiver"
+ end
+
+ # Get the registered subscriptions for index based switching.
+ # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription
+ query("/XioSubscription/Subscriptions") do |subs|
+ self[:subscriptions] = @subscriptions = subs.as_h
+ end
+
+ # Background poll for subscription changes.
+ schedule.every(1.hour) do
+ query("/XioSubscription/Subscriptions", priority: 5) do |subs|
+ self[:subscriptions] = @subscriptions = subs.as_h
+ end
+ end
+
+ # Background poll to remain in sync with any external routing changes
+ schedule.every(5.minutes, immediate: true) { update_source_info }
+ end
+
+ def switch_to(input : Input)
+ switch_layer input
+ end
+
+ protected def switch_layer(input : Input, layer : SwitchLayer? = nil)
+ layer ||= SwitchLayer::All
+
+ do_switch = case input.downcase
+ when "none", "break", "clear", "blank", "black"
+ blank layer
+ when "input1", "hdmi", "hdmi1"
+ switch_local "Input1", layer
+ when "input2", "hdmi2"
+ switch_local "Input2", layer
+ else
+ switch_stream input, layer
+ end
+
+ do_switch.try &.get
+ update_source_info
+ end
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ switch_layer map.keys.first, layer
+ end
+
+ def output(state : Bool)
+ logger.debug { "#{state ? "enabling" : "disabling"} output sync" }
+
+ ws_update(
+ "/AudioVideoInputOutput/Outputs",
+ [{
+ Ports: [{
+ Hdmi: {IsOutputDisabled: !state},
+ }],
+ }],
+ name: :output
+ )
+ end
+
+ # aspect ratio defined in nvx_rx_models
+ def aspect_ratio(mode : AspectRatio)
+ logger.debug { "setting output aspect ratio mode: #{mode}" }
+
+ ws_update(
+ "/AudioVideoInputOutput/Outputs",
+ [{
+ Ports: [{
+ AspectRatioMode: mode,
+ }],
+ }],
+ name: :aspect_ratio
+ )
+ end
+
+ protected def query_device_name
+ query("/Localization/Name", name: "device_name") do |name|
+ self["device_name"] = name
+ end
+ end
+
+ def query_osd_text
+ query("/Osd/Text", name: "osd_text") do |text|
+ self[:osd_text] = text
+ end
+ end
+
+ def set_osd_text(text : String, enabled : Bool = true)
+ ws_update "/Osd/Text", text, name: :set_osd_text
+ ws_update "/Osd/IsEnabled", enabled, name: :set_osd_enabled
+ end
+
+ protected def switch_stream(stream_reference : String | Int32, layer : SwitchLayer)
+ uuid = uuid_for stream_reference
+
+ logger.debug do
+ subscription = @subscriptions[uuid].as_h
+ id, name = subscription.values_at "Position", "SessionName"
+ "switching to Stream#{id} (#{name}) on layer #{layer}"
+ end
+
+ if layer.all? || layer.video?
+ ws_update "/DeviceSpecific/VideoSource", "Stream", name: :input_video
+ resp = ws_update "/AvRouting/Routes", { {VideoSource: uuid} }, name: :switch_video
+ end
+
+ if @audio_follows_video
+ ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio
+ resp = ws_update "/AvRouting/Routes", { {AudioSource: uuid} }, name: :switch_audio
+ elsif layer.all? || layer.audio?
+ ws_update "/DeviceSpecific/AudioSource", "Stream", name: :input_audio
+ resp = ws_update "/AvRouting/Routes", { {AudioSource: uuid} }, name: :switch_audio
+ end
+
+ resp
+ end
+
+ protected def switch_local(input, layer : SwitchLayer)
+ logger.debug { "switching to #{input}" }
+
+ if layer.all? || layer.video?
+ resp = ws_update "/DeviceSpecific/VideoSource", input, name: :input_video
+ end
+
+ if @audio_follows_video
+ resp = ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio
+ elsif layer.all? || layer.audio?
+ resp = ws_update "/DeviceSpecific/AudioSource", input, name: :input_audio
+ end
+
+ resp
+ end
+
+ protected def blank(layer : SwitchLayer)
+ logger.debug { "blanking output" }
+
+ if layer.all? || layer.video?
+ ws_update "/DeviceSpecific/VideoSource", "None", name: :input_video
+ resp = ws_update "/AvRouting/Routes", { {VideoSource: ""} }, name: :switch_video
+ end
+
+ if @audio_follows_video
+ ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio
+ resp = ws_update "/AvRouting/Routes", { {AudioSource: ""} }, name: :switch_audio
+ elsif layer.all? || layer.audio?
+ ws_update "/DeviceSpecific/AudioSource", "None", name: :input_audio
+ resp = ws_update "/AvRouting/Routes", { {AudioSource: ""} }, name: :switch_audio
+ end
+
+ resp
+ end
+
+ # Decoders must first subscribe to encoders they need to receive signals
+ # from. Switching is then based on device UUID's.
+ #
+ # The deivce web UI's (and presumbly XIO director) show these as selectable
+ # 'inputs' - this mapping allows sources to either be specified as a UUID,
+ # or their 'input number' as displayed with Crestron tooling.
+ #
+ # Alternatively, if a string is provided the list of search props will be
+ # searched for a match.
+ protected def uuid_for(reference : String)
+ if /Stream(\d+)/i =~ reference
+ # grab the matching data https://crystal-lang.org/api/latest/Regex.html
+ id = $~[1].to_i
+ return uuid_for id
+ end
+
+ # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription
+ subscriptions = @subscriptions
+
+ if subscriptions.has_key? reference
+ uuid = reference
+ else
+ {"MulticastAddress", "SessionName"}.each do |prop|
+ if result = subscriptions.find { |_, x| x.as_h[prop]? == reference }
+ uuid = result[0]
+ end
+ break if uuid
+ end
+ end
+
+ raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil?
+
+ uuid
+ end
+
+ protected def uuid_for(reference : Int32)
+ subscriptions = @subscriptions
+
+ # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription
+ if result = subscriptions.find { |_, x| x.as_h["Position"]? == reference }
+ uuid = result[0]
+ end
+
+ raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil?
+
+ uuid
+ end
+
+ enum SourceType
+ Audio
+ Video
+ end
+
+ # Build friendly source names based on a device state.
+ #
+ # Maps all streams into `Stream1`...`StreamN` style names based on
+ # subscriptions. Local inputs (`Input1`, `Input2`, `AnalogueAudio` etc) are
+ # left untouched.
+ protected def query_source_name_for(type : SourceType)
+ type_downcase = type.to_s.downcase
+
+ # "ActiveAudioSource":"Input1|Input2|Analog|PrimaryAudio|SecondaryAudio",
+ # "ActiveVideoSource":"None|Input1|Input2|Stream",
+ query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source", priority: 0) do |source_name|
+ if source_name.as_s.includes? "Stream"
+ # "Routes": [{
+ # "AudioSource": "07147488-9e0b-11e7-abc4-cec278b6b50a",
+ # "AutomaticStreamRoutingEnabled": false,
+ # "Name": "PrimaryStream",
+ # "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c",
+ # "VideoSource": "07147488-9e0b-11e7-abc4-cec278b6b50a"
+ # }]
+ query("/AvRouting/Routes", name: :routes, priority: 1) do |routes|
+ uuid = routes.dig?(0, "#{type}Source").try &.as_s?
+ # FIXME: provide 'Stream1..n' rather than uuids
+ self["#{type_downcase}_source"] = uuid.presence ? "Stream-#{uuid}" : "None"
+ end
+ else
+ self["#{type_downcase}_source"] = source_name
+ end
+ end
+ end
+
+ # Query the device for the current source state and update status vars.
+ protected def update_source_info
+ query_source_name_for(:video)
+ query_source_name_for(:audio)
+ query_device_name
+ query_osd_text
+ end
+end
diff --git a/drivers/crestron/nvx_rx_spec.cr b/drivers/crestron/nvx_rx_spec.cr
new file mode 100644
index 00000000000..adcf3a2868e
--- /dev/null
+++ b/drivers/crestron/nvx_rx_spec.cr
@@ -0,0 +1,90 @@
+require "placeos-driver/spec"
+require "uri"
+
+DriverSpecs.mock_driver "Crestron::NvxRx" do
+ # Connected callback makes some queries
+ should_send "/Device/DeviceSpecific/DeviceMode"
+ responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Receiver"}}})
+
+ should_send "/Device/XioSubscription/Subscriptions"
+ responds %({"Device": {"XioSubscription": {"Subscriptions": {
+ "00000000-0000-4002-0054-018a0089fd1c": {
+ "Address": "https://10.254.47.133/onvif/services",
+ "AudioChannels": 0,
+ "AudioFormat": "No Audio",
+ "Bitrate": 750,
+ "Encryption": true,
+ "Fps": 0,
+ "MulticastAddress": "228.228.228.224",
+ "Position": 2,
+ "Resolution": "0x0",
+ "RtspUri": "rtsp://10.254.47.133:554/live.sdp",
+ "SessionName": "DM-NVX-E30-DEADBEEF1234",
+ "SnapshotUrl": "",
+ "Transport": "TS/RTP",
+ "UniqueId": "00000000-0000-4002-0054-018a0089fd1c",
+ "VideoFormat": "Pixel",
+ "IsSyncDetected": false,
+ "Status": "SUBSCRIBED"
+ }
+ }}}})
+
+ should_send "/Device/Localization/Name"
+ responds %({"Device": {"Localization": {"Name": "projector"}}})
+
+ should_send "/Device/Osd/Text"
+ responds %({"Device": {"Osd": {"Text": "Hearing Loop"}}})
+
+ should_send "/Device/DeviceSpecific/ActiveVideoSource"
+ responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Stream"}}})
+
+ should_send "/Device/AvRouting/Routes"
+ responds %({"Device": {"AvRouting": {"Routes": [
+ {
+ "Name": "Routing0",
+ "AudioSource": "00000000-0000-4002-0054-018a0089fd1c",
+ "VideoSource": "00000000-0000-4002-0054-018a0089fd1c",
+ "UsbSource": "00000000-0000-4002-0054-018a0089fd1c",
+ "AutomaticStreamRoutingEnabled": false,
+ "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c"
+ }
+ ]}}})
+
+ should_send "/Device/DeviceSpecific/ActiveAudioSource"
+ responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}})
+
+ status[:video_source].should eq("Stream-00000000-0000-4002-0054-018a0089fd1c")
+ status[:audio_source].should eq("Input1")
+ status[:device_name].should eq("projector")
+ status[:osd_text].should eq("Hearing Loop")
+
+ # we call this manually as the driver isn't loaded in websocket mode
+ exec :authenticate
+
+ # We expect the first thing it to do is authenticate
+ auth = URI::Params.build { |form|
+ form.add("login", "admin")
+ form.add("passwd", "admin")
+ }
+
+ expect_http_request do |request, response|
+ io = request.body
+ if io
+ request_body = io.gets_to_end
+ if request_body == auth
+ response.status_code = 200
+ response.headers["CREST-XSRF-TOKEN"] = "1234"
+ cookies = response.cookies
+ cookies["AuthByPasswd"] = "true"
+ cookies["iv"] = "true"
+ cookies["tag"] = "true"
+ cookies["userid"] = "admin"
+ cookies["userstr"] = "admin"
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include login form #{request.inspect}"
+ end
+ end
+end
diff --git a/drivers/crestron/nvx_scaler_control.cr b/drivers/crestron/nvx_scaler_control.cr
new file mode 100644
index 00000000000..2e34b62734f
--- /dev/null
+++ b/drivers/crestron/nvx_scaler_control.cr
@@ -0,0 +1,67 @@
+require "placeos-driver"
+require "./nvx_models"
+
+class Crestron::NvxScalerControl < PlaceOS::Driver
+ descriptive_name "Crestron NVX Scaler Control"
+ generic_name :NvxAddressManager
+
+ description <<-DESC
+ Synconisation tool for managing scaling settings on NVX decoders based
+ on window aspect ratios of a videowall processor.
+
+ To enable flexible / user selectable distribution of both 16:9 and 21:9
+ signals, aspect ratio control across both the videowall processor and
+ NVX decoders is exploited to keep things looking nice.
+
+ In the case a decoder is being displayed on a 16:9 window it is set to
+ scale-to-fit, enabling ultrawide signals to be letterboxed. When a
+ signal is being send to an ultrawide window it is instead set to
+ scale-to-fill (stretch) on the NVX, then a second level of distortion
+ is applied on the videowall processor to convert this back to it's
+ original aspect.
+
+ This approach keeps all components of the signal chain at 1080p / 4K and
+ enables live switching all all sources without EDID re-negotation.
+ DESC
+
+ default_settings({
+ # Mapping of { : }
+ link_scalers: {
+ window_1: "Decoder_1",
+ window_2: "Decoder_2",
+ },
+ })
+
+ @links : Hash(String, String) = {} of String => String
+
+ # Window of aspect ratio's to detect as 16:9 - allows for +/-5% for
+ # slightly off-shape windows
+ SCALE_TO_FIT_BOUNDS = (16 / 9 * 0.95)..(16 / 9 * 1.05)
+
+ def on_update
+ @links = setting?(Hash(String, String), :link_scalers) || {} of String => String
+ end
+
+ bind VideoWall_1, :windows, :videowall_windows_changed
+
+ private def videowall_windows_changed(_subscription, new_value)
+ windows = Hash(String, NamedTuple(canwidth: Float64, canheight: Float64)).from_json new_value
+ windows.each do |id, props|
+ next unless @links.has_key? id
+
+ nvx = system.get @links[id]
+
+ aspect_ratio = props[:canwidth] / props[:canheight]
+
+ if aspect_ratio.nan?
+ logger.debug { "#{id} not positioned on canvas, skipping" }
+ elsif SCALE_TO_FIT_BOUNDS.includes? aspect_ratio
+ logger.debug { "detected #{id} as 16:9, maintaining aspect" }
+ nvx.aspect_ratio AspectRatio::MaintainAspectRatio
+ else
+ logger.debug { "detected #{id} as ultrawide, filling window" }
+ nvx.aspect_ratio AspectRatio::StretchToFit
+ end
+ end
+ end
+end
diff --git a/drivers/crestron/nvx_scaler_control_spec.cr b/drivers/crestron/nvx_scaler_control_spec.cr
new file mode 100644
index 00000000000..06e402ae452
--- /dev/null
+++ b/drivers/crestron/nvx_scaler_control_spec.cr
@@ -0,0 +1,37 @@
+require "placeos-driver/spec"
+require "./nvx_models"
+
+DriverSpecs.mock_driver "Crestron::NvxScalerControl" do
+ system({
+ Decoder: {NvxDecoderMock, NvxDecoderMock},
+ VideoWall: {VideoWallMock},
+ })
+
+ sleep 1
+
+ system(:Decoder_1)[:aspect_ratio].should eq "MaintainAspectRatio"
+ system(:Decoder_2)[:aspect_ratio].should eq "StretchToFit"
+end
+
+# :nodoc:
+class NvxDecoderMock < DriverSpecs::MockDriver
+ def aspect_ratio(mode : Crestron::AspectRatio)
+ self[:aspect_ratio] = mode
+ end
+end
+
+# :nodoc:
+class VideoWallMock < DriverSpecs::MockDriver
+ def on_load
+ self[:windows] = {
+ "window_1" => {
+ canwidth: 1920,
+ canheight: 1080,
+ },
+ "window_2" => {
+ canwidth: 1080,
+ canheight: 1080,
+ },
+ }
+ end
+end
diff --git a/drivers/crestron/nvx_tx.cr b/drivers/crestron/nvx_tx.cr
new file mode 100644
index 00000000000..3ae11c1de3b
--- /dev/null
+++ b/drivers/crestron/nvx_tx.cr
@@ -0,0 +1,150 @@
+require "./cres_next"
+require "placeos-driver/interface/switchable"
+
+class Crestron::NvxTx < Crestron::CresNext # < PlaceOS::Driver
+ enum Input
+ None
+ Input1
+ Input2
+ end
+ include PlaceOS::Driver::Interface::InputSelection(Input)
+ include Crestron::Transmitter
+
+ descriptive_name "Crestron NVX Transmitter"
+ generic_name :Encoder
+ description <<-DESC
+ Crestron NVX network media encoder
+ DESC
+
+ uri_base "wss://192.168.0.5/websockify"
+
+ def connected
+ super
+
+ # NVX hardware can be confiured a either a RX or TX unit - check this
+ # device is in the correct mode.
+ query("/DeviceSpecific/DeviceMode") do |mode|
+ # "DeviceMode":"Transmitter|Receiver",
+ next if mode == "Transmitter"
+ logger.warn { "device configured as a #{mode}" }
+ self[:WARN] = "device configured as a #{mode}. Expecting Transmitter"
+ end
+
+ # Background poll to remain in sync with any external routing changes
+ schedule.every(5.minutes, immediate: true) { update_source_info }
+ end
+
+ def switch_to(input : Input)
+ logger.debug { "switching to #{input}" }
+ update(
+ "/DeviceSpecific",
+ {VideoSource: input, AudioSource: "AudioFollowsVideo"},
+ name: :switch
+ ).get
+ update_source_info
+ end
+
+ def output(state : Bool)
+ logger.debug { "#{state ? "enabling" : "disabling"} output sync" }
+
+ update(
+ "/AudioVideoInputOutput/Outputs",
+ [{
+ Ports: [{
+ Hdmi: {IsOutputDisabled: !state},
+ }],
+ }],
+ name: :output
+ )
+ end
+
+ def multicast_address(address : String)
+ logger.debug { "setting multicast address to #{address}" }
+ update("/StreamTransmit/Streams", [{MulticastAddress: address}], name: :multicast_address)
+ end
+
+ def emulate_input_sync(state : Bool = true, idx : Int32 = 1)
+ self["input_#{idx}_sync"] = state
+ end
+
+ # Build friendly source names based on a device state.
+ protected def query_source_name_for(type : SourceType)
+ type_downcase = type.to_s.downcase
+ query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source") do |source_name|
+ self["#{type_downcase}_source"] = source_name
+ end
+ end
+
+ protected def query_multicast_address
+ query("/StreamTransmit/Streams", name: "streams") do |streams|
+ self["multicast_address"] = streams.dig(0, "MulticastAddress")
+ end
+ end
+
+ # this is the audio AES67 address
+ # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/NaxAudio.htm
+ protected def query_nax_address
+ query("/NaxAudio/NaxTx/NaxTxStreams/Stream01/SessionNameStatus", name: "audio_name") do |stream|
+ self["nax_address"] = stream
+ end
+ end
+
+ protected def query_stream_name
+ query("/Localization/Name", name: "stream_name") do |name|
+ self["stream_name"] = name
+ end
+ end
+
+ # Query the device for the current source state and update status vars.
+ protected def update_source_info
+ query_stream_name
+ query_nax_address
+ query_multicast_address
+ query_source_name_for(:video)
+ query_source_name_for(:audio)
+ end
+
+ def received(data, task)
+ raw_json = String.new data
+ logger.debug { "Crestron sent: #{raw_json}" }
+
+ return unless raw_json.includes? "AudioVideoInputOutput"
+ raw_json.lines.each do |line|
+ next if line.empty?
+
+ begin
+ payload = JSON.parse(line)
+
+ # we're checking if a device is plugged into a port
+ # Device/AudioVideoInputOutput/Inputs/0/Ports/0/IsSyncDetected
+ if av_inputs = payload.dig?("Device", "AudioVideoInputOutput", "Inputs").try &.as_a?
+ av_inputs.each do |input|
+ name = input["Name"]?.try(&.as_s) || ""
+
+ # Device returns inputs as "input0", "input1" ... "inputN" within
+ # long poll responses, but appears to reference these same inputs
+ # as "input-1", "input-2" ... "input-N" within direct state queries.
+ idx = case name
+ when /input(\d+)/
+ # increment by 1
+ $~[1].to_i.succ
+ when /input-(\d+)/
+ $~[1].to_i
+ else
+ # There also appears to be situations where no name is
+ # returned. As only the first input is in use across all
+ # encoders, default to input 1 as a nasty hack around
+ # this craziness.
+ 1
+ end
+
+ sync = input.dig?("Ports", 0, "IsSyncDetected").try &.as_bool?
+ self["input_#{idx}_sync"] = sync unless sync.nil?
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "error parsing JSON:\n#{line}" }
+ end
+ end
+ end
+end
diff --git a/drivers/crestron/nvx_tx_spec.cr b/drivers/crestron/nvx_tx_spec.cr
new file mode 100644
index 00000000000..bbe720e3551
--- /dev/null
+++ b/drivers/crestron/nvx_tx_spec.cr
@@ -0,0 +1,36 @@
+require "placeos-driver/spec"
+require "uri"
+
+DriverSpecs.mock_driver "Crestron::NvxRx" do
+ # Connected callback makes some queries
+ should_send "/Device/DeviceSpecific/DeviceMode"
+ responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Transmitter"}}})
+
+ should_send "/Device/Localization/Name"
+ responds %({"Device": {"Localization": {"Name": "pc-in-rack"}}})
+
+ should_send "/Device/NaxAudio/NaxTx/NaxTxStreams/Stream01/SessionNameStatus"
+ responds %({"Device": {"NaxAudio": {"NaxTx": {"NaxTxStreams": {"Stream01": {"SessionNameStatus": "pc-in-rack"}}}}}})
+
+ should_send "/Device/StreamTransmit/Streams"
+ responds %({"Device": {"StreamTransmit": {"Streams": [{"MulticastAddress": "192.168.0.2"}]}}})
+
+ should_send "/Device/DeviceSpecific/ActiveVideoSource"
+ responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Input1"}}})
+
+ should_send "/Device/DeviceSpecific/ActiveAudioSource"
+ responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}})
+
+ status[:stream_name].should eq("pc-in-rack")
+ status[:multicast_address].should eq("192.168.0.2")
+ status[:audio_source].should eq("Input1")
+ status[:audio_source].should eq("Input1")
+
+ transmit %({"Device": {"AudioVideoInputOutput": {"Inputs": [
+ {"Name": "input0", "Ports": [{"IsSyncDetected": true}]},
+ {"Name": "input-2", "Ports": [{"IsSyncDetected": false}]}
+ ]}}}).gsub(/\s/, "")
+
+ status["input_1_sync"].should eq(true)
+ status["input_2_sync"].should eq(false)
+end
diff --git a/drivers/crestron/occupancy_sensor.cr b/drivers/crestron/occupancy_sensor.cr
new file mode 100644
index 00000000000..adc74d39c56
--- /dev/null
+++ b/drivers/crestron/occupancy_sensor.cr
@@ -0,0 +1,175 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "./cres_next_auth"
+
+# This device doesn't seem to support a websocket interface
+# and relies on long polling
+
+class Crestron::OccupancySensor < PlaceOS::Driver
+ include Crestron::CresNextAuth
+ include Interface::Sensor
+
+ descriptive_name "Crestron Occupancy Sensor"
+ generic_name :Occupancy
+
+ uri_base "https://192.168.0.5"
+
+ default_settings({
+ username: "admin",
+ password: "admin",
+
+ http_keep_alive_seconds: 600,
+ http_max_requests: 1200,
+ })
+
+ @mac : String = ""
+ @name : String? = nil
+ @occupied : Bool = false
+ @connected : Bool = false
+ getter last_update : Int64 = 0_i64
+ getter poll_counter : UInt64 = 0_u64
+
+ @sensor_data : Array(Interface::Sensor::Detail) = Array(Interface::Sensor::Detail).new(1)
+
+ def on_load
+ spawn { event_monitor }
+ schedule.every(10.minutes) { authenticate }
+ schedule.every(1.hour) { poll_device_state }
+ end
+
+ def connected
+ @connected = true
+
+ authenticate
+ poll_device_state
+ end
+
+ def disconnected
+ @connected = false
+ end
+
+ def poll_device_state : Nil
+ response = get("/Device", concurrent: true)
+ raise "unexpected response code: #{response.status_code}" unless response.success?
+ payload = JSON.parse(response.body)
+
+ @last_update = Time.utc.to_unix
+ self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool
+ self[:presence] = @occupied ? 1.0 : 0.0
+ self[:mac] = @mac = format_mac payload.dig("Device", "DeviceInfo", "MacAddress").as_s
+ self[:name] = @name = payload.dig("Device", "DeviceInfo", "Name").as_s?
+
+ update_sensor
+
+ # Start long polling once we have state
+ @poll_counter += 1
+ end
+
+ protected def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+
+ def event_monitor
+ loop do
+ break if terminated?
+ if @connected
+ # sleep if long poll failed
+ sleep 1.second unless long_poll
+ else
+ # sleep if not connected
+ sleep 1.second
+ end
+ end
+ end
+
+ # NOTE:: /Device/Longpoll
+ # 200 == check data
+ # when nothing new: {"Device":"Response Timeout"}
+ # when update: {"Device":{"SystemClock":{"CurrentTime":"2022-10-22T20:29:03Z","CurrentTimeWithOffset":"2022-10-22T20:29:03+09:30"}}}
+ # 301 == authentication required
+ # could auth every so often to prevent hitting this too
+ protected def long_poll : Bool
+ response = get("/Device/Longpoll")
+
+ # retry after authenticating
+ if response.status_code == 301
+ authenticate
+ response = get("/Device/Longpoll")
+ end
+ raise "unexpected response code: #{response.status_code}" unless response.success?
+
+ raw_json = response.body
+ logger.debug { "long poll sent: #{raw_json}" }
+
+ return true unless raw_json.includes? "IsRoomOccupied"
+ payload = JSON.parse(raw_json)
+
+ @last_update = Time.utc.to_unix
+ self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool
+ self[:presence] = @occupied ? 1.0 : 0.0
+ update_sensor
+
+ true
+ rescue timeout : IO::TimeoutError
+ logger.debug { "timeout waiting for long poll to complete" }
+ false
+ rescue error
+ logger.warn(exception: error) { "during long polling" }
+ false
+ end
+
+ @update_lock = Mutex.new
+
+ protected def update_sensor
+ @update_lock.synchronize do
+ if sensor = @sensor_data[0]?
+ sensor.value = @occupied ? 1.0 : 0.0
+ sensor.last_seen = @connected ? Time.utc.to_unix : @last_update
+ sensor.mac = @mac
+ sensor.name = @name
+ sensor.status = @connected ? Status::Normal : Status::Fault
+ else
+ @sensor_data << Detail.new(
+ type: :presence,
+ value: @occupied ? 1.0 : 0.0,
+ last_seen: @connected ? Time.utc.to_unix : @last_update,
+ mac: @mac,
+ id: nil,
+ name: @name,
+ module_id: module_id,
+ binding: "presence",
+ status: @connected ? Status::Normal : Status::Fault,
+ )
+ end
+ end
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ SENSOR_TYPES = {SensorType::Presence}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if mac && mac != @mac
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+
+ @sensor_data
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless @mac == mac
+ @sensor_data[0]?
+ end
+
+ def get_sensor_details
+ @sensor_data[0]?
+ end
+end
diff --git a/drivers/crestron/occupancy_sensor_spec.cr b/drivers/crestron/occupancy_sensor_spec.cr
new file mode 100644
index 00000000000..857298c8760
--- /dev/null
+++ b/drivers/crestron/occupancy_sensor_spec.cr
@@ -0,0 +1,114 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Crestron::OccupancySensor" do
+ full_query = %({
+ "Device": {
+ "DeviceInfo": {
+ "BuildDate": "May 23 2022 (461338)",
+ "Category": "Linux Device",
+ "DeviceId": "@E-00107fec2d72",
+ "DeviceVersion": "3.0000.00002",
+ "Devicekey": "No SystemKey Server",
+ "MacAddress": "00.10.7f.ec.2d.72",
+ "Manufacturer": "Crestron",
+ "Model": "CEN-ODT-C-POE",
+ "Name": "Room1-Sensor",
+ "PufVersion": "3.0000.00002",
+ "RebootReason": "poweron",
+ "SerialNumber": "2027NEJ00064",
+ "Version": "2.1.0"
+ },
+ "OccupancySensor": {
+ "ForceOccupied": "GET Not Supported, Write Only Property",
+ "ForceVacant": "GET Not Supported, Write Only Property",
+ "IsGraceOccupancyDetected": false,
+ "IsLedFlashEnabled": true,
+ "IsRoomOccupied": false,
+ "IsShortTimeoutEnabled": false,
+ "IsSingleSensorDeterminingOccupancy": true,
+ "IsSingleSensorDeterminingVacancy": true,
+ "Pir": {
+ "IsSensor1Enabled": true,
+ "OccupiedSensitivity": "Low",
+ "VacancySensitivity": "Low"
+ },
+ "RawStates": {
+ "IsRawEnabled": false,
+ "RawOccupancy": false,
+ "RawPir": false,
+ "RawUltrasonic": false
+ },
+ "TimeoutSeconds": 120,
+ "Ultrasonic": {
+ "IsSensor1Enabled": true,
+ "IsSensor2Enabled": true,
+ "OccupiedSensitivity": "Medium",
+ "VacancySensitivity": "Medium"
+ },
+ "Version": "1.0.2"
+ }
+ }
+ })
+
+ # expect authentication
+ expect_http_request do |request, response|
+ data = request.body.try(&.gets_to_end)
+ if data == "login=admin&passwd=admin"
+ response.status_code = 200
+ response.headers.add("Set-Cookie", [
+ "userstr=61646d696e00;Path=/;Secure;HttpOnly;",
+ "userid=483d71e5ce65e6a6689a0e95adb3e2c5ff75ca5582c2f13d669e9213c0eeb9771a6923bf7c1aa1cef460ebf266f3231d;Path=/;Secure;HttpOnly;",
+ "iv=6023331f67beb11c89bb515f87580a6a;Path=/;Secure;HttpOnly;",
+ "tag=3877a0be20e70900c0ffb6b620e70900;Path=/;Secure;HttpOnly;",
+ "AuthByPasswd=crypt:36c319e11b69d1853c6d7070d3da33ec9f3194c840cbbb578f9690a4e9baf7da;Path=/;Secure;HttpOnly;",
+ "redirectCookie=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure;HttpOnly;",
+ ])
+ else
+ response.status_code = 401
+ response << "bad password"
+ end
+ end
+
+ # expect a complete poll
+ expect_http_request do |request, response|
+ if request.path == "/Device"
+ response.status_code = 200
+ response << full_query
+ else
+ response.status_code = 401
+ response << "badly formatted"
+ end
+ end
+
+ sleep 0.5
+ status[:occupied].should be_false
+ status[:name].should eq "Room1-Sensor"
+ status[:mac].should eq "00107fec2d72"
+
+ # followed by a series of long polls
+ expect_http_request do |request, response|
+ if request.path == "/Device/Longpoll"
+ response.status_code = 200
+ response << %({"Device": {"OccupancySensor": {"IsRoomOccupied": true}}})
+ else
+ response.status_code = 401
+ response << "badly formatted"
+ end
+ end
+
+ sleep 0.5
+ status[:occupied].should be_true
+
+ resp = exec(:get_sensor_details).get.not_nil!
+ resp.should eq({
+ "status" => "normal",
+ "type" => "presence",
+ "value" => 1.0,
+ "last_seen" => resp["last_seen"].as_i64,
+ "mac" => "00107fec2d72",
+ "name" => "Room1-Sensor",
+ "module_id" => "spec_runner",
+ "binding" => "presence",
+ "location" => "sensor",
+ })
+end
diff --git a/drivers/crestron/series4.cr b/drivers/crestron/series4.cr
new file mode 100644
index 00000000000..762b1dc0c4f
--- /dev/null
+++ b/drivers/crestron/series4.cr
@@ -0,0 +1,47 @@
+require "placeos-driver"
+require "./cres_next_auth"
+
+class Crestron::Series4 < PlaceOS::Driver
+ include Crestron::CresNextAuth
+
+ descriptive_name "Crestron Series4 Controller"
+ generic_name :AVController
+
+ uri_base "https://192.168.0.5"
+
+ default_settings({
+ username: "admin",
+ password: "admin",
+
+ http_keep_alive_seconds: 600,
+ http_max_requests: 1200,
+ })
+
+ getter last_update : Int64 = 0_i64
+ getter poll_counter : UInt64 = 0_u64
+
+ @time_zone : Time::Location = Time::Location.load("UTC")
+
+ def on_update
+ time_zone = setting?(String, :calendar_time_zone).presence || config.control_system.not_nil!.timezone.presence
+ @time_zone = Time::Location.load(time_zone) if time_zone
+ end
+
+ def connected
+ schedule.every(10.minutes, immediate: true) { authenticate }
+ schedule.every(1.hour, immediate: true) { get_device_info }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def get_device_info : Nil
+ response = get("/Device/DeviceInfo/")
+ raise "unexpected response code: #{response.status_code}" unless response.success?
+
+ payload = JSON.parse(response.body)
+ self[:last_updated] = Time.local(@time_zone)
+ self[:info] = payload.dig("Device", "DeviceInfo")
+ end
+end
diff --git a/drivers/crestron/series4_spec.cr b/drivers/crestron/series4_spec.cr
new file mode 100644
index 00000000000..8b8444cd7e1
--- /dev/null
+++ b/drivers/crestron/series4_spec.cr
@@ -0,0 +1,56 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Crestron::OccupancySensor" do
+ full_query = %({
+ "Device":{
+ "DeviceInfo":{
+ "Model":"",
+ "Category":"",
+ "Manufacturer":"Crestron",
+ "DeviceId":"TSID or UUID",
+ "SerialNumber":"12345",
+ "Name":"Friendly Name",
+ "DeviceVersion":"1.2.3",
+ "PufVersion":"1.3454.00040.001",
+ "BuildDate":"May 13 2016",
+ "DeviceKey":"54857",
+ "MacAddress":"",
+ "RebootReason": "poweron",
+ "Version": "2.1.0"
+ }
+ }
+ })
+
+ # expect authentication
+ expect_http_request do |request, response|
+ data = request.body.try(&.gets_to_end)
+ if data == "login=admin&passwd=admin"
+ response.status_code = 200
+ response.headers.add("Set-Cookie", [
+ "userstr=61646d696e00;Path=/;Secure;HttpOnly;",
+ "userid=483d71e5ce65e6a6689a0e95adb3e2c5ff75ca5582c2f13d669e9213c0eeb9771a6923bf7c1aa1cef460ebf266f3231d;Path=/;Secure;HttpOnly;",
+ "iv=6023331f67beb11c89bb515f87580a6a;Path=/;Secure;HttpOnly;",
+ "tag=3877a0be20e70900c0ffb6b620e70900;Path=/;Secure;HttpOnly;",
+ "AuthByPasswd=crypt:36c319e11b69d1853c6d7070d3da33ec9f3194c840cbbb578f9690a4e9baf7da;Path=/;Secure;HttpOnly;",
+ "redirectCookie=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure;HttpOnly;",
+ ])
+ else
+ response.status_code = 401
+ response << "bad password"
+ end
+ end
+
+ # expect a complete poll
+ expect_http_request do |request, response|
+ if request.path == "/Device/DeviceInfo/"
+ response.status_code = 200
+ response << full_query
+ else
+ response.status_code = 401
+ response << "badly formatted"
+ end
+ end
+
+ sleep 200.milliseconds
+ status[:info]["Manufacturer"].should eq "Crestron"
+end
diff --git a/drivers/crestron/virtual_switcher.cr b/drivers/crestron/virtual_switcher.cr
new file mode 100644
index 00000000000..2769631b826
--- /dev/null
+++ b/drivers/crestron/virtual_switcher.cr
@@ -0,0 +1,196 @@
+require "placeos-driver"
+require "placeos-driver/interface/switchable"
+require "placeos-driver/interface/muteable"
+require "./nvx_models"
+
+class Crestron::VirtualSwitcher < PlaceOS::Driver
+ descriptive_name "Crestron Virtual Switcher"
+ generic_name :Switcher
+ description <<-DESC
+ Enumerates the Creston Transmitters and Receivers in a system and provides
+ a simple interface for switching between avaiable streams
+ DESC
+
+ include Interface::Switchable(String, Int32 | String)
+ include Interface::Muteable
+
+ default_settings({
+ audio_sink: {
+ module_id: "Mixer_1",
+ function_name: "set_string",
+ arguments: ["aes67_control_id"],
+ named_args: {} of String => JSON::Any,
+ },
+ })
+
+ class AudioSink
+ include JSON::Serializable
+
+ getter module_id : String
+ getter function_name : String
+ getter arguments : Array(JSON::Any) { [] of JSON::Any }
+ getter named_args : Hash(String, JSON::Any) { {} of String => JSON::Any }
+ end
+
+ @audio : AudioSink? = nil
+
+ def on_update
+ @audio = setting?(AudioSink, :audio_sink)
+ end
+
+ # dummy to supress errors in routing
+ def power(state : Bool)
+ state
+ end
+
+ protected def switch_audio_to(address : JSON::Any?)
+ return unless address
+ if sink = @audio
+ args = sink.arguments + [address]
+ system[sink.module_id].__send__(sink.function_name, args, sink.named_args)
+ end
+ end
+
+ def transmitters
+ system.implementing(Crestron::Transmitter)
+ end
+
+ def receivers
+ system.implementing(Crestron::Receiver)
+ end
+
+ protected def get_streams(input : Input, layer : SwitchLayer = SwitchLayer::All)
+ if int_input = input.to_i?
+ if int_input == 0
+ {"none", JSON::Any.new("")} # disconnected
+ else
+ # Subtract one as Encoder_1 on the system would be encoder[0] here
+ if tx = transmitters[int_input - 1]?
+ {tx[:stream_name], tx[:nax_address]?}
+ else
+ logger.warn { "could not find Encoder_#{input}" }
+ nil
+ end
+ end
+ else
+ return {input, nil} if layer.video?
+ if tx = transmitters.find { |sender| sender[:stream_name]? == input }
+ {input, tx[:nax_address]?}
+ else
+ {input, nil}
+ end
+ end
+ rescue ex
+ logger.warn { "could not find Encoder_#{input}, due to '#{ex.message}'" }
+ {input, nil}
+ end
+
+ # only support muting the outputs, no unmuting
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ return unless state
+ switch_layer = case layer
+ in MuteLayer::Audio then SwitchLayer::Audio
+ in MuteLayer::Video then SwitchLayer::Video
+ in MuteLayer::AudioVideo then SwitchLayer::All
+ end
+ switch({"none" => [index]}, switch_layer)
+ end
+
+ def switch_to(input : Input)
+ # lookup the input stream
+ stream = get_streams(input)
+ return unless stream
+
+ switch_audio_to stream[1]
+ receivers.switch_to(stream[0])
+ end
+
+ def available_inputs
+ encoder_name_map.keys
+ end
+
+ def available_outputs
+ decoder_name_map.keys
+ end
+
+ protected def decoder_name_map
+ name_map = {} of String => PlaceOS::Driver::Proxy::Driver
+ # map-reduce for speed
+ Promise.all(receivers.map { |rx|
+ Promise.defer { name_map[rx["device_name"].as_s] = rx rescue nil }
+ }).get
+ name_map
+ end
+
+ protected def encoder_name_map
+ name_map = {} of String => PlaceOS::Driver::Proxy::Driver
+ # map-reduce for speed
+ Promise.all(transmitters.map { |tx|
+ Promise.defer { name_map[tx["stream_name"].as_s] = tx rescue nil }
+ }).get
+ name_map
+ end
+
+ DUMMY_OUTPUT = [] of Int32
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ layer ||= SwitchLayer::All
+
+ return unless layer.all? || layer.video? || layer.audio?
+
+ logger.debug { "switching #{layer}: #{map}" }
+
+ connect(map, layer) do |mod, (video, audio)|
+ case layer
+ in .all?
+ switch_audio_to audio
+ mod.switch_to(video)
+ in .audio?
+ switch_audio_to audio
+ in .video?
+ # the NVX RX implements this interface but the output is ignored
+ inp = case video
+ in JSON::Any
+ video.as_s? || video.as_i
+ in String
+ video
+ end
+ mod.switch({inp => DUMMY_OUTPUT}, layer)
+ in .data?, .data2?
+ end
+ end
+ end
+
+ private def connect(inouts : Hash(Input, Array(Output)), layer : SwitchLayer, &)
+ inouts.each do |input, outputs|
+ stream = get_streams(input, layer)
+ next unless stream
+
+ outputs = outputs.is_a?(Array) ? outputs : [outputs]
+ decoders = receivers
+ device_names = nil
+ outputs.each do |output|
+ case output
+ in Int32
+ # Subtract one as Decoder_1 on the system would be decoder[0] here
+ if decoder = decoders[output - 1]?
+ yield(decoder, stream)
+ else
+ logger.warn { "could not find Decoder_#{output}" }
+ end
+ in String
+ device_names = decoder_name_map unless device_names
+ if decoder = device_names[output]?
+ yield(decoder, stream)
+ else
+ logger.warn { "could not find Decoder with name: #{output}" }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/delta/api.cr b/drivers/delta/api.cr
new file mode 100644
index 00000000000..a281299d347
--- /dev/null
+++ b/drivers/delta/api.cr
@@ -0,0 +1,113 @@
+require "placeos-driver"
+require "./models/**"
+
+class Delta::API < PlaceOS::Driver
+ descriptive_name "Delta API Gateway"
+ generic_name :Delta
+ uri_base "https://example.delta.io"
+
+ default_settings({
+ basic_auth: {
+ username: "srvc_acct",
+ password: "password!",
+ },
+ user_agent: "PlaceOS",
+ debug: false,
+ })
+
+ @user_agent : String = "PlaceOS"
+ @debug : Bool = false
+
+ def on_update
+ @user_agent = setting?(String, :user_agent) || "PlaceOS"
+ @debug = setting?(Bool, :debug) || false
+ end
+
+ private def fetch(path : String, skip : Int32 = 0, max_results : Int32 = 1000)
+ logger.debug { config.uri } if @debug
+ request = "#{path}?alt=json&skip=#{skip}&max-results=#{max_results}"
+ logger.debug { request } if @debug
+
+ response = get(request, headers: HTTP::Headers{
+ "User-Agent" => @user_agent,
+ "Accept" => "*/*",
+ })
+
+ logger.debug { response.headers } if @debug
+ logger.debug { response.body } if @debug
+
+ response
+ end
+
+ # list all sites
+ def list_sites
+ response = Models::ListSitesResponse.from_json(fetch("/api/.bacnet").body)
+ response.json_unmapped.keys
+ end
+
+ # list devices for site
+ def list_devices(site_name : String)
+ skip = 0
+ devices = [] of Models::Device
+ path = URI.encode_path("/api/.bacnet/#{site_name}")
+
+ loop do
+ response = fetch(path, skip)
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ # returns this when there are no more results
+ # {"Collection":""}
+
+ body = Models::ListDevicesBySiteNameResponse.from_json(response.body)
+ body.json_unmapped.keys.each do |key|
+ value = body.json_unmapped[key].as_h
+ devices.push(Models::Device.new(id: key.to_u32, base: value["$base"].to_s, node_type: value["nodeType"].to_s, display_name: value["displayName"].to_s))
+ end
+
+ break unless body.next_req.presence
+ skip += 1000
+ end
+
+ devices
+ end
+
+ # list objects from device resource
+ def list_device_objects(site_name : String, device_number : String | UInt32)
+ skip = 0
+ objects = [] of Models::Object
+ path = URI.encode_path("/api/.bacnet/#{site_name}/#{device_number}")
+
+ loop do
+ response = fetch(path, skip)
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ body = Models::ListObjectsByDeviceNumber.from_json(response.body)
+ body.json_unmapped.each do |key, obj|
+ value = obj.as_h
+ object_type, instance = key.split(',', 2)
+ objects.push(Models::Object.new(object_type, instance, base: value["$base"].to_s, display_name: value["displayName"].to_s))
+ end
+
+ break unless body.next_req.presence
+ skip += 1000
+ end
+
+ objects
+ end
+
+ # get value of property from object through instance
+ def get_object_value(site_name : String, device_number : String | UInt32, object_type : String, instance : String | UInt32)
+ path = URI.encode_path("/api/.bacnet/#{site_name}/#{device_number}/#{object_type},#{instance}")
+
+ response = fetch(path)
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ Models::ValueProperty.from_json(response.body)
+ end
+end
diff --git a/drivers/delta/api_spec.cr b/drivers/delta/api_spec.cr
new file mode 100644
index 00000000000..61e7a8e037a
--- /dev/null
+++ b/drivers/delta/api_spec.cr
@@ -0,0 +1,226 @@
+require "placeos-driver/spec"
+require "./models/**"
+
+DriverSpecs.mock_driver "Delta::API" do
+ # List sites
+
+ list_sites = exec :list_sites
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/.bacnet?alt=json&skip=0&max-results=1000"
+ response.status_code = 200
+ response << %({
+ "$base": "Collection",
+ "nodeType": "PROTOCOL",
+ "Random Name": {
+ "$base": "Collection",
+ "nodeType": "NETWORK",
+ "truncated": "true"
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected token request"
+ end
+ end
+
+ sites = Array(String).from_json(list_sites.get.not_nil!.to_json)
+ sites.first.should eq "Random Name"
+
+ # List devices by site name
+
+ list_devices_by_site_name = exec(:list_devices, "Random Name")
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/.bacnet/Random%20Name?alt=json&skip=0&max-results=1000"
+ response.status_code = 200
+ response << %({
+ "$base": "Collection",
+ "251": {
+ "$base": "Collection",
+ "displayName": "Test",
+ "nodeType": "DEVICE",
+ "truncated": "true"
+ },
+ "200": {
+ "$base": "Collection",
+ "displayName": "Test",
+ "nodeType": "DEVICE",
+ "truncated": "true"
+ },
+ "253": {
+ "$base": "Collection",
+ "displayName": "Test",
+ "nodeType": "DEVICE",
+ "truncated": "true"
+ },
+ "254": {
+ "$base": "Collection",
+ "displayName": "Test",
+ "nodeType": "DEVICE",
+ "truncated": "true"
+ },
+ "260": {
+ "$base": "Collection",
+ "displayName": "Test",
+ "nodeType": "DEVICE",
+ "truncated": "true"
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected token request"
+ end
+ end
+
+ devices = Array(Delta::Models::Device).from_json(list_devices_by_site_name.get.not_nil!.to_json)
+ devices.first.display_name.should eq "Test"
+
+ # List objects by device number
+ list_objects_by_device_number = exec(:list_device_objects, "Random Name", "200")
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/.bacnet/Random%20Name/200?alt=json&skip=0&max-results=1000"
+ response.status_code = 200
+ response << %({
+ "$base": "Collection",
+ "nodeType": "Device",
+ "analog-input,83": {
+ "$base": "Object",
+ "displayName": "CPU Board Temperature",
+ "truncated": "true"
+ },
+ "analog-input,84": {
+ "$base": "Object",
+ "displayName": "APU Board Temperature",
+ "truncated": "true"
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected token request"
+ end
+ end
+
+ devices = Array(Delta::Models::Object).from_json(list_objects_by_device_number.get.not_nil!.to_json)
+ devices.first.display_name.should eq "CPU Board Temperature"
+
+ # Get value property by object type through instance
+ get_value_property_by_object_type_through_instance = exec(:get_object_value, "Random Name", "200", "A", 3)
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/.bacnet/Random%20Name/200/A%2C3?alt=json&skip=0&max-results=1000"
+ response.status_code = 200
+ response << %({
+ "$base": "Object",
+ "displayName": "DEL1__AI1_85",
+ "object-identifier": {
+ "$base": "ObjectIdentifier",
+ "value": "del,1"
+ },
+ "object-type": {
+ "$base": "Enumerated",
+ "value": "data-exchange-local-data"
+ },
+ "object-name": {
+ "$base": "String",
+ "value": "DEL1__AI1_85"
+ },
+ "exchange-flags": {
+ "$base": "BitString",
+ "value": ""
+ },
+ "exchange-type": {
+ "$base": "Enumerated",
+ "value": "optimized-broadcast"
+ },
+ "last-error": {
+ "$base": "Signed",
+ "value": 0
+ },
+ "local-ref": {
+ "$base": "Sequence",
+ "type": "0-BACnetDeviceObjectPropertyReference",
+ "deviceIdentifier": {
+ "$base": "ObjectIdentifier",
+ "value": "device,251"
+ },
+ "objectIdentifier": {
+ "$base": "ObjectIdentifier",
+ "value": "analog-input,1"
+ },
+ "propertyIdentifier": {
+ "$base": "Enumerated",
+ "value": "present-value",
+ "type": "0-BACnetPropertyIdentifier"
+ }
+ },
+ "local-flags": {
+ "$base": "BitString",
+ "value": "not-commissioned"
+ },
+ "local-value": {
+ "$base": "Choice",
+ "real": {
+ "$base": "Real",
+ "value": "1"
+ }
+ },
+ "subscribers": {
+ "$base": "Array",
+ "1": {
+ "$base": "Sequence",
+ "subscriber": {
+ "$base": "Choice",
+ "device": {
+ "$base": "ObjectIdentifier",
+ "value": "200"
+ }
+ },
+ "id": {
+ "$base": "Unsigned",
+ "value": 0
+ },
+ "useConfirmed": {
+ "$base": "Boolean",
+ "value": 0
+ },
+ "flags": {
+ "$base": "BitString",
+ "value": ""
+ },
+ "refreshTimer": {
+ "$base": "Choice",
+ "refreshTimer": {
+ "$base": "Unsigned",
+ "value": "478568103"
+ }
+ }
+ }
+ },
+ "last-sent": {
+ "$base": "Unsigned",
+ "value": 0
+ },
+ "send-frequency": {
+ "$base": "Unsigned",
+ "value": 0
+ },
+ "cov-increment": {
+ "$base": "Real",
+ "value": 0
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected token request"
+ end
+ end
+
+ value_property = Delta::Models::ValueProperty.from_json(get_value_property_by_object_type_through_instance.get.not_nil!.to_json)
+ value_property.display_name.should eq "DEL1__AI1_85"
+end
diff --git a/drivers/delta/models/device.cr b/drivers/delta/models/device.cr
new file mode 100644
index 00000000000..9582479d437
--- /dev/null
+++ b/drivers/delta/models/device.cr
@@ -0,0 +1,24 @@
+require "json"
+
+module Delta
+ module Models
+ struct Device
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : UInt32
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "nodeType")]
+ property node_type : String
+
+ @[JSON::Field(key: "displayName")]
+ property display_name : String
+
+ def initialize(@id : UInt32, @base : String, @node_type : String, @display_name : String)
+ end
+ end
+ end
+end
diff --git a/drivers/delta/models/generic_value.cr b/drivers/delta/models/generic_value.cr
new file mode 100644
index 00000000000..cd0292ed0c8
--- /dev/null
+++ b/drivers/delta/models/generic_value.cr
@@ -0,0 +1,15 @@
+require "json"
+
+module Delta
+ module Models
+ struct GenericValue
+ include JSON::Serializable
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "value")]
+ property value : JSON::Any
+ end
+ end
+end
diff --git a/drivers/delta/models/list_devices_by_site_name_response.cr b/drivers/delta/models/list_devices_by_site_name_response.cr
new file mode 100644
index 00000000000..6eabb088393
--- /dev/null
+++ b/drivers/delta/models/list_devices_by_site_name_response.cr
@@ -0,0 +1,20 @@
+require "json"
+
+module Delta
+ module Models
+ struct ListDevicesBySiteNameResponse
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ @[JSON::Field(key: "$base")]
+ property base : String? = nil
+
+ # returns this when there are no more results
+ @[JSON::Field(key: "Collection")]
+ property collection : String? = nil
+
+ @[JSON::Field(key: "next")]
+ property next_req : String? = nil
+ end
+ end
+end
diff --git a/drivers/delta/models/list_objects_by_device_number_response.cr b/drivers/delta/models/list_objects_by_device_number_response.cr
new file mode 100644
index 00000000000..84c04bbd05b
--- /dev/null
+++ b/drivers/delta/models/list_objects_by_device_number_response.cr
@@ -0,0 +1,19 @@
+require "json"
+
+module Delta
+ module Models
+ struct ListObjectsByDeviceNumber
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "nodeType")]
+ property node_type : String
+
+ @[JSON::Field(key: "next")]
+ property next_req : String? = nil
+ end
+ end
+end
diff --git a/drivers/delta/models/list_sites_response.cr b/drivers/delta/models/list_sites_response.cr
new file mode 100644
index 00000000000..171a0b942d7
--- /dev/null
+++ b/drivers/delta/models/list_sites_response.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module Delta
+ module Models
+ struct ListSitesResponse
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "nodeType")]
+ property node_type : String
+ end
+ end
+end
diff --git a/drivers/delta/models/local_value.cr b/drivers/delta/models/local_value.cr
new file mode 100644
index 00000000000..0aaba970812
--- /dev/null
+++ b/drivers/delta/models/local_value.cr
@@ -0,0 +1,15 @@
+require "json"
+
+module Delta
+ module Models
+ struct LocalValue
+ include JSON::Serializable
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "real")]
+ property real : GenericValue
+ end
+ end
+end
diff --git a/drivers/delta/models/object.cr b/drivers/delta/models/object.cr
new file mode 100644
index 00000000000..65821d5c618
--- /dev/null
+++ b/drivers/delta/models/object.cr
@@ -0,0 +1,22 @@
+require "json"
+
+module Delta
+ module Models
+ struct Object
+ include JSON::Serializable
+
+ property object_type : String
+ property instance : UInt32
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "displayName")]
+ property display_name : String
+
+ def initialize(@object_type : String, instance : String, @base : String, @display_name : String)
+ @instance = instance.to_u32
+ end
+ end
+ end
+end
diff --git a/drivers/delta/models/property_identifier.cr b/drivers/delta/models/property_identifier.cr
new file mode 100644
index 00000000000..1735720feac
--- /dev/null
+++ b/drivers/delta/models/property_identifier.cr
@@ -0,0 +1,18 @@
+require "json"
+
+module Delta
+ module Models
+ struct PropertyIdentifier
+ include JSON::Serializable
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "value")]
+ property value : JSON::Any
+
+ @[JSON::Field(key: "type")]
+ property type : String
+ end
+ end
+end
diff --git a/drivers/delta/models/reference.cr b/drivers/delta/models/reference.cr
new file mode 100644
index 00000000000..d4ef5f6c822
--- /dev/null
+++ b/drivers/delta/models/reference.cr
@@ -0,0 +1,24 @@
+require "json"
+
+module Delta
+ module Models
+ struct Reference
+ include JSON::Serializable
+
+ @[JSON::Field(key: "$base")]
+ property base : String
+
+ @[JSON::Field(key: "type")]
+ property type : String
+
+ @[JSON::Field(key: "deviceIdentifier")]
+ property device_identifier : GenericValue
+
+ @[JSON::Field(key: "objectIdentifier")]
+ property object_identifier : GenericValue
+
+ @[JSON::Field(key: "propertyIdentifier")]
+ property property_identifier : PropertyIdentifier
+ end
+ end
+end
diff --git a/drivers/delta/models/value_property.cr b/drivers/delta/models/value_property.cr
new file mode 100644
index 00000000000..d373ef5ee5d
--- /dev/null
+++ b/drivers/delta/models/value_property.cr
@@ -0,0 +1,79 @@
+require "./**"
+require "json"
+
+module Delta
+ module Models
+ struct ValueProperty
+ include JSON::Serializable
+
+ @[JSON::Field(key: "$base")]
+ property base : String?
+
+ @[JSON::Field(key: "displayName")]
+ property display_name : String?
+
+ @[JSON::Field(key: "object-identifier")]
+ property object_identifier : GenericValue?
+
+ @[JSON::Field(key: "object-type")]
+ property object_type : GenericValue?
+
+ @[JSON::Field(key: "object-name")]
+ property object_name : GenericValue?
+
+ @[JSON::Field(key: "exchange-flags")]
+ property exchange_flags : GenericValue?
+
+ @[JSON::Field(key: "exchange-type")]
+ property exchange_type : GenericValue?
+
+ @[JSON::Field(key: "last-error")]
+ property last_error : GenericValue?
+
+ @[JSON::Field(key: "local-ref")]
+ property local_ref : Reference?
+
+ @[JSON::Field(key: "local-flags")]
+ property local_flags : GenericValue?
+
+ @[JSON::Field(key: "local-value")]
+ property local_flags : LocalValue?
+
+ @[JSON::Field(key: "subscribers")]
+ property subscribers : Hash(String, JSON::Any)?
+
+ @[JSON::Field(key: "last-sent")]
+ property last_sent : GenericValue?
+
+ @[JSON::Field(key: "send-frequency")]
+ property send_frequency : GenericValue?
+
+ @[JSON::Field(key: "cov-increment")]
+ property cov_increment : GenericValue?
+
+ @[JSON::Field(key: "present-value")]
+ property present_value : GenericValue?
+
+ @[JSON::Field(key: "status-flags")]
+ property status_flags : GenericValue?
+
+ @[JSON::Field(key: "event-state")]
+ property event_state : GenericValue?
+
+ @[JSON::Field(key: "out-of-service")]
+ property out_of_service : GenericValue?
+
+ @[JSON::Field(key: "present-value")]
+ property present_value : GenericValue?
+
+ @[JSON::Field(key: "units")]
+ property units : GenericValue?
+
+ @[JSON::Field(key: "description")]
+ property description : GenericValue?
+
+ @[JSON::Field(key: "reliability")]
+ property reliability : GenericValue?
+ end
+ end
+end
diff --git a/drivers/delta/uno_next.cr b/drivers/delta/uno_next.cr
new file mode 100644
index 00000000000..3506c10936c
--- /dev/null
+++ b/drivers/delta/uno_next.cr
@@ -0,0 +1,196 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "./models/**"
+
+# documentation: https://isdweb.deltaww.com/resources/files/UNOnext_bacnet_user_guide.pdf
+
+class Delta::UNOnext < PlaceOS::Driver
+ include Interface::Sensor
+
+ descriptive_name "Delta UNOnext Indoor Air Monitor"
+ generic_name :UNOnext
+ description %(collects sensor data from UNOnext sensors)
+
+ default_settings({
+ site_name: "My Office",
+ manager_mappings: [{
+ building_zone: "zone_id_here",
+ level_zone: "zone_id_here",
+ managers: [107100, 107300],
+ }],
+ # seconds between polling
+ poll_every: 10,
+ })
+
+ accessor delta_api : Delta_1
+
+ record ManMap, building_zone : String, level_zone : String, managers : Array(UInt32) do
+ include JSON::Serializable
+ end
+
+ def on_update
+ @site_name = setting(String, :site_name)
+ @manager_mappings = setting(Array(ManMap), :manager_mappings)
+
+ poll_every = setting?(Int32, :poll_every) || 10
+
+ @cached_data = Hash(String, Array(Detail)).new { |hash, key| hash[key] = [] of Detail }
+ schedule.clear
+ schedule.every(poll_every.seconds) { cache_sensor_data }
+ end
+
+ getter site_name : String = "My Office"
+ getter manager_mappings : Array(ManMap) = [] of ManMap
+ getter cached_data : Hash(String, Array(Detail)) = {} of String => Array(Detail)
+
+ # ===================================
+ # Sensor Interface functions
+ # ===================================
+ def sensor(mac : String, id : String? = nil) : Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id && mac.starts_with?("unonext-")
+
+ device_id = mac.lchop("unonext-").to_u32?
+ index = id.to_u32?
+ return nil unless device_id && index
+
+ build_sensor_details(device_id, index)
+ rescue error
+ logger.warn(exception: error) { "checking for sensor" }
+ nil
+ end
+
+ SENSOR_TYPES = {
+ 0 => SensorType::Temperature,
+ 1 => SensorType::Humidity,
+ 2 => SensorType::AirQuality, # PM2.5 (particles smaller than 2.5)
+ 4 => SensorType::PPM, # CO2
+ 5 => SensorType::Illuminance,
+ # 9 => SensorType::PPM, # O3
+ }
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ # skip processing where possible
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.values.includes?(sensor_type)
+ end
+
+ if mac
+ return NO_MATCH unless mac.starts_with?("unonext-")
+ end
+
+ # grab the relevant values
+ result = if zone_id
+ cached_data[zone_id]? || [] of Detail
+ else
+ manager_mappings.flat_map do |man_map|
+ cached_data[man_map.level_zone]? || [] of Detail
+ end
+ end
+
+ # filter them based on the request
+ if sensor_type && mac
+ result.reject! { |details| details.type != sensor_type || details.mac != mac }
+ elsif sensor_type
+ result.reject! { |details| details.type != sensor_type }
+ elsif mac
+ result.reject! { |details| details.mac != mac }
+ end
+
+ result
+ end
+
+ # ===================================
+ # Helper functions
+ # ===================================
+
+ protected def build_sensor_details(device_id : UInt32, index : UInt32, building : String? = nil, level : String? = nil) : Detail?
+ prop = Models::ValueProperty.from_json delta_api.get_object_value(@site_name, device_id, "analog-value", index).get.to_json
+ return nil if (prop.out_of_service.try(&.value.as_i?) || 1) != 0
+
+ value = prop.present_value.try do |pv|
+ if string = pv.value.as_s?
+ string.to_f?
+ elsif int = pv.value.as_i?
+ int.to_f
+ end
+ end
+ return nil unless value
+
+ case prop.units.try &.value
+ when "°C"
+ unit = "Cel"
+ sensor = SensorType::Temperature
+ when "%RH"
+ sensor = SensorType::Humidity
+ when "µg/m³"
+ sensor = SensorType::AirQuality
+ when "lx"
+ unit = "lx"
+ sensor = SensorType::Illuminance
+ when "ppm"
+ modifier = "CO2"
+ sensor = SensorType::PPM
+ end
+ return nil unless sensor
+
+ Detail.new(
+ modifier: modifier,
+ type: sensor,
+ value: value,
+ last_seen: Time.utc.to_unix,
+ mac: "unonext-#{device_id}",
+ id: index.to_s,
+ name: "UNONext #{device_id}.#{index} #{prop.display_name} #{prop.units.try &.value}",
+ binding: "#{device_id}.#{index}",
+ module_id: module_id,
+ unit: unit,
+ building: building,
+ level: level,
+ )
+ rescue error
+ logger.warn(exception: error) { "error requesting object value from #{device_id}.#{index}" }
+ nil
+ end
+
+ NO_OBJECTS = [] of Models::Object
+
+ protected def cache_sensor_data : Nil
+ logger.debug { "caching sensor data" }
+
+ local_cache = Hash(String, Array(Detail)).new { |hash, key| hash[key] = [] of Detail }
+
+ # grab all the UNONext manager objects
+ site = site_name
+ all_objects = manager_mappings.each do |man_map|
+ man_map.managers.each do |id|
+ begin
+ Array(Models::Object).from_json(delta_api.list_device_objects(site, id).get.to_json)
+ .select(&.display_name.includes?("UnoNext"))
+ .each do |object|
+ # skip the data points we don't care about
+ next if object.display_name.includes?("PM10")
+ next if object.display_name.includes?("TVOC")
+
+ if details = build_sensor_details(id, object.instance, man_map.building_zone, man_map.level_zone)
+ self[details.binding] = details
+
+ local_cache[man_map.building_zone] << details
+ local_cache[man_map.level_zone] << details
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "error requesting objects from manager #{id}" }
+ NO_OBJECTS
+ end
+ end
+ end
+
+ logger.debug { "updating sensor cache" }
+ @cached_data = local_cache
+ end
+end
diff --git a/drivers/delta/uno_next_spec.cr b/drivers/delta/uno_next_spec.cr
new file mode 100644
index 00000000000..0670934b069
--- /dev/null
+++ b/drivers/delta/uno_next_spec.cr
@@ -0,0 +1,5 @@
+require "placeos-driver/spec"
+require "./models/**"
+
+DriverSpecs.mock_driver "Delta::UNOnext" do
+end
diff --git a/drivers/delta/zen_pir_location.cr b/drivers/delta/zen_pir_location.cr
new file mode 100644
index 00000000000..8f05945de81
--- /dev/null
+++ b/drivers/delta/zen_pir_location.cr
@@ -0,0 +1,157 @@
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+
+require "./models/**"
+
+class Delta::ZenPIRLocation < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "Zen PIR Locations"
+ generic_name :PIR_Locations
+ description %(maps zen control pir locations to map areas)
+
+ accessor delta_api : Delta_1
+
+ default_settings({
+ site_name: "My Office",
+ zen_id: 12345,
+ pir_mappings: [{
+ building_zone: "building_zone_id",
+ level_zone: "level_zone_id",
+ pirs: [{
+ pir: 1234,
+ map: "area-1234",
+ }],
+ }],
+ # seconds between polling
+ poll_every: 10,
+ })
+
+ record PIR, pir : UInt32, map : String do
+ include JSON::Serializable
+ end
+
+ record PIRMap, building_zone : String, level_zone : String, pirs : Array(PIR) do
+ include JSON::Serializable
+ end
+
+ def on_update
+ @site_name = setting(String, :site_name)
+ @zen_id = setting(UInt32, :zen_id)
+ @pir_mappings = setting(Array(PIRMap), :pir_mappings)
+
+ poll_every = setting?(Int32, :poll_every) || 10
+
+ @cached_data = Hash(String, Array(Location)).new { |hash, key| hash[key] = [] of Location }
+ schedule.clear
+ schedule.every(poll_every.seconds) { cache_sensor_data }
+ end
+
+ getter site_name : String = "My Office"
+ getter zen_id : UInt32 = 1234_u32
+ getter pir_mappings : Array(PIRMap) = [] of PIRMap
+ getter cached_data : Hash(String, Array(Location)) = {} of String => Array(Location)
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "sensor incapable of tracking #{mac_address}" }
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+ return [] of Location if location.presence && location != "area"
+ @cached_data[zone_id]? || [] of Location
+ end
+
+ # ===================================
+ # Caching functions
+ # ===================================
+
+ struct Location
+ include JSON::Serializable
+
+ getter location : Symbol = :area
+ property level : String
+ property map_id : String
+ property area_id : String
+ property capacity : Int32
+ property at_location : Int32
+
+ property zen_device_id : UInt32
+ property zen_object_id : UInt32
+
+ def initialize(
+ @level, @map_id, @area_id, @capacity, @at_location,
+ @zen_device_id, @zen_object_id
+ )
+ end
+ end
+
+ protected def cache_sensor_data : Nil
+ logger.debug { "caching sensor data" }
+
+ # grab all the zen pir objects
+ site = site_name
+ device_id = zen_id
+ cached_count = 0
+ cached_data = Hash(String, Array(Location)).new { |hash, key| hash[key] = [] of Location }
+
+ all_objects = pir_mappings.each do |pir_map|
+ pir_map.pirs.each do |pir|
+ begin
+ prop = Models::ValueProperty.from_json delta_api.get_object_value(site, device_id, "binary-value", pir.pir).get.to_json
+ next if (prop.out_of_service.try(&.value.as_i?) || 1) != 0
+
+ state = prop.present_value.try do |pv|
+ if string = pv.value.as_s?
+ string.downcase
+ end
+ end
+
+ next unless state.presence
+ at_location = case state
+ when "inactive", "off"
+ 0
+ when "active", "on"
+ 1
+ else
+ logger.warn { "unexpected PIR value: #{state} for object #{pir.pir}.#{device_id}" }
+ next
+ end
+
+ loc = Location.new(
+ level: pir_map.level_zone,
+ area_id: pir.map,
+ map_id: pir.map,
+ capacity: 1,
+ at_location: at_location,
+ zen_device_id: device_id,
+ zen_object_id: pir.pir
+ )
+
+ cached_data[pir_map.building_zone] << loc
+ cached_data[pir_map.level_zone] << loc
+ cached_count += 1
+ rescue error
+ logger.warn(exception: error) { "error requesting object #{pir.pir} from zen #{device_id}" }
+ end
+ end
+ end
+
+ @cached_data = cached_data
+ logger.debug { "cached #{cached_count} PIR objects" }
+ end
+end
diff --git a/drivers/delta/zen_pir_location_spec.cr b/drivers/delta/zen_pir_location_spec.cr
new file mode 100644
index 00000000000..8b3235e1e41
--- /dev/null
+++ b/drivers/delta/zen_pir_location_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Delta::ZenPIRLocation" do
+end
diff --git a/drivers/denon/amplifier/av_receiver.cr b/drivers/denon/amplifier/av_receiver.cr
new file mode 100644
index 00000000000..d2d98469ced
--- /dev/null
+++ b/drivers/denon/amplifier/av_receiver.cr
@@ -0,0 +1,216 @@
+require "digest/md5"
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+#
+module Denon; end
+
+module Denon::Amplifier; end
+
+# Protocol: https://aca.im/driver_docs/Denon/Denon%20AVR%20PROTOCOL%20V7.5.0.pdf
+#
+# NOTE:: Denon doesn't respond to commands that request the current state
+# (ie if the volume is 100 and you request 100 it will not respond)
+#
+
+class Denon::Amplifier::AvReceiver < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::Powerable
+ include PlaceOS::Driver::Utilities::Transcoder
+
+ @channel : Channel(String) = Channel(String).new
+ @stable_power : Bool = true
+
+ COMMANDS = {
+ power: :PW,
+ power_query: :PW?,
+ mute: :MU,
+ mute_query: :MU?,
+ volume: :MV,
+ volume_query: :MV?,
+ input: :SI,
+ input_query: :SI?,
+ }
+ COMMANDS.to_h.merge!(COMMANDS.to_h.invert)
+
+ @volume_range = 0..196
+
+ default_settings({
+ max_waits: 10,
+ timeout: 3000,
+ })
+ # Discovery Information
+ tcp_port 23 # Telnet
+ descriptive_name "Denon AVR (Switcher Amplifier)"
+ generic_name :Switcher
+
+ # Denon requires some breathing room
+ # delay between_sends: 30
+ # delay on_receive: 30
+
+ def on_load
+ # transport.tokenizer = Tokenizer.new(Bytes[0x0D])
+ transport.tokenizer = Tokenizer.new("\r")
+ self[:volume_min] = 0
+ self[:volume_max] = @volume_range.max # == 98 * 2 - Times by 2 so we can account for the half steps
+ on_update
+ end
+
+ def on_update
+ self[:max_waits] = 10
+ self[:timeout] = 3000
+ end
+
+ def connected
+ #
+ # Get state
+ #
+ # power?
+ # input?
+ # mute?
+
+ schedule.every(60.seconds) do
+ logger.info { "-- Polling Denon AVR" }
+ power?
+ do_send(:input, priority: 0, name: :input)
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool = false)
+ # self[:power] is current as we would be informed otherwise
+ if state && (self[:power] == "OFF" || self[:power] == "STANDBY") # Request to power on if off
+ do_send(:power, "ON", delay: 3.milliseconds, name: :power) # Manual states delay for 1 second, just to be safe
+ elsif !state && self[:power] == "ON" # Request to power off if on
+ do_send(:power, "STANDBY", delay: 3.milliseconds, name: :power)
+ end
+ end
+
+ def power?
+ # def power?(**options)
+ # options[:emit] = {:power => block} unless block.nil?
+ do_send(:power_query, priority: 0, name: :power_query)
+ end
+
+ def mute?
+ self[:mute] = "OFF"
+ do_send(:mute_query, priority: 0, name: :mute_query)
+ end
+
+ def mute(state : Bool = true)
+ req = state ? "ON" : "OFF"
+ return if self[:mute] == req
+ do_send(:mute, req, name: :mute)
+ end
+
+ def mute_audio(state : Bool = true)
+ mute state
+ end
+
+ def unmute
+ mute false
+ end
+
+ def unmute_audio
+ unmute
+ end
+
+ def volume(level : Float64 | Int32 = 0)
+ level = level.to_f.clamp(0.0, 100.0)
+ return if self[:volume] == level
+
+ percentage = level / 100.0
+ value = (percentage * @volume_range.end.to_f).round_away.to_i
+
+ # The denon is weird 99 is volume off,
+ # 99.5 is the minimum volume,
+ # 0 is the next lowest volume and 985 is the loudest volume
+ # => So we are treating 99, 995 and 0 as 0
+ step = value % 2
+ actual = value / 2
+ req = actual.to_s.rjust(2, '0')
+ req += "5" if step != 0
+
+ do_send(:volume, req, name: :volume) # Name prevents needless queuing of commands
+ end
+
+ def volume?
+ do_send(:volume_query, priority: 0, name: :volume_query)
+ end
+
+ # Just here for documentation (there are many more)
+ #
+ # INPUTS = [:cd, :tuner, :dvd, :bd, :tv, :"sat/cbl", :dvr, :game, :game2, :"v.aux", :dock]
+ def input(input : String = "")
+ status = input.upcase # .downcase.to_sym
+ if status != self[:input]
+ input = input.to_s.upcase
+ do_send(:input, input, name: :input)
+ end
+ end
+
+ def input?
+ do_send(:input_query, priority: 0, name: :input_query)
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.info { "Denon sent #{data.inspect}" }
+
+ return unless task
+
+ # Process the response
+ cmd = data[0..1] # first 2 chars are the key / command
+ val = data[2..-2] # anything following the above and before \r is a response value
+
+ case cmd
+ when "PW"
+ self[:power] = val
+ when "SI"
+ self[:input] = val
+ when "MV"
+ # return :ignore if val.chars.size > 3 # May send 'MVMAX 98' after volume command
+ # self[:volume] = 0
+ # vol = val.to_i32
+ # self[:volume] = val unless val.to_i32 > @volume_range.max
+ vol_percent = ((val.to_f * 2) / @volume_range.end.to_f) * 100.0
+ self[:volume] = vol_percent
+ # return :ignore if param.length > 3 # May send 'MVMAX 98' after volume command
+ # vol = param[0..1].to_i * 2
+ # vol += 1 if param.length == 3
+ # vol == 0 if vol > @volume_range.max # this means the volume was 99 or 995
+ # self[:volume] = vol
+
+ when "MU"
+ self[:mute] = val
+ else
+ return :ignore
+ end
+
+ task.try &.success
+ end
+
+ protected def do_send(command, param = nil, **options)
+ # prepare the command
+ cmd = if param.nil?
+ "#{COMMANDS[command]}"
+ else
+ "#{COMMANDS[command]}#{param}"
+ end
+ logger.info { "Queing: #{cmd}" }
+
+ # queue the request
+ queue(**({
+ name: command,
+ }.merge(options))) do
+ @channel = Channel(String).new
+ # send the request
+ logger.info { " Sending: #{cmd}" }
+ transport.send(cmd)
+ end
+ end
+end
diff --git a/drivers/denon/amplifier/av_receiver_spec.cr b/drivers/denon/amplifier/av_receiver_spec.cr
new file mode 100644
index 00000000000..fa81e17bd9e
--- /dev/null
+++ b/drivers/denon/amplifier/av_receiver_spec.cr
@@ -0,0 +1,73 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Denon::Amplifier::AvReceiver" do
+ ####
+ # POWER
+ #
+ sleep 1.second
+ # query power
+ exec(:power?)
+ should_send("PW?")
+ responds("PWOFF\r")
+ status[:power].should eq("OFF")
+ # turn power on
+ exec(:power, true)
+ should_send("PWON")
+ responds("PWON\r")
+ status[:power].should eq("ON")
+ # power off turns amp to STANDBY not actually OFF
+ exec(:power, false)
+ should_send("PWSTANDBY")
+ responds("PWSTANDBY\r")
+ status[:power].should eq("STANDBY")
+
+ ####
+ # INPUT
+ #
+ sleep 1.second
+ # query input > DVD
+ exec(:input?)
+ should_send("SI?")
+ responds("SIDVD\r")
+ status[:input].should eq("DVD")
+ # chaange input to tuner
+ exec(:input, "TUNER")
+ should_send("SITUNER")
+ responds("SITUNER\r")
+ status[:input].should eq("TUNER")
+
+ ####
+ # VOLUME
+ #
+ sleep 1.second
+ # query
+ exec(:volume?)
+ should_send("MV?")
+ responds("MV49\r")
+ status[:volume].should eq(50.0)
+ # change volume
+ exec(:volume, 100)
+ should_send("MV98.0")
+ responds("MV98.0\r")
+ status[:volume].should eq(100.0)
+
+ ####
+ # MUTE
+ #
+ sleep 1.second
+ # query
+ exec(:mute?)
+ should_send("MU?")
+ responds("MUOFF\r")
+ status[:mute].should eq("OFF")
+ # mute on
+ exec(:mute, true)
+ should_send("MUON")
+ responds("MUON\r")
+ status[:mute].should eq("ON")
+ # mute off
+ exec(:mute, false)
+ should_send("MUOFF")
+ responds("MUOFF\r")
+ status[:mute].should eq("OFF")
+end
diff --git a/drivers/echo360/device_capture.cr b/drivers/echo360/device_capture.cr
new file mode 100644
index 00000000000..9b1b6a50ca6
--- /dev/null
+++ b/drivers/echo360/device_capture.cr
@@ -0,0 +1,167 @@
+require "placeos-driver"
+require "oq"
+
+# Documentation: https://aca.im/driver_docs/Echo360/EchoSystemCaptureAPI_v301.pdf
+
+class Echo360::DeviceCapture < PlaceOS::Driver
+ # Discovery Information
+ generic_name :Capture
+ descriptive_name "Echo365 Device Capture"
+ uri_base "https://echo.server"
+
+ default_settings({
+ basic_auth: {
+ username: "srvc_acct",
+ password: "password!",
+ },
+ })
+
+ def on_update
+ schedule.clear
+ schedule.every(15.seconds) do
+ logger.debug { "-- Polling Capture" }
+ system_status
+ capture_status
+ end
+ end
+
+ STATUS_CMDS = {
+ system_status: :system,
+ capture_status: :captures,
+ next: :next_capture,
+ current: :current_capture,
+ state: :monitoring,
+ }
+
+ {% begin %}
+ {% for function, route in STATUS_CMDS %}
+ {% path = "/status/#{route.id}" %}
+ def {{function.id}}
+ response = get({{path}})
+ process_status check(response)
+ end
+ {% end %}
+ {% end %}
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def restart_application
+ post("/diagnostics/restart_all").success?
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def reboot
+ post("/diagnostics/reboot").success?
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def captures
+ response = get("/diagnostics/recovery/saved-content")
+ self[:captures] = check(response)["captures"]["capture"]
+ end
+
+ @[Security(PlaceOS::Driver::Level::Support)]
+ def upload(id : String)
+ response = post("/diagnostics/recovery/#{id}/upload")
+ raise "upload request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ response.body
+ end
+
+ # This will auto-start a recording
+ def capture(name : String, duration : Int32, profile : String? = nil)
+ profile ||= self[:capture_profiles][0].as_s
+ response = post("/capture/new_capture", body: URI::Params.build { |form|
+ form.add("description", name)
+ form.add("duration", duration.to_s)
+ form.add("capture_profile_name", profile)
+ })
+ check(response)["ok"]["#text"].as_s
+ end
+
+ def test_capture(name : String, duration : Int32, profile : String? = nil)
+ profile ||= self[:capture_profiles][0].as_s
+ response = post("/capture/confidence_monitor", body: URI::Params.build { |form|
+ form.add("description", name)
+ form.add("duration", duration.to_s)
+ form.add("capture_profile_name", profile)
+ })
+ check(response)["ok"]["#text"].as_s
+ end
+
+ def extend(duration : Int32)
+ response = post("/capture/confidence_monitor", body: URI::Params.build { |form|
+ form.add("duration", duration.to_s)
+ })
+ check(response)["ok"]["#text"].as_s
+ end
+
+ def pause
+ response = post("/capture/pause")
+ check(response)["ok"]["#text"].as_s
+ end
+
+ def start
+ response = post("/capture/record")
+ check(response)["ok"]["#text"].as_s
+ end
+
+ def resume
+ start
+ end
+
+ def record
+ start
+ end
+
+ def stop
+ response = post("/capture/stop")
+ check(response)["ok"]["#text"].as_s
+ end
+
+ # Converts the response into the appropriate format and indicates success / failure
+ protected def check(response)
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ # Convert the XML to JSON for simple parsing
+ # https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html
+ input_io = IO::Memory.new response.body
+ output_io = IO::Memory.new
+ OQ::Converters::XML.deserialize input_io, output_io
+
+ output_io.rewind
+ json = JSON.parse(output_io)
+ logger.debug { "response was\n#{json.pretty_inspect}" }
+ json
+ end
+
+ CHECK = {"next", "current"}
+
+ # generic function for processing status and exposing the state
+ protected def process_status(data)
+ if results = data["status"]?.try(&.as_h)
+ results.each do |key, value|
+ if key.in?(CHECK) && (value.as_s?.try(&.strip.empty?) || value["schedule"]?.try(&.as_s?.try(&.strip.empty?)))
+ # next / current recordings are not present
+ self[key] = nil
+ elsif key[-1] == 's' && (hash = value.as_h?)
+ # This handles `{"api-versions" => {"api-version" => "3.0"}}`
+ inner = hash[key[0..-2]]?
+ if inner
+ self[key] = inner
+ else
+ self[key] = hash
+ end
+ elsif str_val = value.as_s?.try(&.strip)
+ # cleanup whitespace around string values
+ self[key] = str_val
+ else
+ # otherwise we don't manipulate the value and expose it for use
+ self[key] = value
+ end
+ end
+ results
+ else
+ logger.debug { "namespace 'status' not found, ignoring payload" }
+ data
+ end
+ end
+end
diff --git a/drivers/echo360/device_capture_spec.cr b/drivers/echo360/device_capture_spec.cr
new file mode 100644
index 00000000000..aec824dcce2
--- /dev/null
+++ b/drivers/echo360/device_capture_spec.cr
@@ -0,0 +1,182 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Echo360::DeviceCapture" do
+ retval = exec(:system_status)
+ expect_http_request do |request, response|
+ if request.path == "/status/system"
+ response.status_code = 200
+ response << SYSTEM_STATUS
+ else
+ puts "unexpected path #{request.path}"
+ response.status_code = 404
+ end
+ end
+ retval.get
+
+ status["api-versions"].should eq "3.0"
+
+ retval = exec(:captures)
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << CAPTURE_STATUS
+ end
+ retval.get
+ status[:captures].as_a.size.should eq(2)
+end
+
+CAPTURE_STATUS = <<-HEREDOC
+
+
+ Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ 2014-02-12T15:30:00.000Z
+ 3000
+ Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+
+
+ John Doe
+
+
+
+
+ Some other capture
+ 2014-02-13T15:30:00.000Z
+ 1500
+
+
+
+ Steve
+
+
+
+
+ HEREDOC
+
+SYSTEM_STATUS = <<-HEREDOC
+
+ 2014-02-12T15:02:19.037Z
+
+ 3.0
+
+
+ Audio Only (Podcast). Balanced between file size & quality
+ Display Only (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality
+ Display/Video (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality
+ Display/Video (Podcast/Vodcast/EchoPlayer). Optimized for quality/full motion video
+ DualDisplay (Podcast/Vodcast/EchoPlayer). Optimized for file size & bandwidth
+ Dual Video (Podcast/Vodcast/EchoPlayer) -Balance between file size & quality
+ Dual Video (Podcast/Vodcast/EchoPlayer) -High Quality
+ Video Only (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality
+
+
+ Display/Video (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality
+
+
+ media
+ 2014-02-12T23:00:00.000Z
+ 3000
+
+ Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+
+ John Doe
+
+
+ Display/Video (Podcast/Vodcast/EchoPlayer). Optimized for quality/full motion video
+ archive
+
+
+
+ balanced
+ stereo
+ -6
+ 44100
+ 0
+ false
+
+
+ 1
+ dvi
+ 50
+ 50
+ 50
+ 10.0
+ 960
+ 720
+ true
+ true
+
+
+ 2
+ composite
+ 50
+ 50
+ 50
+ 29.97
+ 704
+ 480
+ true
+ false
+ ntsc
+
+
+ audio
+ aac
+ true
+
+ 128000
+ lc
+
+
+
+ graphics1
+ h264
+
+ vbr
+ 736000
+ 1104000
+ base
+ 50
+
+
+
+ graphics2
+ h264
+
+ vbr
+ 1056000
+ 1584000
+ base
+ 150
+
+
+
+ audio-archive
+
+ file
+ audio.aac
+
+
+
+ graphics1-archive
+
+ file
+ display.h264
+
+
+
+ graphics2-archive
+
+ file
+ video.h264
+
+
+
+
+
+
+
+
+
+
+
+ HEREDOC
diff --git a/drivers/embedia/control_point.cr b/drivers/embedia/control_point.cr
new file mode 100644
index 00000000000..4b62145a83b
--- /dev/null
+++ b/drivers/embedia/control_point.cr
@@ -0,0 +1,94 @@
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/Embedia/Embedia%20Control%20Point%20rev2013.pdf
+# RS232 Gateway. Baud Rate 9600,8,N,1
+
+class Embedia::ControlPoint < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Embedia Control Point Blinds"
+ generic_name :Blinds
+
+ # Global Cache Port
+ tcp_port 4999
+
+ def on_load
+ queue.wait = false
+ queue.delay = 200.milliseconds
+ transport.tokenizer = Tokenizer.new("\r\n")
+ end
+
+ def connected
+ schedule.every(1.minute) do
+ logger.debug { "Maintaining connection" }
+ query_sensor 0
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ COMMANDS = {
+ stop: 0x28,
+ down: 0x4e, # Also extend
+ up: 0x4b, # Also retract
+ next_extent_preset: 0x4f,
+ previous_extent_preset: 0x50,
+
+ close: 0x16,
+ open: 0x1a,
+ next_tilt_preset: 0x07,
+ previous_tilt_preset: 0x04,
+
+ clear_override: 0x4c,
+ }
+
+ {% begin %}
+ {% for command, value in COMMANDS %}
+ def {{command.id}}(address : UInt8, **options)
+ do_send Bytes[address, 0x06, 0, 1, 0, {{value}}], **options
+ end
+ {% end %}
+ {% end %}
+
+ def extent_preset(address : UInt8, number : UInt8, **options)
+ num = 0x1D + number.clamp(1, 10)
+ do_send Bytes[address, 0x06, 0, 1, 0, num], **options, name: "extent_preset#{address}"
+ end
+
+ def tilt_preset(address : UInt8, number : UInt8, **options)
+ num = 0x39 + number.clamp(1, 10)
+ do_send Bytes[address, 0x06, 0, 1, 0, num], **options, name: "tilt_preset#{address}"
+ end
+
+ def query_sensor(address : UInt8, **options)
+ do_send Bytes[address, 0x03, 0, 1, 0, 1], **options
+ end
+
+ protected def do_send(data : Bytes, **options)
+ sending = data.hexstring.upcase
+ logger.debug { "sending :#{sending}--" }
+ send ":#{sending}--\r\n", **options
+ end
+
+ def received(bytes, task)
+ logger.debug {
+ # remove the newline chars
+ raw_data = String.new(bytes).strip
+
+ # strip the padding ':' and The LRC checksum
+ data = raw_data[1..-3].hexbytes
+ address = data[0]
+ func = data[1]
+
+ case func
+ when 3 # Sensor level
+ "sensor response #{raw_data} on address 0x#{address.to_s(16)}"
+ else
+ "sent #{raw_data} on address 0x#{address.to_s(16)}"
+ end
+ }
+
+ task.try &.success
+ end
+end
diff --git a/drivers/embedia/control_point_spec.cr b/drivers/embedia/control_point_spec.cr
new file mode 100644
index 00000000000..6eef94e4905
--- /dev/null
+++ b/drivers/embedia/control_point_spec.cr
@@ -0,0 +1,10 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Embedia::ControlPoint" do
+ exec(:down, 0xFF)
+ should_send(":FF060001004E--\r\n")
+
+ exec(:query_sensor, 0xFF)
+ should_send(":FF0300010001--\r\n")
+ responds(":FF0300010001AA\r\n")
+end
diff --git a/drivers/epson/projector/esc_vp21.cr b/drivers/epson/projector/esc_vp21.cr
new file mode 100644
index 00000000000..1b9df5ae1ee
--- /dev/null
+++ b/drivers/epson/projector/esc_vp21.cr
@@ -0,0 +1,262 @@
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+class Epson::Projector::EscVp21 < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Input
+ HDMI = 0x30
+ HDBaseT = 0x80
+ end
+
+ include Interface::InputSelection(Input)
+
+ # Discovery Information
+ tcp_port 3629
+ descriptive_name "Epson Projector"
+ generic_name :Display
+
+ default_settings({
+ epson_projectors_poll_video_mute: true,
+ epson_projectors_poll_volume: true,
+ epson_projectors_disable_muting: false
+ })
+
+ @poll_video_mute : Bool = true
+ @poll_volume : Bool = true
+
+ # Mute commands appear to cause significant problems in some Epson models.
+ # meet.cr appears to send mute commands to outputs of unjoined joinable rooms, causing disturbance to other meetings (especially since the UI currently does not have mute/unmute buttons).
+ # The below is a workaround until the meeting rm logic driver is fixed (don't mute other (unjoined) rooms on shutdown/unroute)
+ @muting_disabled : Bool = false
+
+ @ready : Bool = false
+
+ getter power_actual : Bool? = nil # actual power state
+ getter? power_stable : Bool = true # are we in a stable state?
+ getter? power_target : Bool = true # what is the target state?
+
+ @unmute_volume : Float64 = 60.0
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("\r")
+ self[:type] = :projector
+ on_update
+ end
+
+ def on_update
+ @poll_video_mute = setting?(Bool, :epson_projectors_poll_video_mute) || true
+ @poll_volume = setting?(Bool, :epson_projectors_poll_volume) || true
+ @muting_disabled = setting?(Bool, :epson_projectors_disable_muting) || false
+ end
+
+ def connected
+ # Have to init comms
+ send("ESC/VP.net\x10\x03\x00\x00\x00\x00")
+ schedule.every(52.seconds, true) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ if state
+ @power_target = true
+ logger.debug { "-- epson Proj, requested to power on" }
+ do_send(:power, "ON", delay: 40.seconds, name: "power", priority: 99)
+ else
+ @power_target = false
+ logger.debug { "-- epson Proj, requested to power off" }
+ do_send(:power, "OFF", delay: 10.seconds, name: "power", priority: 99)
+ end
+ @power_stable = false
+ self[:power] = state
+ power?
+ end
+
+ def power?(**options) : Bool
+ do_send(:power, **options).get
+ @power_actual || false
+ end
+
+ def switch_to(input : Input)
+ logger.debug { "-- epson Proj, requested to switch to: #{input}" }
+ do_send(:input, input.value.to_s(16), name: :input)
+
+ # for a responsive UI
+ self[:input] = input # for a responsive UI
+ self[:video_mute] = false
+ input?
+ end
+
+ def input?
+ do_send(:input, priority: 0).get
+ self[:input]
+ end
+
+ # Volume commands are sent using the inpt command
+ def volume(vol : Float64 | Int32, **options)
+ vol = vol.to_f.clamp(0.0, 100.0)
+ percentage = vol / 100.0
+ vol_actual = (percentage * 255.0).round_away.to_i
+
+ @unmute_volume = self[:volume].as_f if (muted = vol == 0.0) && self[:volume]?
+ do_send(:volume, vol_actual, **options, name: :volume)
+
+ # for a responsive UI
+ self[:volume] = vol
+ self[:audio_mute] = muted
+ volume? unless @muting_disabled # Affected projectors support volume setting, but not volume query
+ end
+
+ def volume?
+ return if @muting_disabled
+ do_send(:volume, priority: 0).get
+ self[:volume]?.try(&.as_f)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ return if @muting_disabled
+ case layer
+ when .audio_video?
+ do_send(:av_mute, state ? "ON" : "OFF", name: :mute)
+ do_send(:av_mute, name: :mute?, priority: 0)
+ when .video?
+ do_send(:video_mute, state ? "ON" : "OFF", name: :video_mute)
+ video_mute?
+ when .audio?
+ val = state ? 0.0 : @unmute_volume
+ volume(val)
+ end
+ end
+
+ def video_mute?
+ return if @muting_disabled
+ do_send(:video_mute, priority: 0).get
+ !!self[:video_mute]?.try(&.as_bool)
+ end
+
+ ERRORS = [
+ "00: no error",
+ "01: fan error",
+ "03: lamp failure at power on",
+ "04: high internal temperature",
+ "06: lamp error",
+ "07: lamp cover door open",
+ "08: cinema filter error",
+ "09: capacitor is disconnected",
+ "0A: auto iris error",
+ "0B: subsystem error",
+ "0C: low air flow error",
+ "0D: air flow sensor error",
+ "0E: ballast power supply error",
+ "0F: shutter error",
+ "10: peltiert cooling error",
+ "11: pump cooling error",
+ "12: static iris error",
+ "13: power supply unit error",
+ "14: exhaust shutter error",
+ "15: obstacle detection error",
+ "16: IF board discernment error",
+ ]
+
+ def inspect_error
+ do_send(:error, priority: 0)
+ end
+
+ COMMAND = {
+ power: "PWR",
+ input: "SOURCE",
+ volume: "VOL",
+ av_mute: "MUTE",
+ video_mute: "MSEL",
+ error: "ERR",
+ lamp: "LAMP",
+ }
+ RESPONSE = COMMAND.to_h.invert
+
+ def received(data, task)
+ return task.try(&.success) if data.size <= 2
+ data = String.new(data[1..-2])
+ logger.debug { "<< Received from Epson Proj: #{data}" }
+
+ data = data.split('=')
+ case RESPONSE[data[0].lstrip(':')] # Because we see sometimes see responses like ':::PWR'
+ when :error
+ if data[1]?
+ code = data[1].to_i(16)
+ self[:last_error] = ERRORS[code]? || "#{data[1]}: unknown error code #{code}"
+ return task.try(&.success("Epson PJ error was #{self[:last_error]}"))
+ else # Lookup error!
+ return task.try(&.abort("Epson PJ sent error response for #{task.not_nil!.name || "unknown"}"))
+ end
+ when :power
+ state = data[1].to_i
+ @power_actual = powered = state < 3
+ warming = state == 2
+ cooling = state == 3
+
+ if warming || cooling
+ schedule.in(5.seconds) { power?(priority: 10) }
+ elsif !@power_stable
+ if @power_actual == @power_target
+ @power_stable = true
+ else
+ power(@power_target)
+ end
+ end
+
+ self[:power] = powered if @power_stable
+ self[:warming] = warming
+ self[:cooling] = cooling
+
+ if powered == @power_target
+ self[:video_mute] = false unless powered
+ end
+ when :av_mute
+ self[:video_mute] = self[:audio_mute] = data[1] == "ON"
+ self[:volume] = 0.0
+ when :video_mute
+ self[:video_mute] = data[1] == "ON"
+ when :volume
+ # convert to a percentage
+ vol = data[1].to_i
+ vol_percent = (vol.to_f / 255.0) * 100.0
+ self[:volume] = vol_percent
+
+ mute = vol == 0
+ self[:audio_mute] = mute if mute
+ @unmute_volume ||= vol_percent unless mute
+ when :lamp
+ self[:lamp_usage] = data[1].split(" ")[0].to_i #split added as we see responses like "LAMP=1633 1633"
+ when :input
+ self[:input] = Input.from_value(data[1].to_i(16)) || "unknown"
+ end
+
+ task.try(&.success)
+ end
+
+ def do_poll
+ if power?(priority: 20) && @power_stable
+ input?
+ video_mute? if @poll_video_mute
+ volume? if @poll_volume
+ end
+ do_send(:lamp, priority: 20)
+ end
+
+ private def do_send(command, param = nil, **options)
+ command = COMMAND[command]
+ cmd = param ? "#{command} #{param}\r" : "#{command}?\r"
+ logger.debug { ">> Sending to Epson Proj: #{command}: #{cmd}" }
+ send(cmd, **options)
+ end
+end
diff --git a/drivers/epson/projector/esc_vp21_spec.cr b/drivers/epson/projector/esc_vp21_spec.cr
new file mode 100644
index 00000000000..726d7b36df1
--- /dev/null
+++ b/drivers/epson/projector/esc_vp21_spec.cr
@@ -0,0 +1,61 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Epson::Projector::EscVp21" do
+ # connected
+ should_send("ESC/VP.net\x10\x03\x00\x00\x00\x00")
+ responds(":\r")
+ # do_poll
+ # power?
+ should_send("PWR?\r")
+ responds(":PWR=01\r")
+ status[:power].should eq(true)
+ # input?
+ should_send("SOURCE?\r")
+ responds(":SOURCE=30\r")
+ status[:input].should eq("HDMI")
+ # video_mute?
+ should_send("MSEL?\r")
+ responds(":MSEL=0\r")
+ status[:video_mute].should eq(false)
+ # volume?
+ should_send("VOL?\r")
+ responds(":VOL=10\r")
+ status[:volume].should eq(10)
+ # lamp
+ should_send("LAMP?\r")
+ responds(":LAMP=20\r")
+ status[:lamp_usage].should eq(20)
+
+ exec(:mute)
+ should_send("MUTE ON\r")
+ responds(":\r")
+ should_send("MUTE?\r")
+ responds(":MUTE=ON\r")
+ status[:video_mute].should eq(true)
+ status[:audio_mute].should eq(true)
+ status[:volume].should eq(0)
+
+ exec(:switch_to, "HDBaseT")
+ should_send("SOURCE 80\r")
+ responds(":\r")
+ should_send("SOURCE?\r")
+ responds(":SOURCE=80\r")
+ status[:input].should eq("HDBaseT")
+ status[:video_mute].should eq(false)
+
+ exec(:mute_audio, false)
+ should_send("VOL 10\r")
+ responds(":\r")
+ should_send("VOL?\r")
+ responds(":VOL=10\r")
+ status[:volume].should eq(10)
+ status[:audio_mute].should eq(false)
+
+ exec(:volume, 50)
+ should_send("VOL 50\r")
+ responds(":\r")
+ should_send("VOL?\r")
+ responds(":VOL=50\r")
+ status[:volume].should eq(50)
+ status[:audio_mute].should eq(false)
+end
diff --git a/drivers/exterity/avedia_player/m93xx.cr b/drivers/exterity/avedia_player/m93xx.cr
new file mode 100644
index 00000000000..6ccc64796eb
--- /dev/null
+++ b/drivers/exterity/avedia_player/m93xx.cr
@@ -0,0 +1,163 @@
+require "telnet"
+require "placeos-driver"
+
+class Exterity::AvediaPlayer::R93xx < PlaceOS::Driver
+ descriptive_name "Exterity Avedia Player (M93xx)"
+ generic_name :IPTV
+ tcp_port 22
+
+ default_settings({
+ ssh: {
+ username: :ctrl,
+ password: :labrador,
+ term: :xterm,
+ },
+ max_waits: 100,
+ })
+
+ @ready : Bool = false
+
+ def connected
+ self[:ready] = @ready = false
+
+ schedule.every(59.seconds) do
+ logger.debug { "-- Polling Exterity Player" }
+ current_channel
+ current_channel_name
+ end
+
+ schedule.every(1.hour) do
+ logger.debug { "-- Polling Exterity Player" }
+ dump
+ end
+ end
+
+ def disconnected
+ self[:ready] = @ready = false
+ transport.tokenizer = nil
+ schedule.clear
+ end
+
+ def channel(number : Int32 | String)
+ if number.is_a? Number
+ set :playChannelNumber, number, name: :channel
+ else
+ stream number
+ end
+ end
+
+ def channel_name(name : String)
+ set(:currentChannel_name, name, name: :name).get
+ current_channel_name
+ end
+
+ def stream(uri : String) : Nil
+ set(:playChannelUri, uri, name: :channel).get
+ schedule.in(2.second) do
+ current_channel
+ current_channel_name
+ end
+ end
+
+ def current_channel
+ get :currentChannel
+ end
+
+ def current_channel_name
+ get :currentChannel_name
+ end
+
+ def dump
+ do_send "^dump!\r", name: :dump
+ end
+
+ def help
+ do_send "^help!\r", name: :help
+ end
+
+ def reboot
+ remote :reboot
+ end
+
+ def tv_info
+ get :tv_info
+ end
+
+ def version
+ get :SoftwareVersion
+ end
+
+ @[Security(Level::Support)]
+ def manual(cmd : String)
+ do_send cmd
+ end
+
+ def received(data, task)
+ data = String.new(data).strip
+
+ logger.debug { "Exterity sent #{data}" }
+
+ if !@ready
+ if data =~ /Run a Shell/i
+ logger.info { "-- starting the command interface" }
+
+ # select open shell option
+ do_send "6\r", wait: false, delay: 2.seconds, priority: 96
+
+ # launch command processor
+ do_send "/usr/bin/serialCommandInterface\r", wait: false, delay: 200.milliseconds, priority: 95
+
+ # we need to disconnect if we don't see the serialCommandInterface after a certain amount of time
+ schedule.in(20.seconds) do
+ if !@ready
+ logger.error { "Exterity connection failed to be ready after 20 seconds." }
+ disconnect
+ end
+ end
+ elsif data =~ /Terminal Control Interface/i
+ logger.info { "-- got the control interface message, we're READY now" }
+ transport.tokenizer = Tokenizer.new("!")
+ self[:ready] = @ready = true
+ dump
+ return
+ end
+ end
+
+ # Extract response between the ^ and !
+ resp = data.split("^")[1][0..-2]
+ process_resp resp, task
+ end
+
+ protected def process_resp(data, task)
+ logger.debug { "Resp details #{data}" }
+
+ parts = data.split ':', 2
+
+ case parts[0]
+ when "error"
+ message = task ? "Error when requesting: #{task.try &.name}" : "Error response received"
+ logger.warn { message }
+ task.try &.abort(message)
+ else
+ self[parts[0].underscore] = parts[1]
+ task.try &.success(parts[1])
+ end
+ end
+
+ protected def do_send(command, **options)
+ logger.debug { "requesting #{command}" }
+ send command, **options
+ end
+
+ protected def set(command, data, **options)
+ do_send "^set:#{command}:#{data}!\r", **options.merge({wait: false})
+ end
+
+ protected def remote(cmd, **options)
+ do_send "^send:#{cmd}!\r", **options
+ end
+
+ protected def get(status, **options)
+ do_send "^get:#{status}!\r", **options
+ end
+end
diff --git a/drivers/exterity/avedia_player/m93xx_spec.cr b/drivers/exterity/avedia_player/m93xx_spec.cr
new file mode 100644
index 00000000000..d8940064e13
--- /dev/null
+++ b/drivers/exterity/avedia_player/m93xx_spec.cr
@@ -0,0 +1,31 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Exterity::AvediaPlayer::R92xx" do
+ # this lets the driver know it's successfully connected
+ sleep 1
+ status[:ready].should eq(false)
+ responds("Terminal Control Interface\r")
+ status[:ready].should eq(true)
+
+ should_send("^dump!\r").responds %(^currentChannel:udp://239.193.3.169:5000?hwchan=4!
+^currentChannel_name:SBS ONE HD!
+^currentChannel_number:30!
+^currentAVChannel:udp://239.193.3.169:5000?hwchan=4!
+^new_channel:NO VALUE!
+^currentChannel:udp://239.193.3.169:5000?hwchan=4!)
+
+ sleep 100.milliseconds
+
+ status[:current_channel].should eq "udp://239.193.3.169:5000?hwchan=4"
+ status[:current_channel_name].should eq "SBS ONE HD"
+
+ resp = exec(:version)
+ responds("^SoftwareVersion:123!\r")
+ resp.get
+ status[:software_version].should eq("123")
+
+ resp = exec(:tv_info)
+ responds("^tv_info:a,b,c,d,e,f,g!\r")
+ resp.get
+ status[:tv_info].should eq("a,b,c,d,e,f,g")
+end
diff --git a/drivers/exterity/avedia_player/r92xx.cr b/drivers/exterity/avedia_player/r92xx.cr
new file mode 100644
index 00000000000..78bee328f70
--- /dev/null
+++ b/drivers/exterity/avedia_player/r92xx.cr
@@ -0,0 +1,167 @@
+require "telnet"
+require "placeos-driver"
+
+class Exterity::AvediaPlayer::R92xx < PlaceOS::Driver
+ descriptive_name "Exterity Avedia Player (R92xx)"
+ generic_name :IPTV
+ tcp_port 23
+
+ default_settings({
+ max_waits: 100,
+ username: "admin",
+ password: "labrador",
+ })
+
+ @ready : Bool = false
+ @telnet : Telnet? = nil
+
+ def on_load
+ new_telnet_client
+ transport.pre_processor { |bytes| @telnet.try &.buffer(bytes) }
+ end
+
+ def connected
+ @ready = false
+ self[:ready] = false
+
+ schedule.every(60.seconds) do
+ logger.info { "-- Polling Exterity Player" }
+ tv_info
+ end
+ end
+
+ def disconnected
+ # ensures the buffer is cleared
+ new_telnet_client
+
+ schedule.clear
+ end
+
+ def channel(number : Int32 | String)
+ if number.is_a? Number
+ set :playChannelNumber, number
+ else
+ stream number
+ end
+ end
+
+ def stream(uri : String)
+ set :playChannelUri, uri
+ end
+
+ def dump
+ do_send "^dump!", name: :dump
+ end
+
+ def help
+ do_send "^help!", name: :help
+ end
+
+ def reboot
+ remote :reboot
+ end
+
+ def tv_info
+ get :tv_info
+ end
+
+ def version
+ get :SoftwareVersion
+ end
+
+ def manual(cmd : String)
+ do_send cmd
+ end
+
+ def received(data, task)
+ data = String.new(data).strip
+
+ logger.info { "Exterity sent #{data}" }
+
+ if @ready
+ # Detect if logged out of serialCommandInterface
+ if data =~ /sh: .* not found/i
+ # Launch command processor
+ do_send "/usr/bin/serialCommandInterface", wait: false, delay: 2.seconds, priority: 95
+ return :failure
+ end
+
+ # Extract response
+ data.split("!").map(&.strip("^")).each do |resp|
+ process_resp(resp, task)
+ end
+ elsif data =~ /Exterity Control Interface| Exit/i
+ logger.info { "-- got the control interface message, we're READY now" }
+ @ready = true
+ self[:ready] = true
+ version
+ elsif data =~ /login:/i
+ logger.info { "-- got the login: prompt" }
+ transport.tokenizer = Tokenizer.new("\r")
+
+ # login
+ do_send setting(String, :username), wait: false, delay: 200.milliseconds, priority: 98
+ do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97
+
+ # select open shell option
+ do_send "6", wait: false, delay: 2.seconds, priority: 96
+
+ # launch command processor
+ do_send "/usr/bin/serialCommandInterface", wait: false, delay: 200.milliseconds, priority: 95
+
+ # we need to disconnect if we don't see the serialCommandInterface after a certain amount of time
+ schedule.in(20.seconds) do
+ if !@ready
+ logger.error { "Exterity connection failed to be ready after 5 seconds. Check username and password." }
+ disconnect
+ end
+ end
+ elsif logger.info { "Somehow we got here #{data}" }
+ end
+
+ task.try &.success
+ end
+
+ protected def process_resp(data, task)
+ logger.info { "Resp details #{data}" }
+
+ parts = data.split ':'
+
+ case parts[0].to_s
+ when "error"
+ if task != nil
+ logger.warn { "Error when requesting: #{task.try &.name}" }
+ else
+ logger.warn { "Error response received" }
+ end
+ when "tv_info"
+ self[:tv_info] = parts[1]
+ when "SoftwareVersion"
+ self[:version] = parts[1]
+ end
+ end
+
+ protected def new_telnet_client
+ @telnet = Telnet.new do |data|
+ transport.send(data)
+ end
+ end
+
+ protected def do_send(command, **options)
+ logger.info { "requesting #{command}" }
+ send @telnet.not_nil!.prepare(command), **options
+ end
+
+ protected def set(command, data, **options)
+ # options[:name] = :"set_#{command}" unless options[:name]
+ do_send "^set:#{command}:#{data}!", **options
+ end
+
+ protected def remote(cmd, **options)
+ do_send "^send:#{cmd}!", **options
+ end
+
+ protected def get(status, **options)
+ do_send "^get:#{status}!", **options
+ end
+end
diff --git a/drivers/exterity/avedia_player/r92xx_protocol.md b/drivers/exterity/avedia_player/r92xx_protocol.md
new file mode 100644
index 00000000000..fdad269cab1
--- /dev/null
+++ b/drivers/exterity/avedia_player/r92xx_protocol.md
@@ -0,0 +1,415 @@
+
+# Exterity AvediaPlayer r9200 Control Protocol.
+
+NOTE:: All information in this document was obtained via exploration of the R9200 device.
+No information here was provided by Exterity during this process
+
+
+## Connecting
+
+* Telnet Protocol (port 23)
+* `telnet 192.168.1.13`
+* Default username: `admin`
+* Default password: `labrador`
+* Select option `6` to run a shell
+
+
+## Shell Navigation
+
+Once in the shell you can use following tools to read files:
+
+* `less` for scanning through files
+* `cat` for dumping files
+* `ps aux` for viewing processes
+* `ls` for listing files
+
+The file system is readonly so moving files to `/usr/local/www` for downloading was not possible.
+
+
+## Applications
+
+* Application are installed at: `/usr/bin`
+ * `serialCommandInterface` allows programmatic control of the device
+ * `irsend` for sending IR commands
+* Configuration is at: `/etc`
+ * `lircd.conf` contains the human readable names of all the IR commands
+
+```
+begin remote
+
+ name exterity_remote_2
+
+ bits 16
+ flags SPACE_ENC
+ eps 20
+ aeps 200
+
+ header 8800 4400
+ one 550 1650
+ zero 550 550
+ ptrail 550
+ repeat 8800 2200
+ pre_data_bits 16
+ pre_data 0xB5B7
+ gap 38500
+ toggle_bit 0
+ frequency 38000
+
+#! exterity_bit_period 560
+#! exterity_aeps 500
+#! exterity_rmpower_len 66
+#! exterity_rmpower_pattern 16 8 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 1 1 3 1 1 1 1 1 3 1 1
+
+ begin codes
+ rm_1 0x45ba
+ rm_2 0x35ca
+ rm_3 0x6d92
+ rm_4 0xc53a
+ rm_5 0xb54a
+ rm_6 0xed12
+ rm_7 0x25da
+ rm_8 0x758a
+ rm_9 0x1de2
+ rm_cancel 0x03fc
+ rm_0 0xf50a
+ rm_menu 0xa55a
+ rm_power 0xad52
+ rm_chup 0x0df2
+ rm_chdown 0x8d72
+ rm_volup 0x5da2
+ rm_voldown 0xdd22
+ rm_up 0x4db2
+ rm_left 0x956a
+ rm_enter 0xcd32
+ rm_right 0xbd42
+ rm_down 0x2dd2
+ rm_mute 0xa35c
+ rm_red 0x837c
+ rm_green 0x43bc
+ rm_yellow 0xc33c
+ rm_blue 0x23dc
+ rm_rewind 0x15ea
+ rm_play 0x55aa
+ rm_pause 0xe51a
+ rm_ff 0x3dc2
+ rm_skipback 0x639c
+ rm_skipfwd 0xe31c
+ rm_stop 0x7d82
+ rm_record 0x659a
+ rm_exterity 0x13ec
+ rm_fn_tv 0x936c
+ rm_fn_home 0x53ac
+ rm_guide 0xd32c
+ rm_subtitle 0x857A
+ rm_info 0x33CC
+ rm_help 0xB34C
+ rm_audio 0x9D62
+ rm_teletext 0xD52A
+ rm_av 0xFD02
+ end codes
+
+end remote
+
+```
+
+
+## Serial Command Interface
+
+* All lines start with `^`
+* All lines end with `!`
+
+Dump of the help text:
+
+
+```
+^help!
+To display a value: ^get:!
+To change a value: ^set: :!
+To display a list of option values: ^dump!
+To send a remote key press: ^send:!
+To send multiple remote key presses: ^msend!:::...:!
+To send a serial command to the TV: ^sendserial:!
+To exit session: ^exit!
+
+Valid options for get and set commands (tag/alternate tag):
+ Name name
+ Location location
+ Groups groups
+ NetworkBootProto dhcp
+ IPAddress
+ Subnet subnetMask
+ Gateway gateway
+ DNSPrimary
+ DNSSecondary
+ TFTPServer
+ StartupMode startupMode
+ currentMode
+ new_display
+ cur_display
+ DoUpgrade upgrade
+ bootfile
+ AdminPassword adminPassword
+ Volume volume
+ product_type productType
+ boardtype boardType
+ boardmod boardMod
+ boardrev boardRevision
+ boardnum boardNumber
+ mac macAddress
+ serial serialNumber
+ cur_webpage currWebpage
+ new_webpage webpage
+ newpage
+ currentChannel
+ currentAVChannel
+ new_channel
+ cur_channel
+ channel_up upChannel
+ channel_down downChannel
+ stop_channel stopChannel
+ play_channel_uri playChannelUri
+ play_channel_number playChannelNumber
+ Play play
+ FastForward fastForward
+ Rewind rewind
+ failover
+ totalresets totalResets
+ remoteresets remoteResets
+ softresets softResets
+ Timeserver timeserver
+ video_scaler scaleDimensions
+ zapper
+ galio
+ serialCommandInterface
+ admin
+ TZ timezone
+ SoftwareVersion softwareVersion
+ softwareDescription
+ mute mute
+ LanguageIso639 prefAudioLang
+ PrefSubtitleLang prefSubtitleLang
+ cur_audiotrack currAudioTrack
+ cur_subtitletrack currSubtitleTrack
+ PlaylistUrl playlistUrl
+ DisplayHD HD
+ UserBitmap splashFile
+ ScreenFormat screenFormat
+ AspectRatio aspectRatio
+ browser.document.default homepage
+ proxySetting
+ proxy
+ proxyIgnore
+ transports.proxy.http.on
+ transports.proxy.http
+ transports.proxy.http.ignore
+ transports.proxy.ftp.on
+ transports.proxy.ftp
+ transports.proxy.ftp.ignore
+ transports.proxy.https.on
+ transports.proxy.https
+ transports.proxy.https.ignore
+ transports.proxy.mailto.on
+ transports.proxy.mailto
+ controller.toolbar.on
+ browserToolbar
+ BrowserSize browserSize
+ TVCtrlType
+ SerialConfig serialConfig
+ StandbyActionsSer standbyActions
+ UnstandbyActionsSer unstandbyActions
+ IRControllerType
+ enableIRReceiver
+ IRMode
+ IROutControllerType
+ StandbyActionsIR standbyActionsIR
+ UnstandbyActionsIR unstandbyActionsIR
+ MasterIRClient masterIRClient
+ VlanEnable vlanEnable
+ VlanNative vlanNative
+ VlanHost vlanHost
+ VlanEth2 vlanEth2
+ VlanEth3 vlanEth3
+ VlanEth4 vlanEth4
+ Speed speed
+ Duplex duplex
+ Autoneg autoneg
+ linkEth
+ rxstatsEth
+ txstatsEth
+ SpeedEth1 speedEth1
+ DuplexEth1 duplexEth1
+ AutonegEth1 autonegEth1
+ linkEth1
+ rxstatsEth1
+ txstatsEth1
+ SpeedEth2 speedEth2
+ DuplexEth2 duplexEth2
+ AutonegEth2 autonegEth2
+ linkEth2
+ rxstatsEth2
+ txstatsEth2
+ SpeedEth3 speedEth3
+ DuplexEth3 duplexEth3
+ AutonegEth3 autonegEth3
+ linkEth3
+ rxstatsEth3
+ txstatsEth3
+ SpeedEth4 speedEth4
+ DuplexEth4 duplexEth4
+ AutonegEth4 autonegEth4
+ linkEth4
+ rxstatsEth4
+ txstatsEth4
+ configureNetworkPorts
+ RemoteLog remoteLogging
+ RemoteLogAddress remoteLogAddress
+ RemoteLogPort remoteLogPort
+ LogLevel remoteLogLevel
+ TVButton
+ HomeButton homeButton
+ GuideButton guideButton
+ rmTVActions
+ rmHomeActions
+ rmGuideActions
+ SAPListener
+ rStaticChannels
+ staticChannels
+ SAPListenAddr
+ XmlChannelListUrl xmlChannelListUrl
+ NfsMountPoints nfsMountPoints
+ UsbMountPoints usbMountPoints
+ HddMountPoints hddMountPoints
+ FailOverStatus failOverStatus
+ add_nfs
+ rem_nfs
+ serialActions
+ FailOverType failOverType
+ FailOverPlaylist failOverPlaylist
+ FailOverBrowser failOverBrowser
+ FailOverMedia failOverMedia
+ NfsMountStatus nfsMountStatus
+ XmlChannelListRefresh xmlChannelListRefresh
+ LocalXmlChannelListRefresh localXmlChannelListRefresh
+ LedStatus ledStatus
+ reboot
+ ScreenSaverTimeout screenSaverTimeout
+ playstream_status playstreamStatus
+ playstream_speed playstreamSpeed
+ SmServerAddress SMServerAddress
+ SmServerPort SMServerPort
+ Subtitles subtitles
+ closedCaptionsDetected
+ closedCaptionChannel
+ savePlaylist
+ clearPlaylist
+ PlaylistDownloadStatus
+ browserEvent
+ doFactoryReset factoryReset
+ exportConfig
+ importConfig
+ BrowserHeap
+ BrowserFlex
+ screenResolution
+ screenFrameRate
+ teletextVBI teletextVBI
+ SNMPD snmpEnable
+ SNMP_RWCOMMUNITY snmpRWCommunity
+ SNMP_ROCOMMUNITY snmpROCommunity
+ usbMount
+ currentScreenResolution
+ lastScreenResolution
+ outputScreenResolution
+ currentScreenFrameRate
+ upTime
+ Date
+ SNMPManager
+ SNMPTrapManager snmpTrapManager
+ usbFileSize
+ usbSpaceLeft
+ hasSwitchChip
+ timeServerInUse
+ devel
+ updateChannelsList
+ stopOnDestroy
+ serialMode
+ serialTVStatus
+ dcardType
+ dcardSerial
+ dcardRev
+ dcardMod
+ hdmiState
+ playLength
+ playPosition
+ 43AspectDisplay
+ SSM_Uri
+ CECAmpenabled
+ CECStandbyenabled
+ CECVolumeChanged
+ CECStatus
+ CECRequestActiveSrc
+ CECSendCmd
+ CECRequestStandby
+ vodFeed
+ uiLang
+ subtitlesShow
+ DeviceType deviceType
+ animateUI
+ webAccess
+ USBStorageAccess
+ remoteMode
+ sourceIPAddr
+ sendKey
+ factoryResetButton
+ securitySetting
+ ApplyPage
+ CAP_VLAN
+ net_stats
+ channel_learning_addrs
+ product_string
+ font_files
+ del_font_files
+ resource_used
+ resource_total
+ stream_type
+ stream_info
+ tv_info
+ decode_state
+ last_decode_state
+ playState
+ edidStatus
+ rtpErrCount
+ current_font
+ bookmarkOne
+ bookmarkTwo
+ bookmarkThree
+ caching
+ tolerance
+ teletext
+ teletextAvailable
+ teletextPageDigit
+ teletextPageDigitReset
+ teletextNavigate
+ teletextPage
+ teletextZoom
+ teletextHoldSubpage
+ Licence
+ videoWallXPosition
+ videoWallYPosition
+ videoWallXSize
+ videoWallYSize
+ vwTopBezelPercent
+ vwLeftBezelPercent
+ vwRightBezelPercent
+ vwBottomBezelPercent
+ importConfigFile
+ exportConfigFile
+ serialPort
+ dnslookup
+ setFuse
+ readJTAG
+ protect
+ hasStarted
+ ConfigVersion
+ SNMPConfigChangeText
+```
+
diff --git a/drivers/exterity/avedia_player/r92xx_spec.cr b/drivers/exterity/avedia_player/r92xx_spec.cr
new file mode 100644
index 00000000000..2bb5090ec5f
--- /dev/null
+++ b/drivers/exterity/avedia_player/r92xx_spec.cr
@@ -0,0 +1,25 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Exterity::AvediaPlayer::R92xx" do
+ responds("login:")
+ should_send("admin\r\n")
+ should_send("labrador\r\n")
+ should_send("6\r\n")
+ should_send("/usr/bin/serialCommandInterface\r\n", 20.seconds)
+ # this lets the driver know it's successfully connected
+
+ status[:ready].should eq(false)
+ responds("Exterity Control Interface\r")
+ sleep(2)
+ status[:ready].should eq(true)
+
+ exec(:version)
+ responds("^SoftwareVersion:123!\r")
+ sleep(2)
+ status[:version].should eq("123")
+
+ exec(:tv_info)
+ responds("^tv_info:a,b,c,d,e,f,g!\r")
+ sleep(2)
+ status[:tv_info].should eq("a,b,c,d,e,f,g")
+end
diff --git a/drivers/exterity/avedia_player/r93xx.cr b/drivers/exterity/avedia_player/r93xx.cr
new file mode 100644
index 00000000000..52cb11be522
--- /dev/null
+++ b/drivers/exterity/avedia_player/r93xx.cr
@@ -0,0 +1,155 @@
+require "placeos-driver"
+require "telnet"
+
+class Exterity::AvediaPlayer::R93xx < PlaceOS::Driver
+ descriptive_name "Exterity Avedia Player (R93xx)"
+ generic_name :IPTV
+ tcp_port 23
+
+ default_settings({
+ max_waits: 100,
+ username: "admin",
+ password: "labrador",
+ })
+
+ @ready : Bool = false
+
+ def on_load
+ new_telnet_client
+ transport.pre_processor do |bytes|
+ logger.debug { "preprocessing #{bytes.size} bytes" }
+ @telnet.try &.buffer(bytes)
+ end
+ end
+
+ def connected
+ self[:ready] = @ready = false
+
+ # Login
+ do_send setting(String, :username), wait: false, delay: 1.seconds, priority: 98
+ do_send setting(String, :password), wait: false, delay: 1.seconds, priority: 97
+
+ transport.tokenizer = Tokenizer.new("\r")
+
+ schedule.every(60.seconds) do
+ logger.debug { "-- Polling Exterity Player" }
+ tv_info
+ end
+ end
+
+ def disconnected
+ self[:ready] = @ready = false
+ transport.tokenizer = nil
+
+ # Ensures the buffer is cleared
+ new_telnet_client
+ schedule.clear
+ end
+
+ def channel(number : Int32 | String)
+ if number.is_a? Number
+ set :playChannelNumber, number
+ else
+ stream number
+ end
+ end
+
+ def stream(uri : String)
+ set :playChannelUri, uri
+ end
+
+ def dump
+ do_send "^dump!", name: :dump
+ end
+
+ def help
+ do_send "^help!", name: :help
+ end
+
+ def reboot
+ remote :reboot
+ end
+
+ def tv_info
+ get :tv_info
+ end
+
+ def version
+ get :SoftwareVersion
+ end
+
+ def manual(cmd : String)
+ do_send cmd
+ end
+
+ def received(data, task)
+ data = String.new(data).strip
+
+ logger.debug { "Exterity received #{data}" }
+
+ if @ready
+ # Extract response
+ data.split("!").map(&.strip("^")).each do |resp|
+ process_resp(resp, task)
+ end
+ elsif data =~ /Terminal Control Interface/i
+ self[:ready] = @ready = true
+ transport.tokenizer = Tokenizer.new("\r")
+ version
+ elsif data =~ /login:/i
+ # We need to disconnect if we don't see the serialCommandInterface after a certain amount of time
+ schedule.in(20.seconds) do
+ if !@ready
+ logger.error { "Exterity connection failed to be ready after 30 seconds. Check username and password." }
+ disconnect
+ end
+ end
+ end
+
+ task.try &.success
+ end
+
+ protected def process_resp(data, task)
+ logger.debug { "Resp details #{data}" }
+
+ parts = data.split ':'
+
+ case parts[0].to_s
+ when "error"
+ if task != nil
+ logger.warn { "Error when requesting: #{task.try &.name}" }
+ else
+ logger.warn { "Error response received" }
+ end
+ when "tv_info"
+ self[:tv_info] = parts[1]
+ when "SoftwareVersion"
+ self[:version] = parts[1]
+ end
+ end
+
+ protected def new_telnet_client
+ @telnet = Telnet.new do |data|
+ logger.debug { "sending #{data.size} bytes via telnet" }
+ transport.send(data)
+ end
+ end
+
+ protected def do_send(command, **options)
+ logger.debug { "requesting #{command}" }
+ send @telnet.not_nil!.prepare(command), **options
+ end
+
+ protected def set(command, data, **options)
+ # options[:name] = :"set_#{command}" unless options[:name]
+ do_send "^set:#{command}:#{data}!", **options
+ end
+
+ protected def remote(cmd, **options)
+ do_send "^send:#{cmd}!", **options
+ end
+
+ protected def get(status, **options)
+ do_send "^get:#{status}!", **options
+ end
+end
diff --git a/drivers/exterity/avedia_player/r93xx_spec.cr b/drivers/exterity/avedia_player/r93xx_spec.cr
new file mode 100644
index 00000000000..ea13e234e88
--- /dev/null
+++ b/drivers/exterity/avedia_player/r93xx_spec.cr
@@ -0,0 +1,23 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Exterity::AvediaPlayer::R93xx" do
+ responds("login:")
+ should_send("admin\r\n", 3.seconds)
+ should_send("labrador\r\n", 3.seconds)
+
+ # this lets the driver know it's successfully connected
+ status[:ready].should eq(false)
+ responds("Terminal Control Interface\r")
+ sleep(2)
+ status[:ready].should eq(true)
+
+ exec(:version)
+ responds("^SoftwareVersion:123!\r")
+ sleep(2)
+ status[:version].should eq("123")
+
+ exec(:tv_info)
+ responds("^tv_info:a,b,c,d,e,f,g!\r")
+ sleep(2)
+ status[:tv_info].should eq("a,b,c,d,e,f,g")
+end
diff --git a/drivers/extron/matrix.cr b/drivers/extron/matrix.cr
new file mode 100644
index 00000000000..cb30fe8bdf7
--- /dev/null
+++ b/drivers/extron/matrix.cr
@@ -0,0 +1,293 @@
+require "placeos-driver"
+require "placeos-driver/interface/switchable"
+require "placeos-driver/interface/muteable"
+require "./sis"
+
+class Extron::Matrix < PlaceOS::Driver
+ include Extron::SIS
+ include Interface::Switchable(Input, Output)
+ include Interface::InputSelection(Input)
+ include Interface::Muteable
+
+ generic_name :Switcher
+ descriptive_name "Extron matrix switcher"
+ description "Audio-visual signal distribution device"
+ tcp_port TELNET_PORT
+
+ default_settings({
+ ssh: {
+ username: :Administrator,
+ password: :extron,
+ },
+
+ # if using telnet, use this setting
+ password: :extron,
+ })
+
+ @ready : Bool = false
+
+ def on_load
+ # we can tokenise straight away if using SSH
+ if config.role.ssh?
+ @ready = true
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ end
+ queue.delay = 200.milliseconds
+ on_update
+ end
+
+ def on_update
+ inputs = setting?(UInt16, :input_count) || 8_u16
+ outputs = setting?(UInt16, :output_count) || 1_u16
+ io = MatrixSize.new inputs, outputs
+ @device_size = SwitcherInformation.new video: io, audio: io
+ end
+
+ def disconnected
+ schedule.clear
+
+ # We need to wait for a login prompt if using telnet
+ unless config.role.ssh?
+ @ready = false
+ transport.tokenizer = nil
+ end
+ end
+
+ def connected
+ schedule.every(40.seconds) { query_device_info }
+ end
+
+ getter device_size do
+ empty = MatrixSize.new 0_u16, 0_u16
+ SwitcherInformation.new empty, empty
+ end
+
+ def query_device_info
+ send Command['I'], Response::SwitcherInformation do |info|
+ video_io = MatrixSize.new info.video.inputs, info.video.outputs
+ audio_io = MatrixSize.new info.audio.inputs, info.audio.outputs
+ @device_size = SwitcherInformation.new video: video_io, audio: audio_io
+ self[:video_matrix] = "#{info.video.inputs}x#{info.video.outputs}"
+ self[:audio_matrix] = "#{info.audio.inputs}x#{info.audio.outputs}"
+ info
+ end
+ end
+
+ # Implement the mutable interface (output => old inputs, {audio, video})
+ @muted_audio = {} of UInt16 => UInt16
+ @muted_video = {} of UInt16 => UInt16
+
+ MUTE_INPUT = 0_u16
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ output = index.to_u16
+ return unless output > 0
+
+ switch_layer = case layer
+ in MuteLayer::Audio then MatrixLayer::Aud
+ in MuteLayer::Video then MatrixLayer::Vid
+ in MuteLayer::AudioVideo then MatrixLayer::All
+ end
+
+ if state
+ record_mute(output, switch_layer)
+ switch_one(MUTE_INPUT, output, switch_layer)
+ else
+ video_input = audio_input = MUTE_INPUT
+ video_input = @muted_video.delete(output) || MUTE_INPUT if switch_layer.all? || switch_layer.vid?
+ audio_input = @muted_audio.delete(output) || MUTE_INPUT if switch_layer.all? || switch_layer.aud?
+
+ switch_one(audio_input, output, MatrixLayer::Aud) if audio_input > 0
+ switch_one(video_input, output, MatrixLayer::Vid) if video_input > 0
+ end
+ end
+
+ protected def record_mute(output, layer)
+ video = self["audio#{output}"]?.try(&.as_i.to_u16) || MUTE_INPUT
+ audio = self["video#{output}"]?.try(&.as_i.to_u16) || MUTE_INPUT
+
+ @muted_video[output] = video if video != MUTE_INPUT && (layer.all? || layer.vid?)
+ @muted_audio[output] = audio if audio != MUTE_INPUT && (layer.all? || layer.aud?)
+ end
+
+ # Implementing switchable interface
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ extron_layer = case layer
+ in Nil, .all? then MatrixLayer::All
+ in .audio? then MatrixLayer::Aud
+ in .video? then MatrixLayer::Vid
+ in .data?, .data2?
+ logger.debug { "layer #{layer} not available on extron matrix" }
+ return
+ end
+ if map.size == 1 && map.first_value.size == 1
+ switch_one(map.first_key, map.first_value.first, extron_layer)
+ else
+ switch_map(map, extron_layer)
+ end
+ end
+
+ def switch_to(input : Input)
+ switch_layer input
+ end
+
+ alias Outputs = Array(Output)
+
+ alias SignalMap = Hash(Input, Output | Outputs)
+
+ # Connect a signal *input* to an *output* at the specified *layer*.
+ #
+ # `0` may be used as either an input or output to specify a disconnection at
+ # the corresponding signal point. For example, to disconnect input 1 from all
+ # outputs is is currently feeding `switch(1, 0)`.
+ def switch_one(input : Input, output : Output, layer : MatrixLayer = MatrixLayer::All)
+ @muted_audio.delete(output) if layer.all? || layer.aud?
+ @muted_video.delete(output) if layer.all? || layer.vid?
+ send Command[input, '*', output, layer], Response::Tie, name: "switch-#{output}-#{layer}", &->update_io(Tie)
+ end
+
+ # Connect *input* to all outputs at the specified *layer*.
+ def switch_layer(input : Input, layer : MatrixLayer = MatrixLayer::All)
+ @muted_audio = {} of UInt16 => UInt16 if layer.all? || layer.aud?
+ @muted_video = {} of UInt16 => UInt16 if layer.all? || layer.aud?
+ send Command[input, layer], Response::Switch, name: "present-#{input}-#{layer}", &->update_io(Switch)
+ end
+
+ # Applies a `SignalMap` as a single operation. All included ties will take
+ # simultaneously on the device.
+ def switch_map(map : SignalMap, layer : MatrixLayer = MatrixLayer::All)
+ # Switch one by preference so we can rate limit requests effectively
+ # switching multiple inputs at once is not as common
+ if map.size == 1
+ outp = map.first_value
+ if outp.is_a? Array
+ return switch_one(map.first_key, outp.first, layer) if outp.size == 1
+ else
+ return switch_one(map.first_key, outp, layer)
+ end
+ end
+
+ ties = map.flat_map do |(input, outputs)|
+ if outputs.is_a? Enumerable
+ outputs.each.map do |output|
+ @muted_audio.delete(output) if layer.all? || layer.aud?
+ @muted_video.delete(output) if layer.all? || layer.vid?
+ Tie.new input, output, layer
+ end
+ else
+ @muted_audio.delete(outputs) if layer.all? || layer.aud?
+ @muted_video.delete(outputs) if layer.all? || layer.vid?
+ Tie.new input, outputs, layer
+ end
+ end
+
+ conflicts = ties - ties.uniq(&.output)
+ unless conflicts.empty?
+ raise ArgumentError.new "map contains conflicts for output(s) #{conflicts.join(", ", &.output)}"
+ end
+
+ send Command["\e+Q", ties.map { |tie| [tie.input, '*', tie.output, tie.layer] }, '\r'], Response::Qik do
+ ties.each &->update_io(Tie)
+ end
+ end
+
+ # Sets the audio volume *level* (0..100) on the specified mix *group*.
+ def volume(level : Float64 | Int32, group : Int32 = 1)
+ level = level.to_f.clamp 0.0, 100.0
+ # Device use -1000..0 levels
+ device_level = (level * 10.0).round_away.to_i - 1000
+ send Command["\eD", group, '*', device_level, "GRPM\r"], Response::GroupVolume do
+ level
+ end
+ end
+
+ # Sets the audio mute *state* on the specified *group*.
+ #
+ # NOTE: mute groups may differ from volume groups depending on device
+ # configuration. Default group (2) is program audio.
+ def audio_mute(state : Bool = true, group : Int32 = 2)
+ device_state = state ? '1' : '0'
+ send Command["\eD", group, '*', device_state, "GRPM\r"], Response::GroupMute do
+ state
+ end
+ end
+
+ # Send *command* to the device and yield a parsed response to *block*.
+ private def send(command, parser : SIS::Response::Parser(T), **options, &block : T -> _) forall T
+ logger.debug { "Sending #{command}" }
+ send command, **options do |data, task|
+ logger.debug { "Received #{String.new data}" }
+ case response = Response.parse data, parser
+ in T
+ task.success block.call response
+ in Error
+ response.retryable? ? task.retry response : task.abort response
+ in Response::ParseError
+ task.abort response
+ end
+ end
+ end
+
+ private def send(command, parser : SIS::Response::Parser(T)) forall T
+ send command, parser, &.itself
+ end
+
+ # Response callback for async responses.
+ def received(data, task)
+ logger.debug { "Ready #{@ready}, Received #{String.new data}" }
+
+ if !@ready
+ payload = String.new data
+ if payload =~ /Copyright/i
+ if password = setting?(String, :password)
+ send("#{password}\x0D", wait: false, priority: 99)
+ end
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ @ready = true
+ schedule.in(1.second) { query_device_info }
+ end
+ return
+ end
+
+ case response = Response.parse data, as: Response::Unsolicited
+ in Tie
+ update_io response
+ in Error, Response::ParseError
+ logger.error { response }
+ task.try(&.abort) # task most likely nil here
+ return
+ in Time
+ # End of unsolicited comms on connect
+ query_device_info
+ in String
+ # Copyright and other info messages
+ logger.info { response }
+ in Nil
+ # Empty line
+ end
+ task.try(&.success)
+ end
+
+ private def update_io(input : Input, output : Output, layer : MatrixLayer)
+ self["audio#{output}"] = input if layer.includes_audio?
+ self["video#{output}"] = input if layer.includes_video?
+ end
+
+ private def update_io(tie : Tie)
+ update_io tie.input, tie.output, tie.layer
+ end
+
+ # Update exposed driver state to include *switch*.
+ private def update_io(switch : Switch)
+ if switch.layer.includes_video?
+ device_size.video.outputs.times { |o| update_io switch.input, Output.new(o + 1), MatrixLayer::Vid }
+ end
+ if switch.layer.includes_audio?
+ device_size.audio.outputs.times { |o| update_io switch.input, Output.new(o + 1), MatrixLayer::Aud }
+ end
+ end
+end
diff --git a/drivers/extron/matrix_spec.cr b/drivers/extron/matrix_spec.cr
new file mode 100644
index 00000000000..0ce5a71bf8b
--- /dev/null
+++ b/drivers/extron/matrix_spec.cr
@@ -0,0 +1,56 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Extron::Matrix" do
+ responds "\r\n"
+ responds "(c) Copyright YYYY, Extron Electronics, [model], Vx.xx, 60-XXXX-XX\r\nMon, 18 May 2015 11:27:33\r\n\r\nPassword:"
+ should_send "extron\x0D"
+ responds "Login Administrator\r\n"
+ sleep 1
+
+ should_send "I"
+ responds "V08X04 A8X4\r\n"
+
+ exec :switch_one, input: 3, output: 2
+ should_send "3*2!"
+ responds "Out2 In3 All\r\n"
+ status["video2"].should eq 3
+
+ status["video_matrix"].should eq("8x4")
+ status["audio_matrix"].should eq("8x4")
+
+ exec :switch_to, input: 2
+ should_send "2!"
+ responds "In2 All\r\n"
+ status["video1"].should eq 2
+ status["video2"].should eq 2
+ status["video3"].should eq 2
+ status["video4"].should eq 2
+ status["audio1"].should eq 2
+ status["audio2"].should eq 2
+ status["audio3"].should eq 2
+ status["audio4"].should eq 2
+
+ exec :switch_map, {1 => [2, 3, 4]}
+ should_send "\e+Q1*2!1*3!1*4!\r"
+ responds "Qik\r\n"
+ status["video2"].should eq 1
+ status["video3"].should eq 1
+ status["video4"].should eq 1
+
+ expect_raises PlaceOS::Driver::RemoteException do
+ conflict = exec :switch_map, {1 => 1, 2 => 1}
+ conflict.get
+ end
+
+ expect_raises PlaceOS::Driver::RemoteException do
+ invalid = exec :switch_to, input: 999
+ should_send "999!"
+ responds "E01\r\n"
+ invalid.get
+ end
+
+ vol = exec :volume, level: 25
+ should_send "\eD1*-750GRPM\r"
+ responds "GrpmD1*-750\r\n"
+ vol.get.should eq 25
+end
diff --git a/drivers/extron/sis.cr b/drivers/extron/sis.cr
new file mode 100644
index 00000000000..5b298b027fd
--- /dev/null
+++ b/drivers/extron/sis.cr
@@ -0,0 +1,73 @@
+require "./sis/*"
+
+# Implementation, types and utilities for working with the Extron Simple
+# Instruction Set (SIS) device control protocol.
+#
+# This protocol is used for control of all Extron signal distribution,
+# processing and general audio-visual products via SSH, telnet and serial
+# control.
+module Extron::SIS
+ TELNET_PORT = 23
+ SSH_PORT = 22023
+
+ DELIMITER = "\r\n"
+
+ # Illegal characters for use in property names.
+ SPECIAL_CHARS = "+-,@=‘[]{}<>`“;:|?".chars
+
+ # Symbolic type for representating a successfull interactions no useful data.
+ struct Ok; end
+
+ # Device error numbers
+ enum Error
+ InvalidInput = 1
+ InvalidCommand = 10
+ InvalidPresent = 11
+ InvalidOutput = 12
+ InvalidParameter = 13
+ InvalidForConfig = 14
+ Timeout = 17
+ Busy = 22
+ PrivilegesViolation = 24
+ DeviceNotPresent = 25
+ MaxConnectionsExceeded = 26
+ InvalidEventNumber = 27
+ FileNotFound = 28
+
+ def retryable?
+ timeout? || busy?
+ end
+ end
+
+ alias Input = UInt16
+
+ alias Output = UInt16
+
+ # Layers for targetting signal distribution operations.
+ enum MatrixLayer : UInt8
+ All = 0x21 # '!'
+ Aud = 0x24 # '$'
+ Vid = 0x25 # '%'
+ RGB = 0x26 # '&'
+
+ def includes_video?
+ All || Vid || RGB
+ end
+
+ def includes_audio?
+ All || Aud
+ end
+ end
+
+ # Struct for representing a matrix signal path.
+ record Tie, input : Input, output : Output, layer : MatrixLayer
+
+ # Struct for representing a broadcast signal path, or single output switch.
+ record Switch, input : Input, layer : MatrixLayer
+
+ # IO capacity for a switching layer.
+ record MatrixSize, inputs : Input, outputs : Output
+
+ # IO capacity for a full device.
+ record SwitcherInformation, video : MatrixSize, audio : MatrixSize
+end
diff --git a/drivers/extron/sis/command.cr b/drivers/extron/sis/command.cr
new file mode 100644
index 00000000000..87e710b0a4c
--- /dev/null
+++ b/drivers/extron/sis/command.cr
@@ -0,0 +1,34 @@
+# Structure for representing a SIS device command.
+#
+# Commands are composed from a set of *fields*. The contents and types of these
+# are arbitrary, however they must be capable of serialising to an IO.
+struct Extron::SIS::Command(*T)
+ def initialize(*fields : *T)
+ @fields = fields
+ end
+
+ # Serialises `self` in a format suitable for log messages.
+ def to_s(io : IO)
+ io << '‹'
+ to_io io
+ io << '›'
+ end
+
+ # Writes `self` to the passed *io*.
+ def to_io(io : IO, format = IO::ByteFormat::SystemEndian)
+ @fields.each.flatten.each do |field|
+ if field.is_a? Enum
+ io.write_byte field.value
+ else
+ io << field
+ end
+ end
+ end
+
+ # Syntactical suger for `Command` definition. Provides the ability to express
+ # command fields in the same way as `Byte` objects and other similar
+ # collections from the Crystal std lib.
+ macro [](*fields)
+ Extron::SIS::Command.new({{*fields}})
+ end
+end
diff --git a/drivers/extron/sis/response.cr b/drivers/extron/sis/response.cr
new file mode 100644
index 00000000000..14313a90996
--- /dev/null
+++ b/drivers/extron/sis/response.cr
@@ -0,0 +1,113 @@
+require "pars"
+
+# Parsers for responses and asynchronous messages originating from Extron SIS
+# devices.
+module Extron::SIS::Response
+ include Pars
+
+ # Parses a response packet with specified *parser*.
+ #
+ # Returns the parser output, a parse error or a device error.
+ def self.parse(data : String, as parser : Parser(T)) forall T
+ (parser | DeviceError | "unhandled device response").parse data
+ end
+
+ # :ditto:
+ def self.parse(data : Bytes, as parser : Parser(T)) forall T
+ parse String.new(data), parser
+ end
+
+ # Parses a number from the input into *type*.
+ private def self.num(type : T.class) forall T
+ Parse.integer.map &->T.new(String)
+ end
+
+ # Parses a word from the input into an enum of *type*.
+ private def self.word_as_enum(type : T.class) forall T
+ Parse.word.map &->T.parse(String)
+ end
+
+ # :nodoc:
+ BoolField = Parse.char('0') >> Parse.const(false) | Parse.char('1') >> Parse.const(true)
+
+ # :nodoc:
+ Delimiter = Parse.string SIS::DELIMITER
+
+ # Parse a full command response as a String. Delimiter is optional as it may
+ # have already been dropped by an upstream tokenizer.
+ Raw = ((Parse.char ^ Delimiter) * (0..) << Delimiter * (0..1)).map &.join
+
+ # Error codes returned from the device.
+ DeviceError = Parse.char('E') >> Parse.integer.map { |e| SIS::Error.new e.to_i }
+
+ # Copyright message shown on connect.
+ Copyright = (Parse.string("(c) Copyright") + Raw).map &.join
+
+ # Part of the copyright banner, but appears on a new line so will tokenize as
+ # as standalone message.
+ Clock = Raw.map { |date| Time.parse_utc date, "%a, %d %b %Y %T" }
+
+ # Quick response, occurs following quick tie, or switching interaction from
+ # the device's front panel.
+ Qik = Parse.string("Qik") >> Parse.const(Ok.new)
+
+ # Matrix signal route update.
+ Tie = Parse.do({
+ output <= Parse.string("Out") >> num(Output),
+ _ <= Parse.char(' '),
+ input <= Parse.string("In") >> num(Input),
+ _ <= Parse.char(' '),
+ layer <= word_as_enum(MatrixLayer),
+ Parse.const SIS::Tie.new input, output, layer,
+ })
+
+ # Broadcast or single output route update.
+ Switch = Parse.do({
+ input <= Parse.string("In") >> num(Input),
+ _ <= Parse.char(' '),
+ layer <= word_as_enum(MatrixLayer),
+ Parse.const SIS::Switch.new input, layer,
+ })
+
+ # Group volume update / response. Level are provided in the raw device range
+ # of -1000..0.
+ GroupVolume = Parse.do({
+ _ <= Parse.string("GrpmD"),
+ group <= num(Int32),
+ _ <= Parse.char('*'),
+ _ <= Parse.char('-'),
+ level <= num(Int32).map { |val| val * -1 },
+ Parse.const({level, group}),
+ })
+
+ # Group audio mute update / response. Level are provided in the raw device range
+ # of -1000..0.
+ GroupMute = Parse.do({
+ _ <= Parse.string("GrpmD"),
+ group <= num(Int32),
+ _ <= Parse.char('*'),
+ state <= BoolField,
+ Parse.const({state, group}),
+ })
+
+ MatrixSize = Parse.do({
+ inputs <= num(Input),
+ _ <= Parse.char('X'),
+ outputs <= num(Output),
+ Parse.const SIS::MatrixSize.new inputs, outputs,
+ })
+
+ SwitcherInformation = Parse.do({
+ _ <= Parse.char('V'),
+ video <= MatrixSize,
+ _ <= Parse.char(' '),
+ _ <= Parse.char('A'),
+ audio <= MatrixSize,
+ Parse.const SIS::SwitcherInformation.new video, audio,
+ })
+
+ Empty = Parse.string("\r\n") >> Parse.const(nil)
+
+ # Async messages that can be expected outside of a command -> response flow.
+ Unsolicited = DeviceError | Tie | Copyright | Clock | Empty
+end
diff --git a/drivers/extron/sis_spec.cr b/drivers/extron/sis_spec.cr
new file mode 100644
index 00000000000..1ef209192e7
--- /dev/null
+++ b/drivers/extron/sis_spec.cr
@@ -0,0 +1,136 @@
+require "spec"
+require "./sis"
+
+include Extron::SIS
+
+describe Command do
+ it "forms a Command from arbitrary field types" do
+ Command.new 42, 'a', "foo"
+ end
+
+ it "serialises the command to an IO" do
+ command = Command[1, '*', 2, MatrixLayer::All]
+ io = IO::Memory.new
+ io.write_bytes command
+ io.to_s.should eq("1*2!")
+ end
+
+ it "provides a string representation suitable for logging" do
+ command = Command[1, '*', 2, MatrixLayer::All]
+ command.to_s.should eq("‹1*2!›")
+ end
+
+ it "flattens nested fields" do
+ routes = [
+ [1, '*', 2, MatrixLayer::All],
+ [3, '*', 4, MatrixLayer::All],
+ ]
+ command = Command["\e+Q", routes, '\r']
+ io = IO::Memory.new
+ io.write_bytes command
+ io.to_s.should eq("\e+Q1*2!3*4!\r")
+ end
+end
+
+describe Response do
+ describe Response::DeviceError do
+ it "parses to a SIS::Error" do
+ error = Response::DeviceError.parse "E17"
+ error.should eq(Error::Timeout)
+ end
+ end
+
+ describe Response::Copyright do
+ it "parses and provides the full banner" do
+ message = "(c) Copyright YYYY, Extron Electronics, Model Name, Vx.xx, nn-nnnn-nn"
+ parsed = Response::Copyright.parse message
+ parsed.should be_a(String)
+ parsed.should eq(message)
+ end
+
+ it "does not parse other messages" do
+ parsed = Response::Copyright.parse "foo"
+ parsed.should be_a(Response::ParseError)
+ end
+ end
+
+ describe Response::Clock do
+ it "parses" do
+ clock = "Fri, 13 Feb 2009 23:31:30"
+ parsed = Response::Clock.parse clock
+ parsed.as(Time).to_unix.should eq(1234567890)
+ end
+ end
+
+ describe Response::Tie do
+ it "parses" do
+ tie = Response::Tie.parse "Out2 In1 All"
+ tie.should be_a Tie
+ tie = tie.as Tie
+ tie.input.should eq(1)
+ tie.output.should eq(2)
+ tie.layer.should eq(MatrixLayer::All)
+ end
+ end
+
+ describe Response::Switch do
+ it "parses" do
+ tie = Response::Switch.parse "In1 All"
+ tie.should be_a Switch
+ tie = tie.as Switch
+ tie.input.should eq(1)
+ tie.layer.should eq(MatrixLayer::All)
+ end
+ end
+
+ describe Response::SwitcherInformation do
+ it "parses" do
+ info = Response::SwitcherInformation.parse "V1X2 A3X4"
+ info.should be_a SwitcherInformation
+ info = info.as SwitcherInformation
+ info.video.inputs.should eq 1
+ info.video.outputs.should eq 2
+ info.audio.inputs.should eq 3
+ info.audio.outputs.should eq 4
+ end
+ end
+
+ describe Response::GroupVolume do
+ it "parses" do
+ vol = Response::GroupVolume.parse "GrpmD1*-500"
+ if vol.is_a? Response::ParseError
+ fail "parse error: #{vol}"
+ else
+ level, group = vol
+ level.should eq -500
+ group.should eq 1
+ end
+ end
+ end
+
+ describe Response::GroupMute do
+ it "parses" do
+ mute = Response::GroupMute.parse "GrpmD2*1"
+ if mute.is_a? Response::ParseError
+ fail "parse error: #{mute}"
+ else
+ state, group = mute
+ state.should be_true
+ group.should eq 2
+ end
+ end
+ end
+
+ describe ".parse" do
+ it "builds a parser that includes device errors" do
+ resp = Response.parse "Out4 In2 Aud", as: Response::Tie
+ typeof(resp).should eq(Tie | Error | Response::ParseError)
+ end
+
+ it "fails for unhandled responses" do
+ resp = Response.parse "not a real response", as: Response::Switch
+ resp.should be_a(Response::ParseError)
+ resp.as(Response::ParseError).message.should eq("unhandled device response")
+ end
+ end
+end
diff --git a/drivers/extron/usb_extender_plus/endpoint.cr b/drivers/extron/usb_extender_plus/endpoint.cr
new file mode 100644
index 00000000000..2a34feee255
--- /dev/null
+++ b/drivers/extron/usb_extender_plus/endpoint.cr
@@ -0,0 +1,130 @@
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/Extron/usb_extender_plus.pdf
+
+class Extron::UsbExtenderPlus::Endpoint < PlaceOS::Driver
+ generic_name :USB_Device
+ descriptive_name "Extron USB Extender Plus Endpoint"
+ description "Audio-visual signal distribution device"
+ udp_port 6137
+
+ default_settings({
+ mac_address: "FF:FF:FF:FF:FF:FF",
+ location: "under_desk",
+ })
+
+ @joined_to : Array(String) = [] of String
+
+ def on_load
+ queue.delay = 300.milliseconds
+
+ # mac addresses this is connected to
+ self[:joined_to] = @joined_to
+ on_update
+ end
+
+ def on_update
+ # Ensure the MAC address is in a consistent format
+ self[:mac_address] = setting(String, :mac_address).gsub(/\-|\:/, "").downcase
+ self[:ip] = config.ip
+ self[:port] = config.port
+
+ # human readable location of the device
+ self[:location] = setting(String, :location)
+
+ schedule.clear
+ schedule.every(2.minutes) do
+ logger.debug { "-- polling extron USB device" }
+
+ # Manually set the connection state (UDP device)
+ if query_joins.success?
+ set_connected_state(true) unless self[:connected]
+ end
+ end
+ end
+
+ def connected
+ query_joins
+ end
+
+ def query_joins
+ task = send("2f03f4a2000000000300".hexbytes).get
+ if !task.state.success?
+ set_connected_state(false) if self[:connected]
+ logger.warn { "Extron USB Device Probably Offline: #{config.ip}\nJoin query failed." }
+ end
+ task.state
+ end
+
+ def unjoin_all
+ unjoins = [] of PlaceOS::Driver::Task
+
+ if @joined_to.empty?
+ logger.debug { "nothing to unjoin from" }
+ end
+
+ @joined_to.each do |mac|
+ unjoins << send_unjoin(mac)
+ end
+
+ unjoins.each(&.get)
+ query_joins
+ end
+
+ def unjoin(from : String | Int32)
+ mac = case from
+ in Int32
+ @joined_to[from]
+ in String
+ formatted = from.gsub(/\-|\:/, "").downcase
+ formatted if @joined_to.includes? formatted
+ end
+
+ if mac
+ send_unjoin(mac).get
+ query_joins
+ else
+ logger.debug { "not currently joined to #{from}" }
+ end
+ end
+
+ def join(mac : String)
+ mac = mac.gsub(/\-|\:/, "").downcase
+ logger.debug { "joining with #{mac}" }
+ send("2f03f4a2020000000302#{mac}".hexbytes, delay: 600.milliseconds).get
+ query_joins
+ end
+
+ protected def send_unjoin(mac : String)
+ logger.debug { "unjoining from #{mac}" }
+ send "2f03f4a2020000000303#{mac}".hexbytes, delay: 600.milliseconds
+ end
+
+ def received(data, task)
+ resp = data.hexstring
+ logger.debug { "Extron USB sent: #{resp}" }
+
+ check = resp[0..21]
+ if check == "2f03f4a200000000030100" || check == "2f03f4a200000000030101"
+ self[:is_host] = check[-1] == '0'
+
+ macs = resp[22..-1].scan(/.{12}/).map(&.to_s)
+ logger.debug { "Extron USB joined with: #{macs}" }
+ self[:joined_to] = @joined_to = macs
+ else
+ case resp
+ when "2f03f4a2010000000003"
+ logger.debug { "Extron USB responded to UDP ping" }
+ when "2f03f4a2020000000003"
+ logger.debug { "join/unjoin success" }
+ when "2f03f4a2020000000308"
+ # I think this is what this is.. just a guess
+ logger.debug { "join/unjoin might have failed.." }
+ else
+ logger.info { "Unknown response from extron: #{resp}" }
+ end
+ end
+
+ task.try &.success
+ end
+end
diff --git a/drivers/extron/usb_extender_plus/endpoint_spec.cr b/drivers/extron/usb_extender_plus/endpoint_spec.cr
new file mode 100644
index 00000000000..3aa965479ff
--- /dev/null
+++ b/drivers/extron/usb_extender_plus/endpoint_spec.cr
@@ -0,0 +1,42 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Extron::UsbExtenderPlus::Endpoint" do
+ # On connect it queries the state of the device
+ should_send("2f03f4a2000000000300".hexbytes)
+ responds ("2f03f4a200000000030100" + "00155d569914" + "BC091BEC625A").hexbytes
+
+ status[:joined_to].should eq ["00155d569914", "bc091bec625a"]
+ status[:is_host].should be_true
+ status[:mac_address].should eq "ffffffffffff"
+
+ req = exec(:unjoin, "00155D569914")
+ should_send("2f03f4a202000000030300155d569914".hexbytes)
+ responds "2f03f4a2020000000003".hexbytes
+
+ # account for delay of 600ms
+ sleep 0.6
+
+ should_send("2f03f4a2000000000300".hexbytes)
+ responds ("2f03f4a200000000030101" + "BC091BEC625A").hexbytes
+
+ req.get
+
+ status[:joined_to].should eq ["bc091bec625a"]
+ status[:is_host].should be_false
+
+ sleep 0.3
+
+ req = exec(:join, "000000000000")
+ should_send("2f03f4a2020000000302000000000000".hexbytes)
+ responds "2f03f4a2020000000003".hexbytes
+
+ sleep 0.6
+
+ should_send("2f03f4a2000000000300".hexbytes)
+ responds ("2f03f4a200000000030100" + "BC091BEC625A" + "000000000000").hexbytes
+
+ req.get
+
+ status[:joined_to].should eq ["bc091bec625a", "000000000000"]
+ status[:is_host].should be_true
+end
diff --git a/drivers/extron/usb_extender_plus/virtual_switcher.cr b/drivers/extron/usb_extender_plus/virtual_switcher.cr
new file mode 100644
index 00000000000..45b9dbecee7
--- /dev/null
+++ b/drivers/extron/usb_extender_plus/virtual_switcher.cr
@@ -0,0 +1,152 @@
+require "placeos-driver"
+require "placeos-driver/interface/switchable"
+
+# A USB Device (webcam, keyboard, etc) can only connect to a single Host (computer) at a time.
+# --> the USB Hosts are the inputs of this switcher
+# --> the devices are the outputs.
+# This is because a host can connect to multiple devices
+
+class Extron::UsbExtenderPlus::VirtualSwitcher < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::Switchable(Int32, Int32)
+
+ generic_name :USB_Switcher
+ descriptive_name "Extron USB Extender Plus Switcher"
+
+ accessor hosts : Array(USB_Host)
+ accessor devices : Array(USB_Device)
+
+ getter host_macs : Hash(String, Int32) do
+ hash = {} of String => Int32
+ hosts.each_with_index do |host, index|
+ hash[host.status(String, :mac_address)] = index
+ end
+ hash
+ end
+
+ getter device_macs : Hash(String, Int32) do
+ hash = {} of String => Int32
+ devices.each_with_index do |device, index|
+ hash[device.status(String, :mac_address)] = index
+ end
+ hash
+ end
+
+ # lazily obtain host and device mac addresses
+ def on_update
+ @host_macs = nil
+ @device_macs = nil
+ end
+
+ # 0 == unjoin, input is the host index
+ def switch_to(input : Int32)
+ if input == 0
+ unjoin_all
+ else
+ host = hosts[input - 1]
+ host_mac = host.status(String, :mac_address)
+
+ unjoin_all_devices
+ unjoin_all_hosts
+ devices.each { |device| perform_join(host, device) }
+ end
+ end
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ layer ||= SwitchLayer::All
+ return unless layer.all? || layer.data? || layer.data2?
+
+ # input hosts => output devices
+ map.each do |host_idx, device_idxs|
+ # unjoin the devices
+ if host_idx == 0
+ device_idxs.each do |device_idx|
+ device = devices[device_idx - 1]?
+ unless device
+ logger.warn { "device USB_Device_#{device_idx} not found switching to 0" }
+ next
+ end
+ perform_unjoin(device)
+ end
+ next
+ end
+
+ # join the devices to the following host
+ host = hosts[host_idx - 1]?
+ unless host
+ logger.warn { "host not found in switch USB_Host_#{host_idx} => #{device_idxs}" }
+ next
+ end
+
+ device_idxs.each do |device_idx|
+ device = devices[device_idx - 1]?
+ unless device
+ logger.warn { "device USB_Device_#{device_idx} not found switching to USB_Host_#{host_idx}" }
+ next
+ end
+ perform_join(host, device)
+ end
+ end
+ end
+
+ protected def unjoin_all_devices
+ devices.map(&.unjoin_all).each_with_index do |request, index|
+ begin
+ request.get
+ rescue error
+ logger.warn { "failed to unjoin USB_Device_#{index + 1}" }
+ end
+ end
+ end
+
+ protected def unjoin_all_hosts
+ hosts.map(&.unjoin_all).each_with_index do |request, index|
+ begin
+ request.get
+ rescue error
+ logger.warn { "failed to unjoin USB_Host_#{index + 1}" }
+ end
+ end
+ end
+
+ protected def unjoin_all
+ unjoin_all_devices
+ unjoin_all_hosts
+ end
+
+ protected def perform_join(host, device)
+ device_mac = device.status(String, :mac_address)
+ host_mac = host.status(String, :mac_address)
+
+ host_joined_to = host.status(Array(String), :joined_to)
+ device_joined_to = device.status(Array(String), :joined_to)
+
+ host_joined = host_joined_to.includes?(device_mac)
+ device_joined = device_joined_to.includes?(host_mac)
+
+ return if host_joined && device_joined
+
+ if !device_joined
+ # unjoin the device from the previous host
+ if unjoin_host_mac = device_joined_to.first?
+ device.unjoin_all.get
+ hosts[host_macs[unjoin_host_mac]].unjoin(device_mac)
+ end
+
+ # join the device to the new host
+ device.join(host_mac).get
+ end
+
+ host.join(device_mac) unless host_joined
+ rescue error
+ logger.warn(exception: error) { "error joining #{host.module_name}_#{host.index} => #{device.module_name}_#{device.index}" }
+ end
+
+ protected def perform_unjoin(device)
+ device_mac = device.status(String, :mac_address)
+ device_joined_to = device.status(Array(String), :joined_to)
+ if unjoin_host_mac = device_joined_to.first?
+ device.unjoin_all.get
+ hosts[host_macs[unjoin_host_mac]].unjoin(device_mac)
+ end
+ end
+end
diff --git a/drivers/extron/usb_extender_plus/virtual_switcher_spec.cr b/drivers/extron/usb_extender_plus/virtual_switcher_spec.cr
new file mode 100644
index 00000000000..faa840e0e54
--- /dev/null
+++ b/drivers/extron/usb_extender_plus/virtual_switcher_spec.cr
@@ -0,0 +1,80 @@
+require "placeos-driver/spec"
+require "random"
+
+DriverSpecs.mock_driver "Extron::UsbExtenderPlus::VirtualSwitcher" do
+ system({
+ USB_Host: {EndpointMock, EndpointMock},
+ USB_Device: {EndpointMock, EndpointMock},
+ })
+
+ exec(:switch_to, 1).get
+
+ host1_mac = system(:USB_Host_1)["mac_address"].as_s
+ host2_mac = system(:USB_Host_2)["mac_address"].as_s
+ dev1_mac = system(:USB_Device_1)["mac_address"].as_s
+ dev2_mac = system(:USB_Device_2)["mac_address"].as_s
+
+ system(:USB_Device_1)["joined_to"].should eq [host1_mac]
+ system(:USB_Device_2)["joined_to"].should eq [host1_mac]
+ system(:USB_Host_1)["joined_to"].should eq [dev1_mac, dev2_mac]
+ system(:USB_Host_2)["joined_to"].should eq [] of String
+
+ exec(:switch_to, 2).get
+ system(:USB_Device_1)["joined_to"].should eq [host2_mac]
+ system(:USB_Device_2)["joined_to"].should eq [host2_mac]
+ system(:USB_Host_1)["joined_to"].should eq [] of String
+ system(:USB_Host_2)["joined_to"].should eq [dev1_mac, dev2_mac]
+
+ exec(:switch, {1 => [2]}).get
+ sleep 0.1
+ system(:USB_Device_1)["joined_to"].should eq [host2_mac]
+ system(:USB_Device_2)["joined_to"].should eq [host1_mac]
+ system(:USB_Host_1)["joined_to"].should eq [dev2_mac]
+ system(:USB_Host_2)["joined_to"].should eq [dev1_mac]
+
+ exec(:switch, {1 => [1], 2 => [2]}).get
+ sleep 0.1
+ system(:USB_Device_1)["joined_to"].should eq [host1_mac]
+ system(:USB_Device_2)["joined_to"].should eq [host2_mac]
+ system(:USB_Host_1)["joined_to"].should eq [dev1_mac]
+ system(:USB_Host_2)["joined_to"].should eq [dev2_mac]
+end
+
+# :nodoc:
+class EndpointMock < DriverSpecs::MockDriver
+ @joined_to : Array(String) = [] of String
+
+ def on_load
+ self[:mac_address] = Random::Secure.hex(6).downcase
+ self[:joined_to] = [] of String
+ end
+
+ def query_joins
+ @joined_to
+ end
+
+ def unjoin_all
+ self[:joined_to] = @joined_to = [] of String
+ end
+
+ def unjoin(from : String | Int32)
+ mac = case from
+ in Int32
+ @joined_to[from]
+ in String
+ formatted = from.gsub(/\-|\:/, "").downcase
+ formatted if @joined_to.includes? formatted
+ end
+
+ if mac
+ @joined_to.delete(mac)
+ self[:joined_to] = @joined_to
+ end
+ end
+
+ def join(mac : String)
+ mac = mac.gsub(/\-|\:/, "").downcase
+ @joined_to << mac
+ self[:joined_to] = @joined_to
+ end
+end
diff --git a/drivers/floorsense/booking-sync-overview.md b/drivers/floorsense/booking-sync-overview.md
new file mode 100644
index 00000000000..5725377f484
--- /dev/null
+++ b/drivers/floorsense/booking-sync-overview.md
@@ -0,0 +1,93 @@
+# Floorsense Boooking Sync
+
+This is a quick overview of how PlaceOS interacts with Floorsense to enable floorsense features such as desk check-in.
+
+
+## System layout
+
+You'll need the following drivers and corresponding modules added to a system to sync bookings between PlaceOS and Floorsense
+
+* `PlaceOS Staff API` this provides access to PlaceOS API functions
+* `Floorsense Desk Tracking` this implements the floorsense API functions
+* `Floorsense Bookings Sync` this keeps the two systems in sync
+
+
+## Sync Configuration
+
+Plan IDs in Floorsense need to be mapped to zones in PlaceOS, this configuration should be configured in the `Floorsense Bookings Sync` driver
+
+```yaml
+
+floor_mappings:
+ '1':
+ building_id: zone-GAf3dfZq8
+ level_id: zone-GAf5RN-ne
+ name: Building Name - Level 16
+
+# Timezone of the building
+time_zone: Australia/Brisbane
+
+# time in seconds between polling for changes
+poll_rate: 3
+
+```
+
+NOTE:: this assumes that desk ids in Floorsense and PlaceOS match, which is desirable from a maintenance and support standpoint.
+
+There are some additional settings for adding prefixes to desk names, but these should be avoided if at all possible
+
+
+## Driver operation
+
+The driver monitors a couple of things at once
+
+* monitors for real-time changes occurring in PlaceOS Staff API
+ * changes in booking state, bookings added or removed
+* polls PlaceOS bookings periodically in case a booking was missed
+* uses Floorsense websocket to detect new ad-hoc bookings, booking check-outs or check-ins
+
+PlaceOS bookings are added to floorsense 1 day before the booking,
+so todays and tomorrows bookings are kept in sync.
+
+
+### PlaceOS Booking Created
+
+1. Sync determines that a new Floorsense booking needs to be created
+2. Checks the user exists in Floorsense and adds them if they don't
+3. Checks the users card number is correct in Floorsense, updates this if not
+4. Creates the booking in Floorsense
+
+
+### PlaceOS Booking Deleted
+
+1. Sync determines that the Floorsense booking needs to be removed
+2. The Floorsense booking is released
+
+
+### PlaceOS Booking Checked-in
+
+1. Sync determines that the Floorsense booking needs to be confirmed
+2. The Floorsense booking is confirmed, enabling power
+
+
+### Floorsense Booking Created
+
+1. Checks that the booking is an ad-hoc booking
+2. Attempts to locate the equivalent user in PlaceOS
+3. Creates the booking on behalf of the user in PlaceOS
+
+Only ad-hoc bookings are created in PlaceOS.
+All other booking types will be removed automatically as part of the sync if there are no matching PlaceOS bookings.
+
+
+### Floorsense Booking Checked-in
+
+1. Attempts to locate the booking in PlaceOS
+2. Marks the booking as checked-in
+
+
+### Floorsense Booking Checked-out
+
+1. Attempts to locate the booking in PlaceOS
+2. Changes the end time of the booking to now (so the booking has effectively ended)
+3. This has the effect of freeing the desk
diff --git a/drivers/floorsense/bookings_sync.cr b/drivers/floorsense/bookings_sync.cr
new file mode 100644
index 00000000000..7b4bad72055
--- /dev/null
+++ b/drivers/floorsense/bookings_sync.cr
@@ -0,0 +1,666 @@
+require "uri"
+require "json"
+require "oauth2"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "./models"
+
+class Floorsense::BookingsSync < PlaceOS::Driver
+ descriptive_name "Floorsense Desk Bookings Sync"
+ generic_name :FloorsenseBookingSync
+ description %(syncs PlaceOS desk bookings with floorsense booking system)
+
+ accessor floorsense : Floorsense_1
+ accessor staff_api : StaffAPI_1
+ accessor area_management : AreaManagement_1
+
+ bind Floorsense_1, :event_49, :booking_created
+ bind Floorsense_1, :event_50, :booking_released
+ bind Floorsense_1, :event_53, :booking_confirmed
+
+ default_settings({
+ floor_mappings: {
+ "planid": {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+ time_zone: "GMT",
+ poll_rate: 3,
+ key_prefix: "desk-",
+ strip_leading_zero: true,
+ zero_padding_size: 7,
+ })
+
+ @floor_mappings : Hash(String, NamedTuple(building_id: String?, level_id: String)) = {} of String => NamedTuple(building_id: String?, level_id: String)
+ # Level zone => plan_id
+ @zone_mappings : Hash(String, String) = {} of String => String
+ # Level zone => building_zone
+ @building_mappings : Hash(String, String?) = {} of String => String?
+
+ @booking_type : String = "desk"
+ @key_prefix : String = "desk-"
+ @strip_leading_zero : Bool = true
+ @zero_padding_size : Int32 = 7
+ @poll_rate : Time::Span = 3.seconds
+ @time_zone : Time::Location = Time::Location.load("GMT")
+
+ @sync_lock = Mutex.new
+
+ def on_load
+ monitor("staff/booking/changed") do |_subscription, payload|
+ logger.debug { "received booking changed event #{payload}" }
+ booking_changed(Booking.from_json(payload))
+ end
+ on_update
+ end
+
+ def on_update
+ @key_prefix = setting?(String, :key_prefix) || ""
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+ @strip_leading_zero = setting?(Bool, :strip_leading_zero) || false
+ @zero_padding_size = setting?(Int32, :zero_padding_size) || 7
+
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+ @poll_rate = (setting?(Int32, :poll_rate) || 3).seconds
+
+ @floor_mappings = setting(Hash(String, NamedTuple(building_id: String?, level_id: String)), :floor_mappings)
+ @floor_mappings.each do |plan_id, details|
+ level = details[:level_id]
+ @building_mappings[level] = details[:building_id]
+ @zone_mappings[level] = plan_id
+ end
+
+ time_zone = setting?(String, :calendar_time_zone).presence || "GMT"
+ @time_zone = Time::Location.load(time_zone)
+
+ schedule.clear
+
+ # schedule.in(500.milliseconds) { @sync_lock.synchronize { check_floorsense_log } }
+ # schedule.every(@poll_rate) { @sync_lock.synchronize { check_floorsense_log } }
+
+ # between polls, sync the bookings
+ schedule.in(@poll_rate / 2) do
+ schedule.every(@poll_rate * 10) { sync_bookings }
+ sync_bookings
+ end
+ end
+
+ # ===================================
+ # Desk ID manipulation
+ # ===================================
+ def to_place_asset_id(key : String)
+ key = key.lstrip('0') if @strip_leading_zero
+ "#{@key_prefix}#{key}"
+ end
+
+ def to_floor_key(asset_id : String)
+ asset_id = asset_id.lstrip(@key_prefix) if @key_prefix.presence
+ asset_id = asset_id.rjust(@zero_padding_size, '0') if @strip_leading_zero
+ asset_id
+ end
+
+ # ===================================
+ # Listening for events
+ # ===================================
+ private def booking_created(_subscription, event_info)
+ event = NamedTuple(booking: BookingStatus?).from_json(event_info)
+ booking = event[:booking]
+ return unless booking
+ return if booking.booking_type != "adhoc"
+ booking_key = booking.key
+ return unless booking_key
+
+ floor_details = @floor_mappings[booking.planid.to_s]?
+ return unless floor_details
+ booking.user = User.from_json floorsense.get_user(booking.uid).get.to_json
+
+ user_email = booking.user.not_nil!.email.try &.downcase
+
+ if user_email.nil?
+ logger.warn { "no user email defined for floorsense user #{booking.user.not_nil!.name}" }
+ return
+ end
+
+ user = staff_api.user(user_email).get
+ user_id = user["id"]
+ user_name = user["name"]
+
+ logger.debug { "new floorsense booking found #{booking.inspect}" }
+
+ staff_api.create_booking(
+ booking_start: booking.start,
+ booking_end: booking.finish,
+ time_zone: @time_zone.to_s,
+ booking_type: @booking_type,
+ asset_id: to_place_asset_id(booking_key),
+ user_id: user_id,
+ user_email: user_email,
+ user_name: user_name,
+ zones: [floor_details[:building_id]?, floor_details[:level_id]].compact,
+ checked_in: true,
+ extension_data: {
+ floorsense_id: booking.booking_id,
+ },
+ ).get
+
+ area_management.update_available([floor_details[:level_id]])
+ end
+
+ private def booking_released(_subscription, event_info)
+ event = JSON.parse(event_info)
+ booking = BookingStatus.from_json floorsense.get_booking(event["bkid"]).get.to_json
+ floor_details = @floor_mappings[booking.planid.to_s]?
+ return unless floor_details
+
+ # ignore bookings that were cancelled outside of today
+ return if booking.released >= booking.finish || booking.released <= booking.start
+
+ # find placeos booking
+ if place_booking = get_place_booking(booking, floor_details)
+ # change the placeos end time if the booking has started
+ staff_api.update_booking(
+ booking_id: place_booking.id,
+ booking_end: booking.released
+ ).get
+ else
+ logger.warn { "no booking found for released booking #{booking.booking_id}" }
+ end
+
+ area_management.update_available([floor_details[:level_id]])
+ end
+
+ private def booking_confirmed(_subscription, event_info)
+ event = JSON.parse(event_info)
+ booking = BookingStatus.from_json floorsense.get_booking(event["bkid"]).get.to_json
+ floor_details = @floor_mappings[booking.planid.to_s]?
+ return unless floor_details
+
+ begin
+ if desc = booking.desc
+ place_booking = Booking.from_json staff_api.get_booking(desc.to_i64).get.to_json
+ staff_api.booking_check_in(place_booking.id, booking.confirmed)
+
+ area_management.update_available([floor_details[:level_id]])
+ end
+ rescue ArgumentError
+ # was an adhoc booking
+ end
+ end
+
+ # ===================================
+ # Polling for events
+ # ===================================
+ @last_event_id : Int64? = nil
+ @last_event_at : Int64 = 0_i64
+
+ def check_floorsense_log : Nil
+ last_event_id = @last_event_id
+ if last_event_id.nil?
+ recent = floorsense.event_log({49, 50, 53}).get.as_a
+ if !recent.empty?
+ last = recent.last
+ @last_event_id = last["eventid"].as_i64
+ @last_event_at = last["eventtime"].as_i64
+ end
+ return
+ end
+
+ events = Array(LogEntry).from_json floorsense.event_log(
+ codes: {49, 50, 53},
+ after: @last_event_at,
+ limit: 500
+ ).get.to_json
+
+ # it returns all the events that happened at the time specified
+ # some of these might have happened before this event id
+ # and it'll always return the last seen event id
+ events.reject! { |event| event.eventid <= last_event_id }
+ return if events.empty?
+
+ logger.debug { "parsing floorsense event log, #{events.size} new events" }
+
+ @last_event_id = events.last.eventid
+ events.each do |event|
+ begin
+ booking = BookingStatus.from_json floorsense.get_booking(event.bkid).get.to_json
+ floor_details = @floor_mappings[booking.planid.to_s]?
+ next unless floor_details
+
+ booking_key = booking.key
+ next unless booking_key
+
+ case event.code
+ when 49 # BOOKING_CREATE (ad-hoc?)
+ next if booking.booking_type != "adhoc"
+
+ user_email = booking.user.not_nil!.email.try &.downcase
+
+ if user_email.nil?
+ logger.warn { "no user email defined for floorsense user #{booking.user.not_nil!.name}" }
+ next
+ end
+
+ user = staff_api.user(user_email).get
+ user_id = user["id"]
+ user_name = user["name"]
+
+ logger.debug { "new floorsense booking found #{booking}" }
+
+ staff_api.create_booking(
+ booking_start: booking.start,
+ booking_end: booking.finish,
+ time_zone: @time_zone.to_s,
+ booking_type: @booking_type,
+ asset_id: to_place_asset_id(booking_key),
+ user_id: user_id,
+ user_email: user_email,
+ user_name: user_name,
+ zones: [floor_details[:building_id]?, floor_details[:level_id]].compact,
+ checked_in: true,
+ extension_data: {
+ floorsense_id: event.bkid,
+ },
+ ).get
+ when 50 # BOOKING_RELEASE (booking ended)
+ # ignore bookings that were cancelled outside of today
+ next if booking.released >= booking.finish || booking.released <= booking.start
+
+ # find placeos booking
+ if place_booking = get_place_booking(booking, floor_details)
+ # change the placeos end time if the booking has started
+ staff_api.update_booking(
+ booking_id: place_booking.id,
+ booking_end: booking.released
+ ).get
+ else
+ logger.warn { "no booking found for released booking #{booking.booking_id}" }
+ end
+ when 51 # BOOKING_UPDATE (booking changed)
+ when 52 # BOOKING_ACTIVATE (advanced booking - i.e. tomorrow)
+ when 53 # BOOKING_CONFIRM (checked in)
+ # find placeos booking (should only fail here for adhoc which are already checked in)
+ begin
+ if desc = booking.desc
+ place_booking = Booking.from_json staff_api.get_booking(desc.to_i64).get.to_json
+ staff_api.booking_check_in(place_booking.id, booking.confirmed)
+ end
+ rescue ArgumentError
+ # was an adhoc booking
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "while processing #{event.eventid}\n#{event.inspect}" }
+ end
+ end
+ end
+
+ protected def get_place_booking(freespace_booking, floor_details) : Booking?
+ if desc = freespace_booking.desc
+ Booking.from_json staff_api.get_booking(desc.to_i64).get.to_json
+ else
+ search_place_booking(freespace_booking, floor_details)
+ end
+ rescue ArgumentError
+ # in case the description was unexpectedly not an int64 (adhoc for instance)
+ search_place_booking(freespace_booking, floor_details)
+ end
+
+ protected def search_place_booking(freespace_booking, floor_details)
+ user_email = freespace_booking.user.not_nil!.email.try &.downcase
+
+ if user_email.nil?
+ logger.warn { "no user email defined for floorsense user #{freespace_booking.user.not_nil!.name}" }
+ return nil
+ end
+
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: freespace_booking.start,
+ period_end: freespace_booking.finish,
+ zones: {floor_details[:level_id]},
+ email: user_email
+ ).get.as_a
+
+ bookings.compact_map { |book|
+ booking = Booking.from_json(book.to_json)
+ booking.rejected ? nil : booking
+ }.first?
+ end
+
+ # ===================================
+ # Monitoring desk bookings
+ # ===================================
+ protected def booking_changed(event)
+ return unless event.booking_type == @booking_type
+ matching_zones = @zone_mappings.keys & event.zones
+ return if matching_zones.empty?
+
+ logger.debug { "booking event is in a matching zone" }
+
+ sync_floor(matching_zones.first)
+ end
+
+ def sync_bookings
+ @zone_mappings.keys.each { |zone_id| sync_floor(zone_id) }
+ end
+
+ def sync_floor(zone : String)
+ @sync_lock.synchronize { do_sync_floor(zone) }
+ end
+
+ protected def do_sync_floor(zone : String)
+ plan_id = @zone_mappings[zone]?
+ if plan_id.nil?
+ logger.warn { "unknown plan ID for zone #{zone}" }
+ return 0
+ end
+ floor_details = @floor_mappings[plan_id]
+
+ logger.debug { "syncing zone #{zone}, plan-id #{plan_id}" }
+
+ place_bookings = placeos_bookings(zone)
+ sense_bookings = floorsense_bookings(zone)
+
+ adhoc = [] of BookingStatus
+ other = [] of BookingStatus
+
+ sense_bookings.each do |booking|
+ if booking.booking_type == "adhoc"
+ adhoc << booking
+ else
+ other << booking
+ end
+ end
+
+ logger.debug { "found #{adhoc.size} adhoc bookings" }
+
+ place_booking_checked = Set(String).new
+ release_floor_bookings = [] of BookingStatus
+ release_place_bookings = [] of Tuple(Booking, Int64)
+ create_place_bookings = [] of BookingStatus
+ create_floor_bookings = [] of Booking
+ confirm_floor_bookings = [] of BookingStatus
+
+ time_now = 2.minutes.from_now.to_unix
+
+ # adhoc bookings need to be added to PlaceOS
+ adhoc.each do |floor_booking|
+ found = false
+ place_bookings.each do |booking|
+ # match using extenstion data
+ if (ext_data = booking.extension_data) && (floor_id = ext_data["floorsense_id"]?.try(&.as_s)) && floor_id == floor_booking.booking_id
+ found = true
+ place_booking_checked << booking.id.to_s
+ else
+ next
+ end
+
+ if (booking.rejected || booking.booking_end != floor_booking.finish) && floor_booking.released == 0_i64
+ release_floor_bookings << floor_booking
+ elsif floor_booking.released > 0_i64 && floor_booking.released != booking.booking_end && !booking.rejected
+ # need to change end time of this booking
+ release_place_bookings << {booking, floor_booking.released}
+ end
+
+ break
+ end
+
+ if !found && floor_booking.released == 0_i64
+ create_place_bookings << floor_booking
+ end
+ end
+
+ logger.debug { "need to sync #{create_place_bookings.size} adhoc bookings, release #{release_place_bookings.size} bookings" }
+
+ # what bookings need to be added to floorsense
+ place_bookings.each do |booking|
+ booking_id = booking.id.to_s
+ next if place_booking_checked.includes?(booking_id)
+ place_booking_checked << booking_id
+
+ next if time_now >= booking.booking_end
+
+ found = false
+ other.each do |floor_booking|
+ next unless floor_booking.desc == booking_id
+ found = true
+
+ # TODO:: check for booking changes?
+ # we currently are not and probably shouldn't be moving bookings to different days
+
+ if (booking.rejected || booking.booking_end != floor_booking.finish) && floor_booking.released == 0_i64
+ release_floor_bookings << floor_booking
+ elsif floor_booking.released > 0_i64 && floor_booking.released != booking.booking_end && !booking.rejected
+ # need to change end time of this booking
+ release_place_bookings << {booking, floor_booking.released}
+ elsif booking.checked_in && !floor_booking.confirmed
+ confirm_floor_bookings << floor_booking
+ end
+
+ break
+ end
+
+ create_floor_bookings << booking unless found || booking.rejected
+ end
+
+ other.each do |floor_booking|
+ release_floor_bookings << floor_booking unless place_booking_checked.includes?(floor_booking.desc)
+ end
+
+ logger.debug { "need to create #{create_floor_bookings.size} bookings, release #{release_floor_bookings.size} in floorsense" }
+
+ # update floorsense
+ local_floorsense = floorsense
+ release_floor_bookings.each { |floor_booking| local_floorsense.release_booking(floor_booking.booking_id) }
+
+ create_floor_bookings.each do |booking|
+ floor_user = begin
+ get_floorsense_user(booking.user_id)
+ rescue error
+ logger.warn(exception: error) { "unable to find or create user #{booking.user_id} (#{booking.user_email}) in floorsense" }
+ next
+ end
+
+ # We need a floorsense user to own the booking
+ # floor_user = local_floorsense.user_list(booking.user_email).get.as_a.first?
+
+ resp = local_floorsense.create_booking(
+ user_id: floor_user,
+ plan_id: plan_id,
+ key: to_floor_key(booking.asset_id),
+ description: booking.id.to_s,
+ starting: booking.booking_start < time_now ? 5.minutes.ago.to_unix : booking.booking_start,
+ ending: booking.booking_end
+ )
+
+ if booking.checked_in
+ begin
+ local_floorsense.confirm_booking(resp.get["bkid"])
+ rescue error
+ logger.warn(exception: error) { "error confirming newly created booking" }
+ end
+ end
+ end
+
+ logger.debug { "floorsense bookings created" }
+
+ # update placeos
+ local_staff_api = staff_api
+ release_place_bookings.each do |booking, released|
+ local_staff_api.update_booking(
+ booking_id: booking.id,
+ booking_end: released
+ )
+ end
+
+ logger.debug { "#{release_place_bookings.size} place bookings released" }
+
+ create_place_bookings.each do |booking|
+ user_id = booking.user.not_nil!.desc
+ user_email = booking.user.not_nil!.email.try &.downcase
+ booking_key = booking.key
+ next unless booking_key
+
+ if user_id.presence.nil? && user_email.presence.nil?
+ logger.warn { "no user id or email defined for floorsense user #{booking.user.not_nil!.name}" }
+ next
+ end
+
+ user = begin
+ local_staff_api.user(user_id.presence || user_email).get
+ rescue error
+ logger.warn(exception: error) { "floorsense user #{user_email} not found in placeos" }
+ next
+ end
+ user_id = user["id"]
+ user_name = user["name"]
+ user_email = user["email"]
+
+ local_staff_api.create_booking(
+ booking_start: booking.start,
+ booking_end: booking.finish,
+ time_zone: @time_zone.to_s,
+ booking_type: @booking_type,
+ asset_id: to_place_asset_id(booking_key),
+ user_id: user_id,
+ user_email: user_email,
+ user_name: user_name,
+ zones: [floor_details[:building_id]?, floor_details[:level_id]].compact,
+ extension_data: {
+ floorsense_id: booking.booking_id,
+ },
+ )
+ end
+
+ logger.debug { "#{create_place_bookings.size} adhoc place bookings created" }
+
+ confirm_floor_bookings.each do |floor_booking|
+ local_floorsense.confirm_booking(floor_booking.booking_id)
+ end
+
+ # number of bookings checked
+ place_bookings.size + adhoc.size
+ end
+
+ # ===================================
+ # Sync Users
+ # ===================================
+ def get_floorsense_user(placeos_user_id : String) : String
+ users = floorsense.user_list(description: placeos_user_id).get.as_a
+ user_id = users.first?.try(&.[]("uid").as_s)
+
+ # We might need to create a
+ card_number = nil
+ begin
+ place_user = staff_api.user(placeos_user_id).get
+ name = place_user["name"].as_s
+ email = place_user["email"].as_s
+ card_number = place_user["card_number"]?.try(&.as_s)
+
+ # Add the card number to the user
+ user_id ||= floorsense.create_user(name, email, placeos_user_id).get["uid"].as_s
+ rescue error
+ if user_id
+ # if we have a user id, lets just return it
+ # a staff_api outage shouldn't prevent this from working
+ return user_id
+ else
+ raise error
+ end
+ end
+
+ if user_id && card_number && !card_number.empty?
+ ensure_card_synced(card_number, user_id)
+ end
+
+ user_id.not_nil!
+ end
+
+ protected def ensure_card_synced(card_number : String, user_id : String) : Nil
+ existing_user = begin
+ floorsense.get_rfid(card_number).get["uid"].as_s
+ rescue
+ nil
+ end
+
+ if existing_user != user_id
+ floorsense.delete_rfid(card_number)
+ floorsense.create_rfid(user_id, card_number)
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to sync card number #{card_number} for user #{user_id}" }
+ end
+
+ # ===================================
+ # Booking Queries
+ # ===================================
+ def floorsense_bookings(zone_id : String)
+ logger.debug { "querying floorsense bookings in zone #{zone_id}" }
+
+ plan_id = @zone_mappings[zone_id]?
+ return [] of BookingStatus unless plan_id
+
+ current = [] of BookingStatus
+ start_of_day = Time.local(@time_zone).at_beginning_of_day
+ tomorrow_night = (start_of_day.at_end_of_day + 1.hour).at_end_of_day
+
+ raw_bookings = floorsense.bookings(plan_id, start_of_day.to_unix, tomorrow_night.to_unix).get.to_json
+ Hash(String, Array(BookingStatus)).from_json(raw_bookings).each_value do |bookings|
+ current << bookings.first unless bookings.empty?
+ end
+ current
+ end
+
+ def placeos_bookings(zone_id : String)
+ start_of_day = Time.local(@time_zone).at_beginning_of_day
+ tomorrow_night = (start_of_day.at_end_of_day + 1.hour).at_end_of_day
+
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: start_of_day.to_unix,
+ period_end: tomorrow_night.to_unix,
+ zones: {zone_id}
+ ).get.as_a
+
+ bookings.map { |book| Booking.from_json(book.to_json) }
+ end
+
+ class Booking
+ include JSON::Serializable
+
+ # This is to support events
+ property action : String?
+
+ property id : Int64
+ property booking_type : String
+ property booking_start : Int64
+ property booking_end : Int64
+ property timezone : String?
+
+ # events use resource_id instead of asset_id
+ property asset_id : String?
+ property resource_id : String?
+
+ def asset_id : String
+ (@asset_id || @resource_id).not_nil!
+ end
+
+ property user_id : String
+ property user_email : String
+ property user_name : String
+
+ property zones : Array(String)
+
+ property checked_in : Bool?
+ property rejected : Bool?
+
+ property extension_data : JSON::Any?
+
+ def in_progress?
+ now = Time.utc.to_unix
+ now >= @booking_start && now < @booking_end
+ end
+ end
+end
diff --git a/drivers/floorsense/custom_bookings_sync.cr b/drivers/floorsense/custom_bookings_sync.cr
new file mode 100644
index 00000000000..6a653b952af
--- /dev/null
+++ b/drivers/floorsense/custom_bookings_sync.cr
@@ -0,0 +1,968 @@
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "uri"
+require "json"
+require "oauth2"
+require "./models"
+require "placeos"
+
+class Floorsense::CustomBookingsSync < PlaceOS::Driver
+ descriptive_name "Floorsense Desk Bookings Sync"
+ generic_name :FloorsenseBookingSync
+ description %(syncs PlaceOS desk bookings with floorsense booking system)
+
+ accessor floorsense : Floorsense_1
+ accessor staff_api : StaffAPI_1
+ accessor area_management : AreaManagement_1
+ accessor locations : FloorsenseLocationService_1
+
+ bind Floorsense_1, :event_49, :booking_created
+ bind Floorsense_1, :event_50, :booking_released
+ bind Floorsense_1, :event_53, :booking_confirmed
+
+ alias PlaceUser = PlaceOS::Client::API::Models::User
+
+ DESK_SOURCE_EXTENSION_DATA_MODIFICATION = "floorsense"
+ LOCKER_SOURCE_EXTENSION_DATA_MODIFICATION = "smartalock"
+
+ default_settings({
+ floor_mappings: {
+ "planid": {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+ time_zone: "GMT",
+ poll_rate: 3,
+ user_lookup: "email",
+
+ floorsense_lookup_key: "floorsense_id",
+ create_floorsense_users: true,
+ booking_type: "desk",
+ # Keys to map into ad-hoc bookings
+ meta_ext_mappings: {
+ "neighbourhoodID" => "neighbourhood",
+ "features" => "deskAttributes",
+ },
+ meta_ext_static: {} of String => String,
+ })
+
+ @user_ids_cache : Hash(String, String) = {} of String => String
+ @meta_ext_static : Hash(String, String) = {} of String => String
+
+ @meta_ext_mappings : Hash(String, String) = {} of String => String
+ @floor_mappings : Hash(String, NamedTuple(building_id: String?, level_id: String)) = {} of String => NamedTuple(building_id: String?, level_id: String)
+ # Level zone => plan_id
+ @zone_mappings : Hash(String, String) = {} of String => String
+ # Level zone => building_zone
+ @building_mappings : Hash(String, String?) = {} of String => String?
+ # Desk ID mappings cache
+ @desk_mapping_cache : Hash(String, Hash(String, DeskMeta)) = {} of String => Hash(String, DeskMeta)
+ @floorsense_lookup_key : String = "floorsensedeskid"
+ @create_floorsense_users : Bool = false
+
+ @booking_type : String = "desk"
+ @poll_rate : Time::Span = 3.seconds
+ @time_zone : Time::Location = Time::Location.load("GMT")
+ @user_lookup : String = "email"
+
+ # ===================================
+ # Polling for events
+ # ===================================
+ @last_event_id : Int64? = nil
+ @last_event_at : Int64 = 0_i64
+
+ def on_load
+ monitor("staff/booking/changed") do |_subscription, payload|
+ unless (@zone_mappings.keys & Booking.from_json(payload).zones).empty?
+ booking_changed(Booking.from_json(payload))
+ end
+ end
+
+ on_update
+ end
+
+ def on_update
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+
+ @poll_rate = (setting?(Int32, :poll_rate) || 3).seconds
+ @user_lookup = setting?(String, :user_lookup).presence || "email"
+
+ @floorsense_lookup_key = setting?(String, :floorsense_lookup_key).presence || "floorsensedeskid"
+ @create_floorsense_users = setting?(Bool, :create_floorsense_users) || false
+
+ @floor_mappings = setting(Hash(String, NamedTuple(building_id: String?, level_id: String)), :floor_mappings)
+ @floor_mappings.each do |plan_id, details|
+ level = details[:level_id]
+ @building_mappings[level] = details[:building_id]
+ @zone_mappings[level] = plan_id
+ end
+
+ @meta_ext_mappings = setting?(Hash(String, String), :meta_ext_mappings) || {} of String => String
+ @meta_ext_static = setting?(Hash(String, String), :meta_ext_static) || {} of String => String
+
+ time_zone = setting?(String, :time_zone).presence || "GMT"
+ @time_zone = Time::Location.load(time_zone)
+
+ schedule.clear
+
+ schedule.every(@poll_rate * 10) { sync_bookings }
+ schedule.in(1.seconds) { sync_bookings }
+ end
+
+ protected def get_place_booking(freespace_booking, floor_details) : Booking?
+ if desc = freespace_booking.desc
+ Booking.from_json staff_api.get_booking(desc.to_i64).get.to_json
+ else
+ search_place_booking(freespace_booking, floor_details)
+ end
+ rescue ArgumentError
+ # in case the description was unexpectedly not an int64 (adhoc for instance)
+ search_place_booking(freespace_booking, floor_details)
+ end
+
+ protected def search_place_booking(freespace_booking, floor_details)
+ user_email = freespace_booking.user.not_nil!.email.try &.downcase
+
+ if user_email.nil?
+ logger.warn { "no user email defined for floorsense user #{freespace_booking.user.not_nil!.name}" }
+ return nil
+ end
+
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: freespace_booking.start,
+ period_end: freespace_booking.finish,
+ zones: {floor_details[:level_id]},
+ email: user_email
+ ).get.as_a
+
+ bookings.compact_map { |book|
+ booking = Booking.from_json(book.to_json)
+ booking.rejected ? nil : booking
+ }.first?
+ end
+
+ # ===================================
+ # Monitoring desk bookings
+ # ===================================
+ protected def booking_changed(booking)
+ return unless booking.booking_type == "desk"
+ matching_zones = @zone_mappings.keys & booking.zones
+ if matching_zones.empty?
+ logger.debug { "No matching zone for #{booking.id}, zones=#{booking.zones}" }
+ return
+ end
+
+ logger.debug { "Payload: #{booking.inspect} EventSync: #{booking.id}" }
+
+ if booking.action == "checked_in" && (ext_data = booking.extension_data) && (floorsense_id = ext_data["floorsense_booking_id"]?)
+ if (booking.checked_in == true)
+ begin
+ logger.debug { "Attempting fast check-in for #{booking.id}" }
+ activate_booking = booking.booking_start > Time.utc.to_unix
+ confirm_check_in_floorsense(booking.id, floorsense_id, activate: activate_booking, confirm: true)
+ rescue error
+ logger.warn(exception: error) { "attempting fast check-in" }
+ end
+ else
+ begin
+ logger.debug { "Attempting fast check-out for #{booking.id}" }
+ checkout_or_cancel_floorsense(booking.id, floorsense_id, "check_out_floorsens")
+ rescue error
+ logger.warn(exception: error) { "attempting fast check-out" }
+ end
+ end
+ elsif booking.action == "cancelled" && (ext_data = booking.extension_data) && (floorsense_id = ext_data["floorsense_booking_id"]?)
+ begin
+ logger.debug { "Attempting fast cancel for #{booking.id}" }
+ checkout_or_cancel_floorsense(booking.id, floorsense_id, "cancel_floorsens")
+ rescue error
+ logger.warn(exception: error) { "attempting fast cancel" }
+ end
+ else
+ logger.debug { "Skipped fast check-in for #{booking.id}" }
+ end
+
+ sync_floor(matching_zones.first)
+ end
+
+ def sync_bookings
+ @zone_mappings.keys.each { |zone_id| sync_floor(zone_id) }
+ end
+
+ getter sync_busy : Hash(String, Bool) = Hash(String, Bool).new { |hash, key| hash[key] = false }
+ getter sync_queue : Hash(String, Int32) = Hash(String, Int32).new { |hash, key| hash[key] = 0 }
+ getter sync_times : Hash(String, Array(Float64)) = Hash(String, Array(Float64)).new { |hash, key| hash[key] = [] of Float64 }
+
+ def sync_floor(zone : String)
+ @sync_queue[zone] += 1
+ if !@sync_busy[zone]
+ spawn { queue_sync_floor(zone) }
+ Fiber.yield
+ :syncing
+ else
+ :queued
+ end
+ end
+
+ # this effectively batches requests if they come in quickly
+ protected def queue_sync_floor(zone : String)
+ # ensure we're not already syncing
+ return if @sync_busy[zone]
+ @sync_busy[zone] = true
+
+ begin
+ times = sync_times[zone]
+ loop do
+ elapsed_time = Time.measure do
+ begin
+ @sync_queue[zone] = 0
+ unique_id = Random::Secure.hex(10)
+ do_sync_floor(zone, unique_id)
+ rescue error
+ logger.warn(exception: error) { "syncing #{zone}" }
+ end
+ end
+ total_milliseconds = elapsed_time.total_milliseconds
+ times << total_milliseconds
+ times.shift if times.size > 15
+
+ logger.info { "sync_floor zone: #{zone} duration=#{total_milliseconds}" }
+
+ break if @sync_queue[zone].zero?
+ Fiber.yield
+ end
+ rescue error
+ logger.warn(exception: error) { "error syncing floor: #{zone}" }
+ ensure
+ @sync_busy[zone] = false
+ end
+ end
+
+ # ===================================
+ # Booking Queries
+ # ===================================
+ def floorsense_bookings(zone_id : String)
+ logger.debug { "querying floorsense bookings in zone #{zone_id}" }
+
+ plan_id = @zone_mappings[zone_id]?
+ return [] of BookingStatus unless plan_id
+
+ current = [] of BookingStatus
+ start_of_day = Time.local(@time_zone).at_beginning_of_day
+ tomorrow_night = (start_of_day.at_end_of_day + 1.hour).at_end_of_day - 1.minute
+
+ raw_bookings = floorsense.bookings(plan_id, start_of_day.to_unix, tomorrow_night.to_unix).get.to_json
+ Hash(String, Array(BookingStatus)).from_json(raw_bookings).each_value do |bookings|
+ current.concat bookings
+ end
+ current
+ end
+
+ def placeos_bookings(zone_id : String)
+ start_of_day = Time.local(@time_zone).at_beginning_of_day
+ tomorrow_night = (start_of_day.at_end_of_day + 1.hour).at_end_of_day - 1.minute
+
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: start_of_day.to_unix,
+ period_end: tomorrow_night.to_unix,
+ zones: {zone_id},
+ include_checked_out: true,
+ ).get.as_a
+
+ bookings.map { |book| Booking.from_json(book.to_json) }
+ end
+
+ def placeos_desk_metadata(zone_id : String, building_id : String?)
+ desk_lookup = {} of String => DeskMeta
+
+ begin
+ metadata = staff_api.metadata(
+ zone_id,
+ "desks"
+ ).get["desks"]["details"].as_a
+
+ lookup_key = @floorsense_lookup_key
+ metadata.each do |desk|
+ desk = desk.as_h
+ place_id = desk["id"]?.try(&.as_s.presence)
+ next unless place_id
+
+ floor_id = desk[lookup_key]?.try(&.as_s.presence)
+ next unless floor_id
+
+ # Additional data for adhoc bookings
+ ext_data = {
+ "floorsense_id" => JSON::Any.new(floor_id),
+ }
+ title = desk["name"]?.try(&.as_s) || place_id
+ @meta_ext_mappings.each do |meta_key, ext_key|
+ if value = desk[meta_key]?
+ ext_data[ext_key] = value
+ end
+ end
+
+ ids = DeskMeta.new(place_id, floor_id, building_id, title, ext_data)
+ desk_lookup[place_id] = ids
+ desk_lookup[floor_id] = ids
+ end
+ desk_lookup
+ rescue error
+ logger.warn(exception: error) { "unable to obtain desk metadata for #{zone_id}" }
+ desk_lookup
+ end
+ end
+
+ # ===================================
+ # Monitoring desk bookings
+ # ===================================
+
+ protected def do_sync_floor(zone : String, unique_id : String)
+ plan_id = @zone_mappings[zone]?
+ if plan_id.nil?
+ logger.warn { "unknown plan ID for zone #{zone}" }
+ return 0
+ end
+ floor_details = @floor_mappings[plan_id]
+
+ logger.debug { "syncing floor zone #{zone}, plan-id #{plan_id}" }
+
+ place_bookings = placeos_bookings(zone)
+ sense_bookings = floorsense_bookings(zone)
+
+ # Apply desk mappings
+ @desk_mapping_cache[zone] = configured_desk_ids = placeos_desk_metadata(zone, floor_details[:building_id])
+ place_bookings.each do |booking|
+ asset_id = booking.asset_id
+ booking.floor_id = configured_desk_ids[asset_id]?.try(&.floor_id) || asset_id
+ end
+ sense_bookings.select! do |booking|
+ desk_key = booking.key.as(String)
+ if place_id = configured_desk_ids[desk_key]?.try(&.place_id)
+ booking.place_id = place_id
+ else
+ logger.debug { "unmapped floorsense desk id #{desk_key} in floor zone #{zone}, plan-id #{plan_id}" }
+ nil
+ end
+ end
+
+ adhoc = [] of BookingStatus
+ other = [] of BookingStatus
+
+ sense_bookings.each do |booking|
+ if booking.booking_type == "adhoc"
+ adhoc << booking
+ else
+ other << booking
+ end
+ end
+
+ logger.debug { "found #{adhoc.size} adhoc bookings" }
+
+ place_booking_checked = Set(String).new
+ release_floor_bookings = [] of BookingStatus
+ release_place_bookings = [] of Tuple(Booking, Int64)
+ create_place_bookings = [] of BookingStatus
+ create_floor_bookings = [] of Booking
+ confirm_floor_bookings = [] of BookingStatus
+ sync_place_metadata = [] of Tuple(String, String, Int32)
+
+ time_now = 2.minutes.from_now.to_unix
+
+ # adhoc bookings need to be added to PlaceOS
+ adhoc.each do |floor_booking|
+ found = false
+ place_bookings.each do |booking|
+ # match using extenstion data
+ if booking.floorsense_booking_id == floor_booking.booking_id
+ found = true
+ place_booking_checked << booking.id.to_s
+ else
+ next
+ end
+
+ if (booking.rejected || booking.booking_end != floor_booking.finish) && floor_booking.released == 0_i64
+ logger.debug { "releasing floor booking #{floor_booking.booking_id}, as place booking #{booking.id} has been released" }
+ release_floor_bookings << floor_booking
+ elsif booking.released? && floor_booking.released == 0_i64
+ logger.debug { "releasing floor booking #{floor_booking.booking_id}, as place booking #{booking.id} has been released" }
+ release_floor_bookings << floor_booking
+ elsif booking.is_deleted? && floor_booking.released == 0_i64
+ logger.debug { "releasing floor booking #{floor_booking.booking_id}, as place booking #{booking.id} has been deleted" }
+ release_floor_bookings << floor_booking
+ elsif floor_booking.released > 0_i64 && floor_booking.released != booking.booking_end && !booking.rejected
+ logger.debug { "releasing place booking #{booking.id}, as floor booking #{floor_booking.booking_id} has been released" }
+ # need to change end time of this booking
+ release_place_bookings << {booking, floor_booking.released}
+ end
+
+ break
+ end
+
+ if !found && floor_booking.released == 0_i64
+ logger.debug { "found new ad-hoc booking #{floor_booking.booking_id}, will create place booking" }
+ create_place_bookings << floor_booking
+ end
+ end
+
+ logger.debug { "need to sync #{create_place_bookings.size} adhoc bookings, release #{release_place_bookings.size} bookings" }
+
+ # what bookings need to be added to floorsense
+ place_bookings.each do |booking|
+ booking_id = booking.id.to_s
+ next if place_booking_checked.includes?(booking_id)
+ next if time_now >= booking.booking_end
+
+ place_booking_checked << booking_id
+
+ found = false
+ other.each do |floor_booking|
+ next unless floor_booking.desc == booking_id
+ found = true
+
+ sync_place_metadata << {booking_id, floor_booking.booking_id, floor_booking.cid} unless booking.floorsense_booking_id == floor_booking.booking_id
+ logger.debug { "sync_place_metadata-floor_booking_booking_id: #{floor_booking.booking_id} booking_id:#{booking_id} floor_booking_desc: #{floor_booking.desc} booking.floorsense_booking_id: #{booking.floorsense_booking_id}" }
+
+ if (booking.rejected || booking.booking_end != floor_booking.finish) && floor_booking.released == 0_i64
+ logger.debug { "releasing floor booking #{floor_booking.booking_id}, as place booking #{booking.id} has been released" }
+ release_floor_bookings << floor_booking
+ elsif booking.released? && floor_booking.released == 0_i64
+ logger.debug { "releasing floor booking #{floor_booking.booking_id}, as place booking #{booking.id} has been released" }
+ release_floor_bookings << floor_booking
+ elsif booking.is_deleted? && floor_booking.released == 0_i64
+ logger.debug { "releasing floor booking #{floor_booking.booking_id}, as place booking #{booking.id} has been deleted" }
+ release_floor_bookings << floor_booking
+ elsif floor_booking.released > 0_i64 && floor_booking.released != booking.booking_end && !booking.rejected
+ # need to change end time of this booking
+ logger.debug { "releasing place booking #{booking.id}, as floor booking #{floor_booking.booking_id} has been released" }
+ release_place_bookings << {booking, floor_booking.released}
+ elsif booking.checked_in && !floor_booking.confirmed
+ logger.debug { "confirming floor booking #{floor_booking.booking_id}, as place booking #{booking.id} has been confirmed" }
+ confirm_floor_bookings << floor_booking
+ end
+
+ break
+ end
+ next if found || booking.rejected
+
+ # if we get to here then the floor booking was released
+ if booking.floorsense_booking_id.nil? && !booking.released? && !booking.is_deleted?
+ logger.debug { "creating floor booking based on #{booking.id} as no floor booking reference exists" }
+ create_floor_bookings << booking
+ elsif !booking.released? && !booking.is_deleted?
+ logger.debug { "releasing place booking #{booking.id}, as floor booking #{booking.floorsense_booking_id} not found (assuming released)" }
+ release_place_bookings << {booking, 1.minute.ago.to_unix}
+ end
+ end
+
+ other.each do |floor_booking|
+ unless place_booking_checked.includes?(floor_booking.desc)
+ logger.debug { "releasing floor booking #{floor_booking.booking_id}, as no place booking found" }
+ release_floor_bookings << floor_booking
+ end
+ end
+
+ logger.debug { "need to create #{create_floor_bookings.size} bookings, release #{release_floor_bookings.size} in floorsense" }
+
+ # update floorsense
+ local_floorsense = floorsense
+ release_floor_bookings.each { |floor_booking| local_floorsense.release_booking(floor_booking.booking_id) }
+
+ create_floor_bookings.each do |booking|
+ # info("#{booking.id} #{booking.floor_id} #{booking.asset_id}", event_name)
+ # if booking.floor_id.to_s == booking.asset_id.to_s
+ # info("#{booking.id} has no floor id, skipping", "#{event_name} - booking_id #{booking.id}")
+ # next
+ # end
+ floor_user = begin
+ get_floorsense_user(booking.user_id)
+ rescue error
+ logger.warn(exception: error) { "unable to find or create user #{booking.user_id} (#{booking.user_email}) in floorsense" }
+ next
+ end
+
+ # We need a floorsense user to own the booking
+ # floor_user = local_floorsense.user_list(booking.user_email).get.as_a.first?
+ begin
+ create_floorsense_booking(floor_user, plan_id, booking, time_now, local_floorsense)
+ rescue error
+ logger.warn(exception: error) { "unable to create_floorsense_booking #{booking.user_id} booking_id: #{booking.user_id} (#{booking.user_email}) in floorsense" }
+ next
+ end
+ end
+
+ logger.debug { "End creating floorsense bookings" }
+
+ # update placeos
+ local_staff_api = staff_api
+ release_place_bookings.each do |booking, released|
+ if booking.checked_in_at
+ logger.debug { "Booking #{booking.id}: running check in" }
+ staff_api.booking_check_in(booking.id, false, utm_source: DESK_SOURCE_EXTENSION_DATA_MODIFICATION).get
+ else
+ logger.debug { "Booking #{booking.id}: will be no show" }
+ staff_api.update_booking(
+ booking_id: booking.id,
+ booking_end: released
+ ).get
+ end
+ end
+
+ logger.debug { "#{release_place_bookings.size} place bookings released" }
+
+ create_place_bookings.each do |booking|
+ user_id = booking.user.not_nil!.desc
+ user_email = booking.user.not_nil!.email.try &.downcase
+
+ if user_id.presence.nil? && user_email.presence.nil?
+ logger.warn { "no user id or email defined for floorsense user #{booking.user.not_nil!.name}" }
+ next
+ end
+
+ user = begin
+ local_staff_api.user(user_id.presence || user_email).get
+ rescue error
+ logger.warn(exception: error) { "floorsense user #{user_email} not found in placeos" }
+ next
+ end
+
+ user_id = user["id"]
+ user_name = user["name"]
+ user_email = user["email"]
+
+ # Check if there is a desk mapping
+ booking_key = booking.key
+ level_id = floor_details[:level_id]
+
+ if metadata = @desk_mapping_cache[level_id][booking_key]?
+ title = metadata.title
+ ext_data = metadata.ext_data
+ else
+ title = booking.place_id
+ ext_data = {} of String => JSON::Any
+ end
+ ext_data["floorsense_booking_id"] = JSON::Any.new(booking.booking_id)
+ ext_data["floorsense_cid"] = JSON::Any.new(booking.cid.to_i64)
+
+ begin
+ local_staff_api.create_booking(
+ booking_start: booking.start,
+ booking_end: booking.finish,
+ time_zone: @time_zone.to_s,
+ booking_type: @booking_type,
+ asset_id: booking.place_id,
+ user_id: user_id,
+ user_email: user_email,
+ user_name: user_name,
+ checked_in: true,
+ approved: true,
+ title: title,
+ zones: [floor_details[:building_id]?, level_id].compact,
+ extension_data: ext_data,
+ utm_source: DESK_SOURCE_EXTENSION_DATA_MODIFICATION
+ ).get
+ rescue error
+ logger.warn(exception: error) { "unable to create booking #{booking.to_json}" }
+ end
+ end
+
+ logger.debug { "#{create_place_bookings.size} adhoc place bookings created" }
+
+ confirm_floor_bookings.each do |floor_booking|
+ confirm_check_in_floorsense(floor_booking.desc, floor_booking.booking_id, activate: !floor_booking.active, local_floorsense: local_floorsense)
+ end
+
+ logger.debug { "#{sync_place_metadata.size} place booking metadata sync'd" }
+ sync_place_metadata.each { |p_id, f_id, c_id| sync_place_booking_metadata(p_id, f_id, c_id) }
+
+ # number of bookings checked
+ place_bookings.size + adhoc.size
+ end
+
+ private def create_floorsense_booking(floor_user, plan_id, booking, time_now, local_floorsense)
+ start = Time.monotonic
+ resp = local_floorsense.create_booking(
+ user_id: floor_user,
+ plan_id: plan_id,
+ key: booking.floor_id,
+ description: booking.id.to_s,
+ starting: booking.booking_start < time_now ? 5.minutes.ago.to_unix : booking.booking_start,
+ ending: booking.booking_end
+ ).get
+
+ elapsed = Time.monotonic - start
+ logger.debug { "floorsense Booking creation: create_floorsense_booking create_booking duration=#{elapsed.total_milliseconds} for booking-id: #{booking.id}" }
+
+ if booking.checked_in
+ confirm_check_in_floorsense(booking.id, resp["bkid"], activate: !resp["active"].as_bool, confirm: !resp["confirmed"].as_bool, local_floorsense: local_floorsense)
+ end
+
+ # ensure floorsense_booking_id and cid is set on the place booking
+ sync_place_booking_metadata(booking.id, resp["bkid"], resp["cid"])
+ rescue error
+ logger.warn(exception: error) { "Error creating floor_user:#{floor_user} booking-id:#{booking.id} response: #{resp}" }
+ end
+
+ protected def sync_place_booking_metadata(place_booking_id, floorsense_booking_id, floorsense_cid)
+ logger.debug { "sync_place_metadata place_booking_id: #{place_booking_id} floorsense_booking_id:#{floorsense_booking_id} floorsense_cid:#{floorsense_cid}" }
+ staff_api.update_booking(
+ booking_id: place_booking_id,
+ extension_data: {
+ floorsense_cid: floorsense_cid,
+ floorsense_booking_id: floorsense_booking_id,
+ }
+ )
+ end
+
+ protected def confirm_check_in_floorsense(place_booking_id, floor_booking_id, activate : Bool = true, confirm : Bool = true, event_name = "check_in_floorsense", local_floorsense = floorsense)
+ if activate
+ begin
+ local_floorsense.activate_booking(floor_booking_id)
+ logger.debug { "activate_booking booking-id: #{place_booking_id}" }
+ rescue error
+ logger.warn(exception: error) { "error activating newly created booking booking-id: #{place_booking_id}" }
+ end
+ end
+
+ if confirm
+ begin
+ local_floorsense.confirm_booking(floor_booking_id)
+ logger.debug { "confirm_booking booking-id: #{place_booking_id}" }
+ rescue error
+ logger.warn(exception: error) { "error confirming newly created booking booking-id: #{place_booking_id}" }
+ end
+ end
+ end
+
+ protected def checkout_or_cancel_floorsense(place_booking_id, floor_booking_id, event_name, local_floorsense = floorsense)
+ begin
+ local_floorsense.release_booking(floor_booking_id)
+ logger.debug { "#{event_name} booking booking-id: #{place_booking_id}" }
+ rescue error
+ logger.warn(exception: error) { "error #{event_name} booking booking-id: #{place_booking_id}" }
+ end
+ end
+
+ def get_floorsense_user(place_user_id : String) : String
+ place_user = staff_api.user(place_user_id).get
+ placeos_staff_id = place_user[@user_lookup].as_s
+
+ if @user_lookup == "email"
+ placeos_staff_id = placeos_staff_id.downcase
+ floorsense_users = floorsense.user_list(email: placeos_staff_id).get.as_a
+
+ user_id = floorsense_users.first?.try(&.[]("uid").as_s)
+ user_id ||= floorsense.create_user(place_user["name"].as_s, placeos_staff_id).get["uid"].as_s if @create_floorsense_users
+ else
+ floorsense_users = floorsense.user_list(description: placeos_staff_id).get.as_a
+
+ user_id = floorsense_users.first?.try(&.[]("uid").as_s)
+ user_id ||= floorsense.create_user(place_user["name"].as_s, place_user["email"].as_s, placeos_staff_id).get["uid"].as_s if @create_floorsense_users
+ end
+ raise "Floorsense user not found for #{placeos_staff_id}" unless user_id
+
+ card_number = place_user["card_number"]?.try(&.as_s)
+ spawn { ensure_card_synced(card_number, user_id) } if user_id && card_number && !card_number.empty?
+ user_id
+ end
+
+ protected def ensure_card_synced(card_number : String, user_id : String) : Nil
+ existing_user = begin
+ floorsense.get_rfid(card_number).get["uid"].as_s
+ rescue
+ nil
+ end
+
+ if existing_user != user_id
+ floorsense.delete_rfid(card_number)
+ floorsense.create_rfid(user_id, card_number)
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to sync card number #{card_number} for user #{user_id}" }
+ end
+
+ def eui64_to_desk_id(id : String)
+ if foor_id = locations.eui64_to_desk_id(id.downcase).get.raw
+ floor_desk_id = foor_id.as(String)
+ place_id = floor_desk_id
+ level_id = nil
+ building = nil
+
+ @desk_mapping_cache.each do |level, lookup|
+ if meta = lookup[floor_desk_id]?
+ level_id = level
+ place_id = meta.place_id || floor_desk_id
+ building = meta.building
+ break
+ end
+ end
+
+ {level: level_id, desk_id: place_id, building_id: building} if level_id
+ else
+ logger.warn { "No desk found for #{id}" }
+ end
+ end
+
+ private def sync_placeos_booking?(reservation, synced_place_locker_bookings)
+ synced_place_locker_bookings.find { |p| p.smartalock_res_id == reservation.reservation_id }
+ end
+
+ private def booking_created(_subscription, event_info)
+ logger.debug { "Booking created event: #{event_info.to_json}" }
+
+ event = NamedTuple(booking: BookingStatus?).from_json(event_info)
+ booking = event[:booking]
+ return unless booking
+ return if booking.booking_type != "adhoc"
+
+ floor_details = @floor_mappings[booking.planid.to_s]?
+ return unless floor_details
+ booking.user = User.from_json floorsense.get_user(booking.uid).get.to_json
+
+ user_id = booking.user.not_nil!.desc
+ user_email = booking.user.not_nil!.email.try &.downcase
+
+ if user_id.presence.nil? && user_email.presence.nil?
+ logger.warn { "no user id or email defined for floorsense user #{booking.user.not_nil!.name}" }
+ return
+ end
+
+ user = begin
+ staff_api.user(user_id.presence || user_email).get
+ rescue error
+ logger.warn(exception: error) { "floorsense user #{user_id.presence || user_email} (#{booking.user.not_nil!.name}) not found in placeos" }
+ return
+ end
+
+ user_id = user["id"]
+ user_name = user["name"]
+ user_email = user["email"]
+
+ # Check if there is a desk mapping
+ booking_key = booking.key
+ level_id = floor_details[:level_id]
+
+ if metadata = @desk_mapping_cache[level_id][booking_key]?
+ title = metadata.title
+ ext_data = metadata.ext_data
+ asset_id = metadata.place_id
+ else
+ title = asset_id = booking_key
+ ext_data = {} of String => JSON::Any
+ end
+ ext_data["floorsense_booking_id"] = JSON::Any.new(booking.booking_id)
+ ext_data["floorsense_cid"] = JSON::Any.new(booking.cid.to_i64)
+
+ logger.debug { "floorsense booking id: #{booking.booking_id} in Booking created event" }
+
+ placebookings = staff_api.query_bookings(
+ type: @booking_type,
+ zones: {level_id},
+ extension_data: {floorsense_booking_id: booking.booking_id}
+ ).get.as_a
+
+ return unless placebookings.empty?
+
+ body = staff_api.create_booking(
+ booking_start: booking.start,
+ booking_end: booking.finish,
+ booking_type: @booking_type,
+ asset_id: asset_id,
+ user_id: user_id,
+ user_email: user_email,
+ user_name: user_name,
+ time_zone: @time_zone.to_s,
+ zones: [floor_details[:building_id]?, level_id].compact,
+ approved: true,
+ title: title,
+ extension_data: ext_data,
+ utm_source: DESK_SOURCE_EXTENSION_DATA_MODIFICATION,
+ ).get
+
+ logger.debug { "placeos booking created #{body["id"]}" }
+
+ placeos_booking = Booking.from_json(body.to_json)
+ logger.debug { "placeos user LOAD #{placeos_booking.id}" }
+
+ staff_api.booking_check_in(placeos_booking.id, true, utm_source: DESK_SOURCE_EXTENSION_DATA_MODIFICATION).get
+ logger.debug { "placeos booking checked in #{placeos_booking.id}" }
+
+ area_management.update_available([floor_details[:level_id]])
+ rescue error
+ logger.warn(exception: error) { "Something went wrong processing floorsense booking created event" }
+ end
+
+ private def booking_confirmed(_subscription, event_info)
+ event = JSON.parse(event_info)
+ logger.debug { "Booking confirmed event: #{event}" }
+ booking = BookingStatus.from_json floorsense.get_booking(event["bkid"]).get.to_json
+ logger.debug { "Floor_Booking: #{booking}" }
+ floor_details = @floor_mappings[booking.planid.to_s]?
+ logger.debug { "floor_details: #{floor_details}" }
+ return unless floor_details
+
+ begin
+ if desc = booking.desc
+ place_booking = Booking.from_json staff_api.get_booking(desc.to_i64).get.to_json
+ logger.debug { "place_booking: #{place_booking}" }
+ staff_api.booking_check_in(place_booking.id, booking.confirmed, utm_source: DESK_SOURCE_EXTENSION_DATA_MODIFICATION) unless place_booking.checked_in?
+
+ area_management.update_available([floor_details[:level_id]])
+ end
+ rescue ArgumentError
+ # was an adhoc booking
+ end
+ end
+
+ private def booking_released(_subscription, event_info)
+ logger.debug { "Booking released event: #{event_info.to_json}" }
+ event = JSON.parse(event_info)
+ booking = BookingStatus.from_json floorsense.get_booking(event["bkid"]).get.to_json
+ floor_details = @floor_mappings[booking.planid.to_s]?
+ unless floor_details
+ logger.warn { "No floor details found for planid #{booking.planid}" }
+ return
+ end
+
+ # No booking confirm, means floorsense canceled which hasn't been check in on PlaceOS
+ # we ignore this scenario because you can cancel before booking is confirmed.
+ if !booking.confirmed && (booking.released >= booking.finish || booking.released <= booking.start)
+ logger.debug { "Booking was released outside of booking time, ignoring #{booking.to_json}" }
+ return
+ end
+
+ # find placeos booking
+ place_booking = get_strict_place_booking(booking, floor_details)
+
+ if place_booking.nil?
+ logger.debug { "no booking found for released booking #{booking.booking_id}" }
+ elsif !place_booking.is_deleted? && !place_booking.released?
+ # change the placeos end time if the booking has started
+ logger.debug { "Booking #{place_booking.id}: updating placeos end time to #{booking.released}, #{place_booking.checked_in_at}" }
+ if place_booking.checked_in_at
+ logger.debug { "Booking #{place_booking.id}: running check in" }
+ staff_api.booking_check_in(place_booking.id, false, utm_source: DESK_SOURCE_EXTENSION_DATA_MODIFICATION).get
+ else
+ logger.debug { "Booking #{place_booking.id}: will be no show" }
+ staff_api.update_booking(
+ booking_id: place_booking.id,
+ booking_end: booking.released
+ ).get
+ end
+ else
+ logger.debug { "Booking exists but already deleted #{place_booking.is_deleted?} or checkout #{place_booking.released?}" }
+ end
+
+ area_management.update_available([floor_details[:level_id]])
+ end
+
+ protected def get_strict_place_booking(floorsense_booking, floor_details) : Booking?
+ desc = floorsense_booking.desc
+ if desc.nil?
+ search_booking_by_floorsense_id(floorsense_booking, floor_details)
+ else
+ Booking.from_json staff_api.get_booking(desc.to_i64).get.to_json
+ end
+ rescue ArgumentError
+ # in case the description was unexpectedly not an int64 (adhoc for instance)
+ search_booking_by_floorsense_id(floorsense_booking, floor_details)
+ rescue error
+ logger.warn(exception: error) { "error getting place booking" }
+ raise error
+ end
+
+ protected def search_booking_by_floorsense_id(freespace_booking, floor_details)
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: freespace_booking.start,
+ zones: {floor_details[:level_id]},
+ extension_data: {floorsense_booking_id: freespace_booking.booking_id}
+ ).get.as_a
+
+ logger.debug { "booking on search_booking_by_floorsense_id size #{bookings.size}" }
+
+ b1 = bookings.map { |book|
+ booking = Booking.from_json(book.to_json)
+ booking.rejected ? nil : booking
+ }
+
+ b2 = b1.compact
+ b3 = b2.first?
+
+ logger.debug { "booking to be return #{b3.to_json}" }
+ b3
+ rescue error
+ logger.warn(exception: error) { "searching for floorsense id" }
+ raise error
+ end
+
+ private def get_user(floorsense_user_id)
+ placeos_user_id = @user_ids_cache[floorsense_user_id]?
+ if placeos_user_id.nil?
+ floorsense_user = floorsense.get_user(floorsense_user_id).get
+ user = staff_api.user(floorsense_user["desc"].as_s).get
+ @user_ids_cache[floorsense_user_id] = user["id"].as_s
+ else
+ user = staff_api.user(placeos_user_id).get
+ end
+ PlaceUser.from_json(user.to_json)
+ rescue error
+ logger.warn(exception: error) { "User not found in Placeos : #{floorsense_user_id}" }
+ return
+ end
+
+ # Forcing recompilation with latest changes on extension data
+ private def fetch_desk(desk_id)
+ desk_api.fetch_desk(desk_id).get["payload"]
+ rescue error
+ logger.warn(exception: error) { "desk #{desk_id} not found" }
+ raise "Desk not found"
+ end
+
+ def placeos_desk_metadata(zone_id : String, building_id : String?)
+ desk_lookup = {} of String => DeskMeta
+
+ begin
+ metadata = staff_api.metadata(
+ zone_id,
+ "desks"
+ ).get["desks"]["details"].as_a
+
+ lookup_key = @floorsense_lookup_key
+ metadata.each do |desk|
+ desk = desk.as_h
+ place_id = desk["id"]?.try(&.as_s.presence)
+ next unless place_id
+
+ floor_id = desk[lookup_key]?.try(&.as_s.presence)
+ next unless floor_id
+
+ # Additional data for adhoc bookings
+ ext_data = {
+ "floorsense_id" => JSON::Any.new(floor_id),
+ }
+ title = desk["name"]?.try(&.as_s) || place_id
+ @meta_ext_mappings.each do |meta_key, ext_key|
+ if value = desk[meta_key]?
+ ext_data[ext_key] = value
+ end
+ end
+
+ @meta_ext_static.each do |key, value|
+ ext_data[key] = JSON::Any.new(value)
+ end
+
+ ids = DeskMeta.new(place_id, floor_id, building_id, title, ext_data)
+ desk_lookup[place_id] = ids
+ desk_lookup[floor_id] = ids
+ end
+ desk_lookup
+ rescue error
+ logger.warn(exception: error) { "unable to obtain desk metadata for #{zone_id}" }
+ desk_lookup
+ end
+ end
+end
diff --git a/drivers/floorsense/custom_bookings_sync_spec.cr b/drivers/floorsense/custom_bookings_sync_spec.cr
new file mode 100644
index 00000000000..3aea02dc78f
--- /dev/null
+++ b/drivers/floorsense/custom_bookings_sync_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Floorsense::CustomBookingsSync" do
+end
diff --git a/drivers/floorsense/desks_websocket.cr b/drivers/floorsense/desks_websocket.cr
new file mode 100644
index 00000000000..a802a772af1
--- /dev/null
+++ b/drivers/floorsense/desks_websocket.cr
@@ -0,0 +1,1011 @@
+require "placeos-driver"
+require "placeos-driver/interface/lockers"
+require "placeos-driver/interface/desk_control"
+require "uri"
+require "jwt"
+require "./models"
+
+# Documentation
+# https://apiguide.smartalock.com/
+# https://documenter.getpostman.com/view/8843075/SVmwvctF?version=latest#3bfbb050-722d-4433-889a-8793fa90af9c
+
+class Floorsense::DesksWebsocket < PlaceOS::Driver
+ include Interface::DeskControl
+
+ # Discovery Information
+ generic_name :Floorsense
+ descriptive_name "Floorsense Desk Tracking (WS)"
+
+ uri_base "wss://_your_subdomain_.floorsense.com.au/ws"
+
+ default_settings({
+ username: "srvc_acct",
+ password: "password!",
+ ws_username: "srvc_acct",
+ ws_password: "password!",
+
+ # if the websocket is on a different port
+ # http_uri_override: "https://ip"
+ })
+
+ @username : String = ""
+ @password : String = ""
+ @ws_username : String = ""
+ @ws_password : String = ""
+ @auth_token : String = ""
+ @auth_expiry : Time = 1.minute.ago
+ @user_cache : Hash(String, User) = {} of String => User
+
+ @controllers : Hash(Int32, ControllerInfo) = {} of Int32 => ControllerInfo
+
+ # Locker key => controller id
+ getter locker_controllers : Hash(String, LockerInfo) = {} of String => LockerInfo
+
+ # Desk key => controller id
+ getter desk_controllers : Hash(String, DeskInfo) = {} of String => DeskInfo
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("\r\n")
+ on_update
+ end
+
+ def on_update
+ @username = setting(String, :username)
+ @password = setting(String, :password)
+ @ws_username = setting?(String, :ws_username) || @username
+ @ws_password = setting?(String, :ws_password) || @password
+
+ if uri_override = setting?(String, :http_uri_override)
+ transport.http_uri_override = URI.parse uri_override
+ else
+ transport.http_uri_override = nil
+ end
+
+ transport.before_request do |request|
+ logger.debug { "requesting: #{request.method} #{request.path}?#{request.query}\n#{request.body}" }
+ end
+
+ schedule.clear
+ schedule.every(1.hour) { sync_locker_list }
+ schedule.in(5.seconds) { sync_locker_list }
+ schedule.every(1.minute) { check_subscriptions }
+ end
+
+ def connected
+ # authenticate
+ # ws_post("/auth", {username: @ws_username, password: @ws_password}, priority: 99, name: "auth")
+ ws_post("/auth", {user: "kiosk"}.to_json, priority: 99, name: "auth")
+ end
+
+ @[Security(Level::Administrator)]
+ def ws_post(uri : String, body : String? = nil, **options)
+ request = "POST #{uri}\r\n#{body.presence ? body : "{}"}\r\n"
+ logger.debug { "requesting: #{request}" }
+ send(request, **options)
+ end
+
+ @[Security(Level::Administrator)]
+ def ws_get(uri : String, **options)
+ request = "GET #{uri}\r\n"
+ logger.debug { "requesting: #{request}" }
+ send(request, **options)
+ end
+
+ # used to poll the websocket to check for liveliness
+ def check_subscriptions
+ ws_get "/restapi/subscribe"
+ end
+
+ def received(data, task)
+ string = String.new(data).rstrip
+ logger.debug { "websocket sent: #{string}" }
+ payload = Payload.from_json(string)
+
+ case payload
+ in Response
+ if !payload.result
+ logger.warn { "task #{task.try &.name} failed.." }
+ # disconnect
+ return task.try &.abort
+ end
+
+ case task.try &.name
+ when "auth"
+ logger.debug { "authentication success!" }
+
+ # subscribe to all events
+ ws_post("/sub", {mask: 255}.to_json, name: "sub")
+ when "sub"
+ logger.debug { "subscribed to events" }
+ else
+ logger.warn { "unknown task: #{(task.try &.name).inspect}" }
+ end
+ task.try &.success
+ in Event
+ self["event_#{payload.code}"] = payload.info || payload.message
+ in Payload
+ logger.error { "base class, this case will never occur" }
+ end
+ rescue error
+ logger.error(exception: error) { "failed to parse: #{string.inspect}" }
+ end
+
+ def expire_token!
+ @auth_expiry = 1.minute.ago
+ end
+
+ def token_expired?
+ now = Time.utc
+ @auth_expiry < now
+ end
+
+ def get_token
+ return @auth_token unless token_expired?
+
+ response = post("/restapi/login",
+ body: "username=#{URI.encode_www_form @username}&password=#{URI.encode_www_form @password}",
+ headers: {
+ "Content-Type" => "application/x-www-form-urlencoded",
+ "Accept" => "application/json",
+ }
+ )
+
+ data = response.body.not_nil!
+ logger.debug { "received login response #{data}" }
+
+ if response.success?
+ resp = Resp(AuthInfo).from_json(data)
+ token = resp.info.not_nil!.token
+ payload, _ = JWT.decode(token, verify: false, validate: false)
+ @auth_expiry = (Time.unix payload["exp"].as_i64) - 5.minutes
+ @auth_token = "Bearer #{token}"
+ else
+ case response.status_code
+ when 401
+ resp = Resp(AuthInfo).from_json(data)
+ logger.warn { "#{resp.message} (#{resp.code})" }
+ else
+ logger.error { "authentication failed with HTTP #{response.status_code}" }
+ end
+ raise "failed to obtain access token"
+ end
+ end
+
+ protected def check_success(response) : Bool
+ logger.debug { "responed with #{response.status_code}\n#{response.body}" }
+ return true if response.success?
+ expire_token! if response.status_code == 401
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ macro parse(response, klass, &modify)
+ check_success({{response}})
+ %resp_body = {{response}}.body
+ begin
+ check_response Resp({{klass}}).from_json(%resp_body.not_nil!) {{modify}}
+ rescue error
+ begin
+ response = Response.from_json(%resp_body)
+ raise "#{response.message} (#{response.code})" unless response.result
+ raise "unexpected response type: #{%resp_body}"
+ rescue
+ logger.debug { "failed to parse response: #{%resp_body}" }
+ raise error
+ end
+ end
+ end
+
+ def default_headers
+ {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ }
+ end
+
+ def sync_locker_list
+ lockers = {} of String => LockerInfo
+ desks = {} of String => DeskInfo
+
+ controller_list.each do |controller_id, controller|
+ if controller.lockers
+ begin
+ lockers(controller_id).each do |locker|
+ next unless locker.key
+ locker.controller_id = controller_id
+ lockers[locker.key.not_nil!] = locker
+ end
+ rescue error
+ logger.warn(exception: error) { "obtaining locker list for controller #{controller.name} - #{controller_id}, possibly offline" }
+ end
+ end
+
+ if controller.desks
+ begin
+ desk_list(controller_id).each do |desk|
+ next unless desk.key
+ desk.controller_id = controller_id
+ desks[desk.key.not_nil!] = desk
+ end
+ rescue error
+ logger.warn(exception: error) { "obtaining desk list for controller #{controller.name} - #{controller_id}, possibly offline" }
+ end
+ end
+ end
+ @desk_controllers = desks
+ @locker_controllers = lockers
+ end
+
+ def controller_list(locker : Bool? = nil, desks : Bool? = nil)
+ query = URI::Params.build do |form|
+ form.add("locks", "true") if locker
+ form.add("desks", "true") if desks
+ end
+
+ response = get("/restapi/slave-list?#{query}", headers: default_headers)
+ controllers = parse response, Array(ControllerInfo)
+
+ mappings = {} of Int32 => ControllerInfo
+ controllers.each { |ctrl| mappings[ctrl.controller_id] = ctrl }
+ self[:controllers] = mappings if locker.nil? && desks.nil?
+ @controllers = mappings
+ end
+
+ def bank_list(controller_id : String | Int32 | Int64)
+ query = URI::Params.build do |form|
+ form.add("cid", controller_id.to_s)
+ end
+
+ response = get("/restapi/bank-list?#{query}", headers: default_headers)
+ parse response, Array(JSON::Any)
+ end
+
+ def settings_list(
+ group_id : Int32? = nil,
+ user_group_id : Int32? = nil,
+ controller_id : String | Int32 | Int64? = nil
+ )
+ query = URI::Params.build { |form|
+ form.add("cid", controller_id.to_s) if controller_id
+ form.add("groupid", group_id.to_s) if group_id
+ form.add("ugroupid", user_group_id.to_s) if user_group_id
+ }
+
+ response = get("/restapi/setting-list?#{query}", headers: default_headers)
+ parse response, Array(JSON::Any)
+ end
+
+ def get_setting(key : String, user_id : String? = nil)
+ query = URI::Params.build { |form|
+ form.add("key", key)
+ form.add("uid", %("#{user_id.to_s}")) if user_id
+ }
+ response = get("/restapi/setting?#{query}", headers: default_headers)
+ parse response, Setting
+ end
+
+ # example keys: "desk_height_sit", "desk_height_stand"
+ def set_setting(key : String, value : JSON::Any, user_id : String? = nil)
+ body = URI::Params.build { |form|
+ form.add("key", key)
+ form.add("value", value.to_json)
+ form.add("uid", %("#{user_id.to_s}")) if user_id
+ }
+ response = post("/restapi/setting", headers: default_headers, body: body)
+ response.success?
+ end
+
+ def all_lockers
+ return @locker_controllers.values unless @locker_controllers.empty?
+ sync_locker_list.values
+ end
+
+ def lockers(controller_id : String | Int32 | Int64)
+ response = get("/restapi/locker-list?cid=#{controller_id}", headers: default_headers)
+ parse response, Array(LockerInfo)
+ end
+
+ def locker(locker_key : String)
+ lock = @locker_controllers[locker_key]
+ response = get("/restapi/locker-status?cid=#{lock.controller_id}&bid=#{lock.bus_id}&lid=#{lock.locker_id}", headers: default_headers)
+ parse response, LockerInfo
+ end
+
+ def locker_info(locker_key : String)
+ @locker_controllers[locker_key]
+ end
+
+ enum LedState
+ Off
+ On
+ Slow
+ Medium
+ Fast
+ end
+
+ def locker_control(
+ locker_key : String,
+ light : Bool? = nil,
+ led : LedState? = nil,
+ led_colour : String? = nil,
+ buzzer : String? = nil,
+ usb_charging : String? = nil,
+ detect : Bool? = nil
+ )
+ lock = @locker_controllers[locker_key]
+
+ response = post("/restapi/locker-control", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("cid", lock.controller_id.to_s)
+ form.add("bid", lock.bus_id.to_s)
+ form.add("lid", lock.locker_id.to_s)
+
+ form.add("light", light ? "on" : "off") if !light.nil?
+ form.add("led", led.to_s.downcase) if led
+ form.add("led-colour", led_colour) if led_colour
+ form.add("buzzer", buzzer) if buzzer
+ form.add("usbchg", usb_charging) if usb_charging
+ form.add("detect", "true") if detect
+ })
+
+ check_success(response)
+ end
+
+ def get_locker_reservation(reservation_id : String)
+ query = URI::Params.build { |form|
+ form.add("resid", reservation_id) if reservation_id
+ }
+
+ response = get("/restapi/res?#{query}", headers: default_headers)
+ parse response, LockerBooking
+ end
+
+ def locker_reservation(
+ locker_key : String?,
+ user_id : String,
+ type : String? = nil,
+ duration : Int32? = nil,
+ restype : String = "adhoc", # also supports fixed
+ controller_id : String | Int32 | Int64 | Nil = nil,
+ )
+ controller_id ||= @locker_controllers[locker_key].controller_id
+
+ response = post("/restapi/res-create", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("cid", controller_id.to_s)
+ form.add("key", locker_key.to_s) if locker_key.presence
+ form.add("uid", user_id)
+
+ form.add("type", type) if type
+ form.add("duration", duration.to_s) if duration
+ form.add("restype", restype)
+ })
+
+ parse response, LockerBooking
+ end
+
+ def locker_reservations(active : Bool? = nil, user_id : String? = nil, controller_id : String | Int32 | Int64? = nil, shared : Bool? = nil)
+ query = URI::Params.build { |form|
+ form.add("uid", user_id) if user_id
+ form.add("active", "1") if active
+ form.add("cid", controller_id.to_s) if controller_id
+ form.add("shared", "1") if shared
+ }
+
+ response = get("/restapi/res-list?#{query}", headers: default_headers)
+ parse response, Array(LockerBooking)
+ end
+
+ @[Security(Level::Support)]
+ def locker_release(reservation_id : String)
+ response = post("/restapi/res-release", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("resid", reservation_id)
+ })
+
+ check_success(response)
+ end
+
+ @[Security(Level::Support)]
+ def locker_change_pin(reservation_id : String, pin : Int32)
+ response = post("/restapi/res", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("resid", reservation_id)
+ form.add("pin", pin.to_s)
+ })
+
+ check_success(response)
+ end
+
+ @[Security(Level::Support)]
+ def locker_unlock(
+ locker_key : String,
+ user_id : String? = nil,
+ pin : String? = nil
+ )
+ lock = @locker_controllers[locker_key]
+
+ response = post("/restapi/locker-unlock", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("cid", lock.controller_id.to_s)
+ form.add("key", locker_key)
+ form.add("uid", user_id.to_s) if user_id.presence
+ form.add("pin", pin.to_s) if pin.presence
+ })
+
+ check_success(response)
+ end
+
+ # GET res-share
+ def locker_shared?(reservation_id : String)
+ response = get("/restapi/res-share?resid=#{reservation_id}", headers: default_headers)
+ parse response, Array(JSON::Any)
+ end
+
+ # POST res-share
+ def locker_share(
+ reservation_id : String,
+ user_id : String,
+ duration : UInt32? = nil
+ )
+ response = post("/restapi/res-share", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("resid", reservation_id)
+ form.add("uid", user_id)
+ form.add("duration", duration.to_s) if duration
+ })
+
+ check_success(response)
+ end
+
+ # POST res-unshare
+ def locker_unshare(
+ reservation_id : String,
+ user_id : String
+ )
+ response = post("/restapi/res-unshare", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("resid", reservation_id)
+ form.add("uid", user_id)
+ })
+
+ check_success(response)
+ end
+
+ def voucher_templates
+ response = get("/restapi/voucher-template", headers: default_headers)
+ parse response, Array(JSON::Any)
+ end
+
+ def voucher_template(
+ key : String,
+ subject : String,
+ desc : String,
+ bodyhtml : String,
+ body : String,
+
+ createuser : Bool? = nil,
+ email : Bool? = nil,
+ unlock : Bool? = nil,
+ createunlock : Bool? = nil,
+ createres : Bool? = nil,
+ release : Bool? = nil,
+ cardswipe : Bool? = nil,
+ maxuse : Int32? = nil,
+ duration : Int32? = nil,
+ validperiod : Int32? = nil,
+ restype : String? = nil,
+ activatemessage : String? = nil,
+ vouchermessage : String? = nil
+ )
+ response = post("/restapi/res-unshare", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("key", key)
+ form.add("subject", subject)
+ form.add("desc", desc)
+ form.add("bodyhtml", bodyhtml)
+ form.add("body", body)
+ form.add("createuser", createuser.to_s) unless createuser.nil?
+ form.add("email", email.to_s) unless email.nil?
+ form.add("unlock", unlock.to_s) unless unlock.nil?
+ form.add("createunlock", createunlock.to_s) unless createunlock.nil?
+ form.add("createres", createres.to_s) unless createres.nil?
+ form.add("release", release.to_s) unless release.nil?
+ form.add("cardswipe", cardswipe.to_s) unless cardswipe.nil?
+ form.add("maxuse", maxuse.to_s) unless maxuse.nil?
+ form.add("duration", duration.to_s) unless duration.nil?
+ form.add("validperiod", validperiod.to_s) unless validperiod.nil?
+ form.add("restype", restype.to_s) unless restype.nil?
+ form.add("activatemessage", activatemessage.to_s) unless activatemessage.nil?
+ form.add("vouchermessage", vouchermessage.to_s) unless vouchermessage.nil?
+ })
+
+ check_success(response)
+ end
+
+ def voucher_create(
+ template_key : String,
+ user_name : String,
+ user_email : String,
+ user_id : String? = nil, # if the user already exists
+ reservation_id : String? = nil, # if a reservation already exists
+ locker_key : String? = nil,
+ controller_id : String | Int32 | Int64? = nil,
+ notes : String? = nil,
+ validfrom : Int64? = nil,
+ validto : Int64? = nil,
+ duration : Int32? = nil
+ )
+ response = post("/restapi/res-unshare", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("key", template_key)
+ form.add("name", user_name)
+ form.add("email", user_email)
+
+ form.add("uid", user_id) unless user_id.nil?
+ form.add("resid", reservation_id.to_s) unless reservation_id.nil?
+ form.add("cid", controller_id.to_s) unless controller_id.nil?
+ form.add("key", locker_key.to_s) unless locker_key.nil?
+ form.add("notes", notes.to_s) unless notes.nil?
+ form.add("validfrom", validfrom.to_s) unless validfrom.nil?
+ form.add("validto", validto.to_s) unless validto.nil?
+ form.add("duration", duration.to_s) unless duration.nil?
+ })
+
+ parse response, NamedTuple(user: User, voucher: Voucher)
+ end
+
+ def voucher_activate(
+ voucher_id : String,
+ pin : String
+ )
+ response = post("/restapi/voucher-activate", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("vid", voucher_id)
+ form.add("pin", pin)
+ })
+ check_success(response)
+ end
+
+ def voucher(
+ voucher_id : String,
+ pin : String
+ )
+ response = get("/restapi/voucher?vid=#{voucher_id}&pin=#{pin}", headers: default_headers)
+ parse response, Voucher
+ end
+
+ def floors
+ response = get("/restapi/floorplan-list", headers: default_headers)
+ parse response, Array(Floor)
+ end
+
+ def desks(plan_id : String | Int32)
+ response = get("/restapi/floorplan-desk?planid=#{plan_id}", headers: default_headers)
+ parse response, Array(DeskStatus)
+ end
+
+ def bookings(plan_id : String, period_start : Int64? = nil, period_end : Int64? = nil)
+ period_start ||= Time.utc.to_unix
+ period_end ||= 15.minutes.from_now.to_unix
+ uri = "/restapi/floorplan-booking?planid=#{plan_id}&start=#{period_start}&finish=#{period_end}"
+
+ response = get(uri, headers: default_headers)
+ bookings_map = parse response, Hash(String, Array(BookingStatus))
+ bookings_map.each do |_id, bookings|
+ # get the user information
+ bookings.each { |booking| booking.user = get_user(booking.uid) }
+ end
+ bookings_map
+ end
+
+ def get_booking(booking_id : String | Int64)
+ response = get("/restapi/booking?bkid=#{booking_id}", headers: default_headers)
+ booking = parse response, BookingStatus
+ booking.user = get_user(booking.uid)
+ booking
+ end
+
+ def confirm_booking(booking_id : String | Int64)
+ response = post("/restapi/booking-confirm", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("bkid", booking_id.to_s)
+ form.add("method", "1")
+ })
+ parse response, JSON::Any
+ end
+
+ def activate_booking(
+ booking_id : String | Int64,
+ controller_id : String | Int32 | Int64 | Nil = nil,
+ key : String | Nil = nil,
+ eui64 : String | Nil = nil,
+ userpresent : Bool? = nil
+ )
+ response = post("/restapi/booking-activate", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("bkid", booking_id.to_s)
+ form.add("cid", controller_id.to_s) unless controller_id.nil?
+ form.add("key", key.to_s) unless key.nil?
+ form.add("userpresent", userpresent.to_s) unless userpresent.nil?
+ })
+ parse response, JSON::Any
+ end
+
+ # More details on: https://apiguide.smartalock.com/#d685f36e-a513-44d9-8205-2b071922733a
+ def desk_scan(
+ eui64 : String,
+ key : String | Int64 | Nil = nil,
+ cid : String? = nil,
+ uid : String? = nil
+ )
+ response = post("/restapi/desk-scan", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("eui64", eui64.to_s)
+ form.add("key", key.to_s)
+ form.add("cid", cid.to_s) unless cid.nil?
+ form.add("uid", uid.to_s) unless uid.nil?
+ })
+ parse response, JSON::Any
+ end
+
+ def create_booking(
+ user_id : String | Int64,
+ plan_id : String | Int32,
+ key : String,
+ description : String? = nil,
+ starting : Int64? = nil,
+ ending : Int64? = nil,
+ time_zone : String? = nil,
+ booking_type : String = "advance"
+ )
+ desks_on_plan = desks(plan_id)
+ desk = desks_on_plan.find(&.key.==(key))
+
+ raise "could not find desk #{key} on plan #{plan_id}" unless desk
+
+ now = time_zone ? Time.local(Time::Location.load(time_zone)) : Time.local
+ starting ||= now.at_beginning_of_day.to_unix
+ ending ||= now.at_end_of_day.to_unix
+
+ response = post("/restapi/booking-create", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("uid", user_id.to_s)
+ form.add("cid", desk.cid.to_s)
+ form.add("key", key)
+ form.add("bktype", booking_type)
+ form.add("desc", description.not_nil!) if description
+ form.add("start", starting.to_s)
+ form.add("finish", ending.to_s)
+ form.add("confexpiry", ending.to_s)
+ })
+
+ booking = parse response, BookingStatus
+ booking.user = get_user(booking.uid)
+ booking
+ end
+
+ def release_booking(booking_id : String | Int64)
+ response = post("/restapi/booking-release", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build(&.add("bkid", booking_id.to_s)))
+
+ check_success(response)
+ end
+
+ def update_booking(
+ booking_id : String | Int64,
+ privacy : Bool? = nil
+ )
+ response = post("/restapi/booking", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("bkid", booking_id.to_s)
+ form.add("privacy", privacy.to_s)
+ })
+
+ booking = parse response, BookingStatus
+ booking.user = get_user(booking.uid)
+ booking
+ end
+
+ def desk_list(controller_id : String | Int32 | Int64)
+ response = get("/restapi/desk-list?cid=#{controller_id}", headers: default_headers)
+ parse response, Array(DeskInfo)
+ rescue error
+ # code 34 "unknown command" indicates the desk api is unavailable
+ raise error unless error.message.try &.includes?("34")
+ [] of DeskInfo
+ end
+
+ def desk_info(desk_key : String)
+ controller_id = @desk_controllers[desk_key].controller_id
+ response = get("/restapi/desk-status?cid=#{controller_id}&key=#{desk_key}", headers: default_headers)
+ desk_info = parse response, DeskInfo
+ desk_info
+ end
+
+ # NOTE:: here for backwards compatibility
+ def desk_details(controller_id : String | Int32, desk_id : String)
+ response = get("/restapi/desk-status?cid=#{controller_id}&key=#{desk_id}", headers: default_headers)
+ desk_info = parse response, DeskInfo
+ desk_info
+ end
+
+ enum LedColour
+ Red
+ Green
+ Blue
+ end
+
+ enum DeskPower
+ On
+ Off
+ Policy
+ end
+
+ enum DeskHeight
+ Sit
+ Stand
+ end
+
+ enum QiMode
+ On
+ Off
+ Auto
+ end
+
+ def desk_control(
+ desk_key : String,
+ led_state : LedState? = nil,
+ led_colour : LedColour? = nil,
+ desk_power : DeskPower? = nil,
+ desk_height : DeskHeight | Int32? = nil,
+ qi_mode : QiMode? = nil,
+ reboot : Bool = false,
+ clean : Bool = false
+ )
+ controller_id = @desk_controllers[desk_key].controller_id
+
+ response = post("/restapi/desk-control", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("cid", controller_id.to_s)
+ form.add("key", desk_key)
+
+ form.add("led", led_state.to_s.downcase) if led_state
+ form.add("led-colour", led_colour.to_s.downcase) if led_colour
+ form.add("desk-power", desk_power.to_s.downcase) if desk_power
+ form.add("desk-height", desk_height.to_s.downcase) if desk_height
+ form.add("qi-mode", qi_mode.to_s.downcase) if qi_mode
+ form.add("reboot", "true") if reboot
+ form.add("clean", "true") if clean
+ })
+
+ check_success(response)
+ end
+
+ # ======================
+ # Desk control interface
+ # ======================
+
+ def set_desk_height(desk_key : String, desk_height : Int32)
+ desk_control(desk_key, desk_height: desk_height)
+ end
+
+ def get_desk_height(desk_key : String) : Int32?
+ desk_info(desk_key).deskheight
+ end
+
+ def set_desk_power(desk_key : String, desk_power : Bool?)
+ power = case desk_power
+ when true
+ DeskPower::On
+ when false
+ DeskPower::Off
+ when nil
+ DeskPower::Policy
+ else
+ raise "unknown power state: #{desk_power}"
+ end
+ desk_control(desk_key, desk_power: power)
+ end
+
+ def get_desk_power(desk_key : String) : Bool?
+ nil
+ end
+
+ # ======================
+
+ def user_groups_list(in_use : Bool = true)
+ query = in_use ? "inuse=1" : ""
+ response = get("/restapi/usergroup-list?#{query}", headers: default_headers)
+ parse response, Array(UserGroup)
+ end
+
+ def create_user(
+ name : String,
+ email : String,
+ description : String? = nil,
+ extid : String? = nil,
+ pin : String? = nil,
+ usertype : String = "user"
+ )
+ response = post("/restapi/user-create", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("name", name)
+ form.add("email", email)
+ form.add("desc", description.not_nil!) if description
+ form.add("pin", pin.not_nil!) if pin
+ form.add("extid", extid.not_nil!) if extid
+ form.add("usertype", "user")
+ })
+
+ user = parse response, User
+ @user_cache[user.uid] = user
+ user
+ end
+
+ def create_rfid(
+ user_id : String,
+ card_number : String,
+ description : String? = nil
+ )
+ response = post("/restapi/rfid-create", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("uid", user_id)
+ form.add("csn", card_number)
+ form.add("desc", description.not_nil!) if description
+ })
+
+ parse(response, User) { |resp| resp || JSON::Any.new(true) }
+ end
+
+ def delete_rfid(card_number : String)
+ response = post("/restapi/rfid-delete", headers: {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ "Content-Type" => "application/x-www-form-urlencoded",
+ }, body: URI::Params.build { |form|
+ form.add("csn", card_number)
+ })
+
+ check_success(response)
+ end
+
+ def get_rfid(card_number : String)
+ response = get("/restapi/rfid?csn=#{card_number}", headers: default_headers)
+ parse response, RFID
+ end
+
+ def get_user(user_id : String)
+ existing = @user_cache[user_id]?
+ return existing if existing
+
+ response = get("/restapi/user?uid=#{user_id}", headers: default_headers)
+ user = parse response, User
+ @user_cache[user_id] = user
+ user
+ end
+
+ def user_list(email : String? = nil, name : String? = nil, description : String? = nil, user_group_id : String | Int32? = nil, limit : Int32 = 500, offset : Int32 = 0)
+ query = URI::Params.build { |form|
+ form.add("email", email.not_nil!) if email
+ form.add("name", name.not_nil!) if name
+ form.add("desc", description.not_nil!) if description
+ form.add("ugroupid", user_group_id.to_s) if user_group_id
+ form.add("limit", limit.to_s)
+ form.add("offset", offset.to_s)
+ }
+
+ response = get("/restapi/user-list?#{query}", headers: default_headers)
+ parse response, Array(User)
+ end
+
+ def event_log(codes : Array(String | Int32), event_id : Int64? = nil, after : Int64? = nil, limit : Int32 = 1)
+ query = URI::Params.build { |form|
+ form.add("codes", codes.join(",", &.to_s))
+ form.add("after", after.not_nil!.to_s) if after
+ form.add("event_id", event_id.not_nil!.to_s) if event_id
+ form.add("limit", limit.to_s)
+ }
+
+ response = get("/restapi/event-log?#{query}", headers: default_headers)
+ logs = parse response, Array(LogEntry)
+ logs.sort do |a, b|
+ if a.eventtime == b.eventtime
+ a.eventid <=> b.eventid
+ else
+ a.eventtime <=> b.eventtime
+ end
+ end
+ end
+
+ def at_location(controller_id : String | Int32 | Int64, desk_key : String)
+ response = get("/restapi/user-locate?cid=#{controller_id}&desk_key=#{desk_key}", headers: default_headers)
+ logger.debug { "at_location response: #{response.body}" }
+ users = parse response, Array(User)
+ users.first?
+ end
+
+ @[Security(Level::Support)]
+ def clear_user_cache!
+ @user_cache.clear
+ end
+
+ def locate(key : String, controller_id : String | Int32 | Int64? = nil)
+ uri = if controller_id
+ "/restapi/user-locate?cid=#{controller_id}&key=#{URI.encode_www_form key}"
+ else
+ "/restapi/user-locate?name=#{URI.encode_www_form key}"
+ end
+
+ response = get(uri, headers: default_headers)
+ parse response, Array(UserLocation)
+ end
+
+ protected def check_response(resp)
+ check_response(resp, &.not_nil!)
+ end
+
+ protected def check_response(resp)
+ if resp.result
+ yield resp.info
+ else
+ raise "bad response result (#{resp.code}) #{resp.message}"
+ end
+ end
+end
diff --git a/drivers/floorsense/desks_websocket_spec.cr b/drivers/floorsense/desks_websocket_spec.cr
new file mode 100644
index 00000000000..a2a73a491c9
--- /dev/null
+++ b/drivers/floorsense/desks_websocket_spec.cr
@@ -0,0 +1,54 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Floorsense::DesksWebsocket" do
+ should_send %(POST /auth\r\n{"user":"kiosk"}\r\n)
+ resp = {
+ type: "response",
+ result: true,
+ message: "Authenticated",
+ info: {"lockers": true, "desks": true},
+ }
+ transmit "#{resp.to_json}\r\n"
+
+ should_send %(POST /sub\r\n{"mask":255}\r\n)
+ resp = {
+ type: "response",
+ result: true,
+ message: "event mask set",
+ }
+ transmit "#{resp.to_json}\r\n"
+
+ # Send the request
+ retval = exec(:get_token)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |request, response|
+ if io = request.body
+ data = io.gets_to_end
+
+ # The request is param encoded
+ if data == "username=srvc_acct&password=password%21"
+ response.status_code = 200
+ response.output.puts %({"type":"response","result":true,"message":"Authentication successful","info":{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzbWFydGFsb2NrLWQ1MGJjZC5sb2NhbGRvbWFpbiIsInN1YiI6ImFjYSIsImF1ZCI6ImFwaSIsImV4cCI6MTU3MjMwODMzMiwiaWF0IjoxNTcyMzA0NzMyfQ.KMlzvjYPFw9e5d5LQjb1BF5R1Je9KkgoigkNOUZnR4U","sessionid":"ace555fe-4914-4203-b0a3-a1a6f532fef7"}})
+ else
+ response.status_code = 401
+ response.output.puts %({"type":"response","result":false,"message":"Authentication failed","code":17})
+ end
+ else
+ raise "expected request to include username and password"
+ end
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzbWFydGFsb2NrLWQ1MGJjZC5sb2NhbGRvbWFpbiIsInN1YiI6ImFjYSIsImF1ZCI6ImFwaSIsImV4cCI6MTU3MjMwODMzMiwiaWF0IjoxNTcyMzA0NzMyfQ.KMlzvjYPFw9e5d5LQjb1BF5R1Je9KkgoigkNOUZnR4U")
+
+ event = {
+ type: "event",
+ code: 1234,
+ message: "event mask set",
+ info: {"lockers": true, "desks": true},
+ }
+ transmit "#{event.to_json}\r\n"
+
+ status[:event_1234].should eq({"lockers" => true, "desks" => true})
+end
diff --git a/drivers/floorsense/location_service.cr b/drivers/floorsense/location_service.cr
new file mode 100644
index 00000000000..fccef0013a0
--- /dev/null
+++ b/drivers/floorsense/location_service.cr
@@ -0,0 +1,219 @@
+require "placeos-driver"
+require "placeos-driver/interface/lockers"
+require "placeos-driver/interface/locatable"
+require "uri"
+require "json"
+require "oauth2"
+require "./models"
+
+class Floorsense::LocationService < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "Floorsense Location Service"
+ generic_name :FloorsenseLocationService
+ description %(collects desk booking data from the staff API and overlays Floorsense data for visualising on a map)
+
+ accessor floorsense : Floorsense_1
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ floor_mappings: {
+ "planid": {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+ include_bookings: false,
+
+ user_lookup: "email",
+ floorsense_filter: "email",
+ })
+
+ @floor_mappings : Hash(String, NamedTuple(building_id: String?, level_id: String)) = {} of String => NamedTuple(building_id: String?, level_id: String)
+ # Level zone => plan_id
+ @zone_mappings : Hash(String, String) = {} of String => String
+ # Level zone => building_zone
+ @building_mappings : Hash(String, String?) = {} of String => String?
+
+ @include_bookings : Bool = false
+
+ # eui64 => floorsense desk id
+ @eui64_to_desk_id : Hash(String, String) = {} of String => String
+
+ def on_update
+ @include_bookings = setting?(Bool, :include_bookings) || false
+ @floor_mappings = setting(Hash(String, NamedTuple(building_id: String?, level_id: String)), :floor_mappings)
+ @user_lookup = setting?(String, :user_lookup).presence || "email"
+ @floorsense_filter = setting?(String, :floorsense_filter).presence || "email"
+ @create_floorsense_users = setting?(Bool, :create_floorsense_users) || false
+ @floor_mappings.each do |plan_id, details|
+ level = details[:level_id]
+ @building_mappings[level] = details[:building_id]
+ @zone_mappings[level] = plan_id
+ end
+ end
+
+ def eui64_to_desk_id(id : String)
+ @eui64_to_desk_id[id]?
+ end
+
+ # ===================================
+ # Get and set a users desk height
+ # ===================================
+
+ @user_lookup : String = "email"
+ @floorsense_filter : String = "email"
+ @create_floorsense_users : Bool = true
+
+ def get_floorsense_user(place_user_id : String) : String
+ place_user = staff_api.user(place_user_id).get
+ placeos_staff_id = place_user[@user_lookup].as_s
+
+ user_query = case @floorsense_filter
+ when "name"
+ floorsense.user_list(name: placeos_staff_id)
+ when "email"
+ floorsense.user_list(email: placeos_staff_id)
+ else
+ floorsense.user_list(description: placeos_staff_id)
+ end
+ floorsense_users = user_query.get.as_a
+
+ user_id = floorsense_users.first?.try(&.[]("uid").as_s)
+ user_id ||= floorsense.create_user(place_user["name"].as_s, place_user["email"].as_s, placeos_staff_id).get["uid"].as_s if @create_floorsense_users
+ raise "Floorsense user not found for #{placeos_staff_id}" unless user_id
+
+ card_number = place_user["card_number"]?.try(&.as_s)
+ spawn { ensure_card_synced(card_number, user_id) } if user_id && card_number && !card_number.empty?
+ user_id
+ end
+
+ protected def ensure_card_synced(card_number : String, user_id : String) : Nil
+ existing_user = begin
+ floorsense.get_rfid(card_number).get["uid"].as_s
+ rescue
+ nil
+ end
+
+ if existing_user != user_id
+ floorsense.delete_rfid(card_number)
+ floorsense.create_rfid(user_id, card_number)
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to sync card number #{card_number} for user #{user_id}" }
+ end
+
+ def get_place_user_id : String
+ user_id = invoked_by_user_id
+ raise "must be invoked by a user" unless user_id
+ user_id
+ end
+
+ def get_desk_height_sit
+ user_id = get_place_user_id
+ uid = get_floorsense_user(user_id)
+ floorsense.get_setting("desk_height_sit", uid).get["value"]
+ end
+
+ def get_desk_height_stand
+ user_id = get_place_user_id
+ uid = get_floorsense_user(user_id)
+ floorsense.get_setting("desk_height_stand", uid).get["value"]
+ end
+
+ def set_desk_height_sit(value : UInt32)
+ user_id = get_place_user_id
+ uid = get_floorsense_user(user_id)
+ floorsense.set_setting("desk_height_sit", value, uid)
+ end
+
+ def set_desk_height_stand(value : UInt32)
+ user_id = get_place_user_id
+ uid = get_floorsense_user(user_id)
+ floorsense.set_setting("desk_height_stand", value, uid)
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ return nil unless mac_address.starts_with?("cid=")
+ floor_mac = URI::Params.parse mac_address
+ user = floorsense.at_location(floor_mac["cid"], floor_mac["key"]).get
+ {
+ location: "desk",
+ assigned_to: user["name"].as_s,
+ mac_address: mac_address,
+ }
+ rescue
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+ return [] of Nil if location && location != "desk"
+
+ plan_id = @zone_mappings[zone_id]?
+ return [] of Nil unless plan_id
+
+ building = @building_mappings[zone_id]?
+
+ raw_desks = floorsense.desks(plan_id).get.to_json
+ desks = Array(DeskStatus).from_json(raw_desks).compact_map do |desk|
+ @eui64_to_desk_id[desk.eui64] = desk.key
+
+ if desk.occupied
+ {
+ location: :desk,
+ at_location: 1,
+ map_id: desk.key,
+ level: zone_id,
+ building: building,
+ capacity: 1,
+
+ # So we can look up who is at a desk at some point in the future
+ mac: "cid=#{desk.cid}&key=#{desk.key}",
+
+ floorsense_status: desk.status,
+ floorsense_desk_type: desk.desk_type,
+ }
+ end
+ end
+
+ current = [] of BookingStatus
+
+ if @include_bookings
+ raw_bookings = floorsense.bookings(plan_id).get.to_json
+ Hash(String, Array(BookingStatus)).from_json(raw_bookings).each_value do |bookings|
+ current << bookings.first unless bookings.empty?
+ end
+ end
+
+ current.map { |booking|
+ {
+ location: :booking,
+ type: "desk",
+ checked_in: booking.active,
+ asset_id: booking.key,
+ booking_id: booking.booking_id,
+ building: building,
+ level: zone_id,
+ ends_at: booking.finish,
+ mac: "cid=#{booking.cid}&key=#{booking.key}",
+ staff_email: booking.user.try &.email.try(&.downcase),
+ staff_name: booking.user.try &.name,
+ }
+ } + desks
+ end
+end
diff --git a/drivers/floorsense/location_service_spec.cr b/drivers/floorsense/location_service_spec.cr
new file mode 100644
index 00000000000..a861f64cd22
--- /dev/null
+++ b/drivers/floorsense/location_service_spec.cr
@@ -0,0 +1,75 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Floorsense::LocationService" do
+ system({
+ Floorsense: {FloorsenseMock},
+ })
+
+ resp = exec(:device_locations, "zone-level").get
+ resp.should eq([
+ {"location" => "desk", "at_location" => 1, "map_id" => "D403-01", "level" => "zone-level", "building" => "zone-building", "capacity" => 1, "mac" => "cid=3&key=D403-01", "floorsense_status" => 17, "floorsense_desk_type" => "a"},
+ ])
+end
+
+# :nodoc:
+class FloorsenseMock < DriverSpecs::MockDriver
+ def desks(plan_id : String)
+ JSON.parse %([
+ {
+ "cid": 14,
+ "status": 17,
+ "cached": true,
+ "eui64": "00124b0018ae56d0",
+ "occupied": false,
+ "freq": "915",
+ "groupid": 0,
+ "netid": 3,
+ "key": "915-09",
+ "reservable": true,
+ "bkid": "",
+ "deskid": 2,
+ "hwfeat": 0,
+ "created": 1568887923,
+ "hardware": "E-20",
+ "firmware": "401",
+ "type": "a",
+ "planid": 6,
+ "reserved": false,
+ "features": 0,
+ "confirmed": false,
+ "privacy": false,
+ "uid": "",
+ "occupiedtime": 0
+ },
+ {
+ "cid": 3,
+ "status": 17,
+ "cached": true,
+ "eui64": "00124b0018ae54e5",
+ "occupied": true,
+ "freq": "",
+ "groupid": 0,
+ "netid": 2,
+ "key": "D403-01",
+ "reservable": false,
+ "bkid": "",
+ "deskid": 129,
+ "hwfeat": 0,
+ "created": 1568887941,
+ "hardware": "",
+ "firmware": "",
+ "type": "a",
+ "planid": 6,
+ "reserved": false,
+ "features": 0,
+ "confirmed": false,
+ "privacy": false,
+ "uid": "",
+ "occupiedtime": 0
+ }])
+ end
+
+ def bookings(plan_id : String)
+ JSON.parse %({})
+ end
+end
diff --git a/drivers/floorsense/locker_location_service.cr b/drivers/floorsense/locker_location_service.cr
new file mode 100644
index 00000000000..eaba5a627f4
--- /dev/null
+++ b/drivers/floorsense/locker_location_service.cr
@@ -0,0 +1,448 @@
+require "placeos-driver"
+require "placeos-driver/interface/lockers"
+require "uri"
+require "json"
+require "oauth2"
+require "./models"
+
+class Floorsense::LockerLocationService < PlaceOS::Driver
+ include Interface::Lockers
+ alias PlaceLocker = PlaceOS::Driver::Interface::Lockers::PlaceLocker
+
+ descriptive_name "Floorsense Locker Location Service"
+ generic_name :FloorsenseLockers
+ description %(collects locker booking data from the staff API and overlays Floorsense data for visualising on a map)
+
+ accessor floorsense : Floorsense_1
+ accessor staff_api : StaffAPI_1
+
+ bind Floorsense_1, :controllers, :controllers_changed
+
+ default_settings({
+ # execute Floorsense.controller_list to define these mappings:
+ locker_building_location: "Building Location",
+ locker_floor_mappings: {
+ "Floor Location": {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+
+ user_lookup: "email",
+ floorsense_filter: "email",
+ create_floorsense_users: true,
+ })
+
+ @building_location : String = ""
+ @floor_mappings : Hash(String, NamedTuple(building_id: String?, level_id: String)) = {} of String => NamedTuple(building_id: String?, level_id: String)
+
+ def on_update
+ @building_location = setting(String, :locker_building_location)
+ @floor_mappings = setting(Hash(String, NamedTuple(building_id: String?, level_id: String)), :locker_floor_mappings)
+
+ @user_lookup = setting?(String, :user_lookup).presence || "email"
+ @floorsense_filter = setting?(String, :floorsense_filter).presence || "email"
+ @create_floorsense_users = setting?(Bool, :create_floorsense_users) || false
+ end
+
+ # Controller id => Controller info
+ getter controllers : Hash(Int32, ControllerInfo) = {} of Int32 => ControllerInfo
+
+ # controller id => Locker bank ids
+ @locker_banks : Hash(Int32, Array(Int64)) = {} of Int32 => Array(Int64)
+
+ # level zone_id => controller ids
+ getter zone_mappings : Hash(String, Array(Int32)) = {} of String => Array(Int32)
+ getter zone_building : String? = nil
+
+ private def controllers_changed(_subscription, new_value)
+ logger.debug { "controller list changed: #{new_value}" }
+
+ # find the relevant controllers
+ @controllers = Hash(Int32, ControllerInfo).from_json(new_value).reject! do |_key, value|
+ !value.lockers && value.locations.includes?(@building_location)
+ end
+
+ # map the locker banks to these controllers
+ @locker_banks = locker_banks.transform_values do |locker_banks, controller_id|
+ locker_banks.map do |bank|
+ bank["bid"].as_i64
+ end
+ end
+
+ # map the controllers on each floor
+ @floor_mappings.each do |floor_name, zones|
+ if building_zone = zones[:building_id].presence
+ @zone_building = building_zone
+ end
+
+ @zone_mappings[zones[:level_id]] = @controllers.values.compact_map do |info|
+ info.controller_id if info.locations.includes?(floor_name)
+ end
+ end
+ rescue ex
+ logger.warn(exception: ex) { "failed to parse controller list" }
+ end
+
+ def locker_banks
+ banks = {} of Int32 => Array(JSON::Any)
+ @controllers.each_key do |controller_id|
+ if json = (floorsense.bank_list(controller_id).get rescue nil)
+ banks[controller_id] = json.as_a
+ end
+ end
+ banks
+ end
+
+ # ===================================
+ # User management
+ # ===================================
+
+ @user_lookup : String = "email"
+ @floorsense_filter : String = "email"
+ @create_floorsense_users : Bool = true
+
+ def get_floorsense_user(place_user_id : String) : String
+ place_user = staff_api.user(place_user_id).get
+ placeos_staff_id = place_user[@user_lookup].as_s
+
+ logger.debug { "found place id: #{placeos_staff_id}" }
+
+ user_query = case @floorsense_filter
+ when "name"
+ floorsense.user_list(name: placeos_staff_id)
+ when "email"
+ floorsense.user_list(email: placeos_staff_id)
+ else
+ floorsense.user_list(description: placeos_staff_id)
+ end
+ floorsense_users = user_query.get.as_a
+
+ logger.debug { "found #{floorsense_users.size} matching floorsense users" }
+
+ user_id = floorsense_users.first?.try(&.[]("uid").as_s)
+ user_id ||= floorsense.create_user(place_user["name"].as_s, place_user["email"].as_s, placeos_staff_id).get["uid"].as_s if @create_floorsense_users
+ raise "Floorsense user not found for #{placeos_staff_id}" unless user_id
+
+ card_number = place_user["card_number"]?.try(&.as_s)
+ spawn { ensure_card_synced(card_number, user_id) } if user_id && card_number && !card_number.empty?
+ user_id
+ end
+
+ protected def ensure_card_synced(card_number : String, user_id : String) : Nil
+ existing_user = begin
+ floorsense.get_rfid(card_number).get["uid"].as_s
+ rescue
+ nil
+ end
+
+ if existing_user != user_id
+ floorsense.delete_rfid(card_number)
+ floorsense.create_rfid(user_id, card_number)
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to sync card number #{card_number} for user #{user_id}" }
+ end
+
+ def get_place_user_id(floorsense_id : String | Int64) : String
+ floor_user = floorsense.get_user(floorsense_id).get
+ place_lookup = case @floorsense_filter
+ when "name", "email"
+ floor_user[@floorsense_filter].as_s
+ else
+ floor_user["desc"].as_s
+ end
+
+ return place_lookup if place_lookup.starts_with?("user-") && !place_lookup.includes?('@')
+ staff_api.user(place_lookup).get["id"].as_s
+ end
+
+ def get_user_email(floorsense_id : String | Int64) : String
+ floor_user = floorsense.get_user(floorsense_id).get
+ begin
+ floor_user["email"].as_s
+ rescue
+ place_lookup = case @floorsense_filter
+ when "name", "email"
+ floor_user[@floorsense_filter].as_s
+ else
+ floor_user["desc"].as_s
+ end
+ staff_api.user(place_lookup).get["email"].as_s
+ end
+ end
+
+ # ========================================
+ # Lockers Interface
+ # ========================================
+
+ class ::PlaceOS::Driver::Interface::Lockers::PlaceLocker
+ def initialize(@bank_id, locker : ::Floorsense::LockerBooking, @building = nil, @level = nil)
+ @locker_id = locker.key
+ @locker_name = locker.key
+ @mac = "lc=#{locker.controller_id}&lk=#{locker.key}"
+ @expires_at = Time.unix(locker.finish)
+ @allocated = !locker.released?
+ @allocation_id = locker.reservation_id
+ end
+ end
+
+ # allocates a locker now, the allocation may expire
+ @[Security(Level::Support)]
+ def locker_allocate(
+ # PlaceOS user id
+ user_id : String,
+
+ # the locker location
+ bank_id : String | Int64,
+
+ # allocates a random locker if this is nil
+ locker_id : String | Int64? = nil,
+
+ # attempts to create a booking that expires at the time specified
+ expires_at : Int64? = nil
+ ) : PlaceLocker
+ bank_id = get_locker_bank(locker_id.to_s)
+ floorsense_user_id = get_floorsense_user(user_id)
+ duration = (expires_at - Time.local.to_unix) // 60 if expires_at
+ booking = LockerBooking.from_json floorsense.locker_reservation(
+ locker_key: locker_id,
+ user_id: floorsense_user_id,
+ duration: duration,
+ controller_id: bank_id
+ ).get.to_json
+
+ level = nil
+ @zone_mappings.each do |level_zone, controllers|
+ if bank_id.in?(controllers)
+ level = level_zone
+ break
+ end
+ end
+
+ PlaceLocker.new(bank_id, booking, @zone_building, level)
+ end
+
+ # return the locker to the pool
+ @[Security(Level::Support)]
+ def locker_release(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # release / unshare just this user - otherwise release the whole locker
+ owner_id : String? = nil
+ ) : Nil
+ bank_id = get_locker_bank(locker_id.to_s)
+ if place_id = owner_id.presence
+ floorsense_user_id = get_floorsense_user(place_id)
+ end
+
+ reservation = Array(LockerBooking).from_json(floorsense.locker_reservations(
+ active: true,
+ user_id: floorsense_user_id,
+ controller_id: bank_id
+ ).get.to_json).find! { |booking| booking.key == locker_id }
+
+ floorsense.locker_release(reservation.reservation_id).get
+ end
+
+ # a list of lockers that are allocated to the user
+ @[Security(Level::Support)]
+ def lockers_allocated_to(user_id : String) : Array(PlaceLocker)
+ floorsense_user_id = get_floorsense_user(user_id)
+ Array(LockerBooking).from_json(floorsense.locker_reservations(
+ active: true,
+ user_id: floorsense_user_id
+ ).get.to_json).compact_map do |floor_booking|
+ level = nil
+ @zone_mappings.each do |level_zone, controllers|
+ if floor_booking.controller_id.in?(controllers)
+ level = level_zone
+ break
+ end
+ end
+
+ # if we can find the level then we are interested in this booking
+ if level
+ PlaceLocker.new(get_locker_bank(floor_booking.key), floor_booking, @zone_building, level)
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def locker_share(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ share_with : String
+ ) : Nil
+ bank_id = get_locker_bank(locker_id.to_s)
+ floorsense_user_id = get_floorsense_user(owner_id)
+ share_with = get_floorsense_user(share_with)
+
+ reservation = Array(LockerBooking).from_json(floorsense.locker_reservations(
+ active: true,
+ user_id: floorsense_user_id,
+ controller_id: bank_id
+ ).get.to_json).find! { |booking| booking.key == locker_id }
+
+ floorsense.locker_share(reservation.reservation_id, share_with).get
+ end
+
+ @[Security(Level::Support)]
+ def locker_unshare(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ # the individual you previously shared with (optional)
+ shared_with_id : String? = nil
+ ) : Nil
+ floorsense_user_id = get_floorsense_user(owner_id)
+ bank_id = get_locker_bank(locker_id.to_s)
+
+ if reservation = Array(LockerBooking).from_json(floorsense.locker_reservations(
+ active: true,
+ user_id: floorsense_user_id,
+ controller_id: bank_id,
+ shared: true,
+ ).get.to_json).find { |booking| booking.key == locker_id }
+ res_id = reservation.reservation_id
+ if shared_with = shared_with_id.presence
+ shared_with_id = get_floorsense_user(shared_with)
+ floorsense.locker_unshare(res_id, shared_with_id).get
+ else
+ floorsense.locker_shared?(res_id).get.as_a.map do |shared_with|
+ floorsense.locker_unshare(res_id, shared_with["uid"].as_s).get
+ end
+ end
+ end
+ end
+
+ # a list of user-ids that the locker is shared with.
+ # this can be placeos user ids or emails
+ @[Security(Level::Support)]
+ def locker_shared_with(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String
+ ) : Array(String)
+ floorsense_user_id = get_floorsense_user(owner_id)
+ bank_id = get_locker_bank(locker_id.to_s)
+
+ if reservation = Array(LockerBooking).from_json(floorsense.locker_reservations(
+ active: true,
+ user_id: floorsense_user_id,
+ controller_id: bank_id,
+ shared: true,
+ ).get.to_json).find { |booking| booking.key == locker_id }
+ return floorsense.locker_shared?(reservation.reservation_id).get.as_a.map do |shared_with|
+ get_place_user_id shared_with["uid"].as_s
+ end
+ end
+
+ [] of String
+ end
+
+ @[Security(Level::Support)]
+ def locker_unlock(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # sometimes required by locker systems
+ owner_id : String? = nil,
+ # time in seconds the locker should be unlocked
+ # (can be ignored if not implemented)
+ open_time : Int32 = 60,
+ # optional pin code - if user entered from a kiosk
+ pin_code : String? = nil
+ ) : Nil
+ floorsense_user_id = get_floorsense_user(owner_id.to_s) if owner_id.presence
+ floorsense.locker_unlock(
+ locker_key: locker_id.to_s,
+ user_id: floorsense_user_id,
+ pin: pin_code
+ )
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ # we could find the floorsense user, grab the reservations the user has
+ # and list them here, but probably not amazingly useful
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ return nil unless mac_address.starts_with?("lc=")
+ floor_mac = URI::Params.parse mac_address
+ controller_id = floor_mac["lc"]
+ locker_key = floor_mac["lk"]
+ reservations = Array(LockerBooking).from_json(floorsense.locker_reservations(active: true, controller_id: controller_id).get.to_json)
+
+ if reservation = reservations.find { |booking| booking.key == locker_key }
+ {
+ location: "locker",
+ assigned_to: get_user_email(reservation.user_id),
+ mac_address: mac_address,
+ }
+ end
+ rescue
+ nil
+ end
+
+ # locker bank ids
+ @locker_key_to_bank = {} of String => String | Int64
+
+ def get_locker_bank(locker_key : String)
+ if bank_id = @locker_key_to_bank[locker_key]?
+ return bank_id
+ end
+
+ bank_id = floorsense.locker_info(locker_key).get["controller_id"].as_i64
+ @locker_key_to_bank[locker_key] = bank_id
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching lockers in zone #{zone_id}" }
+ return [] of Nil if location && location != "locker"
+
+ controller_list = @zone_mappings[zone_id]?
+ return [] of Nil unless controller_list
+
+ building = @zone_building
+ controller_list.flat_map do |controller_id|
+ bookings = Array(LockerBooking).from_json(floorsense.locker_reservations(active: true, controller_id: controller_id).get.to_json)
+ bookings.map do |booking|
+ PlaceLocker.new(get_locker_bank(booking.key), booking, @zone_building, zone_id)
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def release_all_lockers : Int32
+ released = 0
+ floor = floorsense
+ @zone_mappings.each_value do |controllers|
+ controllers.each do |controller_id|
+ bookings = Array(LockerBooking).from_json(floor.locker_reservations(active: true, controller_id: controller_id).get.to_json)
+ bookings.each do |booking|
+ begin
+ floor.locker_release(booking.reservation_id).get
+ released += 1
+ rescue error
+ logger.warn(exception: error) { "failed to release locker: controller #{controller_id}, locker_id: " }
+ end
+ end
+ end
+ end
+ released
+ end
+end
diff --git a/drivers/floorsense/mobile_checkin_logic.cr b/drivers/floorsense/mobile_checkin_logic.cr
new file mode 100644
index 00000000000..74af76364bf
--- /dev/null
+++ b/drivers/floorsense/mobile_checkin_logic.cr
@@ -0,0 +1,146 @@
+require "placeos-driver"
+
+class Floorsense::MobileCheckinLogic < PlaceOS::Driver
+ descriptive_name "Floorsense Mobile Checkin Logic"
+ generic_name :MobileCheckin
+ description %(provides methods for emulating a card swipe using a mobile phone)
+
+ accessor booking_sync : FloorsenseBookingSync_1
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ time_zone: "Australia/Sydney",
+ booking_period: 120,
+ meta_ext_mappings: {
+ "neighbourhoodID" => "neighbourhood",
+ "features" => "deskAttributes",
+ },
+ })
+
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+ @booking_period : Time::Span? = nil
+ @meta_ext_mappings : Hash(String, String) = {} of String => String
+
+ def on_update
+ time_zone = setting?(String, :time_zone).presence || config.control_system.not_nil!.timezone.presence
+ @time_zone = Time::Location.load(time_zone) if time_zone
+ @booking_period = setting?(Int32, :booking_period).try &.minutes
+ @meta_ext_mappings = setting?(Hash(String, String), :meta_ext_mappings) || {} of String => String
+ end
+
+ def eui64_scanned(id : String, user_id : String, booking_minutes : Int32? = nil)
+ logger.debug { "#{user_id} scanned mac #{id}" }
+
+ desk_details = booking_sync.eui64_to_desk_id(id).get
+ raise "could not find eui64 id: #{id}" if desk_details.raw.nil?
+
+ logger.debug { "desk details found: #{desk_details.inspect}" }
+
+ level_zone = desk_details["level"].as_s
+ place_desk = desk_details["desk_id"].as_s
+ building_raw = desk_details["building_id"]?.try &.raw
+ build_zone = building_raw.try &.as(String)
+
+ logger.debug { "located #{place_desk} for #{user_id}" }
+
+ now = Time.utc.to_unix
+ booking = staff_api.query_bookings(type: "desk", zones: [level_zone]).get.as_a.find do |book|
+ book["asset_id"].as_s == place_desk && book["booking_start"].as_i64 <= now && book["booking_end"].as_i64 > now
+ end
+
+ if booking
+ owner_id = booking["user_id"].as_s
+ if owner_id == user_id
+ # check in or out depending on the current booking status
+ checkin_out = !booking["checked_in"].as_bool
+ booking_id = booking["id"].as_i64
+ logger.debug { "found existing booking #{booking_id} with current checked-in status #{!checkin_out}" }
+
+ if checkin_out
+ staff_api.booking_check_in(booking_id, true).get.as_bool
+ "checked-in"
+ else
+ # 1 min ago to account for any server clock sync issues
+ now = 1.minute.ago.to_unix
+ staff_api.update_booking(
+ booking_id: booking_id,
+ booking_end: now,
+ checked_in: false
+ ).get
+ "checked-out"
+ end
+ else
+ # Desk is booked for another user
+ logger.debug { "#{user_id} scanned desk owned by #{owner_id}" }
+ "forbidden"
+ end
+ else
+ # Perform an adhoc booking
+ booking_period = booking_minutes.try(&.minutes) || @booking_period
+ now = Time.local(@time_zone)
+ future = booking_period ? (now + booking_period) : now.at_end_of_day
+
+ user_details = staff_api.user(user_id).get
+ zones = [level_zone]
+ zones.unshift(build_zone) if build_zone
+
+ # Grab additional details out of the desk metadata
+ title = place_desk
+ ext_data = {} of String => JSON::Any
+ begin
+ logger.debug { "obtaining metadata for desk #{place_desk} on level #{level_zone}" }
+ if desk_details = placeos_desk_metadata(level_zone, place_desk)
+ # check if the desk is bookable
+ if bookable = desk_details["bookable"]?
+ return "forbidden" if (bookable.as_s?.try(&.upcase) == "FALSE") || (bookable.as_bool? == false)
+ end
+
+ title = desk_details["name"]?.try(&.as_s) || place_desk
+
+ @meta_ext_mappings.each do |meta_key, ext_key|
+ if value = desk_details[meta_key]?
+ ext_data[ext_key] = value
+ end
+ end
+ else
+ logger.warn { "desk details not found!" }
+ end
+ rescue error
+ logger.warn(exception: error) { "obtaining desk metadata" }
+ end
+
+ logger.debug { "creating new booking for #{user_id} on #{place_desk}" }
+
+ staff_api.create_booking(
+ booking_type: "desk",
+ asset_id: place_desk,
+ user_id: user_id,
+ user_email: user_details["email"],
+ user_name: user_details["name"],
+ zones: zones,
+ booking_start: now.to_unix,
+ booking_end: future.to_unix,
+ checked_in: true,
+ approved: true,
+ title: title,
+ time_zone: @time_zone.name,
+ extension_data: ext_data
+ ).get
+ "adhoc"
+ end
+ end
+
+ def placeos_desk_metadata(zone_id : String, asset_id : String)
+ metadata = staff_api.metadata(
+ zone_id,
+ "desks"
+ ).get["desks"]["details"].as_a
+
+ metadata.each do |desk|
+ place_id = desk["id"]?.try(&.as_s)
+ next unless place_id
+ return desk.as_h if place_id == asset_id
+ end
+ nil
+ end
+end
diff --git a/drivers/floorsense/mobile_checkin_logic_spec.cr b/drivers/floorsense/mobile_checkin_logic_spec.cr
new file mode 100644
index 00000000000..3ba7bc328a3
--- /dev/null
+++ b/drivers/floorsense/mobile_checkin_logic_spec.cr
@@ -0,0 +1,88 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Floorsense::MobileCheckinLogic" do
+ system({
+ FloorsenseBookingSync: {FloorsenseBookingSyncMock},
+ StaffAPI: {StaffAPIMock},
+ })
+
+ resp = exec(:eui64_scanned, "euid-rand-chars", "test-user").get
+ resp.should eq("checked-in")
+
+ resp = exec(:eui64_scanned, "euid-rand-chars", "test-user").get
+ resp.should eq("adhoc")
+
+ resp = exec(:eui64_scanned, "euid-rand-chars", "test-user").get
+ resp.should eq("forbidden")
+end
+
+# :nodoc:
+class FloorsenseBookingSyncMock < DriverSpecs::MockDriver
+ def eui64_to_desk_id(id : String)
+ {level: "level_zone", desk_id: "place_desk", building_id: "building_zone"}
+ end
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ @query_count : Int32 = 0
+
+ def query_bookings(type : String, zones : Array(String))
+ @query_count += 1
+ case @query_count
+ when 1
+ [{id: 1234, asset_id: "place_desk", user_id: "test-user", checked_in: false, booking_start: 1.minute.ago.to_unix, booking_end: 10.minutes.from_now.to_unix}]
+ when 2
+ [] of Nil
+ when 3
+ # desk owned by a different user
+ [{id: 1234, asset_id: "place_desk", user_id: "other-user", checked_in: false, booking_start: 1.minute.ago.to_unix, booking_end: 10.minutes.from_now.to_unix}]
+ end
+ end
+
+ def booking_check_in(booking_id : String | Int64, check_in : Bool)
+ raise "wrong booking_id #{booking_id} or check_in state #{check_in}" unless booking_id == 1234 && check_in
+ true
+ end
+
+ def metadata(zone_id : String, asset_type : String)
+ {
+ desks: {
+ details: [{id: "place_desk", name: "Cool Desk", features: ["standing"]}],
+ },
+ }
+ end
+
+ def user(user_id : String)
+ raise "unexpected user #{user_id}" unless user_id == "test-user"
+ {
+ id: "test-user",
+ email: "email@org.com",
+ name: "Bob Jane",
+ }
+ end
+
+ def update_booking(booking_id : String | Int64, booking_end : Int64, checked_in : Bool)
+ true
+ end
+
+ def create_booking(
+ booking_type : String,
+ asset_id : String,
+ user_id : String,
+ user_email : String,
+ user_name : String,
+ zones : Array(String),
+ booking_start : Int64,
+ booking_end : Int64,
+ checked_in : Bool,
+ title : String,
+ approved : Bool,
+ time_zone : String,
+ extension_data : Hash(String, JSON::Any)
+ )
+ raise "bad data" unless user_id == "test-user" && asset_id == "place_desk"
+ raise "bad data2" unless checked_in && title == "Cool Desk" && extension_data["deskAttributes"].as_a.map(&.as_s) == ["standing"]
+ true
+ end
+end
diff --git a/drivers/floorsense/models.cr b/drivers/floorsense/models.cr
new file mode 100644
index 00000000000..0c49af17db2
--- /dev/null
+++ b/drivers/floorsense/models.cr
@@ -0,0 +1,475 @@
+require "json"
+
+# Floorsense Data Models
+module Floorsense
+ # Websocket payloads
+ struct DeskMeta
+ include JSON::Serializable
+
+ def initialize(@place_id, @floor_id, @building, @title, @ext_data)
+ end
+
+ property place_id : String
+ property floor_id : String
+ property building : String?
+ getter ext_data : Hash(String, JSON::Any)
+ getter title : String
+ end
+
+ class Payload
+ include JSON::Serializable
+
+ use_json_discriminator "type", {
+ "event" => Event,
+ "response" => Response,
+ }
+ end
+
+ class Event < Payload
+ getter type : String = "event"
+ getter code : Int32
+ getter message : String
+ getter info : JSON::Any?
+ end
+
+ class Response < Payload
+ getter type : String = "response"
+ getter result : Bool
+ getter code : Int32?
+ getter message : String?
+ getter info : JSON::Any?
+
+ def info
+ @info || JSON.parse("{}")
+ end
+ end
+
+ class Resp(T)
+ include JSON::Serializable
+
+ @[JSON::Field(key: "type")]
+ property msg_type : String
+ property result : Bool
+
+ # Returned on failure
+ property message : String?
+ property code : Int32?
+
+ # Returned on success
+ property info : T?
+ end
+
+ class Setting
+ include JSON::Serializable
+
+ property value : JSON::Any
+ property key : String
+ end
+
+ class AuthInfo
+ include JSON::Serializable
+
+ property token : String
+ property sessionid : String
+ end
+
+ class LockerInfo
+ include JSON::Serializable
+
+ property canid : Int32
+
+ @[JSON::Field(key: "bid")]
+ property bus_id : Int32
+
+ @[JSON::Field(key: "lid")]
+ property locker_id : Int32
+
+ property reserved : Bool
+ property status : Int32
+ property firmware : String
+ property disabled : Bool
+ property confirmed : Bool
+
+ property closed : Bool?
+ property usbcharger : Bool?
+ property usbcharging : Bool?
+ property typename : String?
+ property uid : String?
+ property groupid : Int32?
+ property hardware : Int32?
+ property type : String?
+ property key : String?
+ property usbcurrent : Int32?
+
+ @[JSON::Field(key: "resid")]
+ property reservation_id : String?
+
+ def resid : String?
+ reservation_id
+ end
+
+ # not included by default, used by locker mappings
+ property! controller_id : Int32
+ end
+
+ class LockerBooking
+ include JSON::Serializable
+
+ property created : Int64
+ property start : Int64
+ property finish : Int64
+
+ @[JSON::Field(key: "cid")]
+ property controller_id : Int32
+
+ @[JSON::Field(key: "resid")]
+ property reservation_id : String
+
+ @[JSON::Field(key: "uid")]
+ property user_id : String
+
+ property key : String
+ property pin : String
+ property restype : String
+ property lastopened : Int64
+ property released : Int64
+ property active : Int32
+ property releasecode : Int32
+
+ def released?
+ self.active != 1
+ end
+
+ # not included in the responses but we will merge this
+ property user : User?
+ end
+
+ class DeskStatus
+ include JSON::Serializable
+
+ property cid : Int32
+ property cached : Bool
+ property reservable : Bool
+ property netid : Int32
+ property status : Int32
+ property deskid : Int32
+
+ property hwfeat : Int32
+ property hardware : String
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ property created : Time
+ property key : String
+ property occupied : Bool
+ property uid : String
+ property eui64 : String
+
+ @[JSON::Field(key: "type")]
+ property desk_type : String
+ property firmware : String
+ property features : Int32
+ property freq : String
+ property groupid : Int32
+ property bkid : String
+ property planid : Int32
+ property reserved : Bool
+ property confirmed : Bool
+ property privacy : Bool
+ property occupiedtime : Int32
+ end
+
+ class DeskInfo
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property eui64 : String
+ property key : String?
+ property planid : Int32?
+ property deskheight : Int32?
+
+ @[JSON::Field(key: "type")]
+ property desk_type : String?
+ property typename : String?
+
+ @[JSON::Field(ignore: true)]
+ property! controller_id : Int32
+ end
+
+ class UserGroup
+ include JSON::Serializable
+
+ @[JSON::Field(key: "ugroupid")]
+ property id : Int32
+ property name : String
+ property count : Int32
+ end
+
+ class UserLocation
+ include JSON::Serializable
+
+ property name : String
+ property uid : String
+
+ # Optional properties (when a user is located):
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ property start : Time?
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ property finish : Time?
+
+ property planid : Int32?
+ property occupied : Bool?
+ property groupid : Int32?
+ property key : String?
+ property floorname : String?
+ property cid : Int32?
+ property occupiedtime : Int32?
+ property groupname : String?
+ property privacy : Bool?
+ property confirmed : Bool?
+ property active : Bool?
+ end
+
+ class Floor
+ include JSON::Serializable
+
+ property planid : Int32
+ property name : String
+
+ property imgname : String?
+ property imgwidth : Int32?
+ property imgheight : Int32?
+
+ property location1 : String?
+ property location2 : String?
+ property location3 : String?
+ end
+
+ class BookingStatus
+ include JSON::Serializable
+
+ property key : String?
+ property uid : String
+
+ @[JSON::Field(key: "bktype")]
+ property booking_type : String
+
+ @[JSON::Field(key: "bkid")]
+ property booking_id : String
+
+ property desc : String?
+ property created : Int64
+ property start : Int64
+ property finish : Int64
+
+ property conftime : Int64?
+ property confmethod : Int32?
+ property confexpiry : Int64?
+
+ property cid : Int32
+ property planid : Int32
+ property groupid : Int32
+
+ # Time the booking was released
+ property released : Int64
+ property releasecode : Int32
+ property active : Bool
+ property confirmed : Bool
+ property privacy : Bool
+
+ # not included in the responses but we will merge this
+ property user : User?
+
+ @[JSON::Field(ignore: true)]
+ property! place_id : String
+ end
+
+ class Booking
+ include JSON::Serializable
+
+ # This is to support events
+ property action : String?
+
+ property id : Int64
+ property booking_type : String
+ property booking_start : Int64
+ property booking_end : Int64
+ property timezone : String?
+
+ # events use resource_id instead of asset_id
+ property asset_id : String?
+ property resource_id : String?
+
+ def asset_id : String
+ (@asset_id || @resource_id).not_nil!
+ end
+
+ property user_id : String
+ property user_email : String
+ property user_name : String
+ property deleted : Bool?
+ property deleted_at : Int64?
+
+ property zones : Array(String)
+
+ property checked_in : Bool?
+ property rejected : Bool?
+ property approved : Bool?
+ property process_state : String?
+ property last_changed : Int64?
+ property checked_in_at : Int64?
+ property checked_out_at : Int64?
+
+ property booked_by_name : String?
+ property booked_by_email : String?
+
+ property extension_data : JSON::Any?
+
+ @[JSON::Field(ignore: true)]
+ property! floor_id : String?
+
+ def in_progress?
+ now = Time.utc.to_unix
+ now >= @booking_start && now < @booking_end
+ end
+
+ def floorsense_booking_id : String?
+ ext_data = extension_data
+ return unless ext_data
+ ext_data["floorsense_booking_id"]?.try(&.as_s)
+ end
+
+ def released?
+ checked_out? || booking_end <= Time.local.to_unix
+ end
+
+ def checked_out?
+ !checked_out_at.nil?
+ end
+
+ def checked_in?
+ !checked_in.nil? && checked_in.not_nil!
+ end
+
+ def deleted?
+ action == "cancelled"
+ end
+
+ def is_deleted?
+ !!deleted && !deleted_at.nil?
+ end
+ end
+
+ class User
+ include JSON::Serializable
+
+ property uid : String
+ property email : String?
+ property name : String
+ property desc : String?
+ property lastlogin : Int64?
+ property expiry : Int64?
+ property reslimit : Int64?
+ property pin : String?
+ property ugroupid : Int64?
+ property uidtoken : String?
+ property extid : String?
+ property usertype : String?
+ property privacy : Int32?
+ end
+
+ class LogEntry
+ include JSON::Serializable
+
+ property eventid : Int64
+
+ # this is the locker or table name
+ property key : String
+
+ # the event code
+ property code : Int32
+
+ # booking id
+ property bkid : String
+
+ # Possibly includes the booking information
+ # not required as we need to grab the user information anyway
+ # property extra : JSON::Any?
+
+ property eventtime : Int64
+ end
+
+ class RFID
+ include JSON::Serializable
+
+ property csn : String
+ property uid : String
+ property desc : String?
+ end
+
+ class ControllerInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "cid")]
+ property controller_id : Int32
+
+ property online : Bool
+ property lockers : Bool
+ property desks : Bool
+
+ property id : String
+ property name : String
+ property location1 : String
+ property location2 : String
+ property location3 : String
+ property location4 : String
+
+ property mode : String
+
+ def locations
+ {location1, location2, location3, location4}
+ end
+ end
+
+ class Voucher
+ include JSON::Serializable
+
+ property lastuse : Int64
+ property email : String
+
+ @[JSON::Field(key: "vid")]
+ property voucher_id : String
+
+ @[JSON::Field(key: "key")]
+ property locker_key : String
+
+ @[JSON::Field(key: "cid")]
+ property controller_id : String
+
+ @[JSON::Field(key: "resid")]
+ property reservation_id : String
+
+ property pin : String
+ property created : Int64
+ property release : Bool
+ property duration : Int64
+ property expired : Int64
+ property usecount : Int64
+ property maxuse : Int64
+ property restype : String
+ property notified : Int64
+ property validfrom : Int64
+ property validto : Int64
+
+ property unlock : Bool
+ property template : String
+ property name : String
+ property notes : String
+ property cardswipe : Bool
+
+ @[JSON::Field(key: "uid")]
+ property user_id : String
+ property uri : String
+ end
+end
diff --git a/drivers/freespace/models.cr b/drivers/freespace/models.cr
new file mode 100644
index 00000000000..44f6b495f25
--- /dev/null
+++ b/drivers/freespace/models.cr
@@ -0,0 +1,126 @@
+require "json"
+
+module Freespace
+ class SpaceActivity
+ include JSON::Serializable
+
+ property id : Int64
+
+ @[JSON::Field(key: "spaceId")]
+ property space_id : Int64
+
+ @[JSON::Field(key: "utcEpoch")]
+ property utc_epoch : Int64
+ property state : Int32
+
+ def presence?
+ @state > 0
+ end
+
+ @[JSON::Field(ignore: true)]
+ property! location_id : Int64
+
+ @[JSON::Field(ignore: true)]
+ property! capacity : Int32
+
+ @[JSON::Field(ignore: true)]
+ property! name : String
+ end
+
+ # ====
+ # Classes related to a space
+ # ====
+
+ class Location
+ include JSON::Serializable
+
+ property id : Int64
+
+ # undocumented, can be nil
+ # @[JSON::Field(key: "scalingFactor")]
+ # property scaling_factor : Float64?
+
+ property raw : Bool
+ property policy : Bool
+ end
+
+ class SRF
+ include JSON::Serializable
+
+ property x : Int32
+ property y : Int32
+ property z : Int32
+ end
+
+ class Category
+ include JSON::Serializable
+
+ property id : Int64
+ property name : String
+
+ @[JSON::Field(key: "shortName")]
+ property short_name : String?
+
+ @[JSON::Field(key: "showOnSignage")]
+ property show_on_signage : Bool
+
+ @[JSON::Field(key: "showInAnalytics")]
+ property show_in_analytics : Bool
+
+ @[JSON::Field(key: "iconUrl")]
+ property icon_url : String?
+
+ # RGB value i.e. #ffb3b3
+ @[JSON::Field(key: "colorScheme")]
+ property color_scheme : String?
+
+ @[JSON::Field(key: "orderingIndex")]
+ property ordering_index : Int32?
+ end
+
+ class Device
+ include JSON::Serializable
+
+ property id : Int64
+
+ @[JSON::Field(key: "displayName")]
+ property name : String
+
+ # Many more undocumented fields
+ end
+
+ class Space
+ include JSON::Serializable
+
+ property id : Int64
+ property location : Location
+ property name : String
+ property srf : SRF
+
+ # undocumented, possibly polymorphic: {"type" => "CIRCLE", "data" => "20"},
+ property marker : Hash(String, JSON::Any)
+
+ @[JSON::Field(key: "subCategory")]
+ property sub_category : Category
+ property category : Category
+ property department : Category
+
+ @[JSON::Field(key: "sensingPolicyId")]
+ property sensing_policy_id : Int32
+ property device : Device
+
+ @[JSON::Field(key: "markerUniqueId")]
+ property marker_unique_id : String?
+ property live : Bool
+ property capacity : Int32
+
+ # unsure about this field
+ # property counter : String
+
+ property serial : Int32
+
+ @[JSON::Field(key: "locationId")]
+ property location_id : Int64
+ property counted : Bool
+ end
+end
diff --git a/drivers/freespace/sensor_api.cr b/drivers/freespace/sensor_api.cr
new file mode 100644
index 00000000000..fdc9b53462f
--- /dev/null
+++ b/drivers/freespace/sensor_api.cr
@@ -0,0 +1,238 @@
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "uri"
+require "stomp"
+require "./models"
+
+# https://aca.im/driver_docs/Freespace/Freespace%20Socket%20API-V1.2.pdf
+
+class Freespace::SensorAPI < PlaceOS::Driver
+ include Interface::Locatable
+
+ # Discovery Information
+ generic_name :Freespace
+ descriptive_name "Freespace Websocket API"
+
+ uri_base "https://_instance_.afreespace.com"
+
+ default_settings({
+ username: "user",
+ password: "pass",
+
+ floor_mappings: {
+ "775" => {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+ })
+
+ def on_update
+ @username = setting(String, :username)
+ @password = setting(String, :password)
+ @floor_mappings = setting(Hash(String, NamedTuple(building_id: String?, level_id: String)), :floor_mappings)
+
+ # configure the zone mappings
+ @zone_mappings.clear
+ @floor_mappings.each do |location_id, details|
+ @zone_mappings[details[:level_id]] << location_id
+ @zone_mappings[details[:building_id]] << location_id
+ end
+
+ # We want to rebind to everything
+ disconnect if @connected
+ end
+
+ # We need an API key to connect to the websocket
+ def websocket_headers
+ HTTP::Headers{
+ "X-Auth-Key" => get_token,
+ }
+ end
+
+ getter! client : STOMP::Client
+ @auth_key : String? = nil
+ @spaces : Hash(Int64, Space) = {} of Int64 => Space
+ @space_state : Hash(Int64, SpaceActivity) = {} of Int64 => SpaceActivity
+ @username : String = ""
+ @password : String = ""
+ @connected : Bool = false
+
+ @floor_mappings : Hash(String, NamedTuple(building_id: String?, level_id: String)) = {} of String => NamedTuple(building_id: String?, level_id: String)
+ # Level zone => location_id
+ @zone_mappings : Hash(String, Array(String)) = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String }
+
+ def connected
+ @connected = true
+
+ # Send the CONNECT message
+ hostname = URI.parse(config.uri.not_nil!).hostname.not_nil!
+ @client = STOMP::Client.new(hostname)
+ send(client.stomp.to_s)
+
+ schedule.clear
+ schedule.in(5.seconds) { @auth_key = nil }
+ schedule.every(10.seconds) { heart_beat }
+ end
+
+ def disconnected
+ @connected = false
+ schedule.clear
+ @spaces.clear
+ @auth_key = @client = nil
+ end
+
+ def heart_beat
+ send(client.send("/beat/#{Time.utc.to_unix}").to_s, wait: false, priority: 0)
+ end
+
+ protected def subscribe_location(location_id) : Nil
+ get_location(location_id).each do |space|
+ id = space.id
+ request = client.subscribe("space-#{id}", "/topic/spaces/#{id}/activities", HTTP::Headers{
+ "receipt" => "rec-#{id}",
+ })
+
+ # Wait false as the server is not STOMP compliant, it won't respond to receipt headers
+ send(request.to_s, wait: false)
+ end
+ end
+
+ @[Security(Level::Support)]
+ def spaces_details
+ @spaces
+ end
+
+ @[Security(Level::Support)]
+ def spaces_state
+ @space_state
+ end
+
+ @[Security(Level::Support)]
+ def get_location(location_id : String | Int64) : Array(Space)
+ response = http("POST",
+ "/api/locations/#{location_id}/spaces",
+ headers: {
+ "X-Auth-Key" => get_token,
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ }, body: {
+ username: @username,
+ password: @password,
+ }.to_json
+ )
+
+ raise "issue obtaining to location #{location_id}: status code #{response.status_code}\n#{response.body}" unless response.success?
+
+ spaces = Array(Space).from_json response.body
+ spaces.each { |space| @spaces[space.id] = space }
+ spaces
+ end
+
+ # Alternative to using basic auth, but here really only for testing with postman
+ @[Security(Level::Support)]
+ def get_token : String
+ auth_key = @auth_key
+ return auth_key if auth_key
+
+ response = http("POST",
+ "/login",
+ headers: {
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ }, body: {
+ username: @username,
+ password: @password,
+ }.to_json
+ )
+ logger.debug { "login response: #{response.body}" }
+ raise "issue obtaining token: #{response.status_code}\n#{response.body}" unless response.success?
+
+ # auth key is valid for 5 seconds
+ schedule.in(5.seconds) { @auth_key = nil }
+ @auth_key = response.headers["X-Auth-Key"]
+ end
+
+ def received(bytes, task)
+ frame = STOMP::Frame.new(bytes)
+
+ case frame.command
+ when .connected?
+ client.negotiate(frame)
+ @floor_mappings.keys.each do |location_id|
+ begin
+ subscribe_location(location_id)
+ rescue error
+ logger.error(exception: error) { "failed to subscribe to #{location_id}, skipping" }
+ end
+ end
+ when .message?
+ activity = SpaceActivity.from_json(frame.body_text)
+ if space = @spaces[activity.space_id]?
+ activity.location_id = space.location_id
+ activity.capacity = space.capacity
+ activity.name = space.name
+ @space_state[activity.space_id] = activity
+ self["space-#{activity.space_id}"] = {
+ location: space.location_id,
+ name: space.name,
+ capacity: space.capacity,
+ count: activity.state,
+ last_updated: activity.utc_epoch,
+ }
+ self["last_change"] = Time.utc.to_unix
+ else
+ # NOTE:: this should never happen
+ logger.warn { "unknown space id: #{activity.space_id}" }
+ end
+ end
+
+ task.try &.success
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "sensor incapable of tracking #{mac_address}" }
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+ return [] of Nil if location && location != "desk"
+
+ loctions = @zone_mappings[zone_id]?
+ return [] of Nil unless loctions
+
+ # loc_id is a string
+ loctions.flat_map do |loc_id|
+ location_id = loc_id.to_i64
+ loc_details = @floor_mappings[loc_id]
+
+ @space_state.values.compact_map do |activity|
+ next if activity.location_id != location_id || activity.state == 0 || activity.capacity > 1
+
+ {
+ location: activity.capacity == 1 ? "desk" : "area",
+ at_location: activity.state,
+ map_id: activity.name,
+ level: loc_details[:level_id],
+ building: loc_details[:building_id],
+ capacity: activity.capacity,
+ }
+ end
+ end
+ end
+end
diff --git a/drivers/freespace/sensor_api_spec.cr b/drivers/freespace/sensor_api_spec.cr
new file mode 100644
index 00000000000..91fed8f1cc4
--- /dev/null
+++ b/drivers/freespace/sensor_api_spec.cr
@@ -0,0 +1,154 @@
+require "json"
+require "stomp"
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Freespace::SensorAPI" do
+ # ===========
+ # Negotiation
+ # ===========
+
+ client = STOMP::Client.new("127.0.0.1")
+ should_send(client.stomp.to_s)
+
+ connect_message = STOMP::Frame.new(STOMP::Command::Connected, HTTP::Headers{
+ "version" => "1.2",
+ # server sends a blank heartbeat
+ "heart-beat" => "0,0",
+ })
+ responds(connect_message.to_s)
+
+ # ==========
+ # GET SPACES
+ # ==========
+
+ expect_http_request do |request, response|
+ headers = request.headers
+ io = request.body
+ if io = request.body
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request["username"] == "user" && request["password"] == "pass"
+ response.status_code = 200
+ response.headers["X-Auth-Key"] = "12345"
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include dialing details #{request.inspect}"
+ end
+ end
+
+ expect_http_request do |request, response|
+ headers = request.headers
+
+ if headers["X-Auth-Key"]? == "12345"
+ response.status_code = 200
+ response.output.puts SPACES_RESPONSE
+ else
+ response.status_code = 401
+ end
+ end
+
+ # =============
+ # Subscriptions
+ # =============
+
+ should_send(client.subscribe("space-96978", "/topic/spaces/96978/activities", HTTP::Headers{
+ "receipt" => "rec-96978",
+ }).to_s)
+
+ # ==========
+ # GET TOKEN
+ # ==========
+
+ # cached
+ retval = exec(:get_token)
+ retval.get.should eq("12345")
+
+ # =============
+ # Status update
+ # =============
+ time_now = Time.utc.to_unix
+ status_update = STOMP::Frame.new(STOMP::Command::Message, HTTP::Headers{
+ "subscription" => "space-96978",
+ "destination" => "/topic/spaces/96978/activities",
+ }, {
+ id: 1234,
+ spaceId: 96978,
+ utcEpoch: time_now,
+ state: 1,
+ }.to_json)
+
+ transmit status_update.to_s
+
+ status["space-96978"].should eq({
+ "location" => 775,
+ "name" => "WS7-01",
+ "capacity" => 1,
+ "count" => 1,
+ "last_updated" => time_now,
+ })
+
+ # =================
+ # location services
+ # =================
+ exec(:device_locations, "zone-building").get.should eq([{
+ "location" => "desk",
+ "at_location" => 1,
+ "map_id" => "WS7-01",
+ "level" => "zone-level",
+ "building" => "zone-building",
+ "capacity" => 1,
+ }])
+end
+
+SPACES_RESPONSE = [{"id" => 96978,
+ "location" => {"id" => 775, "scalingFactor" => nil, "raw" => true, "policy" => true},
+ "name" => "WS7-01",
+ "srf" => {"x" => 91, "y" => 2169, "z" => 0},
+ "marker" => {"type" => "CIRCLE", "data" => "20"},
+ "category" => {"id" => 297,
+ "name" => "Assigned Desks",
+ "shortName" => nil,
+ "showOnSignage" => false,
+ "showInAnalytics" => true,
+ "iconUrl" => nil,
+ "colorScheme" => "#ffb3b3",
+ "orderingIndex" => 113},
+ "sensingPolicyId" => 247,
+ "department" => {"id" => 498,
+ "name" => "Sales",
+ "shortName" => nil,
+ "showOnSignage" => false,
+ "showInAnalytics" => false,
+ "colorScheme" => nil,
+ "orderingIndex" => nil},
+ "subCategory" => {"id" => 194,
+ "name" => "None",
+ "shortName" => nil,
+ "showOnSignage" => false,
+ "showInAnalytics" => false,
+ "colorScheme" => nil,
+ "orderingIndex" => 194},
+ "device" => {"id" => 2016090160,
+ "displayName" => "1609010160",
+ "updatedAt" => nil,
+ "floorId" => nil,
+ "shape" => nil,
+ "coord" => nil,
+ "blessId" => 1609010160,
+ "blessQr" => nil,
+ "accessedAt" => "2021-03-11T08:06:01.000+0000",
+ "installedOn" => nil,
+ "licenseeId" => nil,
+ "hardware" => nil,
+ "network" => nil,
+ "itemId" => nil},
+ "markerUniqueId" => "K_2493713878097_18542",
+ "live" => false,
+ "capacity" => 1,
+ "counter" => "NO_COUNTER",
+ "serial" => 1,
+ "locationId" => 775,
+ "counted" => true,
+}].to_json
diff --git a/drivers/gallagher/rest_api.cr b/drivers/gallagher/rest_api.cr
new file mode 100644
index 00000000000..979edca3c50
--- /dev/null
+++ b/drivers/gallagher/rest_api.cr
@@ -0,0 +1,735 @@
+require "placeos-driver"
+require "placeos-driver/interface/door_security"
+require "placeos-driver/interface/zone_access_security"
+require "uri"
+require "semantic_version"
+require "./rest_api_models"
+require "base64"
+
+# Documentation: https://aca.im/driver_docs/Gallagher/Gallagher_CC_REST_API_Docs%208.10.1113.zip
+# https://gallaghersecurity.github.io/
+
+class Gallagher::RestAPI < PlaceOS::Driver
+ include Interface::DoorSecurity
+ include Interface::ZoneAccessSecurity
+
+ # Discovery Information:
+ generic_name :Gallagher
+ descriptive_name "Gallagher Security System"
+ uri_base "https://gallagher.your.org"
+
+ default_settings({
+ api_key: "your api key",
+ unique_pdf_name: "email",
+
+ # The division to pass when creating cardholders.
+ default_division_href: "",
+
+ # The default card type to use when creating a new card. This will be in the form of a URL.
+ default_card_type_href: "",
+
+ # URL of the access group
+ default_access_group_href: "",
+
+ # the building / organisation code
+ default_facility_code: "",
+
+ disabled_card_value: "Disabled (manually)",
+
+ # changes the channel when you want to isolate signals
+ door_event_channel: "event",
+
+ # for client certificate authentication
+ # https_private_key: "PEM format",
+ # https_client_cert: "PEM format",
+
+ # obtain the list of these at: /api/events/groups/
+ event_mappings: [
+ {
+ group: 1,
+ action: "tamper",
+ },
+ {
+ group: 18,
+ action: "denied",
+ },
+ {
+ # card swipe events for various door / lift types
+ group: 23,
+ types: [15800, 15816, 20001, 20002, 20003, 20006, 20047, 41500, 41501, 41520, 41521, 42102, 42415],
+ action: "granted",
+ },
+ ],
+ })
+
+ record EventMap, group : Int32, types : Array(Int32)?, action : Action do
+ include JSON::Serializable
+ end
+
+ def on_load
+ on_update
+
+ spawn { event_monitor }
+ schedule.every(1.minutes) { query_endpoints }
+ transport.before_request do |req|
+ logger.debug { "requesting #{req.method} #{req.path}?#{req.query}\n#{req.headers}\n#{req.body}" }
+ end
+ end
+
+ def on_unload
+ @poll_events = false
+ end
+
+ @poll_events : Bool = true
+ @api_key : String = ""
+ @unique_pdf_name : String = "email"
+ @door_event_channel : String = "event"
+ @headers : Hash(String, String) = {} of String => String
+ @disabled_card_value : String = "Disabled (manually)"
+ @event_map : Hash(String, EventMap) = {} of String => EventMap
+
+ def on_update
+ uri = URI.parse(config.uri.not_nil!)
+ @uri_base ||= "#{uri.scheme}://#{uri.host}"
+ api_key = setting(String, :api_key)
+ @api_key = "GGL-API-KEY #{api_key}"
+ @door_event_channel = setting?(String, :door_event_channel) || "event"
+
+ new_map = {} of String => EventMap
+ (setting?(Array(EventMap), :event_mappings) || [] of EventMap).each do |event|
+ new_map[event.group.to_s] = event
+ end
+ @event_map = new_map
+
+ @unique_pdf_name = setting(String, :unique_pdf_name)
+
+ @default_division = setting?(String, :default_division_href)
+ @default_facility_code = setting?(String, :default_facility_code)
+ @default_card_type = setting?(String, :default_card_type_href)
+ @default_access_group = setting?(String, :default_access_group_href)
+ @disabled_card_value = setting(String?, :disabled_card_value) || "Disabled (manually)"
+
+ @headers = {
+ "Authorization" => @api_key,
+ "Content-Type" => "application/json",
+ }
+ end
+
+ def connected
+ query_endpoints
+ end
+
+ getter! uri_base : String
+ getter access_groups_endpoint : String = "/api/access_groups"
+ getter access_zones_endpoint : String = "/api/access_zones"
+ getter cardholders_endpoint : String = "/api/cardholders"
+ getter divisions_endpoint : String = "/api/divisions"
+ getter card_types_endpoint : String = "/api/card_types"
+ getter events_endpoint : String = "/api/events"
+ getter pdfs_endpoint : String = "/api/personal_data_fields"
+ getter doors_endpoint : String = "/api/doors"
+
+ @fixed_pdf_id : String = ""
+ @default_division : String? = nil
+ @default_facility_code : String? = nil
+ @default_card_type : String? = nil
+ @default_access_group : String? = nil
+
+ def query_endpoints
+ response = get("/api", headers: @headers)
+ raise "endpoints request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ payload = JSON.parse response.body
+
+ logger.debug { "endpoints query returned:\n#{payload.inspect}" }
+
+ api_version = SemanticVersion.parse(payload["version"].as_s.split('.')[0..2].join('.'))
+ raw_uri = payload["features"]["cardholders"]["cardholders"]["href"].as_s
+ uri = URI.parse(raw_uri)
+ @uri_base = "#{uri.scheme}://#{uri.host}"
+ @cardholders_endpoint = get_path raw_uri
+ @divisions_endpoint = @cardholders_endpoint.sub("cardholders", "divisions")
+ @access_groups_endpoint = get_path payload["features"]["accessGroups"]["accessGroups"]["href"].as_s
+ @access_zones_endpoint = get_path payload["features"]["accessZones"]["accessZones"]["href"].as_s
+ @events_endpoint = get_path payload["features"]["events"]["events"]["href"].as_s
+
+ # not sure what version of Gallagher this was added
+ begin
+ @doors_endpoint = get_path payload["features"]["doors"]["doors"]["href"].as_s
+ rescue error
+ logger.debug(exception: error) { "error locating doors feature URI" }
+ end
+
+ if api_version >= SemanticVersion.parse("8.10.0")
+ @card_types_endpoint = get_path payload["features"]["cardTypes"]["assign"]["href"].as_s
+ @pdfs_endpoint = get_path payload["features"]["personalDataFields"]["personalDataFields"]["href"].as_s
+ response = get(@pdfs_endpoint, {"name" => @unique_pdf_name}, @headers)
+ else
+ @card_types_endpoint = get_path payload["features"]["cardTypes"]["cardTypes"]["href"].as_s
+ @pdfs_endpoint = get_path payload["features"]["items"]["items"]["href"].as_s
+ response = get(@pdfs_endpoint, {
+ "name" => @unique_pdf_name,
+ "type" => "33",
+ }, @headers)
+ end
+
+ if response.success?
+ logger.debug { "PDFS request returned:\n#{response.body}" }
+ else
+ raise "PDFS request failed with #{response.status_code}\n#{response.body}"
+ end
+
+ # There should only be one result
+ results = JSON.parse(response.body)["results"].as_a
+ @fixed_pdf_id = results.first["id"].as_s unless results.empty?
+ end
+
+ protected def get_path(uri : String) : String
+ URI.parse(uri).request_target.not_nil!
+ end
+
+ ##
+ # Personal Data Fields (PDFs) are custom fields that Gallagher allows definintions of on a site-by-site basis.
+ # They will usually be for things like email address, employee ID or some other field specific to whoever is hosting the Gallagher instance.
+ # Allows retrieval of the PDFs used in the Gallagher instance, primarily so we can get the PDF's ID and use that to filter cardholders based on that PDF.
+ #
+ # @param name [String] The name of the PDF which we want to retrieve. This will only return one result (as the PDF names are unique).
+ # @return [Hash] A list of PDF results and a next link for pagination (we will generally have less than 100 PDFs so 'next' link will mostly be unused):
+ # @example An example response:
+ # {
+ # "results": [
+ # {
+ # "name": "email",
+ # "id": "5516",
+ # "href": "https://localhost:8904/api/personal_data_fields/5516"
+ # },
+ # {
+ # "name": "cellphone",
+ # "id": "9998",
+ # "href": "https://localhost:8904/api/personal_data_fields/9998",
+ # "serverDisplayName": "Site B"
+ # }
+ # ],
+ # "next": {
+ # "href": "https://localhost:8904/api/personal_data_fields?pos=900&sort=id"
+ # }
+ # }
+ def get_pdfs(name : String? = nil, exact_match : Bool = true)
+ # surround the parameter with double quotes for an exact match
+ name = %("#{name}") if name && exact_match
+ response = get(@pdfs_endpoint, headers: @headers, params: {"top" => "10000", "name" => name}.compact)
+ raise "PDFS request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ get_results(PDF, response.body)
+ end
+
+ def get_pdf(user_id : String, pdf_id : String | UInt64)
+ response = get("#{@cardholders_endpoint}/#{user_id}/personal_data/#{pdf_id}", headers: @headers)
+ raise "cardholder PDF request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ response.body
+ end
+
+ def get_base64_pdf(user_id : String, pdf_id : String | UInt64)
+ response = get("#{@cardholders_endpoint}/#{user_id}/personal_data/#{pdf_id}", headers: @headers)
+ raise "cardholder PDF request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ Base64.strict_encode(response.body)
+ end
+
+ def get_cardholder(id : String | Int32)
+ response = get("#{@cardholders_endpoint}/#{id}", headers: @headers)
+ raise "cardholder request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ Cardholder.from_json(response.body)
+ end
+
+ def query_cardholders(filter : String, pdf_name : String? = nil, exact_match : Bool = true)
+ pdf_id = "pdf_" + (pdf_name ? get_pdfs(pdf_name).first.id : @fixed_pdf_id).not_nil!
+ query = {
+ pdf_id => exact_match ? %("#{filter}") : filter,
+ "top" => "10000",
+ }
+
+ response = get(@cardholders_endpoint, query, headers: @headers)
+ raise "cardholder query request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ get_results(Cardholder, response.body)
+ end
+
+ def query_card_types
+ response = get(@card_types_endpoint, {"top" => "10000"}, headers: @headers)
+ raise "card types request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ get_results(CardType, response.body)
+ end
+
+ def get_card_type(id : String | Int32 | Nil = nil)
+ card = id || @default_card_type || raise("no default card type provided")
+ response = get("#{@card_types_endpoint}/#{card}", headers: @headers)
+ raise "card type request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ CardType.from_json(response.body)
+ end
+
+ ##
+ # Create a new cardholder.
+ # @param first_name [String] The first name of the new cardholder. Either this or last name is required (but we should assume both are for most instances).
+ # @param last_name [String] The last name of the new cardholder. Either this or first name is required (but we should assume both are for most instances).
+ # @option options [String] :division The division to add the cardholder to. This is required when making the request to create the cardholder but if none is passed the `default_division` is used.
+ # @option options [Hash] :pdfs A hash containing all PDFs to add to the user in the form `{ some_pdf_name: some_pdf_value, another_pdf_name: another_pdf_value }`.
+ # @option options [Array] :cards An array of cards to be added to this cardholder which can include both virtual and physical cards.
+ # @option options [Array] :access_groups An array of access groups to add this cardholder to. These may include `from` and `until` fields to dictate temporary access.
+ # @option options [Array] :competencies An array of competencies to add this cardholder to.
+ # @return [Hash] The cardholder that was created.
+ def create_cardholder(
+ first_name : String,
+ last_name : String,
+ description : String = "a cardholder",
+ authorised : Bool = true,
+ pdfs : Hash(String, String)? = nil,
+ cards : Array(Card)? = nil,
+ access_groups : Array(CardholderAccessGroup)? = nil,
+ short_name : String? = nil,
+ division_href : String? = nil
+ )
+ short_name ||= "#{first_name} #{last_name}"
+ short_name = short_name[0..15]
+
+ payload = Cardholder.new(
+ first_name, last_name, short_name, description, authorised,
+ cards, access_groups, division_href || @default_division.not_nil!
+ ).to_json
+
+ if pdfs && !pdfs.empty?
+ payload = "#{payload[0..-2]},#{pdfs.transform_keys { |key| "@#{key}" }.to_json[1..-1]}"
+ end
+
+ response = post(@cardholders_endpoint, headers: @headers, body: payload)
+ Cardholder.from_json process(response)
+ end
+
+ def update_cardholder(
+ id : String | Int32? = nil,
+ href : String? = nil,
+ first_name : String? = nil,
+ last_name : String? = nil,
+ description : String? = nil,
+ authorised : Bool = true,
+ pdfs : Hash(String, String)? = nil,
+ cards : Array(Card)? = nil,
+ remove_cards : Array(Card)? = nil,
+ update_cards : Array(Card)? = nil,
+ access_groups : Array(CardholderAccessGroup)? = nil,
+ remove_access_groups : Array(CardholderAccessGroup)? = nil,
+ update_access_groups : Array(CardholderAccessGroup)? = nil,
+ short_name : String? = nil,
+ division_href : String? = nil
+ )
+ url = href ? get_path(href) : "#{@cardholders_endpoint}/#{id.not_nil!}"
+
+ if cards || remove_cards || update_cards
+ card_updates = {} of String => Array(Card)
+ card_updates["add"] = cards if cards
+ card_updates["update"] = update_cards if update_cards
+ if remove_cards
+ card_updates["remove"] = remove_cards.map { |card| Card.new(card.href, nil) }
+ end
+ end
+
+ if access_groups || remove_access_groups || update_access_groups
+ groups_update = {} of String => Array(CardholderAccessGroup)
+ groups_update["add"] = access_groups if access_groups
+ groups_update["update"] = update_access_groups if update_access_groups
+ groups_update["remove"] = remove_access_groups if remove_access_groups
+ end
+
+ payload = Cardholder.new(
+ first_name, last_name, short_name, description, authorised,
+ card_updates, groups_update, division_href
+ ).to_json
+
+ if pdfs && !pdfs.empty?
+ payload = "#{payload[0..-2]},#{pdfs.transform_keys { |key| "@#{key}" }.to_json[1..-1]}"
+ end
+
+ response = patch(url, headers: @headers, body: payload)
+ result = process(response)
+ result.presence && Cardholder.from_json(result)
+ end
+
+ def disable_card(href : String)
+ uri = get_path(href)
+ cardholder_id = uri.split('/')[-3]
+ card = Card.new uri, {value: @disabled_card_value, type: nil.as(String?)}
+ update_cardholder(cardholder_id, update_cards: [card])
+ end
+
+ def delete_card(href : String)
+ response = delete(get_path(href), headers: @headers)
+ raise "failed to delete card #{href}" unless response.success?
+ end
+
+ def cardholder_exists?(filter : String)
+ !query_cardholders(filter).empty?
+ end
+
+ def remove_cardholder_access(
+ id : String? = nil,
+ href : String? = nil
+ )
+ update_cardholder(id, href, authorised: false)
+ end
+
+ def get_access_group(id : String)
+ response = get("#{@access_groups_endpoint}/#{id}", headers: @headers)
+ raise "access group request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ AccessGroup.from_json(response.body)
+ end
+
+ def get_access_groups(name : String? = nil, exact_match : Bool = true)
+ # surround the parameter with double quotes for an exact match
+ name = %("#{name}") if name && exact_match
+ response = get(@access_groups_endpoint, headers: @headers, params: {"top" => "10000", "name" => name}.compact)
+ raise "access groups request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ get_results(AccessGroup, response.body)
+ end
+
+ def get_access_group_members(id : String)
+ response = get("#{@access_groups_endpoint}/#{id}/cardholders", headers: @headers)
+ raise "access group members request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ json = response.body
+ begin
+ NamedTuple(cardholders: Array(NamedTuple(href: String?, cardholder: NamedTuple(name: String, href: String?)))).from_json(json)
+ rescue error
+ logger.warn(exception: error) { "#get_access_group_members failed to parse:\n#{json}" }
+ end
+ end
+
+ def access_group_member?(group_id : String | Int32, cardholder_id : String | Int32) : String?
+ group_id = group_id.to_s
+ details = get_cardholder(cardholder_id).access_groups
+ access_groups = case details
+ in Array(CardholderAccessGroup)
+ details
+ in Hash(String, Array(CardholderAccessGroup))
+ details.values.flatten
+ in Nil
+ return nil
+ end
+
+ access = access_groups.find do |group|
+ if href = group.access_group[:href]
+ href.ends_with?(group_id)
+ end
+ end
+
+ access.try(&.href)
+ end
+
+ def remove_access_group_member(group_id : String | Int32, cardholder_id : String | Int32) : Bool
+ if href = access_group_member?(group_id, cardholder_id)
+ response = delete(get_path(href), headers: @headers)
+ raise "remove access group member request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ true
+ else
+ false
+ end
+ end
+
+ def add_access_group_member(group_id : String | Int32, cardholder_id : String | Int32, from_unix : Int64? = nil, until_unix : Int64? = nil)
+ from_time = Time.unix(from_unix) if from_unix
+ until_time = Time.unix(until_unix) if until_unix
+ group = CardholderAccessGroup.new({href: "#{@uri_base}#{@access_groups_endpoint}/#{group_id}".as(String?), name: nil.as(String?)})
+ update_cardholder(cardholder_id, access_groups: [group])
+ end
+
+ def get_division(id : String)
+ response = get("#{@divisions_endpoint}/#{id}", headers: @headers)
+ raise "division request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def get_divisions(name : String? = nil, exact_match : Bool = true)
+ # surround the parameter with double quotes for an exact match
+ name = %("#{name}") if name && exact_match
+ response = get(@divisions_endpoint, headers: @headers, params: {"top" => "10000", "name" => name}.compact)
+ raise "divisions request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ get_results(JSON::Any, response.body)
+ end
+
+ def get_zones(name : String? = nil, exact_match : Bool = true)
+ # surround the parameter with double quotes for an exact match
+ name = %("#{name}") if name && exact_match
+ response = get(@access_zones_endpoint, headers: @headers, params: {"top" => "10000", "name" => name}.compact)
+ raise "zones request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ get_results(JSON::Any, response.body)
+ end
+
+ # forces a zone to be free, that is doors are unlocked
+ @[Security(Level::Support)]
+ def free_zone(zone_id : String | Int32) : Bool?
+ response = post("#{@access_zones_endpoint}/#{zone_id}/free", headers: @headers)
+ response.success?
+ end
+
+ # forces a zone to be secure and require a swipe card to access
+ @[Security(Level::Support)]
+ def secure_zone(zone_id : String | Int32) : Bool?
+ response = post("#{@access_zones_endpoint}/#{zone_id}/secure", headers: @headers)
+ response.success?
+ end
+
+ # returns the zone to it's default scheduled state, removing any overrides
+ @[Security(Level::Support)]
+ def reset_zone(zone_id : String | Int32) : Bool?
+ response = post("#{@access_zones_endpoint}/#{zone_id}/cancel", headers: @headers)
+ response.success?
+ end
+
+ # returns the zone details
+ @[Security(Level::Support)]
+ def get_access_zone(zone_id : String | Int32) : JSON::Any
+ response = get("#{@access_zones_endpoint}/#{zone_id}", headers: @headers)
+ raise "zone request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ @[Security(Level::Support)]
+ def get_events
+ response = get(@events_endpoint, headers: @headers)
+ raise "events request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def get_event_groups
+ response = get("#{@events_endpoint}/groups", headers: @headers)
+ raise "event groups request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ macro get_results(klass, response)
+ %body = {{response}}
+ begin
+ %results = Results({{klass}}).from_json %body
+ %result_array = %results.results
+ loop do
+ %next_uri = %results.next_uri
+ break unless %next_uri
+ %body = get_raw(%next_uri[:href])
+ %results = Results({{klass}}).from_json(%body)
+ %result_array.concat %results.results
+ end
+ %result_array
+ rescue error
+ logger.debug { "failed to parse response body:\n#{%body}\n" }
+ raise error
+ end
+ end
+
+ protected def get_raw(href : String)
+ response = get(get_path(href), headers: @headers)
+ raise "raw request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ response.body
+ end
+
+ @[Security(Level::Support)]
+ def get_href(href : String)
+ response = get(get_path(href), headers: @headers)
+ raise "generic request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ @[Security(Level::Support)]
+ def delete_href(href : String)
+ delete_card(href)
+ end
+
+ protected def process(response) : String
+ if response.status.created?
+ response = get(get_path(response.headers["Location"]), headers: @headers)
+ end
+
+ case response.status
+ when .bad_request?
+ # TODO:: check for card number in use and card number out of range
+ raise BadRequest.new("request failed with #{response.status_code}\n#{response.body}")
+ when .not_found?
+ raise NotFound.new("request failed with #{response.status_code}\n#{response.body}")
+ when .conflict?
+ raise Conflict.new("request failed with #{response.status_code}\n#{response.body}")
+ else
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ end
+
+ response.body
+ end
+
+ class Conflict < Exception; end
+
+ class NotFound < Exception; end
+
+ class BadRequest < Exception; end
+
+ def doors
+ response = get(@doors_endpoint, headers: @headers)
+ raise "cardholder PDF request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ NamedTuple(results: Array(DoorDetails)).from_json(response.body)[:results]
+ end
+
+ def door(id : String | Int64)
+ response = get("#{@doors_endpoint}/#{id}", headers: @headers)
+ raise "door lookup request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ DoorDetails.from_json(response.body)
+ end
+
+ # =======================
+ # Door Security Interface
+ # =======================
+
+ # user id => email
+ @user_email_cache : Hash(String, String?) = {} of String => String?
+
+ def get_cardholder_email(user_id : String?) : String?
+ return nil unless user_id
+
+ if @user_email_cache.has_key? user_id
+ return @user_email_cache[user_id]
+ end
+
+ details = get_cardholder(user_id)
+ email_key = "@#{@unique_pdf_name}"
+ @user_email_cache[user_id] = details.json_unmapped[email_key]?.try(&.as_s)
+ rescue error
+ logger.warn(exception: error) { "failed to lookup email for user: #{user_id}" }
+ nil
+ end
+
+ def door_list : Array(Door)
+ doors.map { |d| Door.new(d.id, d.name) }
+ end
+
+ @[Security(Level::Support)]
+ def unlock(door_id : String) : Bool?
+ response = post("#{@doors_endpoint}/#{door_id}/open", headers: @headers)
+ response.success?
+ end
+
+ protected def event_monitor
+ uri = URI.parse(config.uri.not_nil!)
+ uri.path = @events_endpoint
+ uri.query = "after=#{Time.utc.to_rfc3339}"
+
+ sleep 2
+
+ loop do
+ break unless @poll_events
+
+ begin
+ logger.debug { "checking for events #{uri.request_target}" }
+
+ response = get(uri.request_target, headers: @headers, concurrent: true)
+ if response.success?
+ logger.debug { "new event: #{response.body}" }
+ events_resp = Events.from_json(response.body)
+
+ update_url = URI.parse(events_resp.update_url)
+ uri.path = update_url.path
+ uri.query = update_url.query
+
+ events = events_resp.events
+ next if events.empty?
+ events.each do |event|
+ if mapped = @event_map[event.group.id]?
+ if event.matching_type? mapped.types
+ publish("security/#{@door_event_channel}/door", DoorEvent.new(
+ module_id: module_id,
+ security_system: "Gallagher",
+ door_id: event.source.id,
+ action: mapped.action,
+ card_id: event.card.try &.number,
+ user_name: event.cardholder.try &.name,
+ user_email: get_cardholder_email(event.cardholder.try &.id)
+ ).to_json)
+ end
+ end
+ end
+ else
+ # we don't want to thrash the server
+ logger.warn { "event polling failed with\nStatus #{response.status_code}\n#{response.body}" }
+ sleep 2
+ end
+ rescue timeout : IO::TimeoutError
+ # if no events came in for 2min (default timeout), 10 seconds to account for server clock drift
+ last_event = 10.second.ago
+ logger.debug { "no events detected" }
+ rescue error
+ logger.warn(exception: error) { "monitoring for events" }
+ # jump over anything that potentially caused the error
+ sleep 1
+ last_event = 1.second.from_now
+ end
+ end
+ end
+
+ # ==============================
+ # Zone Access Security Interface
+ # ==============================
+
+ alias CardHolderDetails = PlaceOS::Driver::Interface::ZoneAccessSecurity::CardHolderDetails
+ alias ZoneDetails = PlaceOS::Driver::Interface::ZoneAccessSecurity::ZoneDetails
+
+ struct CardHolder < CardHolderDetails
+ def initialize(@id, @name, @email)
+ end
+ end
+
+ struct ZoneInfo < ZoneDetails
+ def initialize(@id, @name, @description)
+ end
+ end
+
+ # using an email address, lookup the security system id for a user
+ @[Security(Level::Support)]
+ def card_holder_id_lookup(email : String) : String | Int64 | Nil
+ query_cardholders(email, @unique_pdf_name).first?.try(&.id)
+ end
+
+ # given a card holder id, lookup the details of the card holder
+ def card_holder_lookup(id : String | Int64) : CardHolderDetails
+ details = get_cardholder(id.to_s)
+ first_name = details.first_name
+ last_name = details.last_name
+ short_name = details.short_name
+ name = if first_name.presence
+ "#{first_name} #{last_name}"
+ else
+ short_name || ""
+ end
+ email_key = "@#{@unique_pdf_name}"
+ CardHolder.new(id, name, details.json_unmapped[email_key]?.try(&.as_s))
+ end
+
+ # using a name, lookup the access zone id
+ @[Security(Level::Support)]
+ def zone_access_id_lookup(name : String, exact_match : Bool = true) : String | Int64 | Nil
+ get_access_groups(name, exact_match).first?.try(&.id)
+ end
+
+ # given an access zone id, lookup the details of the zone
+ def zone_access_lookup(id : String | Int64) : ZoneDetails
+ details = get_access_group(id.to_s)
+ ZoneInfo.new(id, details.name, details.description)
+ end
+
+ # return the id that represents the access permission (truthy indicates access)
+ @[Security(Level::Support)]
+ def zone_access_member?(zone_id : String | Int64, card_holder_id : String | Int64) : String | Int64 | Nil
+ access_group_member?(zone_id.to_s, card_holder_id.to_s)
+ end
+
+ # add a member to the zone
+ @[Security(Level::Support)]
+ def zone_access_add_member(zone_id : String | Int64, card_holder_id : String | Int64, from_unix : Int64? = nil, until_unix : Int64? = nil)
+ add_access_group_member(zone_id.to_s, card_holder_id.to_s, from_unix, until_unix)
+ end
+
+ # remove a member from the zone
+ @[Security(Level::Support)]
+ def zone_access_remove_member(zone_id : String | Int64, card_holder_id : String | Int64)
+ remove_access_group_member zone_id.to_s, card_holder_id.to_s
+ end
+end
diff --git a/drivers/gallagher/rest_api_models.cr b/drivers/gallagher/rest_api_models.cr
new file mode 100644
index 00000000000..e67106cb7e3
--- /dev/null
+++ b/drivers/gallagher/rest_api_models.cr
@@ -0,0 +1,264 @@
+require "json"
+
+module Gallagher
+ class Results(ResultType)
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property results : Array(ResultType)
+
+ @[JSON::Field(key: "next")]
+ property next_uri : NamedTuple(href: String)?
+ end
+
+ # Personal Data Field
+ class PDF
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ def initialize(@id, @name, @href)
+ end
+
+ property id : String
+ property name : String
+ property href : String
+
+ @[JSON::Field(key: "serverDisplayName")]
+ property server_display_name : String? = nil
+
+ property required : Bool? = nil
+ property unique : Bool? = nil
+ property default : String? = nil
+ property description : String? = nil
+ end
+
+ class DoorDetails
+ include JSON::Serializable
+
+ def initialize(@id, @name, @href)
+ end
+
+ property id : String
+ property name : String
+ property href : String
+
+ property description : String?
+ end
+
+ class Cardholder
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ def initialize(
+ @first_name,
+ @last_name,
+ @short_name,
+ @description,
+ @authorised,
+ cards,
+ access_groups,
+ division : String?
+ )
+ @cards = cards
+ @division = division ? {href: division} : nil
+ @access_groups = access_groups
+ end
+
+ property href : String?
+ property id : String?
+
+ @[JSON::Field(key: "firstName")]
+ property first_name : String?
+
+ @[JSON::Field(key: "lastName")]
+ property last_name : String?
+
+ @[JSON::Field(key: "shortName")]
+ property short_name : String?
+ property description : String?
+ property authorised : Bool?
+
+ @[JSON::Field(key: "lastSuccessfulAccessTime")]
+ property last_accessed : Time?
+
+ property division : NamedTuple(href: String)?
+ property usercode : String?
+
+ property cards : Array(Card) | Hash(String, Array(Card))?
+
+ @[JSON::Field(key: "accessGroups")]
+ property access_groups : Array(CardholderAccessGroup) | Hash(String, Array(CardholderAccessGroup))?
+ end
+
+ class CardType
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property id : String
+ property name : String
+ property href : String
+
+ @[JSON::Field(key: "facilityCode")]
+ property facility_code : String
+
+ @[JSON::Field(key: "availableCardStates")]
+ property available_card_states : Array(String)
+
+ @[JSON::Field(key: "credentialClass")]
+ property credential_class : String
+
+ @[JSON::Field(key: "minimumNumber")]
+ property minimum_number : String?
+
+ @[JSON::Field(key: "maximumNumber")]
+ property maximum_number : String?
+ end
+
+ class Invitation
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property email : String?
+ property mobile : String?
+
+ @[JSON::Field(key: "singleFactorOnly")]
+ property single_factor_only : Bool?
+
+ property status : String?
+ property href : String?
+ end
+
+ struct Card
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ def initialize(@href, @status)
+ end
+
+ property href : String?
+ property type : NamedTuple(href: String?, name: String?)? = nil
+ property number : String? = nil
+ property status : NamedTuple(value: String, type: String?)? = nil
+
+ @[JSON::Field(key: "facilityCode")]
+ property facility_code : String? = nil
+
+ @[JSON::Field(key: "cardSerialNumber")]
+ property card_serial_number : String? = nil
+
+ @[JSON::Field(key: "issueLevel")]
+ property issue_level : Int32? = nil
+
+ @[JSON::Field(key: "credentialClass")]
+ property credential_class : String? = nil
+
+ @[JSON::Field(key: "e2eKey")]
+ property e2e_key : String? = nil
+
+ @[JSON::Field(key: "bleFacilityId")]
+ property ble_facility_id : Int64? = nil
+
+ @[JSON::Field(key: "credentialId")]
+ property credential_id : String? = nil
+
+ property invitation : Invitation? = nil
+
+ property from : Time? = nil
+ property until : Time? = nil
+ end
+
+ class CardholderAccessGroup
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property href : String?
+
+ @[JSON::Field(key: "accessGroup")]
+ property access_group : NamedTuple(href: String?, name: String?)
+
+ property status : NamedTuple(value: String, type: String?)? = nil
+ property from : Time?
+ property until : Time?
+
+ def initialize(@access_group, @from = nil, @until = nil)
+ end
+ end
+
+ class AccessGroup
+ include JSON::Serializable
+
+ property href : String?
+ property id : String
+ property name : String
+ property description : String?
+
+ property parent : NamedTuple(
+ href: String?,
+ name: String,
+ )?
+
+ property division : NamedTuple(
+ id: String,
+ href: String,
+ )
+
+ property cardholders : NamedTuple(
+ href: String,
+ )
+
+ property children : Array(NamedTuple(
+ href: String,
+ name: String,
+ ))?
+ end
+
+ class AccessGroupMembership
+ include JSON::Serializable
+
+ property href : String
+ property cardholder : NamedTuple(
+ href: String,
+ name: String,
+ )
+ property from : Time?
+ property until : Time?
+ end
+
+ struct IdName
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String
+ end
+
+ struct Event
+ include JSON::Serializable
+
+ getter group : IdName
+ getter type : IdName
+ getter source : IdName
+
+ getter id : String
+ getter time : Time
+ getter message : String?
+
+ getter card : Card?
+ getter cardholder : IdName?
+
+ def matching_type?(types : Array(Int32)?)
+ return true unless types
+ types.map(&.to_s).includes?(type.id)
+ end
+ end
+
+ struct Events
+ include JSON::Serializable
+
+ getter events : Array(Event)
+ getter updates : NamedTuple(href: String)
+
+ def update_url
+ updates[:href]
+ end
+ end
+end
diff --git a/drivers/gallagher/rest_api_spec.cr b/drivers/gallagher/rest_api_spec.cr
new file mode 100644
index 00000000000..efcaaf37171
--- /dev/null
+++ b/drivers/gallagher/rest_api_spec.cr
@@ -0,0 +1,76 @@
+require "./rest_api_models"
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Gallagher::RestAPI" do
+ Log.debug { "expecting API paths request..." }
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output.puts %({
+ "version": "8.50.1414.0",
+ "features": {
+ "doors": {
+ "doors": {
+ "href": "https://localhost:8904/api/doors"
+ }
+ },
+ "cardholders": {
+ "cardholders": {
+ "href": "https://localhost:8904/api/cardholders"
+ }
+ },
+ "accessGroups": {
+ "accessGroups": {
+ "href": "https://localhost:8904/api/access_groups"
+ }
+ },
+ "events": {
+ "events": {
+ "href": "https://localhost:8904/api/events"
+ }
+ },
+ "cardTypes": {
+ "assign": {
+ "href": "https://localhost:8904/api/card_types"
+ }
+ },
+ "personalDataFields": {
+ "personalDataFields": {
+ "href": "https://localhost:8904/api/personal_data_fields"
+ }
+ }
+ }
+ })
+ end
+ Log.debug { "API paths received" }
+
+ Log.debug { "creating a cardholder..." }
+ exec(:create_cardholder,
+ first_name: "Steve",
+ last_name: "Takach",
+ cards: [{number: "12345"}],
+ pdfs: {
+ "email" => "test@email.com",
+ }
+ )
+ data = ""
+ expect_http_request do |request, response|
+ response.status_code = 201
+ response.headers["Location"] = "https://localhost:8904/api/cardholders/4567"
+ data = request.body.not_nil!.gets_to_end
+ end
+ Log.debug { "cardholder created" }
+
+ Log.debug { "expecting cardholder request..." }
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output.puts({
+ first_name: "Steve",
+ last_name: "Takach",
+ cards: [{number: "12345"}],
+ pdfs: {
+ "email" => "test@email.com",
+ },
+ }.to_json)
+ end
+ Log.debug { "cardholder data sent" }
+end
diff --git a/drivers/gallagher/zone_schedule.cr b/drivers/gallagher/zone_schedule.cr
new file mode 100644
index 00000000000..27d13eb8a49
--- /dev/null
+++ b/drivers/gallagher/zone_schedule.cr
@@ -0,0 +1,381 @@
+require "placeos-driver"
+require "simple_retry"
+
+class Gallagher::ZoneSchedule < PlaceOS::Driver
+ descriptive_name "Gallagher Zone Schedule"
+ generic_name :GallagherZoneSchedule
+ description "maps a booking state to a gallagher access zone state"
+
+ accessor bookings : Bookings_1
+
+ default_settings({
+ # gallagher_system: "sys-12345"
+ zone_id: "1234",
+ _access_group_id: "140623",
+
+ # booking status => zone state
+ state_mappings: {
+ "pending" => "free",
+ "busy" => "free",
+ "free" => "default",
+ },
+
+ # max time in minutes that presence can prevent a lock.
+ presence_timeout: 30,
+ grant_hosts_access: false,
+
+ disable_unlock: {
+ keys: ["extended_properties", "Don't Unlock"],
+ value: "TRUE",
+ },
+ })
+
+ struct DisableUnlock
+ include JSON::Serializable
+
+ getter keys : Array(String)
+ getter value : String
+ end
+
+ getter system_id : String = ""
+ getter count : UInt64 = 0_u64
+
+ # Tracking meeting details
+ getter zone_id : String | Int64 = ""
+ getter state_mappings : Hash(String, String) = {} of String => String
+
+ @update_mutex = Mutex.new
+ @disable_unlock : DisableUnlock? = nil
+
+ def on_update
+ @system_id = setting?(String, :gallagher_system).presence || config.control_system.not_nil!.id
+ @state_mappings = setting(Hash(String, String), :state_mappings)
+ @zone_id = setting?(String | Int64, :zone_id) || setting(String | Int64, :door_zone_id)
+ @presence_timeout = (setting?(Int32, :presence_timeout) || 30).minutes
+ @access_group_id = nil
+
+ @grant_hosts_access = setting?(Bool, :grant_hosts_access) || false
+ @host_access_mutex.synchronize do
+ @access_granted = setting?(Hash(String, String | Int64), :access_granted) || {} of String => String | Int64
+ end
+
+ @disable_unlock = setting?(DisableUnlock, :disable_unlock) rescue nil
+ # @current_state = setting?(String, :saved_current_state)
+ end
+
+ bind Bookings_1, :status, :status_changed
+ bind Bookings_1, :presence, :presence_changed
+
+ getter last_status : String? = nil
+ getter last_presence : Bool? = nil
+ getter grant_hosts_access : Bool = false
+
+ @current_state : String? = nil
+
+ getter access_group_id : String | Int64 do
+ setting?(String | Int64, :access_group_id) || find_access_group_from_zone
+ end
+
+ @presence_relevant : Bool = false
+ @presence_timeout : Time::Span = 30.minutes
+
+ private def status_changed(_subscription, new_value)
+ logger.debug { "new room status: #{new_value}" }
+ new_status = (String?).from_json(new_value) rescue new_value.to_s
+ @last_status = new_status
+ @update_mutex.synchronize { apply_new_state(new_status, @last_presence) }
+ end
+
+ private def presence_changed(_subscription, new_value)
+ logger.debug { "new room status: #{new_value}" }
+ new_presence = (Bool?).from_json(new_value) rescue nil
+ @last_presence = new_presence
+ @update_mutex.synchronize { apply_new_state(@last_status, new_presence) }
+ end
+
+ private def apply_new_state(new_status : String?, presence : Bool?)
+ logger.debug { "#apply_new_state called with new_status: #{new_status}" }
+
+ # we'll ignore nil values, most likely only when drivers are updated or starting
+ return unless new_status
+
+ # check if we want to disable unlock for this booking
+ unlock_disabled = !should_unlock_booking?
+ if unlock_disabled
+ new_status = "free"
+ @presence_relevant = false
+ end
+
+ # ignore redis errors as this is a critical system component
+ begin
+ self[:unlock_disabled] = unlock_disabled
+ self[:booking_status] = new_status
+ self[:people_present] = presence
+ rescue
+ end
+
+ apply_zone_state = state_mappings[new_status]?
+ if apply_zone_state.nil?
+ logger.debug { "no mapping for booking status #{new_status}, ignoring" }
+ return
+ end
+
+ schedule.clear
+
+ # This is checking if want to lock the room (not free)
+ # and if someone is present and presence matters
+ # then change zone state to unlock
+ if apply_zone_state == "free"
+ @presence_relevant = true
+ elsif presence && @presence_relevant
+ apply_zone_state = "free"
+ @presence_relevant = false
+ schedule.in(@presence_timeout) do
+ @update_mutex.synchronize { apply_new_state(@last_status, @last_presence) }
+ end
+ end
+
+ self[:zone_state] = apply_zone_state rescue nil
+
+ if apply_zone_state != @current_state
+ logger.debug { "mapping #{new_status} => #{apply_zone_state} in #{zone_id}" }
+
+ begin
+ SimpleRetry.try_to(
+ max_attempts: 5,
+ base_interval: 500.milliseconds,
+ max_interval: 1.seconds,
+ randomise: 100.milliseconds
+ ) do
+ case apply_zone_state
+ when "free"
+ gallagher.free_zone(zone_id).get
+ when "secure"
+ gallagher.secure_zone(zone_id).get
+ when "default", "reset"
+ gallagher.reset_zone(zone_id).get
+ else
+ logger.warn { "unknown zone state #{apply_zone_state}" }
+ false
+ end
+ end
+ @count += 1
+ rescue error
+ self[:last_error] = {
+ message: error.message,
+ at: Time.utc.to_s,
+ }
+ end
+
+ @current_state = apply_zone_state
+ # @host_access_mutex.synchronize do
+ # define_setting(:saved_current_state, apply_zone_state) rescue nil
+ # end
+ else
+ logger.debug { "zone state already applied, skipping step" }
+ end
+
+ schedule.in(1.second) { check_host_access } if @grant_hosts_access
+ end
+
+ private def gallagher
+ system(system_id)["Gallagher"]
+ end
+
+ # ==========================================
+ # check if the current booking should unlock
+ # ==========================================
+
+ def should_unlock_booking? : Bool
+ # do we need to check if the room should unlock
+ disable_unlock = @disable_unlock
+ if disable_unlock.nil?
+ logger.debug { "unlock check disabled" }
+ return true
+ end
+
+ # if so we need to grab the current bookings
+ booking_mod = bookings
+ current_booking = booking_mod[:current_booking]?
+ if current_booking.nil? && booking_mod.status?(Bool, :pending)
+ logger.debug { "looking at next_booking as current_booking has not started" }
+ current_booking = booking_mod[:next_booking]?
+ end
+
+ if current_booking.nil?
+ logger.debug { "ignoring as no booking found" }
+ return true
+ end
+
+ # check if the booking should allow unlocking or not
+ value = current_booking
+ disable_unlock.keys.each do |key|
+ value = value[key]?
+ break if value.nil?
+ end
+
+ if value.nil?
+ logger.debug { "could not find relevant key, ignoring" }
+ return true
+ end
+
+ result = !(value.as_s? == disable_unlock.value)
+ logger.debug { "checking #{value.as_s?.inspect} == #{disable_unlock.value.inspect} (#{!result})" }
+ result
+ rescue error
+ logger.error(exception: error) { "error checking if a room should not be unlocked" }
+ self[:last_error] = {
+ message: error.message,
+ at: Time.utc.to_s,
+ }
+ true
+ end
+
+ # ============================================
+ # Grant host access to space for locking doors
+ # ============================================
+
+ def find_access_group_from_zone : String
+ gal = gallagher
+ zone_name = gal.get_access_zone(zone_id).get["name"].as_s
+ gal.get_access_groups(zone_name).get.as_a.first["id"].as_s
+ end
+
+ # we want to do this as the local Calendar module may
+ # not be a graph or google calendar (which we need)
+ private def calendar
+ system(system_id)["Calendar"]
+ end
+
+ # email => cardholder_id
+ getter access_granted : Hash(String, String | Int64) = {} of String => String | Int64
+ getter existing_access : Hash(String, String | Int64) = {} of String => String | Int64
+
+ @host_access_mutex = Mutex.new
+
+ enum Status
+ Pending
+ Busy
+ Free
+ end
+
+ def check_host_access
+ return unless @grant_hosts_access
+
+ host_email = bookings.status?(String, :host_email).try(&.strip.downcase)
+ next_host = bookings.status?(String, :next_host).try(&.strip.downcase)
+ status = bookings.status?(Status, :status)
+
+ security = gallagher
+ return remove_all_access(security) unless status
+
+ case status
+ in .pending?, .busy?
+ # should we be removing any access?
+ active_hosts = [host_email, next_host].compact
+ current_access = access_granted.keys + existing_access.keys
+ remove_access = current_access - active_hosts
+ remove_access_from security, remove_access
+
+ # do we need to grant access?
+ needs_access = active_hosts - current_access
+ return if needs_access.empty?
+
+ # tuple: needs access, email, cardholder_id
+ access_required = [] of Tuple(Bool, String, String | Int64)
+ needs_access.each do |email|
+ begin
+ # get the users username (for lookup in the security system)
+ user = calendar.get_user(email).get
+ username = (user["username"]? || user["email"]).as_s.downcase
+
+ # find the user in the security system
+ cardholder = security.card_holder_id_lookup(email).get
+ cardholder_id = cardholder.as_s? || cardholder.as_i64
+
+ # check if the user already has access
+ if (String | Int64 | Nil).from_json(security.zone_access_member?(access_group_id, cardholder_id).get.to_json)
+ access_required << {false, username, cardholder_id}
+ next
+ end
+
+ # the user needs access
+ access_required << {true, username, cardholder_id}
+ rescue error
+ logger.warn(exception: error) { "failed to grant room access to #{email}" }
+ self[:staff_access_error] = {
+ message: "failed to grant room access to #{email}",
+ error: error.message,
+ at: Time.utc.to_s,
+ }
+ end
+ end
+
+ grant_access_to security, access_required
+ in .free?
+ remove_all_access(security)
+ end
+ end
+
+ protected def remove_all_access(security)
+ current_access = access_granted.keys + existing_access.keys
+ remove_access_from security, current_access
+
+ # we define the setting here as remove access does not define this setting
+ @host_access_mutex.synchronize do
+ define_setting(:access_granted, access_granted)
+ end
+ end
+
+ protected def remove_access_from(security, users : Array(String))
+ users.each do |user|
+ if cardholder_id = access_granted[user]?
+ security.zone_access_remove_member(access_group_id, cardholder_id).get rescue nil
+ end
+ end
+
+ # update after as no harm removing access again
+ @host_access_mutex.synchronize do
+ users.each do |user|
+ access_granted.delete user
+ existing_access.delete user
+ end
+ end
+ rescue error
+ logger.error(exception: error) { "failed to remove access from #{users}" }
+ self[:staff_access_error] = {
+ message: "failed to remove access from #{users}",
+ error: error.message,
+ at: Time.utc.to_s,
+ }
+ end
+
+ protected def grant_access_to(security, access_required)
+ # update the setting first as we want to ensure access is removed
+ @host_access_mutex.synchronize do
+ access_required.each do |(needs_access, email, cardholder_id)|
+ if needs_access
+ access_granted[email] = cardholder_id
+ else
+ existing_access[email] = cardholder_id
+ end
+ end
+
+ # we define the setting here as remove access would have run first
+ define_setting(:access_granted, access_granted)
+ end
+
+ # grant users access to zones
+ access_required.each do |(needs_access, email, cardholder_id)|
+ next unless needs_access
+ security.zone_access_add_member(access_group_id, cardholder_id).get rescue nil
+ end
+ rescue error
+ logger.error(exception: error) { "failed to grant access to #{access_required.map(&.[](1))}" }
+ self[:staff_access_error] = {
+ message: "failed to grant access to #{access_required.map(&.[](1))}",
+ error: error.message,
+ at: Time.utc.to_s,
+ }
+ end
+end
diff --git a/drivers/gallagher/zone_schedule_spec.cr b/drivers/gallagher/zone_schedule_spec.cr
new file mode 100644
index 00000000000..31cfa46b28f
--- /dev/null
+++ b/drivers/gallagher/zone_schedule_spec.cr
@@ -0,0 +1,74 @@
+require "placeos-driver/spec"
+require "uuid"
+
+DriverSpecs.mock_driver "Gallagher::ZoneSchedule" do
+ system({
+ Gallagher: {GallagherMock},
+ Bookings: {BookingsMock},
+ })
+
+ # Start a new meeting
+ exec(:count).get.should eq 0
+ bookings = system(:Bookings).as(BookingsMock)
+ bookings.new_meeting
+ sleep 500.milliseconds
+ exec(:count).get.should eq 1
+
+ # check the update that was applied
+ system(:Gallagher)[:state].should eq(["free", "1234"])
+
+ bookings.presence(true)
+ sleep 500.milliseconds
+ exec(:count).get.should eq 1
+ system(:Gallagher)[:state].should eq(["free", "1234"])
+
+ bookings.end_meeting
+ sleep 500.milliseconds
+ exec(:count).get.should eq 1
+ system(:Gallagher)[:state].should eq(["free", "1234"])
+
+ bookings.presence(false)
+ sleep 500.milliseconds
+ exec(:count).get.should eq 2
+ system(:Gallagher)[:state].should eq(["locked", "1234"])
+
+ bookings.disable_unlock
+ sleep 500.milliseconds
+ exec(:should_unlock_booking?).get.should_not eq true
+end
+
+# :nodoc:
+class BookingsMock < DriverSpecs::MockDriver
+ def disable_unlock
+ self[:current_booking] = {
+ extended_properties: {
+ "Don't Unlock" => "TRUE",
+ },
+ }
+ end
+
+ def new_meeting : Nil
+ self[:status] = "pending"
+ end
+
+ def presence(state : Bool)
+ self[:presence] = state
+ end
+
+ def end_meeting : Nil
+ self[:status] = "free"
+ end
+end
+
+# :nodoc:
+class GallagherMock < DriverSpecs::MockDriver
+ def free_zone(zone_id : String | Int32)
+ self[:state] = {:free, zone_id.to_s}
+ true
+ end
+
+ def reset_zone(zone_id : String | Int32)
+ self[:state] = {:locked, zone_id.to_s}
+ true
+ end
+end
diff --git a/drivers/global_cache/gc_100.cr b/drivers/global_cache/gc_100.cr
new file mode 100644
index 00000000000..0dcbe837bc8
--- /dev/null
+++ b/drivers/global_cache/gc_100.cr
@@ -0,0 +1,178 @@
+require "placeos-driver/interface/electrical_relay"
+require "placeos-driver"
+
+class GlobalCache::Gc100 < PlaceOS::Driver
+ include Interface::ElectricalRelay
+
+ # Discovery Information
+ tcp_port 4999
+ descriptive_name "GlobalCache IO Gateway"
+ generic_name :DigitalIO
+
+ DELIMITER = "\r"
+
+ # @relay_config maps the GC100 into a linear set of ir and relays so models can be swapped in and out
+ # E.g. @relay_config = {"relay" => {0 => "2:1",1 => "2:2",2 => "2:3",3 => "3:1"}}
+ @relay_config : Hash(String, Hash(Int32, String)) = {} of String => Hash(Int32, String)
+ @port_config : Hash(String, Tuple(String, Int32)) = {} of String => Tuple(String, Int32)
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ self[:num_relays] = 0
+ self[:num_ir] = 0
+ end
+
+ def connected
+ @relay_config = {} of String => Hash(Int32, String)
+ @port_config = {} of String => Tuple(String, Int32)
+
+ schedule.clear
+ schedule.every(10.seconds, true) do
+ logger.debug { "-- Polling GC100" }
+
+ if status?(Bool, :config_indexed)
+ do_send("get_NET,0:1", priority: 0, wait: false)
+ else
+ get_devices
+ end
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def get_devices
+ do_send("getdevices") # , :max_waits => 100)
+ end
+
+ def relay(state : Bool, index : Int32 = 0, **options)
+ if index < self[:num_relays].as_i
+ relays = (self[:relay_config]["relay"]? || self[:relay_config]["relaysensor"]?).not_nil!.as_h
+ logger.debug { "relays = #{relays}" }
+ connector = relays[index.to_s]
+ do_send("setstate,#{connector},#{state ? 1 : 0}", **options)
+ else
+ logger.warn { "Attempted to set relay on GlobalCache that does not exist: #{index}" }
+ end
+ end
+
+ def ir(index : Int32, command : String, **options)
+ do_send("sendir,1:#{index},#{command}", **options)
+ end
+
+ enum IrMode
+ IR
+ SENSOR
+ SENSOR_NOTIFY
+ IR_NOCARRIER
+ end
+
+ def set_ir(index : Int32, mode : IrMode, **options)
+ if index < self[:num_ir].as_i
+ connector = self[:relay_config]["ir"][index.to_s]
+ do_send("set_IR,#{connector},#{mode}", **options)
+ else
+ logger.warn { "Attempted to set IR mode on GlobalCache that does not exist: #{index}" }
+ end
+ end
+
+ def relay_status?(index : Int32, **options)
+ if index < self[:num_relays].as_i
+ connector = self[:relay_config]["relay"][index.to_s]
+ do_send("getstate,#{connector}", **options)
+ else
+ logger.warn { "Attempted to check IO on GlobalCache that does not exist: #{index}" }
+ end
+ end
+
+ def ir_status?(index : Int32, **options)
+ if index < self[:num_ir].as_i
+ connector = self[:relay_config]["ir"][index.to_s]
+ do_send("getstate,#{connector}", **options)
+ else
+ logger.warn { "Attempted to check IO on GlobalCache that does not exist: #{index}" }
+ end
+ end
+
+ def received(data, task)
+ # Remove the delimiter
+ data = String.new(data[0..-2])
+ logger.debug { "GlobalCache sent #{data}" }
+ data = data.split(',')
+ task_name = task.try &.name || "unknown"
+
+ case data[0]
+ when "state", "statechange"
+ type, index = self[:port_config][data[1]]
+ self["#{type}#{index}"] = data[2] == "1" # Is relay index on?
+ when "device"
+ address = data[1]
+ number, type = data[2].split(' ') # The response was "device,2,3 RELAY"
+
+ type = type.downcase
+
+ @relay_config[type] ||= {} of Int32 => String
+ current = @relay_config[type].size
+
+ (current..(current + number.to_i - 1)).each_with_index(1) do |i, dev_index|
+ port = "#{address}:#{dev_index}"
+ @relay_config[type][i] = port
+ @port_config[port] = {type, i}
+ end
+
+ return task.try &.success
+ when "endlistdevices"
+ self[:num_relays] = @relay_config["relay"].size if @relay_config["relay"]?
+ if @relay_config["relaysensor"]?
+ @relay_config["relaysensor"][1] = "1:2"
+ @relay_config["relaysensor"][2] = "1:3"
+ @relay_config["relaysensor"][3] = "1:4"
+ self[:num_relays] = @relay_config["relaysensor"].size
+ end
+ self[:num_ir] = @relay_config["ir"].size if @relay_config["ir"]?
+ self[:relay_config] = @relay_config
+ self[:port_config] = @port_config
+ logger.debug { "self[:relay_config] is #{self[:relay_config]}" }
+ logger.debug { "self[:port_config] is #{self[:port_config]}" }
+ @relay_config = {} of String => Hash(Int32, String)
+ @port_config = {} of String => Tuple(String, Int32)
+ self[:config_indexed] = true
+
+ return task.try &.success
+ end
+
+ if data.size == 1
+ error = case data[0].split(' ')[1].to_i
+ when 1 then "Command was missing the carriage return delimiter"
+ when 2 then "Invalid module address when looking for version"
+ when 3 then "Invalid module address"
+ when 4 then "Invalid connector address"
+ when 5 then "Connector address 1 is set up as \"sensor in\" when attempting to send an IR command"
+ when 6 then "Connector address 2 is set up as \"sensor in\" when attempting to send an IR command"
+ when 7 then "Connector address 3 is set up as \"sensor in\" when attempting to send an IR command"
+ when 8 then "Offset is set to an even transition number, but should be set to an odd transition number in the IR command"
+ when 9 then "Maximum number of transitions exceeded (256 total on/off transitions allowed)"
+ when 10 then "Number of transitions in the IR command is not even (the same number of on and off transitions is required)"
+ when 11 then "Contact closure command sent to a module that is not a relay"
+ when 12 then "Missing carriage return. All commands must end with a carriage return"
+ when 13 then "State was requested of an invalid connector address, or the connector is programmed as IR out and not sensor in."
+ when 14 then "Command sent to the unit is not supported by the GC-100"
+ when 15 then "Maximum number of IR transitions exceeded"
+ when 16 then "Invalid number of IR transitions (must be an even number)"
+ when 21 then "Attempted to send an IR command to a non-IR module"
+ when 23 then "Command sent is not supported by this type of module"
+ else "Unknown error"
+ end
+ return task.try &.abort("GlobalCache error for command #{task_name}: #{error}")
+ end
+
+ task.try &.success
+ end
+
+ private def do_send(command : String, **options)
+ logger.debug { "-- GlobalCache, sending: #{command}" }
+ command = "#{command}#{DELIMITER}"
+ send(command, **options)
+ end
+end
diff --git a/drivers/global_cache/gc_100_spec.cr b/drivers/global_cache/gc_100_spec.cr
new file mode 100644
index 00000000000..832dfe0322b
--- /dev/null
+++ b/drivers/global_cache/gc_100_spec.cr
@@ -0,0 +1,45 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "GlobalCache::Gc100" do
+ # connected
+ # get_devices
+ should_send("getdevices\r")
+ responds("device,2,3 RELAY\r")
+ responds("device,1,2 RELAYSENSOR\r")
+ responds("device,3,1 IR\r")
+ responds("endlistdevices\r")
+
+ sleep 1
+
+ status[:relay_config].should eq({
+ "relay" => {"0" => "2:1", "1" => "2:2", "2" => "2:3"},
+ "relaysensor" => {"0" => "1:1", "1" => "1:2", "2" => "1:3", "3" => "1:4"},
+ "ir" => {"0" => "3:1"},
+ })
+ status[:port_config].should eq({
+ "2:1" => ["relay", 0], "2:2" => ["relay", 1], "2:3" => ["relay", 2], "1:1" => ["relaysensor", 0], "1:2" => ["relaysensor", 1], "3:1" => ["ir", 0],
+ })
+
+ exec(:relay, true, 1)
+ should_send("setstate,2:2,1\r")
+ responds("state,2:2,1\r")
+ status[:relay1].should eq(true)
+
+ exec(:ir, 0, "4444")
+ should_send("sendir,1:0,4444\r")
+ responds("completeir,1:0,4444\r")
+
+ exec(:set_ir, 0, "ir")
+ should_send("set_IR,3:1,IR\r")
+ responds("TODO 1\r")
+
+ exec(:relay_status?, 2)
+ should_send("getstate,2:3\r")
+ responds("state,2:3,0\r")
+ status[:relay2].should eq(false)
+
+ exec(:ir_status?, 0)
+ should_send("getstate,3:1\r")
+ responds("state,3:1,1\r")
+ status[:ir0].should eq(true)
+end
diff --git a/drivers/global_cache/ir_tv.cr b/drivers/global_cache/ir_tv.cr
new file mode 100644
index 00000000000..9f6c759acf3
--- /dev/null
+++ b/drivers/global_cache/ir_tv.cr
@@ -0,0 +1,183 @@
+require "placeos-driver"
+
+class GlobalCache::IRTV < PlaceOS::Driver
+ descriptive_name "Infrared TV Control via Globalcache"
+ generic_name :IPTV
+
+ default_settings({
+ default_ir_set: "foxtel_iq2",
+ default_ir_index: 1,
+ globalcache_module: "DigitalIO_1",
+ channel_details: [
+ {
+ name: "ABC News",
+ icon: "https://os.place.tech/placeos.pwc.com.au/tv_icons/ABC_News_AU.svg",
+ channel: "abc_news", # A Unique ID that PlaceOS tabbed UI frontend expects under this key.
+ ir_commands: ["DIGIT 0", "DIGIT 2", "DIGIT 4"], # comma seperated IR commands from ir_set to transmit
+ },
+ {
+ name: "Channel Down",
+ icon: "https://static.thenounproject.com/png/1129950-200.png",
+ channel: "down",
+ ir_commands: ["CHANNEL DOWN"],
+ },
+ {
+ name: "Channel Up",
+ icon: "https://static.thenounproject.com/png/1129949-200.png",
+ channel: "up",
+ ir_commands: ["CHANNEL UP"],
+ },
+ ],
+ globalcache_ir_sets: {
+ # Globalcache IR Database entries downloaded from https://irdb.globalcache.com/Home/Database are in the format
+ # function, code1, hexcode1, code2, hexcode2
+ # we are interested in function and code 1 only
+ "foxtel_iq2": <<-PASTE_FROM_GLOBALCACHE_IR_DATABASE
+"ACTIVE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,28,6,28,6,28,6,22,6,3176","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 001C 0006 001C 0006 001C 0006 0016 0006 0C68","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,28,6,28,6,28,6,22,6,3164","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 001C 0006 001C 0006 001C 0006 0016 0006 0C5C"
+"AV MODE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,28,6,22,6,10,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 0016 0006 000A 0006 0C8C","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,28,6,22,6,10,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 0016 0006 000A 0006 0C80"
+"BACK","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,22,6,10,6,10,6,28,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 001C 0006 0C8C","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,22,6,10,6,10,6,28,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 001C 0006 0C80"
+"CHANNEL DOWN","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,10,6,16,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 000A 0006 0010 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,22,6,10,6,16,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 000A 0006 0010 0006 0C8C"
+"CHANNEL UP","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,10,6,10,6,3231","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 000A 0006 000A 0006 0C9F","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,22,6,10,6,10,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 000A 0006 000A 0006 0C93"
+"CURSOR DOWN","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,22,6,16,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 0010 0006 0C8C","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,16,6,22,6,16,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 0010 0006 0C80"
+"CURSOR ENTER","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,28,6,10,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 001C 0006 000A 0006 0C8C","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,16,6,28,6,10,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 001C 0006 000A 0006 0C80"
+"CURSOR LEFT","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,22,6,22,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 0016 0006 0C86","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,16,6,22,6,22,6,3194","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 0016 0006 0C7A"
+"CURSOR RIGHT","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,22,6,28,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 001C 0006 0C80","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,16,6,22,6,28,6,3188","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 001C 0006 0C74"
+"CURSOR UP","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,22,6,10,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 000A 0006 0C93","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,16,6,22,6,10,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0016 0006 000A 0006 0C86"
+"DIGIT 0","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,10,6,10,6,3243","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 000A 0006 0CAB","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,10,6,10,6,3231","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 000A 0006 0C9F"
+"DIGIT 1","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,10,6,16,6,3237","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 0010 0006 0CA5","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,10,6,16,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 0010 0006 0C99"
+"DIGIT 2","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,10,6,22,6,3231","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0C9F","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,10,6,22,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0C93"
+"DIGIT 3","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,10,6,28,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 001C 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,10,6,28,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 000A 0006 001C 0006 0C8C"
+"DIGIT 4","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,16,6,10,6,3237","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 000A 0006 0CA5","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,16,6,10,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 000A 0006 0C99"
+"DIGIT 5","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,16,6,16,6,3231","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 0010 0006 0C9F","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,16,6,16,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 0010 0006 0C93"
+"DIGIT 6","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,16,6,22,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 0016 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,16,6,22,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 0016 0006 0C8C"
+"DIGIT 7","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,16,6,28,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 001C 0006 0C93","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,16,6,28,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0010 0006 001C 0006 0C86"
+"DIGIT 8","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,22,6,10,6,3231","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0016 0006 000A 0006 0C9F","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,22,6,10,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0016 0006 000A 0006 0C93"
+"DIGIT 9","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,22,6,16,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0016 0006 0010 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,22,6,16,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 0016 0006 0010 0006 0C8C"
+"FORWARD","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,22,6,10,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 0016 0006 000A 0006 0C93","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,22,6,22,6,10,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 0016 0006 000A 0006 0C86"
+"FUNCTION BLUE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,28,6,10,6,10,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 001C 0006 000A 0006 000A 0006 0C93","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,28,6,10,6,10,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 001C 0006 000A 0006 000A 0006 0C86"
+"FUNCTION GREEN","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,22,6,28,6,22,6,3194","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0016 0006 001C 0006 0016 0006 0C7A","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,22,6,28,6,22,6,3182","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0016 0006 001C 0006 0016 0006 0C6E"
+"FUNCTION RED","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,22,6,28,6,16,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0016 0006 001C 0006 0010 0006 0C80","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,22,6,28,6,16,6,3188","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0016 0006 001C 0006 0010 0006 0C74"
+"FUNCTION YELLOW","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,22,6,28,6,28,6,3188","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0016 0006 001C 0006 001C 0006 0C74","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,22,6,28,6,28,6,3176","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0016 0006 001C 0006 001C 0006 0C68"
+"GUIDE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,28,6,10,6,28,6,10,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 001C 0006 000A 0006 001C 0006 000A 0006 0C86","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,28,6,10,6,28,6,10,6,3194","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 001C 0006 000A 0006 001C 0006 000A 0006 0C7A"
+"HELP","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,22,6,10,6,10,6,16,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0010 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,22,6,10,6,10,6,16,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0010 0006 0C8C"
+"INFO","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,28,6,28,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 001C 0006 001C 0006 0C86","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,28,6,28,6,3194","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 001C 0006 001C 0006 0C7A"
+"MENU FOXTEL","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,22,6,10,6,28,6,22,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 001C 0006 0016 0006 0C80","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,22,6,10,6,28,6,22,6,3188","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 001C 0006 0016 0006 0C74"
+"MENU MAIN","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,22,6,10,6,28,6,22,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 001C 0006 0016 0006 0C80","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,22,6,10,6,28,6,22,6,3188","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 001C 0006 0016 0006 0C74"
+"MENU SETUP","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,16,6,16,6,16,6,10,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0010 0006 000A 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,16,6,16,6,16,6,10,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0010 0006 0010 0006 0010 0006 000A 0006 0C8C"
+"PAUSE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,28,6,10,6,10,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 000A 0006 000A 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,28,6,10,6,10,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 000A 0006 000A 0006 0C8C"
+"PLANNER","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,28,6,28,6,16,6,10,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 001C 0006 001C 0006 0010 0006 000A 0006 0C80","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,28,6,28,6,16,6,10,6,3188","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 001C 0006 001C 0006 0010 0006 000A 0006 0C74"
+"PLAY","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,28,6,10,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 001C 0006 000A 0006 0C8C","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,22,6,28,6,10,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 001C 0006 000A 0006 0C80"
+"POWER TOGGLE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,10,6,28,6,10,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 001C 0006 000A 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,10,6,28,6,10,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 000A 0006 001C 0006 000A 0006 0C8C"
+"PREVIOUS CHANNEL","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,22,6,10,6,10,6,28,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 001C 0006 0C8C","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,22,6,10,6,10,6,28,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 001C 0006 0C80"
+"RECORD","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,28,6,16,6,28,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 0010 0006 001C 0006 0C80","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,28,6,16,6,28,6,3188","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 0010 0006 001C 0006 0C74"
+"REVERSE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,22,6,22,6,16,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 0016 0006 0010 0006 0C8C","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,22,6,22,6,16,6,3200","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 0016 0006 0016 0006 0010 0006 0C80"
+"STOP","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,28,6,10,6,16,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 000A 0006 0010 0006 0C93","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,28,6,10,6,16,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 000A 0006 0010 0006 0C86"
+"VIDEO ON DEMAND","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,28,6,16,6,16,6,16,6,3206","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 001C 0006 0010 0006 0010 0006 0010 0006 0C86","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,28,6,16,6,16,6,16,6,3194","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 001C 0006 0010 0006 0010 0006 0010 0006 0C7A"
+"VOLUME DOWN","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,16,6,10,6,16,6,3231","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 0010 0006 000A 0006 0010 0006 0C9F","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,16,6,10,6,16,6,3219","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 0010 0006 000A 0006 0010 0006 0C93"
+"VOLUME UP","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,16,6,10,6,10,6,3237","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 0010 0006 000A 0006 000A 0006 0CA5","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,16,6,10,6,10,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 0010 0006 000A 0006 000A 0006 0C99""
+PASTE_FROM_GLOBALCACHE_IR_DATABASE
+ },
+ })
+
+ class Channel
+ include JSON::Serializable
+
+ getter name : String
+ getter icon : String?
+ getter channel : String
+ getter ir_commands : Array(String)
+ end
+
+ @default_ir_set : String = "foxtel_iq2"
+ @default_ir_index : Int32 = 1
+ @globalcache : String = "DigitalIO_1"
+ @channels : Array(Channel) = [] of Channel
+ @channel_lookup : Hash(String, Channel) = {} of String => Channel
+ # e.g.
+ # {
+ # "abc_news": {
+ # name: "ABC News",
+ # channel: "https://url-to-svg-or-png",
+ # id: "abc_news",
+ # ir_commands: ["DIGIT 0","DIGIT 2","DIGIT 4"]
+ # },
+ # ...
+ # }
+
+ @ir_commands : Hash(String, Hash(String, String)) = {} of String => Hash(String, String)
+
+ # e.g.
+ # {
+ # "foxtel_iq2": {
+ # "ACTIVE": "sendir,1:1,1,36000,1,1,15,10...",
+ # "AV MODE": "sendir,1:1,1,36000,1,1,15,10...",
+ # ...
+ # },
+ # ...
+ # }
+
+ def on_update
+ @globalcache = setting(String, :globalcache_module)
+ @default_ir_set = setting(String, :default_ir_set)
+ @default_ir_index = setting(Int32, :default_ir_index)
+ @channels = setting(Array(Channel), :channel_details)
+
+ # Parse channels
+ updated_channel_lookup = {} of String => Channel
+ @channels.each do |channel|
+ updated_channel_lookup[channel.channel] = channel # the channel's ".channel" property is more like a ".id" (unique string). We are stuck calling it .channel because a frontend expects this naming as it was stipulated by the first IPTV driver (Exterity)
+ end
+ @channel_lookup = updated_channel_lookup
+
+ # Parse globalcache ir commands
+ globalcache_ir_sets = setting(Hash(String, String), :globalcache_ir_sets)
+ updated_ir_commands = {} of String => Hash(String, String)
+ globalcache_ir_sets.each do |device, all_commands|
+ updated_ir_commands[device] = parse_all_commands(all_commands)
+ end
+ @ir_commands = updated_ir_commands
+
+ # expose channels and IR commands
+ self[:channel_details] = @channels
+ self[:ir_commands] = @ir_commands
+ end
+
+ # Actually send the IR commands, via the globalcache
+ def channel(id : String, ir_set : String = "", ir_index : Int32 = 0)
+ # Workaround for "Error: @instance_vars are not yet allowed in metaclasses"
+ ir_set = @default_ir_set unless ir_set.presence
+ ir_index = @default_ir_index if ir_index == 0
+
+ # Determine which IR Commands need to be sent, look up their code and then transmit them in sequence
+ result = @channel_lookup[id].ir_commands.map do |ir_command_name|
+ system[@globalcache].ir(ir_index, @ir_commands[ir_set][ir_command_name]).get
+ sleep 500.milliseconds
+ end
+
+ # update current_channel if successful
+ self[:current_channel] = id
+ end
+
+ ###
+ # ## Functions to parse raw globalcache data into a Hash
+ ###
+ private def parse_all_commands(all_commands : String)
+ result = {} of String => String
+ all_commands.each_line do |line|
+ name_code = extract_name_and_ir_code_from_1_line(line)
+ result[name_code[0]] = name_code[1]
+ end
+ result
+ end
+
+ # Sample input String (including the quotes):
+ # "PAUSE","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,10,6,22,6,16,6,22,6,10,6,28,6,10,6,10,6,3225","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 000A 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 000A 0006 000A 0006 0C99","sendir,1:1,1,36000,1,1,15,10,6,10,6,22,6,10,6,16,6,22,6,22,6,10,6,10,6,22,6,22,6,16,6,22,6,10,6,28,6,10,6,10,6,3212","0000 0073 0000 0012 000F 000A 0006 000A 0006 0016 0006 000A 0006 0010 0006 0016 0006 0016 0006 000A 0006 000A 0006 0016 0006 0016 0006 0010 0006 0016 0006 000A 0006 001C 0006 000A 0006 000A 0006 0C8C"
+ # function, code1, hexcode1, code2, hexcode2
+ # we are interested in function name and code 1 only
+ private def extract_name_and_ir_code_from_1_line(name_and_all_codes : String)
+ function_code1_hex1_code2_hex2 = name_and_all_codes.split(%(","))
+ function_name = function_code1_hex1_code2_hex2[0].lchop('"')
+ code1 = function_code1_hex1_code2_hex2[1].lchop("sendir,1:1,")
+ [function_name, code1]
+ end
+end
diff --git a/drivers/global_cache/ir_tv_spec.cr b/drivers/global_cache/ir_tv_spec.cr
new file mode 100644
index 00000000000..07b646fb5d8
--- /dev/null
+++ b/drivers/global_cache/ir_tv_spec.cr
@@ -0,0 +1,45 @@
+require "placeos-driver/spec"
+
+class DigitalIO < DriverSpecs::MockDriver
+ @called : Int32 = 0
+
+ def ir(index : Int32, command : String)
+ @called += 1
+ self[:call_count] = @called
+ nil
+ end
+end
+
+DriverSpecs.mock_driver "GlobalCache::IRTV" do
+ system({
+ DigitalIO: {DigitalIO},
+ })
+
+ exec(:channel, "abc_news").get
+ status[:current_channel].should eq("abc_news")
+
+ system(:DigitalIO_1)[:call_count].should eq 3
+
+ status[:channel_details].should eq(
+ [
+ {
+ "name" => "ABC News",
+ "icon" => "https://os.place.tech/placeos.pwc.com.au/tv_icons/ABC_News_AU.svg",
+ "channel" => "abc_news",
+ "ir_commands" => ["DIGIT 0", "DIGIT 2", "DIGIT 4"],
+ },
+ {
+ "name" => "Channel Down",
+ "icon" => "https://static.thenounproject.com/png/1129950-200.png",
+ "channel" => "down",
+ "ir_commands" => ["CHANNEL DOWN"],
+ },
+ {
+ "name" => "Channel Up",
+ "icon" => "https://static.thenounproject.com/png/1129949-200.png",
+ "channel" => "up",
+ "ir_commands" => ["CHANNEL UP"],
+ },
+ ]
+ )
+end
diff --git a/drivers/global_cache/projector_screen.cr b/drivers/global_cache/projector_screen.cr
new file mode 100644
index 00000000000..a29ef0f3de8
--- /dev/null
+++ b/drivers/global_cache/projector_screen.cr
@@ -0,0 +1,60 @@
+require "placeos-driver"
+
+# Ideally, this driver should be made compatible with these interfaces in the future
+# require "placeos-driver/interface/moveable"
+# require "placeos-driver/interface/stoppable"
+
+class GlobalCache::ProjectorScreen < PlaceOS::Driver
+ # include Interface::Moveable
+ # include Interface::Stoppable
+
+ # Discovery Information
+ generic_name :Screen
+ descriptive_name "Projector Screen via Global Cache Relays"
+
+ default_settings({
+ globalcache_module: "DigitalIO_1",
+ globalcache_relay_method: "pulse",
+ # OR globalcache_relay_method: "hold"
+ # To Do: support "hold" including determine better Settings format
+ globalcache_relay_index_down: 0,
+ globalcache_relay_index_up: 1,
+ globalcache_relay_pulse_milliseconds: 1000,
+ })
+
+ @globalcache_module : String = "DigitalIO_1"
+ @relay_index_down : Int32 = 0
+ @relay_index_up : Int32 = 1
+ @relay_method : String = "pulse"
+ @relay_pulse_milliseconds : Int32 = 1000
+
+ def on_update
+ @globalcache_module = setting(String, :globalcache_module) || "DigitalIO_1"
+ @relay_method = setting(String, :globalcache_relay_method) || "pulse"
+ @relay_index_down = setting(Int32, :globalcache_relay_index_down) || 0
+ @relay_index_up = setting(Int32, :globalcache_relay_index_up) || 1
+ @relay_pulse_milliseconds = setting(Int32, :globalcache_relay_pulse_milliseconds) || 1000
+ end
+
+ def up
+ case @relay_method
+ when "pulse"
+ system[@globalcache_module].pulse(@relay_pulse_milliseconds, @relay_index_up)
+ when "hold"
+ logger.error { "Not yet implemented by this driver." }
+ else
+ logger.error { "Invalid globalcache_relay_method setting \"#{@relay_method}}\". Must be \"pulse\" or \"hold\" " }
+ end
+ end
+
+ def down
+ case @relay_method
+ when "pulse"
+ system[@globalcache_module].pulse(@relay_pulse_milliseconds, @relay_index_down)
+ when "hold"
+ logger.error { "Not yet implemented by this driver." }
+ else
+ logger.error { "Invalid globalcache_relay_method setting \"#{@relay_method}}\". Must be \"pulse\" or \"hold\" " }
+ end
+ end
+end
diff --git a/drivers/global_cache/projector_screen_spec.cr b/drivers/global_cache/projector_screen_spec.cr
new file mode 100644
index 00000000000..52a253c2a7b
--- /dev/null
+++ b/drivers/global_cache/projector_screen_spec.cr
@@ -0,0 +1,12 @@
+require "placeos-driver/spec"
+
+# To do: Actually write the spec
+
+class Screen < DriverSpecs::MockDriver
+end
+
+DriverSpecs.mock_driver "GlobalCache::ProjectorScreen" do
+ system({
+ Screen: {Screen},
+ })
+end
diff --git a/drivers/gobright/api.cr b/drivers/gobright/api.cr
new file mode 100644
index 00000000000..4dbdeeb455b
--- /dev/null
+++ b/drivers/gobright/api.cr
@@ -0,0 +1,173 @@
+require "placeos-driver"
+require "./models"
+
+# documentation: https://t1b.gobright.cloud/swagger/index.html?url=/swagger/v1/swagger.json#/
+
+class GoBright::API < PlaceOS::Driver
+ descriptive_name "GoBright API Gateway"
+ generic_name :GoBright
+ uri_base "https://example.gobright.cloud"
+
+ default_settings({
+ api_key: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ user_agent: "PlaceOS",
+ })
+
+ @api_key : String = ""
+ @user_agent : String = "PlaceOS"
+
+ def on_update
+ @api_key = setting(String, :api_key)
+ @user_agent = setting?(String, :user_agent) || "PlaceOS"
+ end
+
+ @[Security(Level::Support)]
+ def fetch(location : String) : String
+ next_page = location
+ append = location.includes?('?') ? '&' : '?'
+
+ String.build do |str|
+ str << "["
+ loop do
+ logger.debug { "requesting: #{next_page}" }
+ response = get(next_page, headers: HTTP::Headers{
+ "Authorization" => get_token,
+ "User-Agent" => @user_agent,
+ "Content-Type" => "application/json",
+ })
+
+ @expires = 1.minute.ago if response.status_code == 401
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ # extract the response data
+ payload = begin
+ Response.from_json response.body
+ rescue error : JSON::SerializableError
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise error
+ end
+
+ if data = payload.data || payload.items
+ str << data.strip[1..-2]
+ end
+
+ # perform pagination
+ continuation = payload.paging.try &.token
+ total_items = payload.paging.try &.total
+
+ if continuation
+ next_page = "#{location}#{append}continuationToken=#{continuation}"
+ elsif total_items
+ uri = URI.parse next_page
+ params = uri.query_params
+ skip = params["pagingSkip"]?.try(&.to_i) || 0
+ taking = params["pagingTake"]?.try(&.to_i) || 100
+
+ # skip once at the end
+ break if (skip + taking) >= total_items
+
+ params["pagingSkip"] = (skip + taking).to_s
+ uri.query_params = params
+ next_page = uri.to_s
+ else
+ break
+ end
+
+ str << ","
+ end
+ str << "]"
+ end
+ end
+
+ # the list of buildings, levels, areas etc
+ def locations
+ Array(Location).from_json fetch("/api/v2.0/locations?pagingTake=100")
+ end
+
+ # a list of spaces in the locations. rooms, desks and parking
+ def spaces(location : String? = nil, types : SpaceType | Array(SpaceType)? = nil)
+ params = URI::Params.build do |form|
+ form.add "pagingTake", "100"
+ form.add "LocationId", location.to_s unless location.presence.nil?
+ if types
+ types = types.is_a?(Array) ? types : [types]
+ types.each do |type|
+ form.add "SpaceTypes", type.value.to_s
+ end
+ end
+ end
+
+ Array(Space).from_json fetch("/api/v2.0/spaces?#{params}")
+ end
+
+ # Paged list of state per space, filtered by location/spacetype
+ def spaces_state(location : String? = nil, types : SpaceType | Array(SpaceType)? = nil)
+ params = URI::Params.build do |form|
+ form.add "pagingTake", "100"
+ form.add "filterLocationId", location.to_s unless location.presence.nil?
+ if types
+ types = types.is_a?(Array) ? types : [types]
+ types.each do |type|
+ form.add "filterSpaceType", type.value.to_s
+ end
+ end
+ end
+
+ Array(Space).from_json fetch("/api/v2.0/spaces/state?#{params}")
+ end
+
+ # the list of booking occurances in the time period specified
+ def bookings(starting : Int64, ending : Int64, location_id : String | Array(String)? = nil, space_id : String | Array(String)? = nil)
+ params = URI::Params.build do |form|
+ form.add "pagingTake", "1000"
+ form.add "include", "spaces,organizer,attendees"
+ form.add "start", Time.unix(starting).to_rfc3339
+ form.add "end", Time.unix(ending).to_rfc3339
+ if location_id
+ location_ids = location_id.is_a?(Array) ? location_id : [location_id]
+ location_ids.each do |loc|
+ form.add "locationIds", loc
+ end
+ end
+ if space_id
+ space_ids = space_id.is_a?(Array) ? space_id : [space_id]
+ space_ids.each do |space|
+ form.add "spaceIds", space
+ end
+ end
+ end
+ Array(Occurrence).from_json fetch("/api/v2.0/bookings/occurrences?#{params}")
+ end
+
+ # the occupancy status of the spaces
+ def live_occupancy(location : String, type : SpaceType? = nil)
+ params = URI::Params.build do |form|
+ form.add "pagingTake", "100"
+ form.add "filterLocationId", location
+ form.add "filterSpaceType", type.value.to_s if type
+ end
+
+ Array(Occupancy).from_json fetch("/api/v2.0/occupancy/space/live?#{params}")
+ end
+
+ @expires : Time = Time.utc
+ @token : String = ""
+
+ protected def get_token
+ return @token if 1.minute.from_now < @expires
+
+ response = post("/token",
+ headers: {
+ "Content-Type" => "application/x-www-form-urlencoded",
+ },
+ body: "grant_type=apikey&apikey=#{@api_key}"
+ )
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ token = AccessToken.from_json response.body
+ @expires = token.expires_at
+ @token = "Bearer #{token.access_token}"
+ end
+end
diff --git a/drivers/gobright/api_spec.cr b/drivers/gobright/api_spec.cr
new file mode 100644
index 00000000000..e77a0ee6ff0
--- /dev/null
+++ b/drivers/gobright/api_spec.cr
@@ -0,0 +1,65 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "GoBright::API" do
+ resp = exec :locations
+
+ expect_http_request do |request, response|
+ case request.path
+ when "/token"
+ response.status_code = 200
+ response << %({
+ "access_token": "1234",
+ "expires_in": 300
+ })
+ else
+ response.status_code = 500
+ response << "expected token request"
+ end
+ end
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/v2.0/locations?pagingTake=100"
+ response.status_code = 200
+ response << %({
+ "data": [{
+ "id": "loc-1234",
+ "name": "Level 2"
+ }],
+ "paging": {
+ "continuationToken": "continue123"
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected locations request"
+ end
+ end
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/v2.0/locations?pagingTake=100&continuationToken=continue123"
+ response.status_code = 200
+ response << %({
+ "data": [{
+ "id": "loc-5678",
+ "name": "Level 3"
+ }]
+ })
+ else
+ response.status_code = 500
+ response << "expected second locations request"
+ end
+ end
+
+ resp.get.should eq([
+ {
+ "id" => "loc-1234",
+ "name" => "Level 2",
+ },
+ {
+ "id" => "loc-5678",
+ "name" => "Level 3",
+ },
+ ])
+end
diff --git a/drivers/gobright/location_service.cr b/drivers/gobright/location_service.cr
new file mode 100644
index 00000000000..06c8f032224
--- /dev/null
+++ b/drivers/gobright/location_service.cr
@@ -0,0 +1,246 @@
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "./models"
+
+class GoBright::LocationService < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "GoBright Location Service"
+ generic_name :GoBrightLocationService
+ description %(collects GoBright data for visualising on a map)
+
+ accessor staff_api : StaffAPI_1
+ accessor gobright : GoBright_1
+ accessor area_management : AreaManagement_1
+
+ default_settings({
+ gobright_floor_mappings: {
+ "placeos_zone_id": {
+ location_id: "level",
+ name: "friendly name for documentation",
+ },
+ },
+ return_empty_spaces: true,
+ desk_space_types: ["desk"],
+ space_cache_cron: "0 5 * * *",
+ })
+
+ # place_zone_id => gobright_location_id
+ @floor_mappings : Hash(String, String) = {} of String => String
+ @zone_filter : Array(String) = [] of String
+ @desk_space_types : Array(SpaceType) = [SpaceType::Desk]
+ @default_space_type : SpaceType?
+
+ struct Mapping
+ include JSON::Serializable
+ getter location_id : String
+ end
+
+ struct LevelCapacity
+ include JSON::Serializable
+
+ getter desk_mappings : Hash(String, String)
+ end
+
+ def on_update
+ @return_empty_spaces = setting?(Bool, :return_empty_spaces) || false
+ @desk_space_types = setting?(Array(SpaceType), :desk_space_types) || [SpaceType::Desk] # By default the setting will not be present (so will be nil, which should query all Space Types)
+ @default_space_type = setting?(SpaceType, :default_space_type) || nil
+ @floor_mappings = setting(Hash(String, Mapping), :gobright_floor_mappings).transform_values(&.location_id)
+ @zone_filter = @floor_mappings.keys
+ @building_id = nil
+
+ timezone = Time::Location.load(system.timezone.presence || "Australia/Sydney")
+ schedule.clear
+ schedule.cron(setting?(String, :space_cache_cron) || "0 5 * * *", timezone) { cache_space_details }
+ schedule.every(10.minutes) { @level_details = nil }
+ end
+
+ getter level_details : Hash(String, LevelCapacity) do
+ Hash(String, LevelCapacity).from_json area_management.level_details.get.to_json
+ end
+
+ # Finds the building ID for the current location services object
+ def get_building_id
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ raise error
+ end
+
+ getter building_id : String { get_building_id }
+ getter space_details : Hash(String, Space) { cache_space_details }
+
+ def cache_space_details
+ space_details = {} of String => Space
+ Array(Space).from_json(gobright.spaces.get.to_json).each do |space|
+ space_details[space.id] = space
+ end
+ @space_details = space_details
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+
+ # zone_id => bookings
+ @cached_booking_data = {} of String => Hash(String, Occurrence)
+
+ # NOTE:: we could keep track of current bookings and then use that information to assign ownership of a desk
+ # if the desks are being booked via the check-in/check-out
+ # this would allow us to locate
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "searching for user #{email}" }
+ matches = [] of Occurrence
+
+ # find bookings with the user
+ @cached_booking_data.each do |zone_id, lookup|
+ lookup.each_value do |booking|
+ next unless booking.organizer.try(&.email_address) == email
+ matches << booking
+ end
+ end
+
+ # return the data in the correct format
+ matches.compact_map do |booking|
+ zone_id = booking.zone_id
+ map_booking(booking, booking.matched_space, zone_id, level_details[zone_id]?.try(&.desk_mappings))
+ end
+ end
+
+ NO_MATCHES = [] of String
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ return NO_MATCHES unless email
+ logger.debug { "checking if any bookings for email: #{email}" }
+ matches = [] of String
+ @cached_booking_data.each do |zone_id, lookup|
+ lookup.each_value do |booking|
+ matches << "gobright-#{booking.id}" if booking.organizer.try(&.email_address) == email
+ end
+ end
+ matches
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "checking ownership of: #{mac_address}" }
+ return unless mac_address.starts_with?("gobright-")
+
+ id = mac_address.split("gobright-")[1]
+ @cached_booking_data.each do |zone_id, lookup|
+ if booking = lookup[id]?
+ return {
+ location: "booking",
+ assigned_to: booking.organizer.try(&.email_address) || booking.attendees.first.email_address.as(String),
+ mac_address: mac_address,
+ }
+ end
+ end
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+
+ if building_id == zone_id
+ return @zone_filter.flat_map { |level_id| device_locations(level_id, location) }
+ end
+ return [] of Nil unless @zone_filter.includes?(zone_id)
+ return [] of Nil if location && !location.in?({"desk", "area", "booking"})
+
+ # grab all the spaces for the current zone_id
+ gobright_location_id = @floor_mappings[zone_id]
+ spaces = {} of String => Space
+ space_details.each_value do |space|
+ next unless space.location_id == gobright_location_id
+ spaces[space.id] = space.dup
+ end
+
+ # mark if the space is occupied
+ occupancy = Array(Occupancy).from_json(gobright.live_occupancy(gobright_location_id, @default_space_type).get.to_json)
+ occupancy.each do |details|
+ space = spaces[details.id]?
+ next unless space
+
+ space.occupied = details.occupied? || false
+ end
+
+ # build the response
+ desk_types = @desk_space_types
+ occupancy_locs = spaces.values.compact_map do |space|
+ loc_type = space.type.in?(desk_types) ? "desk" : "area"
+ next if location.presence && location != loc_type
+
+ if (occupied = space.occupied?) || @return_empty_spaces
+ {
+ location: loc_type,
+ at_location: occupied ? 1 : 0,
+ map_id: space.name,
+ level: zone_id,
+ building: building_id,
+ capacity: space.capacity || 1,
+
+ gobright_location_id: gobright_location_id,
+ gobright_space_name: space.name,
+ gobright_space_type: space.type,
+ gobright_space_id: space.id,
+ }
+ end
+ end
+
+ return spaces if location && location != "booking"
+
+ # mark if the desk is booked
+ bookings = Array(Occurrence).from_json(gobright.bookings(1.minutes.ago.to_unix, 10.minutes.from_now.to_unix, gobright_location_id).get.to_json)
+
+ lookup = {} of String => Occurrence
+ booking_locs = bookings.compact_map do |occurrence|
+ space = nil
+ occurrence.spaces.each do |details|
+ space = spaces[details.id]?
+ break if space
+ end
+
+ next unless space
+ occurrence.zone_id = zone_id
+ occurrence.matched_space = space
+ lookup[occurrence.id] = occurrence
+ map_booking(occurrence, space, zone_id)
+ end
+
+ @cached_booking_data[zone_id] = lookup
+
+ # merge occupancy and spaces
+ booking_locs.map(&.as(typeof(booking_locs[0]) | typeof(occupancy_locs[0]))) + occupancy_locs.map(&.as(typeof(booking_locs[0]) | typeof(occupancy_locs[0])))
+ end
+
+ protected def map_booking(occurrence, space, zone_id, mappings = nil)
+ owner = occurrence.organizer || occurrence.attendees.first?
+ starting = occurrence.start_date.to_unix
+ ending = occurrence.end_date.to_unix
+ space_name = space.name
+ map_id = mappings ? (mappings[space_name]? || space_name) : space_name
+
+ {
+ location: :booking,
+ type: "desk",
+ checked_in: !!occurrence.confirmation_active,
+
+ # We supply map_id here as this will be mapped to the correct id
+ # and the frontend preferences map_id over asset_id
+ asset_id: space_name,
+ map_id: map_id,
+
+ booking_id: occurrence.id,
+ building: building_id,
+ level: zone_id,
+ ends_at: ending,
+ started_at: starting,
+ duration: ending - starting,
+ mac: "gobright-#{occurrence.id}",
+ staff_email: owner.try &.email_address,
+ staff_name: owner.try &.name,
+ }
+ end
+end
diff --git a/drivers/gobright/location_service_spec.cr b/drivers/gobright/location_service_spec.cr
new file mode 100644
index 00000000000..559d5059e2a
--- /dev/null
+++ b/drivers/gobright/location_service_spec.cr
@@ -0,0 +1,91 @@
+require "placeos-driver/spec"
+require "./models"
+
+DriverSpecs.mock_driver "GoBright::LocationService" do
+ system({
+ GoBright: {GoBrightMock},
+ StaffAPI: {StaffAPIMock},
+ AreaManagement: {AreaManagementMock},
+ })
+
+ exec(:device_locations, "placeos_zone_id").get.should eq([
+ {
+ "location" => "desk",
+ "at_location" => 0,
+ "map_id" => "desk-1",
+ "level" => "placeos_zone_id",
+ "building" => "zone-1234",
+ "capacity" => 1,
+ "gobright_location_id" => "level",
+ "gobright_space_name" => "desk-1",
+ "gobright_space_type" => "desk",
+ "gobright_space_id" => "space-1234",
+ }, {
+ "location" => "area",
+ "at_location" => 1,
+ "map_id" => "room-1",
+ "level" => "placeos_zone_id",
+ "building" => "zone-1234",
+ "capacity" => 1,
+ "gobright_location_id" => "level",
+ "gobright_space_name" => "room-1",
+ "gobright_space_type" => "room",
+ "gobright_space_id" => "space-4567",
+ },
+ ])
+end
+
+# :nodoc:
+class GoBrightMock < DriverSpecs::MockDriver
+ def spaces(location : String? = nil, types : GoBright::SpaceType | Array(GoBright::SpaceType)? = nil)
+ [
+ {
+ id: "space-1234",
+ locationId: "level",
+ name: "desk-1",
+ type: 1,
+ },
+ {
+ id: "space-4567",
+ locationId: "level",
+ name: "room-1",
+ type: 0,
+ },
+ ]
+ end
+
+ def live_occupancy(location : String? = nil, type : GoBright::SpaceType? = nil)
+ [
+ {
+ spaceId: "space-1234",
+ occupationDetected: false,
+ },
+ {
+ spaceId: "space-4567",
+ occupationDetected: true,
+ },
+ ]
+ end
+
+ def bookings(starting : Int64, ending : Int64, location_id : String | Array(String)? = nil, space_id : String | Array(String)? = nil)
+ [] of Nil
+ end
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def zones(tags : String)
+ logger.info { "zones requested from staff api" }
+ raise "unexpected tags, expected building, got: #{tags}" unless tags == "building"
+
+ # NOTE:: zone-1234 is the default zone used in the spec runner
+ [{id: "zone-1234"}]
+ end
+end
+
+# :nodoc:
+class AreaManagementMock < DriverSpecs::MockDriver
+ def level_details
+ {} of String => String
+ end
+end
diff --git a/drivers/gobright/models.cr b/drivers/gobright/models.cr
new file mode 100644
index 00000000000..3edf2a52f54
--- /dev/null
+++ b/drivers/gobright/models.cr
@@ -0,0 +1,280 @@
+require "json"
+
+module GoBright
+ struct Metadata
+ include JSON::Serializable
+
+ @[JSON::Field(key: "statusCode")]
+ getter status_code : Int32?
+
+ @[JSON::Field(key: "message")]
+ getter message : String?
+
+ @[JSON::Field(key: "validationErrors")]
+ getter validation_errors : Array(Hash(String, String))?
+ end
+
+ struct Paging
+ include JSON::Serializable
+
+ @[JSON::Field(key: "totalCount")]
+ getter total : Int32?
+
+ @[JSON::Field(key: "continuationToken")]
+ getter token : String?
+ end
+
+ struct Response
+ include JSON::Serializable
+
+ @[JSON::Field(key: "meta")]
+ getter metadata : Metadata?
+
+ @[JSON::Field(converter: String::RawConverter)]
+ getter data : String?
+
+ @[JSON::Field(converter: String::RawConverter)]
+ getter items : String?
+
+ @[JSON::Field(key: "paging")]
+ getter paging : Paging?
+ end
+
+ # pagingTake == 100
+ # include=spaces,attendees
+ #
+ # code 429 - wait for `RateLimit-Reset` time before making another request
+ # RateLimit-Limit header is total count
+ # RateLimit-Remaining header is requests remaining
+ # RateLimit-Reset header is seconds until reset
+
+ struct DeskPeriod
+ include JSON::Serializable
+
+ @[JSON::Field(key: "mode")]
+ getter mode : Int64?
+
+ @[JSON::Field(key: "workingMode")]
+ getter working_mode : Int64?
+
+ @[JSON::Field(key: "startOfDay")]
+ getter start_of_day : String?
+
+ @[JSON::Field(key: "middleOfDay")]
+ getter middle_of_day : String?
+
+ @[JSON::Field(key: "endOfDay")]
+ getter end_of_day : String?
+ end
+
+ struct ParkingPeriod
+ include JSON::Serializable
+
+ @[JSON::Field(key: "mode")]
+ getter mode : Int64?
+
+ @[JSON::Field(key: "workingMode")]
+ getter working_mode : Int64?
+
+ @[JSON::Field(key: "startOfDay")]
+ getter start_of_day : String?
+
+ @[JSON::Field(key: "middleOfDay")]
+ getter middle_of_day : String?
+
+ @[JSON::Field(key: "endOfDay")]
+ getter end_of_day : String?
+ end
+
+ struct Amenity
+ include JSON::Serializable
+
+ getter id : String
+ getter description : String?
+ getter icon : String?
+ getter order : Int32?
+
+ @[JSON::Field(key: "availableForRoom")]
+ getter available_for_room : Bool?
+
+ @[JSON::Field(key: "availableForDesk")]
+ getter available_for_desk : Bool?
+
+ @[JSON::Field(key: "availableForParking")]
+ getter available_for_parking : Bool?
+ end
+
+ struct Location
+ include JSON::Serializable
+
+ getter id : String
+
+ @[JSON::Field(key: "oldId")]
+ getter old_id : Int64?
+
+ @[JSON::Field(key: "parentId")]
+ getter parent_id : String?
+
+ getter name : String
+
+ @[JSON::Field(key: "nameIndented")]
+ getter name_indented : String?
+
+ @[JSON::Field(key: "order")]
+ getter order : Int64?
+
+ @[JSON::Field(key: "level")]
+ getter level : Int64?
+
+ @[JSON::Field(key: "fullPath")]
+ getter full_path : String?
+
+ @[JSON::Field(key: "ianaTimeZone")]
+ getter iana_time_zone : String?
+
+ @[JSON::Field(key: "visitorKioskEnabled")]
+ getter visitor_kiosk_enabled : Bool?
+
+ @[JSON::Field(key: "imageId")]
+ getter image_id : String?
+
+ @[JSON::Field(key: "bookingDeskPeriods")]
+ getter booking_desk_periods : DeskPeriod?
+
+ @[JSON::Field(key: "bookingParkingPeriods")]
+ getter booking_parking_periods : ParkingPeriod?
+ end
+
+ enum SpaceType
+ Room = 0
+ Desk = 1
+ CombinedRoom = 2
+ Parking = 3
+ end
+
+ class Space
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String
+ getter amenities : Array(Amenity) = [] of Amenity
+
+ @[JSON::Field(converter: Enum::ValueConverter(::GoBright::SpaceType))]
+ getter type : SpaceType?
+
+ @[JSON::Field(key: "locationId")]
+ getter location_id : String?
+
+ @[JSON::Field(key: "ianaTimeZone")]
+ getter iana_time_zone : String?
+ getter capacity : Int64?
+
+ @[JSON::Field(key: "integrationExternalId")]
+ getter integration_external_id : String?
+
+ @[JSON::Field(key: "isBookable")]
+ getter is_bookable : Bool?
+
+ @[JSON::Field(ignore: true)]
+ property? occupied : Bool = false
+ end
+
+ struct Occupancy
+ include JSON::Serializable
+
+ @[JSON::Field(key: "spaceId")]
+ getter id : String?
+
+ @[JSON::Field(key: "occupationDetected")]
+ getter? occupied : Bool?
+ end
+
+ struct AccessToken
+ include JSON::Serializable
+
+ getter access_token : String
+ getter expires_in : Int32
+
+ def expires_at : Time
+ expires_in.seconds.from_now
+ end
+ end
+
+ enum ApprovalState
+ Inactive = 0
+ NeedsApproval = 1
+ Approved = 2
+ Rejected = 3
+ end
+
+ enum BookingType
+ BookingOnRoom = 0
+ ServiceOnly = 1
+ BookingOnDesk = 2
+ BookingAsTeam = 3
+ BookingOnParking = 4
+ end
+
+ struct Attendee
+ include JSON::Serializable
+
+ @[JSON::Field(key: "emailAddress")]
+ property email_address : String?
+ property name : String?
+ end
+
+ struct Occurrence
+ include JSON::Serializable
+
+ property id : String
+
+ @[JSON::Field(key: "composedId")]
+ property composed_id : String
+
+ @[JSON::Field(key: "bookingType", converter: Enum::ValueConverter(::GoBright::BookingType))]
+ property booking_type : BookingType
+
+ @[JSON::Field(key: "intentionType")]
+ property intention_type : Int32?
+
+ @[JSON::Field(key: "recurrenceType")]
+ property recurrence_type : Int32?
+
+ @[JSON::Field(key: "approvalState", converter: Enum::ValueConverter(::GoBright::ApprovalState))]
+ property approval_state : ApprovalState?
+
+ @[JSON::Field(key: "isAnonymouslyBooked")]
+ property is_anonymously_booked : Bool?
+
+ @[JSON::Field(key: "licensePlate")]
+ property license_plate : String?
+
+ @[JSON::Field(key: "start")]
+ property start_date : Time
+
+ @[JSON::Field(key: "end")]
+ property end_date : Time
+ property subject : String?
+ property organizer : Attendee?
+ property spaces : Array(Space) = [] of Space
+ property attendees : Array(Attendee) = [] of Attendee
+
+ @[JSON::Field(key: "attendeeAmount")]
+ property attendee_amount : Int32?
+
+ @[JSON::Field(key: "confirmationActive")]
+ property confirmation_active : Bool?
+
+ @[JSON::Field(key: "confirmationWindowStart")]
+ property confirmation_window_start : String?
+
+ @[JSON::Field(key: "confirmationWindowEnd")]
+ property confirmation_window_end : String?
+
+ @[JSON::Field(ignore: true)]
+ property! zone_id : String
+
+ @[JSON::Field(ignore: true)]
+ property! matched_space : Space
+ end
+end
diff --git a/drivers/google/workspace_api.cr b/drivers/google/workspace_api.cr
new file mode 100644
index 00000000000..c3316b5f4b7
--- /dev/null
+++ b/drivers/google/workspace_api.cr
@@ -0,0 +1,32 @@
+require "../place/calendar_common"
+
+class Place::WorkspaceAPI < PlaceOS::Driver
+ include Place::CalendarCommon
+
+ # update to trigger build
+ descriptive_name "Google Workplace APIs"
+ generic_name :Calendar
+
+ uri_base "https://staff.app.api.com"
+
+ default_settings({
+ calendar_service_account: "service_account@email.address",
+ calendar_config: {
+ scopes: ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/admin.directory.user.readonly"],
+ domain: "primary.domain.com",
+ sub: "default.service.account@google.com",
+ issuer: "placeos@organisation.iam.gserviceaccount.com",
+ signing_key: "PEM encoded private key",
+ },
+
+ # google can handle about 10 requests a second
+ rate_limit: 9,
+
+ # defaults to calendar_service_account if not configured
+ mailer_from: "email_or_office_userPrincipalName",
+ email_templates: {visitor: {checkin: {
+ subject: "%{name} has arrived",
+ text: "for your meeting at %{time}",
+ }}},
+ })
+end
diff --git a/drivers/helvar/helvar_net_protocol.md b/drivers/helvar/helvar_net_protocol.md
new file mode 100644
index 00000000000..6ec61f6a72a
--- /dev/null
+++ b/drivers/helvar/helvar_net_protocol.md
@@ -0,0 +1,95 @@
+
+# Helvar.net Protocol
+
+Reference: https://aca.im/driver_docs/Helvar/HelvarNet-Overview.pdf
+
+For use with Helvar to DALI routers
+
+* TCP port: 50000
+* UDP port: 50001
+
+
+## Addressing
+
+The Helvar lighting router system would consist of a number of routers (910 or 920) that enable
+connection to a variety of different inputs and outputs using a different data buses.
+
+The backbone structure of the system uses Ethernet Cat 5 cabling & the TCP/IP protocol. As
+such each system (or workgroup) is a cluster of routers. The cluster (3rd Octet in IP addressing)
+forms the first part of the unique device address
+
+Each router within the system will then have a unique IP address with the 4th octet providing the
+unique router number. This number forms the second digit of the unique device address.
+
+The cluster.router is then followed by a subnet. The subnet refers to the data bus on which
+inputs or output devices are connected. Depending on the router type (910 or 920) there are 2
+or 4 subnets available. In both case’s subnet 1 & 2 use the DALI protocol. For the 920 you have
+additional subnets 3 (using S-Dim) and 4 (DMX).
+
+Following cluster.router.subnet is then the device address. This number is limited by the type of
+subnet to which the device is connected and in the case of output devices completes the device
+address.
+
+For input devices there is a further sub-device which will refer to a particular property of that
+input device for example a control panel (device) would have a number of buttons (sub-device).
+
+So a full address would be written:-
+
+* Cluster (1..253), Router (1..254), Subnet (1..4), Device (1..255), Subdevice (1..16)
+* cluster.router.subnet.address for output devices
+* cluster.router.subnet.address for input devices
+* cluster.router.subnet.address.sub-address for input sub-devices
+
+### Address Structure
+
+* Cluster = the 3rd octet of the IP address range used
+* Router = the 4th octet of the IP address of that particular router
+* Subnet = the data bus on which devices are connected (Dali 1 = 1, Dali 2 = 2, S-Dim = 3, DMX = 4)
+* Address = the device address, dependant on the data bus (Dali = 1-64, S-Dim = 1-252, DMX = 1-512)
+* Sub-address = the sub-device of the device (button, sensor, input etc.)
+
+
+## Commands
+
+* `>V:` is the command prefix (`>V:2` represents the protocol version 2)
+* `C:` is the command type
+ * `11` == select scene
+ * `13` == direct level group address
+ * `14` == direct level short address
+ * `109` == query selected scene
+* `G:` specifies the lighting group
+* `S:` specifices the lighting scene
+* `F:` specifies the fade time (in 1/100ths of a second. So a fade of 900 is 9 seconds)
+* `L:` specifies the level (between 1 and 100)
+* `@` specifies the short address (looks like: 1.2.1.1)
+* all commands end with a `#`
+
+
+### Example Commands
+
+* Direct level, short address: `>V:1,C:14,L:{0},F:{1},@{2}#`
+ * {0} == level, {1} == fade_time, {2} == address
+* Direct level, group address: `>V:1,C:13,G:{0},L:{1},F:{2}#`
+ * {0} == address, {1} == level, {2} == fade_time
+* Keep socket alive: `>V:1,C:14,L:0,F:9000,@65#`
+ * Write to dummy address to keep socket alive
+
+
+### Example Query
+
+* `>V:2,C:109,G:17#` query Group 17 as to which scene it is currently in
+ * responds with: `?V:2,C:109,G:17=14#`
+ * i.e. Group 17 is in scene 14
+
+### Example Error
+
+* `>V:1,C:104,@:2.2.1.1#` query device type
+ * responds with: `!V:1,C:104,@:2.2.1.1=11#`
+ * i.e. error 11, device does not exist
+
+
+References:
+
+* https://github.com/tkln/HelvarNet/blob/master/helvar.py
+* https://github.com/houmio/houmio-driver-helvar-router/blob/master/src/driver.coffee
+
diff --git a/drivers/helvar/net.cr b/drivers/helvar/net.cr
new file mode 100644
index 00000000000..3578c321c48
--- /dev/null
+++ b/drivers/helvar/net.cr
@@ -0,0 +1,345 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+
+# Documentation: https://aca.im/driver_docs/Helvar/HelvarNet-Overview.pdf
+
+class Helvar::Net < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ # Discovery Information
+ tcp_port 50000
+ descriptive_name "Helvar Net Lighting Gateway"
+ generic_name :Lighting
+
+ default_settings({
+ version: 2,
+ ignore_blocks: true,
+ poll_group: nil,
+ })
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("#")
+ on_update
+ end
+
+ def on_update
+ @version = setting?(Int32, :version) || 2
+ @ignore_blocks = setting?(Bool, :ignore_blocks) || true
+ @poll_group = setting?(Int32, :poll_group)
+ end
+
+ @poll_group : Int32?
+
+ def connected
+ schedule.every(40.seconds) do
+ logger.debug { "-- Polling Helvar" }
+ if poll_group = @poll_group
+ get_current_preset poll_group
+ else
+ query_software_version
+ end
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def lighting(group : Int32, state : Bool)
+ level = state ? 100 : 0
+ light_level(group, level)
+ end
+
+ def light_level(group : Int32, level : Int32, fade : Int32 = 1000)
+ fade = (fade / 10).to_i
+ self["area#{group}_level"] = level
+ group_level(group: group, level: level, fade: fade, name: "group_level#{group}")
+ end
+
+ def trigger(group : Int32, scene : Int32, fade : Int32 = 1000)
+ fade = (fade / 10).to_i
+ self["area#{group}"] = scene
+ group_scene(group: group, scene: scene, fade: fade, name: "group_scene#{group}")
+ end
+
+ def get_current_preset(group : Int32)
+ query_last_scene(group: group, name: "query_scene#{group}")
+ end
+
+ def query_scene_levels(group : Int32)
+ query_scene_info(group: group, name: "query_scene#{group}_info")
+ end
+
+ CMD_METHODS = {
+ group_scene: 11,
+ device_scene: 12,
+ group_level: 13,
+ device_level: 14,
+ group_proportion: 15,
+ device_proportion: 16,
+ group_modify_proportion: 17,
+ device_modify_proportion: 18,
+ group_emergency_test: 19,
+ device_emergency_test: 20,
+ group_emergency_duration_test: 21,
+ device_emergency_duration_test: 22,
+ group_emergency_stop: 23,
+ device_emergency_stop: 24,
+
+ # Query commands
+ query_lamp_hours: 70,
+ query_ballast_hours: 71,
+ query_max_voltage: 72,
+ query_min_voltage: 73,
+ query_max_temp: 74,
+ query_min_temp: 75,
+ query_device_types_with_addresses: 100,
+ query_clusters: 101,
+ query_routers: 102,
+ query_LSIB: 103,
+ query_device_type: 104,
+ query_description_group: 105,
+ query_description_device: 106,
+ query_workgroup_name: 107, # must use UDP
+ query_workgroup_membership: 108,
+ query_last_scene: 109,
+ query_device_state: 110,
+ query_device_disabled: 111,
+ query_lamp_failure: 112,
+ query_device_faulty: 113,
+ query_missing: 114,
+ query_emergency_battery_failure: 129,
+ query_measurement: 150,
+ query_inputs: 151,
+ query_load: 152,
+ query_power_consumption: 160,
+ query_group_power_consumption: 161,
+ query_group: 164,
+ query_groups: 165,
+ query_scene_names: 166,
+ query_scene_info: 167,
+ query_emergency_func_test_time: 170,
+ query_emergency_func_test_state: 171,
+ query_emergency_duration_time: 172,
+ query_emergency_duration_state: 173,
+ query_emergency_battery_charge: 174,
+ query_emergency_battery_time: 175,
+ query_emergency_total_lamp_time: 176,
+ query_time: 185,
+ query_longitude: 186,
+ query_latitude: 187,
+ query_time_zone: 188,
+ query_daylight_savings: 189,
+ query_software_version: 190,
+ query_helvar_net: 191,
+ }
+
+ # Dynamically define methods based on the tuple above
+ {% for name, command in CMD_METHODS %}
+ def {{name.id}}(group : Int32? = nil, block : Int32? = nil, level : Int32? = nil, scene : Int32? = nil, fade : Int32? = nil, addr : Int32? = nil, **options)
+ do_send({{command.id.stringify}}, @version, group, block, level, scene, fade, addr, **options)
+ end
+ {% end %}
+
+ # Generate a String => String hash based on the data above
+ macro build_command_hash
+ COMMANDS = {
+ {% for name, command in CMD_METHODS %}
+ {{name.id.stringify}} => {{command.id.stringify}},
+ {% end %}
+ }
+ COMMANDS.merge!(COMMANDS.invert)
+ end
+
+ build_command_hash
+
+ PARAMS = {
+ "V" => :ver,
+ "Q" => :seq,
+ "C" => :cmd,
+ "A" => :ack,
+ "@" => :addr,
+ "F" => :fade,
+ "T" => :time,
+ "L" => :level,
+ "G" => :group,
+ "S" => :scene,
+ "B" => :block,
+ "N" => :latitude,
+ "E" => :longitude,
+ "Z" => :time_zone,
+ # brighter or dimmer than the current level by a % of the difference
+ "P" => :proportion,
+ "D" => :display_screen,
+ "Y" => :daylight_savings,
+ "O" => :force_store_scene,
+ "K" => :constant_light_scene,
+ }
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Helvar sent: #{data}" }
+ task_name = task.try(&.name)
+
+ # Remove the # at the end of the message
+ data = data[0..-2]
+
+ # Group level changed: ?V:2,C:109,G:12706=13 (query scene response)
+ # Update pushed >V:2,C:11,G:25007,B:1,S:13,F:100 (current scene level)
+
+ # Remove junk data (when whitelisting gateway is in place)
+ start_of_message = data.index(/[\?\>\!]V:/i)
+ if start_of_message != 0
+ logger.warn { "Lighting error response: #{data[0...start_of_message]}" }
+ data = data[start_of_message..-1]
+ end
+
+ # remove connectors from multi-part responses
+ data = data.delete("$")
+
+ indicator = data[0]
+ case indicator
+ when '?', '>'
+ # remove indicator
+ data = data[1..-1]
+
+ # check if this is a result
+ parts = data.split("=")
+ data = parts[0]
+ value = parts[1]?
+
+ # Extract components of the message
+ params = {} of Symbol => String
+ data.split(",").each do |param|
+ parts = param.split(":")
+ if parts.size > 1
+ params[PARAMS[parts[0]]] = parts[1]
+ elsif parts[0][0] == '@'
+ params[:addr] == parts[0][1..-1]
+ else
+ logger.debug { "unknown param type #{param}" }
+ end
+ end
+
+ # Check for :ack
+ ack = params[:ack]?
+ if ack
+ return task.try &.abort("request failed") if ack != "1"
+ return task.try &.success
+ end
+
+ cmd = COMMANDS[params[:cmd]]
+ case cmd
+ when "query_last_scene"
+ scene = value.try &.to_i
+ group = params[:group]
+ self["area#{group}"] = scene
+ task.not_nil!.success(scene) if task_name == "query_scene#{group}"
+ when "group_scene"
+ block = params[:block]
+ group = params[:group]
+ scene = params[:scene].to_i
+ if block
+ if @ignore_blocks
+ self["area#{group}"] = scene
+ else
+ self["area#{group}_#{block}"] = scene
+ end
+ else
+ self["area#{group}"] = scene
+ end
+ task.not_nil!.success(scene) if task_name == "group_scene#{group}"
+ when "group_level"
+ task.not_nil!.success if task_name == "group_level#{params[:group]}"
+ when "query_scene_info"
+ group = params[:group]
+ if value && task_name == "query_scene#{group}_info"
+ levels = value.split(",L")[0].split(',').map(&.to_i)
+ task.not_nil!.success(levels)
+ end
+ else
+ logger.debug { "unknown response value\n#{cmd} = #{value}" }
+ end
+ when '!'
+ error = ERRORS[data.split("=")[1]]
+ error = "#{error} for #{data}"
+ self[:last_error] = error
+ logger.warn { error }
+ return task.try &.abort(error)
+ else
+ logger.info { "unknown request #{data}" }
+ end
+
+ task.try(&.success) unless task_name
+ end
+
+ ERRORS = {
+ "0" => "success",
+ "1" => "invalid group index parameter",
+ "2" => "invalid cluster parameter",
+ "3" => "invalid router",
+ "4" => "invalid router subnet",
+ "5" => "invalid device parameter",
+ "6" => "invalid sub device parameter",
+ "7" => "invalid block parameter",
+ "8" => "invalid scene",
+ "9" => "cluster does not exist",
+ "10" => "router does not exist",
+ "11" => "device does not exist",
+ "12" => "property does not exist",
+ "13" => "invalid RAW message size",
+ "14" => "invalid messages type",
+ "15" => "invalid message command",
+ "16" => "missing ASCII terminator",
+ "17" => "missing ASCII parameter",
+ "18" => "incompatible version",
+ }
+
+ protected def do_send(cmd : String, ver = @version, group = nil, block = nil, level = nil, scene = nil, fade = nil, addr = nil, **options)
+ req = String.build do |str|
+ str << ">V:" << ver << ",C:" << cmd
+ str << ",G:" << group if group
+ str << ",B:" << block if block
+ str << ",L:" << level if level
+ str << ",S:" << scene if scene
+ str << ",F:" << fade if fade
+ str << ",@:" << addr if addr
+ str << "#"
+ end
+ logger.debug { "Requesting helvar: #{req}" }
+ send(req, **options)
+ end
+
+ # ==================
+ # Lighting Interface
+ # ==================
+ protected def check_arguments(area : Area?)
+ area_id = area.try(&.id)
+ raise ArgumentError.new("area.id required (helvar group)") unless area_id
+ area_id.to_i
+ end
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ trigger(check_arguments(area), scene.to_i, fade_time.to_i)
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ get_current_preset check_arguments(area)
+ end
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area_id = check_arguments area
+ light_level(area_id, level.round_even.to_i, fade_time.to_i)
+ end
+
+ def lighting_level?(area : Area? = nil)
+ group = check_arguments area
+ if scene = get_current_preset(group).get(response_required: true).payload.to_i
+ payload = query_scene_levels(group).get(response_required: true).payload
+ levels = Array(Int32).from_json(payload)
+ self["area#{group}_level"] = levels[scene]
+ end
+ end
+end
diff --git a/drivers/helvar/net_spec.cr b/drivers/helvar/net_spec.cr
new file mode 100644
index 00000000000..7c9b29673a6
--- /dev/null
+++ b/drivers/helvar/net_spec.cr
@@ -0,0 +1,35 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Helvar::Net" do
+ # Perform actions
+ resp = exec(:trigger, group: 1, scene: 2, fade: 1100)
+ should_send(">V:2,C:11,G:1,S:2,F:110#")
+ responds(">V:2,C:11,G:1,S:2,F:110,A:1#")
+ resp.get
+ status[:area1].should eq(2)
+
+ resp = exec(:get_current_preset, group: 17)
+ should_send(">V:2,C:109,G:17#")
+ responds("?V:2,C:109,G:17=14#")
+ resp.get.should eq 14
+ status[:area17].should eq(14)
+
+ resp = exec(:get_current_preset, group: 20)
+ should_send(">V:2,C:109,G:20#")
+ responds("!V:2,C:109,G:20=1#")
+ expect_raises(PlaceOS::Driver::RemoteException, "invalid group index parameter for !V:2,C:109,G:20=1 (Abort)") do
+ resp.get
+ end
+ status[:last_error].should eq("invalid group index parameter for !V:2,C:109,G:20=1")
+
+ transmit(">V:2,C:11,G:2001,B:1,S:1,F:100#")
+ status[:area2001].should eq(1)
+
+ resp = exec(:lighting_level?, area: {id: 17})
+ should_send(">V:2,C:109,G:17#")
+ responds("?V:2,C:109,G:17=1#")
+ should_send(">V:2,C:167,G:17#")
+ responds("?V:2,C:167,G:17=100,75,50,25,0,L,L,L,0,*,*,*,*,*#")
+ resp.get.should eq 75
+ status[:area17_level].should eq(75)
+end
diff --git a/drivers/hitachi/projector/cp_tw_series_basic.cr b/drivers/hitachi/projector/cp_tw_series_basic.cr
new file mode 100644
index 00000000000..2f44d06890e
--- /dev/null
+++ b/drivers/hitachi/projector/cp_tw_series_basic.cr
@@ -0,0 +1,244 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+
+class Hitachi::Projector::CpTwSeriesBasic < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ # Discovery Information
+ tcp_port 23
+ descriptive_name "Hitachi CP-TW Projector (no auth)"
+ generic_name :Display
+
+ @recover_power : PlaceOS::Driver::Proxy::Scheduler::TaskWrapper? = nil
+ @recover_input : PlaceOS::Driver::Proxy::Scheduler::TaskWrapper? = nil
+ # nil by default (allows manual on and off)
+ @power_target : Bool? = nil
+ @input_target : Input? = nil
+
+ def on_load
+ # Response time is slow
+ # and as a make break device it may take time
+ # to actually setup the connection with the projector
+ queue.delay = 100.milliseconds
+ queue.timeout = 5.seconds
+ queue.retries = 3
+
+ # Meta data for inquiring interfaces
+ self[:type] = :projector
+ end
+
+ def connected
+ schedule.every(50.seconds, true) { poll_1 }
+ schedule.every(10.minutes, true) { poll_2 }
+ end
+
+ def poll_1
+ power?(priority: 0).get
+ if self[:power]?.try &.as_bool
+ input?(priority: 0)
+ audio_mute?(priority: 0)
+ video_mute?(priority: 0)
+ freeze?(priority: 0)
+ end
+ end
+
+ def poll_2
+ lamp?(priority: 0)
+ filter?(priority: 0)
+ error?(priority: 0)
+ end
+
+ def disconnected
+ schedule.clear
+ @recover_power = nil
+ @recover_input = nil
+ end
+
+ def power(state : Bool)
+ @power_target = state
+ if state
+ logger.debug { "requested to power on" }
+ do_send(:power_on)
+ else
+ logger.debug { "requested to power off" }
+ do_send(:power_off)
+ end
+ power?
+ end
+
+ def switch_to(input : Input)
+ @input_target = input
+ do_send(input.to_s.downcase)
+ input?
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ mute_video(state) if layer.video? || layer.audio_video?
+ mute_audio(state) if layer.audio? || layer.audio_video?
+ end
+
+ def mute_video(state : Bool = true)
+ if state
+ do_send(:mute_video)
+ else
+ do_send(:unmute_video)
+ end
+ video_mute?
+ end
+
+ def mute_audio(state : Bool = true)
+ if state
+ do_send(:mute_audio)
+ else
+ do_send(:unmute_audio)
+ end
+ audio_mute?
+ end
+
+ def lamp_hours_reset
+ do_send(:lamp_hours_reset)
+ lamp?
+ end
+
+ def filter_hours_reset
+ do_send(:filter_hours_reset)
+ filter?
+ end
+
+ enum Response
+ Ack = 0x06
+ Nak = 0x15
+ Error = 0x1c
+ Data = 0x1d
+ Busy = 0x1f
+ end
+
+ enum Input
+ Hdmi = 0x03
+ Hdmi2 = 0x0d
+ HdbaSet = 0x11
+ end
+
+ enum Error
+ Normal
+ Cover
+ Fan
+ Lamp
+ Temp
+ AirFlow
+ Cold
+ Filter
+ end
+
+ def received(data, task)
+ logger.debug { "received 0x#{data}" }
+ command = task.try &.name
+
+ case Response.from_value(data[0])
+ when .ack?
+ task.try &.success
+ when .nak?
+ task.try &.abort("NAK response")
+ when .error?
+ task.try &.abort("Error response")
+ when .data?
+ if command
+ case command
+ when "power?"
+ self[:power] = data[1] == 1
+ self[:cooling] = data[1] == 2
+
+ if self[:power]? == @power_target
+ @power_target = nil
+ elsif @power_target && @recover_power.nil?
+ logger.debug { "recovering power state #{self[:power]} != target #{@power_target}" }
+ @recover_power = schedule.in(3.seconds) do
+ @recover_power = nil
+ power(@power_target.not_nil!)
+ end
+ end
+ when "input?"
+ input = Input.from_value?(data[1])
+ self[:input] = input || "unknown"
+ if @input_target
+ if input == @input_target
+ @input_target = nil
+ elsif @recover_input.nil?
+ logger.debug { "recovering input #{self[:input]} != target #{@input_target}" }
+ @recover_input = schedule.in(3.seconds) do
+ @recover_input = nil
+ switch_to(@input_target.not_nil!)
+ end
+ end
+ end
+ when "error?"
+ self[:error_status] = Error.from_value?(data[1]) || "unknown"
+ when "freeze?"
+ self[:frozen] = data[1] == 1
+ when "audio_mute?"
+ self[:audio_mute] = data[1] == 1
+ when "video_mute?"
+ self[:video_mute] = data[1] == 1
+ when "lamp?"
+ self[:lamp] = data[1] * data[2]
+ when "filter?"
+ self[:filter] = data[1] * data[2]
+ end
+ task.try &.success
+ else
+ task.try &.abort("data received for unknown command")
+ end
+ when .busy?
+ if data[1] == 4 && data[2] == 0
+ task.try &.abort("authentication enabled, please disable")
+ else
+ task.try &.retry("projector busy, retrying")
+ end
+ end
+ end
+
+ # Note: commands have spaces in between each byte for readability
+ Commands = {
+ # SetRequests
+ power_on: "BA D2 01 00 00 60 01 00",
+ power_off: "2A D3 01 00 00 60 00 00",
+ hdmi: "0E D2 01 00 00 20 03 00",
+ hdmi2: "6E D6 01 00 00 20 0D 00",
+ mute_video: "6E F1 01 00 A0 20 01 00",
+ unmute_video: "FE F0 01 00 A0 20 00 00",
+ mute_audio: "D6 D2 01 00 02 20 01 00",
+ unmute_audio: "46 D3 01 00 02 20 00 00",
+ lamp_hours_reset: "58 DC 06 00 30 70 00 00",
+ filter_hours_reset: "98 C6 06 00 40 70 00 00",
+ # GetRequests
+ power?: "19 D3 02 00 00 60 00 00",
+ input?: "CD D2 02 00 00 20 00 00",
+ error?: "D9 D8 02 00 20 60 00 00",
+ freeze?: "B0 D2 02 00 02 30 00 00",
+ audio_mute?: "75 D3 02 00 02 20 00 00",
+ video_mute?: "CD F0 02 00 A0 20 00 00",
+ lamp?: "C2 FF 02 00 90 10 00 00",
+ filter?: "C2 F0 02 00 A0 10 00 00",
+ }
+
+ GetRequests = %i(power? input? error? freeze? audio_mute? video_mute? lamp? filter?)
+ {% for name in GetRequests %}
+ @[Security(Level::Administrator)]
+ def {{name.id}}(**options)
+ do_send({{name.id.stringify}}, **options)
+ end
+ {% end %}
+
+ private def do_send(cmd, **options)
+ data = "BEEF030600 #{Commands[cmd]}"
+ logger.debug { "requesting \"0x#{data}\" name: #{cmd}" }
+ # Remove spaces that have been added for readability
+ send(data.delete(' ').hexbytes, **options, name: cmd)
+ end
+end
diff --git a/drivers/hitachi/projector/cp_tw_series_basic_spec.cr b/drivers/hitachi/projector/cp_tw_series_basic_spec.cr
new file mode 100644
index 00000000000..7032112a398
--- /dev/null
+++ b/drivers/hitachi/projector/cp_tw_series_basic_spec.cr
@@ -0,0 +1,81 @@
+require "placeos-driver"
+require "./cp_tw_series_basic"
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Hitachi::Projector::CpTwSeriesBasic" do
+ c = Hitachi::Projector::CpTwSeriesBasic::Commands
+
+ # connected
+ # power?
+ should_send("BEEF030600#{c[:power?]}".delete(' ').hexbytes)
+ responds("\x1d\x01\x00")
+ status[:power].should eq(true)
+ # lamp?
+ should_send("BEEF030600#{c[:lamp?]}".delete(' ').hexbytes)
+ responds("\x1d\x03\x01")
+ status[:lamp].should eq(3)
+ # filter?
+ should_send("BEEF030600#{c[:filter?]}".delete(' ').hexbytes)
+ responds("\x1d\x04\x01")
+ status[:filter].should eq(4)
+ # error?
+ should_send("BEEF030600#{c[:error?]}".delete(' ').hexbytes)
+ responds("\x1d\x00\x00")
+ status[:error_status].should eq("Normal")
+ # input?
+ should_send("BEEF030600#{c[:input?]}".delete(' ').hexbytes)
+ responds("\x1d\x0d\x00")
+ status[:input].should eq("Hdmi2")
+ # audio_mute?
+ should_send("BEEF030600#{c[:audio_mute?]}".delete(' ').hexbytes)
+ responds("\x1d\x01\x00")
+ status[:audio_mute].should eq(true)
+ # video_mute?
+ should_send("BEEF030600#{c[:video_mute?]}".delete(' ').hexbytes)
+ responds("\x1d\x01\x00")
+ status[:video_mute].should eq(true)
+ # freeze?
+ should_send("BEEF030600#{c[:freeze?]}".delete(' ').hexbytes)
+ responds("\x1d\x01\x00")
+ status[:frozen].should eq(true)
+
+ exec(:mute, false)
+ should_send("BEEF030600#{c[:unmute_video]}".delete(' ').hexbytes)
+ responds("\x06")
+ should_send("BEEF030600#{c[:video_mute?]}".delete(' ').hexbytes)
+ responds("\x1d\x00\x00")
+ status[:video_mute].should eq(false)
+ should_send("BEEF030600#{c[:unmute_audio]}".delete(' ').hexbytes)
+ responds("\x06")
+ should_send("BEEF030600#{c[:audio_mute?]}".delete(' ').hexbytes)
+ responds("\x1d\x00\x00")
+ status[:video_mute].should eq(false)
+
+ exec(:switch_to, "hdmi")
+ should_send("BEEF030600#{c[:hdmi]}".delete(' ').hexbytes)
+ responds("\x06")
+ should_send("BEEF030600#{c[:input?]}".delete(' ').hexbytes)
+ responds("\x1d\x03\x00")
+ status[:input].should eq("Hdmi")
+
+ exec(:lamp_hours_reset)
+ should_send("BEEF030600#{c[:lamp_hours_reset]}".delete(' ').hexbytes)
+ responds("\x06")
+ should_send("BEEF030600#{c[:lamp?]}".delete(' ').hexbytes)
+ responds("\x1d\x00\x00")
+ status[:lamp].should eq(0)
+
+ exec(:filter_hours_reset)
+ should_send("BEEF030600#{c[:filter_hours_reset]}".delete(' ').hexbytes)
+ responds("\x06")
+ should_send("BEEF030600#{c[:filter?]}".delete(' ').hexbytes)
+ responds("\x1d\x00\x00")
+ status[:filter].should eq(0)
+
+ exec(:power, false)
+ should_send("BEEF030600#{c[:power_off]}".delete(' ').hexbytes)
+ responds("\x06")
+ should_send("BEEF030600#{c[:power?]}".delete(' ').hexbytes)
+ responds("\x1d\x00\x00")
+ status[:power].should eq(false)
+end
diff --git a/drivers/infosilem/campus.cr b/drivers/infosilem/campus.cr
new file mode 100644
index 00000000000..18e572e2e2e
--- /dev/null
+++ b/drivers/infosilem/campus.cr
@@ -0,0 +1,121 @@
+require "placeos-driver"
+require "sabo"
+
+class Infosilem::Campus < PlaceOS::Driver
+ descriptive_name "Infosilem Campus Gateway"
+ generic_name :Campus
+ uri_base "https://example.com/InfosilemCampus/API"
+
+ alias Client = Sabo::Client
+
+ default_settings({
+ username: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ password: "ABCDEF123456",
+ })
+
+ protected getter! integration : Client
+ protected getter! booking : Client
+
+ def on_update
+ host_name = config.uri.not_nil!.to_s
+
+ @integration = Sabo::Client.new(
+ document: Sabo::WSDL::Document.new([host_name, "/Integration/Integration.asmx?WSDL"].join),
+ prefix: "http://www.infosilem.com/",
+ version: "1.2"
+ )
+
+ @booking = Sabo::Client.new(
+ document: Sabo::WSDL::Document.new([host_name, "/ExportOnly/RoomBookingPub.asmx?WSDL"].join),
+ prefix: "http://www.infosilem.com/",
+ version: "1.2"
+ )
+ end
+
+ def bookings?(building_id : String, room_id : String, start_date : String, end_date : String)
+ response = @integration.try(&.call(operation: "StartTransfer", body: {"StartTransferOptions" => Sabo::Parameter.from_hash(start_transfer_options(username: setting(String, :username), password: setting(String, :password)))}))
+ transfer_id = response.try(&.result)
+
+ response = @booking.try(&.call(operation: "RoomBookingOccurrence_ExportAll", body: {
+ "TransferID" => Sabo::Parameter.new(transfer_id.to_s),
+ "Options" => Sabo::Parameter.from_hash(booking_options(building: building_id, room: room_id, start_date: start_date, end_date: end_date, start_time: start_date, end_time: end_date)),
+ }
+ ))
+
+ @integration.try(&.call(operation: "EndTransfer", body: end_transfer_body(transfer_id: transfer_id.to_s)))
+
+ self["room_#{room_id}_bookings_#{start_date}_#{end_date}"] =
+ response
+ .try(&.result
+ .["ObjectData"]
+ .["ReservationOccurrences"]
+ .["ReservationOccurrence"]?) || [] of Int32
+ end
+
+ private def start_transfer_options(
+ username : String = "",
+ password : String = "",
+ description_resource_id : String = "",
+ for_import : Bool = false,
+ allow_same_concurrent_import : Bool = false,
+ queued_timeout : Int32 = 15,
+ log_unchanged_rows : Bool = true,
+ log_rejected_records_xml : Bool = true
+ )
+ {
+ "Username" => username,
+ "Password" => password,
+ "DesciptionResourceID" => description_resource_id,
+ "ForImport" => for_import,
+ "AllowSameConcurrentImport" => allow_same_concurrent_import,
+ "QueuedTimeout" => queued_timeout,
+ "LogUnchangedRows" => log_unchanged_rows,
+ "LogRejectedRecordsXML" => log_rejected_records_xml,
+ }
+ end
+
+ private def end_transfer_body(transfer_id : String)
+ {
+ "TransferID" => Sabo::Parameter.new(transfer_id),
+ "EmailAddresses" => Sabo::Parameter.from_array([] of String),
+ "SendSummary" => Sabo::Parameter.new(true),
+ "SummaryStyle" => Sabo::Parameter.new(""),
+ "SendDetails" => Sabo::Parameter.new(true),
+ "DetailsStyle" => Sabo::Parameter.new(""),
+ "SendRejects" => Sabo::Parameter.new(true),
+ "RejectsStyle" => Sabo::Parameter.new(""),
+ }
+ end
+
+ private def booking_options(
+ export_as_object : Bool = true,
+ compress_export : Bool = true,
+ room : String = "",
+ building : String = "",
+ campus : String = "",
+ event_filter : String = "",
+ start_time : String = "",
+ end_time : String = "",
+ use_time_filter : Bool = false,
+ start_date : String = "",
+ end_date : String = "",
+ event_id : String = "",
+ activity_id : String = ""
+ )
+ {
+ "ExportAsObject" => export_as_object,
+ "CompressedExport" => compress_export,
+ "Room" => room,
+ "Building" => building,
+ "Campus" => campus,
+ "EventFilter" => event_filter,
+ "StartTime" => start_time,
+ "EndTime" => end_time,
+ "UseTimeFilter" => use_time_filter,
+ "StartDate" => start_date,
+ "EndDate" => end_date,
+ "EventID" => event_id,
+ "ActivityID" => activity_id,
+ }
+ end
+end
diff --git a/drivers/infosilem/mock_campus.cr b/drivers/infosilem/mock_campus.cr
new file mode 100644
index 00000000000..289c8da881a
--- /dev/null
+++ b/drivers/infosilem/mock_campus.cr
@@ -0,0 +1,229 @@
+require "placeos-driver"
+require "./models"
+
+class Infosilem::MockCampus < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Mock Infosilem Campus Driver"
+ generic_name :Campus
+
+ default_settings({
+ response: <<-STRING
+ [
+ {
+ "EventID": "* GENERAL AND BLOCKOFF BOOKINGS",
+ "EventType": "N",
+ "ActivityID": "UTS",
+ "ActivityType": "Z",
+ "ReservationID": "01",
+ "ReservationType": "BL",
+ "OccurrenceDate": "2022-10-31",
+ "OccurrenceDOW": "M",
+ "StartTime": "00:00:00",
+ "EndTime": "04:00:00",
+ "SetupDuration": "00:00:00",
+ "TeardownDuration": "00:00:00",
+ "ReservationStartDate": "2022-10-31",
+ "ReservationEndDate": "2022-10-31",
+ "ReservationDOW": "M",
+ "RecurrenceType": "0",
+ "ReservationStatus": "1",
+ "OccurrenceStatus": "0",
+ "OccurrenceIsConflicting": "1",
+ "RoomRequestStatus": "2",
+ "Campus": "MCMST",
+ "Building": "PGCLL",
+ "Room": "B138",
+ "NumberOfAttendees": "0",
+ "RequestorUnit": "EMPLOYEE",
+ "RequestorContactID": "Dennis Tian",
+ "EventFunctionalUnit": "UGRD",
+ "EventSchedulingDataSet": "ACADEMIC_BOOKINGS",
+ "ReservationDescription": "BLOCKOFF",
+ "EventManagedBy": "test.admin",
+ "ActivityManagedBy": "test.admin",
+ "ReservationManagedBy": "test.admin"
+ },
+ {
+ "EventID": "* GENERAL AND BLOCKOFF BOOKINGS",
+ "EventType": "N",
+ "ActivityID": "UTS",
+ "ActivityType": "Z",
+ "ReservationID": "05",
+ "ReservationType": "BL",
+ "OccurrenceDate": "2022-10-31",
+ "OccurrenceDOW": "M",
+ "StartTime": "03:00:00",
+ "EndTime": "03:30:00",
+ "SetupDuration": "00:00:00",
+ "TeardownDuration": "00:00:00",
+ "ReservationStartDate": "2022-10-31",
+ "ReservationEndDate": "2022-10-31",
+ "ReservationDOW": "M",
+ "RecurrenceType": "0",
+ "ReservationStatus": "1",
+ "OccurrenceStatus": "0",
+ "OccurrenceIsConflicting": "1",
+ "RoomRequestStatus": "2",
+ "Campus": "MCMST",
+ "Building": "PGCLL",
+ "Room": "B138",
+ "NumberOfAttendees": "0",
+ "RequestorUnit": "EMPLOYEE",
+ "RequestorContactID": "Dennis Tian",
+ "EventFunctionalUnit": "UGRD",
+ "EventSchedulingDataSet": "ACADEMIC_BOOKINGS",
+ "ReservationDescription": "TEST EVENT 4",
+ "EventManagedBy": "test.admin",
+ "ActivityManagedBy": "test.admin",
+ "ReservationManagedBy": "test.admin"
+ },
+ {
+ "EventID": "* GENERAL AND BLOCKOFF BOOKINGS",
+ "EventType": "N",
+ "ActivityID": "UTS",
+ "ActivityType": "Z",
+ "ReservationID": "04",
+ "ReservationType": "BL",
+ "OccurrenceDate": "2022-10-31",
+ "OccurrenceDOW": "M",
+ "StartTime": "02:00:00",
+ "EndTime": "02:30:00",
+ "SetupDuration": "00:00:00",
+ "TeardownDuration": "00:00:00",
+ "ReservationStartDate": "2022-10-31",
+ "ReservationEndDate": "2022-10-31",
+ "ReservationDOW": "M",
+ "RecurrenceType": "0",
+ "ReservationStatus": "1",
+ "OccurrenceStatus": "0",
+ "OccurrenceIsConflicting": "1",
+ "RoomRequestStatus": "2",
+ "Campus": "MCMST",
+ "Building": "PGCLL",
+ "Room": "B138",
+ "NumberOfAttendees": "0",
+ "RequestorUnit": "EMPLOYEE",
+ "RequestorContactID": "Dummy Name",
+ "EventFunctionalUnit": "UGRD",
+ "EventSchedulingDataSet": "ACADEMIC_BOOKINGS",
+ "ReservationDescription": "TEST EVENT 3",
+ "EventManagedBy": "test.admin",
+ "ActivityManagedBy": "test.admin",
+ "ReservationManagedBy": "test.admin"
+ },
+ {
+ "EventID": "* GENERAL AND BLOCKOFF BOOKINGS",
+ "EventType": "N",
+ "ActivityID": "UTS",
+ "ActivityType": "Z",
+ "ReservationID": "06",
+ "ReservationType": "BL",
+ "OccurrenceDate": "2022-10-31",
+ "OccurrenceDOW": "M",
+ "StartTime": "03:00:00",
+ "EndTime": "03:30:00",
+ "SetupDuration": "00:00:00",
+ "TeardownDuration": "00:00:00",
+ "ReservationStartDate": "2022-10-31",
+ "ReservationEndDate": "2022-10-31",
+ "ReservationDOW": "M",
+ "RecurrenceType": "0",
+ "ReservationStatus": "1",
+ "OccurrenceStatus": "0",
+ "OccurrenceIsConflicting": "1",
+ "RoomRequestStatus": "2",
+ "Campus": "MCMST",
+ "Building": "PGCLL",
+ "Room": "B138",
+ "NumberOfAttendees": "0",
+ "RequestorUnit": "EMPLOYEE",
+ "RequestorContactID": "Dummy Name",
+ "EventFunctionalUnit": "UGRD",
+ "EventSchedulingDataSet": "ACADEMIC_BOOKINGS",
+ "ReservationDescription": "TEST EVENT 5",
+ "EventManagedBy": "test.admin",
+ "ActivityManagedBy": "test.admin",
+ "ReservationManagedBy": "test.admin"
+ },
+ {
+ "EventID": "* GENERAL AND BLOCKOFF BOOKINGS",
+ "EventType": "N",
+ "ActivityID": "UTS",
+ "ActivityType": "Z",
+ "ReservationID": "03",
+ "ReservationType": "BL",
+ "OccurrenceDate": "2022-10-31",
+ "OccurrenceDOW": "M",
+ "StartTime": "00:30:00",
+ "EndTime": "01:00:00",
+ "SetupDuration": "00:00:00",
+ "TeardownDuration": "00:00:00",
+ "ReservationStartDate": "2022-10-31",
+ "ReservationEndDate": "2022-10-31",
+ "ReservationDOW": "M",
+ "RecurrenceType": "0",
+ "ReservationStatus": "1",
+ "OccurrenceStatus": "0",
+ "OccurrenceIsConflicting": "1",
+ "RoomRequestStatus": "2",
+ "Campus": "MCMST",
+ "Building": "PGCLL",
+ "Room": "B138",
+ "NumberOfAttendees": "0",
+ "RequestorUnit": "EMPLOYEE",
+ "RequestorContactID": "Dummy Name",
+ "EventFunctionalUnit": "UGRD",
+ "EventSchedulingDataSet": "ACADEMIC_BOOKINGS",
+ "ReservationDescription": "TEST EVENT 2",
+ "EventManagedBy": "test.admin",
+ "ActivityManagedBy": "test.admin",
+ "ReservationManagedBy": "test.admin"
+ },
+ {
+ "EventID": "* GENERAL AND BLOCKOFF BOOKINGS",
+ "EventType": "N",
+ "ActivityID": "UTS",
+ "ActivityType": "Z",
+ "ReservationID": "02",
+ "ReservationType": "BL",
+ "OccurrenceDate": "2022-10-31",
+ "OccurrenceDOW": "M",
+ "StartTime": "00:15:00",
+ "EndTime": "00:30:00",
+ "SetupDuration": "00:00:00",
+ "TeardownDuration": "00:00:00",
+ "ReservationStartDate": "2022-10-31",
+ "ReservationEndDate": "2022-10-31",
+ "ReservationDOW": "M",
+ "RecurrenceType": "0",
+ "ReservationStatus": "1",
+ "OccurrenceStatus": "0",
+ "OccurrenceIsConflicting": "1",
+ "RoomRequestStatus": "2",
+ "Campus": "MCMST",
+ "Building": "PGCLL",
+ "Room": "B138",
+ "NumberOfAttendees": "0",
+ "RequestorUnit": "EMPLOYEE",
+ "RequestorContactID": "Dummy Name",
+ "EventFunctionalUnit": "UGRD",
+ "EventSchedulingDataSet": "ACADEMIC_BOOKINGS",
+ "ReservationDescription": "TEST EVENT 1",
+ "EventManagedBy": "test.admin",
+ "ActivityManagedBy": "test.admin",
+ "ReservationManagedBy": "test.admin"
+ }
+ ]
+ STRING
+ })
+
+ @response = [] of JSON::Any
+
+ def on_update
+ @response = setting?(Array(JSON::Any), :response) || [] of JSON::Any
+ end
+
+ def bookings?(building_id : String, room_id : String, start_date : String, end_date : String)
+ @response
+ end
+end
diff --git a/drivers/infosilem/models.cr b/drivers/infosilem/models.cr
new file mode 100644
index 00000000000..6e7d0a017a8
--- /dev/null
+++ b/drivers/infosilem/models.cr
@@ -0,0 +1,53 @@
+module Infosilem
+ class Event
+ include JSON::Serializable
+
+ @[JSON::Field(key: "EventID")]
+ property id : String
+
+ @[JSON::Field(key: "EventDescription")]
+ property description : String?
+
+ @[JSON::Field(key: "NumberOfAttendees", converter: Infosilem::IntegerConverter)]
+ property number_of_attendees : Int32?
+
+ @[JSON::Field(key: "OccurrenceIsConflicting", converter: Infosilem::IntegerConverter)]
+ property conflicting : Int32?
+
+ @[JSON::Field(key: "StartTime", converter: Infosilem::DateTimeConvertor)]
+ property start_time : Time
+
+ @[JSON::Field(key: "EndTime", converter: Infosilem::DateTimeConvertor)]
+ property end_time : Time
+
+ property container : Bool?
+
+ def duration
+ end_time - start_time
+ end
+ end
+
+ module DateTimeConvertor
+ extend self
+
+ def to_json(value, json : JSON::Builder)
+ json.string(value.to_s("%H:%M:%S"))
+ end
+
+ def from_json(value : JSON::PullParser)
+ Time.parse_local("#{Time.local.to_s("%F")} #{value.read_string}", "%F %H:%M:%S")
+ end
+ end
+
+ module IntegerConverter
+ extend self
+
+ def to_json(value, json : JSON::Builder)
+ json.string(value.to_s)
+ end
+
+ def from_json(value : JSON::PullParser)
+ value.read_string.to_i
+ end
+ end
+end
diff --git a/drivers/infosilem/room_schedule.cr b/drivers/infosilem/room_schedule.cr
new file mode 100644
index 00000000000..0011fe449d0
--- /dev/null
+++ b/drivers/infosilem/room_schedule.cr
@@ -0,0 +1,159 @@
+require "placeos-driver"
+require "./models"
+
+class Infosilem::RoomSchedule < PlaceOS::Driver
+ descriptive_name "Infosilem Room Schedule Logic"
+ generic_name :RoomSchedule
+ description %(Polls Infosilem Campus Module to expose bookings relevant for the selected System)
+
+ default_settings({
+ infosilem_room_id: "set Infosilem Room ID here",
+ polling_cron: "*/15 * * * *",
+ debug: false,
+ })
+
+ accessor infosilem : Campus_1
+
+ @building_id : String = "set Infosilem Building ID here"
+ @room_id : String = "set Infosilem Room ID here"
+ @cron_string : String = "*/15 * * * *"
+ @debug : Bool = false
+ @next_countdown : PlaceOS::Driver::Proxy::Scheduler::TaskWrapper? = nil
+ @request_lock : Mutex = Mutex.new
+ @request_running : Bool = false
+
+ def on_update
+ @debug = setting(Bool, :debug) || false
+ @building_id = setting(String, :infosilem_building_id)
+ @room_id = setting(String, :infosilem_room_id)
+ @cron_string = setting(String, :polling_cron)
+ schedule.clear
+ schedule.cron(@cron_string, immediate: true) { fetch_and_expose_todays_events }
+ end
+
+ def fetch_and_expose_todays_events
+ return if @request_running
+
+ @request_lock.synchronize do
+ begin
+ @request_running = true
+ @next_countdown.try &.cancel
+ @next_countdown = nil
+ today = Time.local.to_s("%Y-%m-%d")
+ todays_events = Array(Event).from_json(fetch_events(today, today))
+
+ # Determine which events contain other events
+ todays_events.sort_by(&.duration).reverse!
+ todays_events.each_with_index do |e, i|
+ if todays_events.skip(i + 1).find { |f| contains?(e, f) }
+ e.container = true
+ else
+ e.container = false
+ end
+ end
+
+ current_and_past_events, future_events = todays_events.partition { |e| Time.local > e.start_time }
+ current_events, past_events = current_and_past_events.partition { |e| in_progress?(e) }
+
+ if @debug
+ self[:todays_upcoming_events] = future_events
+ self[:todays_past_events] = past_events
+ end
+
+ next_event = future_events.min_by? &.start_time
+ previous_event = past_events.max_by? &.end_time
+ current_event = current_events.find { |e| !e.container }
+ current_container_event = current_events.find(&.container)
+
+ update_event_details(previous_event, current_event, next_event)
+ advance_countdowns(previous_event, current_event, next_event, current_container_event)
+ todays_events
+ ensure
+ @request_running = false
+ end
+ end
+ end
+
+ def fetch_events(start_date : String, end_date : String)
+ events = infosilem.bookings?(@building_id, @room_id, start_date, end_date).get.to_json
+ logger.debug { "Infosilem Campus returned: #{events}" } if @debug
+ events
+ end
+
+ private def update_event_details(previous_event : Event | Nil = nil, current_event : Event | Nil = nil, next_event : Event | Nil = nil)
+ if previous_event
+ self[:previous_event_ends_at] = previous_event.end_time
+ self[:previous_event_was_container] = previous_event.container
+ self[:previous_event_id] = previous_event.id if @debug
+ end
+
+ if current_event
+ self[:current_event_starts_at] = current_event.start_time
+ self[:current_event_ends_at] = current_event.end_time
+ self[:current_event_attendees] = current_event.number_of_attendees
+ self[:current_event_conflicting] = current_event.conflicting
+ self[:current_event_id] = current_event.id if @debug
+ self[:current_event_description] = current_event.description if @debug
+ else
+ self[:current_event_attendees] = 0
+ end
+
+ if next_event
+ self[:next_event_starts_at] = next_event.start_time
+ self[:next_event_is_container] = next_event.container
+ self[:next_event_id] = next_event.id if @debug
+ end
+ end
+
+ private def advance_countdowns(previous : Event | Nil, current : Event | Nil, next_event : Event | Nil, container : Event | Nil)
+ previous ? countup_previous_event(previous) : (self[:minutes_since_previous_event] = nil)
+ next_event_started = next_event ? countdown_next_event(next_event) : (self[:minutes_til_next_event] = nil)
+ current_event_ended = current ? countdown_current_event(current) : (self[:minutes_since_current_event_started] = self[:minutes_til_current_event_ends] = nil)
+
+ logger.debug { "Next event started? #{next_event_started}\nCurrent event ended? #{current_event_ended}" } if @debug
+ @next_countdown = if next_event_started || current_event_ended
+ schedule.in(1.minutes) { fetch_and_expose_todays_events.as(Array(Event)) }
+ else
+ schedule.in(1.minutes) { advance_countdowns(previous, current, next_event, container).as(Bool) }
+ end
+
+ self[:event_in_progress] = current ? in_progress?(current) : false
+ self[:container_event_in_progess] = container ? in_progress?(container) : false
+ self[:no_upcoming_events] = next_event.nil?
+ end
+
+ private def countup_previous_event(previous : Event)
+ time_since_previous = Time.local - previous.end_time
+ self[:minutes_since_previous_event] = time_since_previous.total_minutes.to_i
+ end
+
+ private def countdown_next_event(next_event : Event)
+ time_til_next = next_event.start_time - Time.local
+ self[:minutes_til_next_event] = time_til_next.total_minutes.to_i
+ # return whether the next event has started
+ Time.local >= next_event.start_time
+ end
+
+ private def countdown_current_event(current : Event)
+ time_since_start = Time.local - current.start_time
+ time_til_end = current.end_time - Time.local
+ self[:minutes_since_current_event_started] = time_since_start.total_minutes.to_i
+ self[:minutes_til_current_event_ends] = time_til_end.total_minutes.to_i
+ # return whether the current event has ended
+ Time.local > current.end_time
+ end
+
+ private def in_progress?(event : Event)
+ now = Time.local
+ now >= event.start_time && now <= event.end_time
+ end
+
+ # Does a contain b?
+ private def contains?(a : Event, b : Event)
+ b.start_time >= a.start_time && b.end_time <= a.end_time
+ end
+
+ private def overlaps?(a : Event, b : Event)
+ b.start_time < a.end_time || b.end_time > a.start_time
+ end
+end
diff --git a/drivers/inner_range/integriti.cr b/drivers/inner_range/integriti.cr
new file mode 100644
index 00000000000..434b0a225a5
--- /dev/null
+++ b/drivers/inner_range/integriti.cr
@@ -0,0 +1,1305 @@
+require "placeos-driver"
+require "placeos-driver/interface/door_security"
+require "placeos-driver/interface/guest_building_access"
+# require "../wiegand/models"
+
+require "xml"
+
+# https://integriti-api.innerrange.com/API/v2/doc/
+
+class InnerRange::Integriti < PlaceOS::Driver
+ include Interface::DoorSecurity
+
+ descriptive_name "Inner Range Integriti Security System"
+ generic_name :Integriti
+ uri_base "https://integriti-api.innerrange.com/restapi"
+
+ default_settings({
+ # NOTE:: we need both the basic auth and API key
+ # the API key is provided by innerrange and is a
+ # the same for each client (one per-integrator)
+ basic_auth: {
+ username: "installer",
+ password: "installer",
+ },
+ api_key: "api-access-key",
+ default_unlock_time: 10,
+ default_site_id: 1,
+ default_partition_id: nil,
+
+ custom_field_hid_origo: "cf_HasVirtualCard",
+ custom_field_email: "cf_EmailAddress",
+ custom_field_phone: "cf_Mobile",
+ custom_field_csv_sync: "cf_CSVCustom",
+ custom_field_license: "cf_LicensePlate",
+
+ # 16 bit card number in Wiegand 26
+ # Ideally guests have their own site id and the full range of card numbers
+ # this ensures there is little chance of a clash
+ guest_card_template: "TM2",
+ guest_access_group: "QG36",
+ guest_card_start: 0,
+ guest_card_end: (UInt16::MAX - 1),
+ guest_exclude_range: ["19350-21599", "22505-22754"],
+
+ timezone: "Australia/Sydney",
+ long_poll_seconds: 10,
+ })
+
+ def on_update
+ api_key = setting?(String, :api_key) || ""
+ @cf_origo = setting?(String, :custom_field_hid_origo) || "cf_HasVirtualCard"
+ @cf_email = setting?(String, :custom_field_email) || "cf_EmailAddress"
+ @cf_phone = setting?(String, :custom_field_phone) || "cf_Mobile"
+ @cf_csv = setting?(String, :custom_field_csv_sync) || "cf_CSVCustom"
+ @cf_license = setting?(String, :custom_field_license) || "cf_LicensePlate"
+ @guest_card_template = setting?(String, :guest_card_template) || ""
+ guest_card_start = setting?(UInt16, :guest_card_start) || 0_u16
+ guest_card_end = setting?(UInt16, :guest_card_end) || (UInt16::MAX - 1_u16)
+ @guest_card_range = Range.new(guest_card_start, guest_card_end)
+ @guest_access_group = setting?(String, :guest_access_group) || ""
+ @long_poll_seconds = setting?(Int32, :long_poll_seconds) || 10
+
+ transport.before_request do |request|
+ logger.debug { "requesting: #{request.method} #{request.path}?#{request.query}\n#{request.body}" }
+ request.headers["API-KEY"] = api_key
+ request.headers["Accept"] = "application/xml"
+ request.headers["Content-Type"] = "application/xml"
+ end
+
+ @default_unlock_time = setting?(Int32, :default_unlock_time) || 10
+ @default_site_id = setting?(Int32, :default_site_id) || 1
+ @default_partition_id = setting?(Int32, :default_partition_id) || 0
+
+ time_zone = setting?(String, :timezone).presence
+ @timezone = Time::Location.load(time_zone) if time_zone
+
+ exclude_range = setting?(Array(String), :guest_exclude_range) || [] of String
+ ranges = [] of Range(Int32, Int32)
+ exclude_range.each do |range_str|
+ range = range_str.split('-').map(&.to_i)
+ ranges << (range[0]..range[1])
+ rescue error
+ logger.warn(exception: error) { "failed to parse range: #{range_str}" }
+ end
+ @guest_exclude_ranges = ranges
+
+ @guest_card_site_code = nil
+ end
+
+ getter long_poll_seconds : Int32 = 10
+ getter default_unlock_time : Int32 = 10
+ getter default_site_id : Int32 = 1
+ getter default_partition_id : Int32 = 0
+ getter cf_email : String = "cf_EmailAddress"
+ getter cf_phone : String = "cf_Mobile"
+ getter cf_origo : String = "cf_HasVirtualCard"
+ getter cf_csv : String = "cf_CSVCustom"
+ getter cf_license : String = "cf_LicensePlate"
+ getter guest_card_template : String = ""
+ getter guest_access_group : String = ""
+ @guest_card_range : Range(UInt16, UInt16) = 0_u16..UInt16::MAX
+ @guest_exclude_ranges : Array(Range(Int32, Int32)) = [] of Range(Int32, Int32)
+ @timezone : Time::Location = Time::Location.load("Australia/Sydney")
+
+ macro check(response)
+ begin
+ %resp = {{response}}
+ raise "request failed with #{%resp.status_code} (#{%resp.body})" unless %resp.success?
+ %body = %resp.body
+ logger.debug { "response was:\n#{%body}" }
+ begin
+ XML.parse %body
+ rescue error
+ logger.error { "error: #{error.message}, failed to parse:\n#{%body}" }
+ raise error
+ end
+ end
+ end
+
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%9N"
+ PROPS = {} of String => String
+
+ abstract struct IntegritiObject
+ include JSON::Serializable
+ end
+
+ macro define_xml_type(klass, keys, lookup = nil, &block)
+ struct {{klass}} < IntegritiObject
+ {% for _node, variable in keys %}
+ getter! {{ variable.var }} : {{ variable.type }}
+ {% end %}
+
+ def initialize(
+ {% for _node, variable in keys %}
+ @{{ variable.var }} = nil,
+ {% end %}
+ )
+ end
+
+ {% if block %}
+ {{block.body}}
+ {% end %}
+ end
+
+ {% PROPS[lookup || klass.stringify] = keys.keys.join(",") %}
+
+ protected def extract_{{klass.id.stringify.underscore.id}}(document : XML::Node) : {{klass}}
+ {% for _node, variable in keys %}
+ var_{{ variable.var }} = nil
+ {% end %}
+
+ if %data = document.document? ? document.first_element_child : document
+ {% for node, variable in keys %}
+ {% if node.starts_with? "attr_" %}
+ {% attribute_name = node.split("_")[1] %}
+ if %content = %data[{{attribute_name}}]?
+
+ # extract the data
+ {% resolved_type = variable.type.resolve %}
+ {% variable_var = variable.var %}
+ {% if resolved_type == Int32 %}
+ var_{{ variable_var }} = %content.to_i?
+ {% elsif resolved_type == Int64 %}
+ var_{{ variable_var }} = %content.to_i64?
+ {% elsif resolved_type == Bool %}
+ var_{{ variable_var }} = %content.downcase == "true"
+ {% elsif resolved_type == Float64 %}
+ var_{{ variable_var }} = %content.to_f?
+ {% elsif resolved_type == Time %}
+ var_{{ variable_var }} = Time.parse(%content, TIME_FORMAT, Time::Location::UTC)
+ {% elsif resolved_type.superclass == IntegritiObject %}
+ var_{{ variable_var }} = extract_{{variable.type.stringify.underscore.id}}(child)
+ {% else %}
+ var_{{ variable_var }} = %content
+ {% end %}
+ else
+ var_{{ variable_var }} = nil
+ end
+ {% end %}
+ {% end %}
+
+ %data.children.select(&.element?).each do |child|
+ case child.name
+ when "Ref"
+ # minimal data provided in attributes
+ {% for node, variable in keys %}
+ {% if node.starts_with? "attr_" %}
+ {% attribute_name = node.split("_")[1] %}
+ {% else %}
+ {% attribute_name = node %}
+ {% end %}
+
+ # ID in ref's are actually the Address in objects
+ {% if attribute_name == "Address" %}
+ {% attribute_name = "ID" %}
+ {% end %}
+
+ if %content = child[{{attribute_name}}]?
+ # extract the data
+ {% resolved_type = variable.type.resolve %}
+ {% variable_var = variable.var %}
+
+ {% if resolved_type == Int32 %}
+ var_{{ variable_var }} = %content.to_i?
+ {% elsif resolved_type == Int64 %}
+ var_{{ variable_var }} = %content.to_i64?
+ {% elsif resolved_type == Bool %}
+ var_{{ variable_var }} = %content.downcase == "true"
+ {% elsif resolved_type == Float64 %}
+ var_{{ variable_var }} = %content.to_f?
+ {% elsif resolved_type == Time %}
+ var_{{ variable_var }} = Time.parse(%content, TIME_FORMAT, Time::Location::UTC)
+ {% elsif resolved_type.superclass == IntegritiObject %}
+ var_{{ variable_var }} = extract_{{variable.type.stringify.underscore.id}}(child)
+ {% else %}
+ var_{{ variable_var }} = %content
+ {% end %}
+ else
+ var_{{ variable_var }} = nil
+ end
+ {% end %}
+ {% for node, variable in keys %}
+ {% if node.starts_with? "cf_" %}
+ # handle custom fields using accessors
+ when {{node.id}}
+ {% else %}
+ when {{node.id.stringify}}
+ {% end %}
+
+ if %content = child.content
+ # extract the data
+ {% resolved_type = variable.type.resolve %}
+ {% variable_var = variable.var %}
+ {% if resolved_type == Int32 %}
+ var_{{ variable_var }} = %content.to_i?
+ {% elsif resolved_type == Int64 %}
+ var_{{ variable_var }} = %content.to_i64?
+ {% elsif resolved_type == Bool %}
+ var_{{ variable_var }} = %content.downcase == "true"
+ {% elsif resolved_type == Float64 %}
+ var_{{ variable_var }} = %content.to_f?
+ {% elsif resolved_type == Time %}
+ var_{{ variable_var }} = Time.parse(%content, TIME_FORMAT, Time::Location::UTC)
+ {% elsif resolved_type.superclass == IntegritiObject %}
+ var_{{ variable_var }} = extract_{{variable.type.stringify.underscore.id}}(child)
+ {% else %}
+ var_{{ variable_var }} = %content
+ {% end %}
+ else
+ var_{{ variable_var }} = nil
+ end
+ {% end %}
+ end
+ end
+ end
+
+ {{klass}}.new(
+ {% for node, variable in keys %}
+ {{ variable.var }}: var_{{ variable.var }},
+ {% end %}
+ )
+ end
+ end
+
+ alias Filter = Hash(String, String | Bool | Int64 | Int32 | Float64 | Float32 | Nil)
+
+ def build_filter(filter : Filter) : String
+ XML.build(indent: " ") do |xml|
+ xml.element("FilterExpression", {
+ "xmlns:xsd" => "http://www.w3.org/2001/XMLSchema",
+ "xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance",
+ "xsi:type" => "AggregateExpression",
+ }) do
+ # xml.element("OperatorType") { xml.text "Or" }
+ xml.element("OperatorType") { xml.text "And" }
+ xml.element("SubExpressions") do
+ filter.each do |key, value|
+ next if value.nil?
+
+ xml.element("FilterExpression", {
+ "xsi:type" => "PropertyExpression",
+ }) do
+ xml.element("PropertyName") { xml.text key }
+ # also supports: Greater, Less
+ xml.element("OperatorType") { xml.text "Equals" }
+ xml.element("Args") do
+ compare_type = case value
+ in String
+ "xsd:string"
+ in Bool
+ "xsd:boolean"
+ in Int32
+ "xsd:int"
+ in Int64
+ "xsd:long"
+ in Float32
+ "xsd:float"
+ in Float64
+ "xsd:double"
+ in Nil
+ raise "nil values not supported"
+ end
+
+ xml.element("anyType", {
+ "xsi:type" => compare_type,
+ }) do
+ xml.text value.to_s
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ # &FullObject=true doesn't work for cards annoyingly...
+ protected def prop_param(type : String, summary_only : Bool = false)
+ return "" if summary_only
+ if props = PROPS[type]?
+ "AdditionalProperties=#{props}"
+ else
+ "FullObject=true"
+ end
+ end
+
+ protected def paginate_request(category : String, type : String, filter : Filter = Filter.new, summary_only : Bool = false, query : URI::Params | String? = nil, page_limit : Int64? = nil, &)
+ query_params = "#{query}&"
+ query_params = "" if query_params.size == 1
+ filter.compact!
+ page_count = 0_i64
+
+ next_page = if filter.empty?
+ "/v2/#{category}/#{type}?PageSize=1000{query_params}#{prop_param(type, summary_only)}"
+ else
+ body = build_filter(filter)
+ "/v2/#{category}/GetFilteredEntities/#{type}?PageSize=1000{query_params}#{prop_param(type, summary_only)}"
+ end
+
+ next_uri = URI.parse(next_page)
+
+ loop do
+ document = if filter.empty?
+ check get(next_page)
+ else
+ check post(next_page, body: body)
+ end
+
+ page_size = 0
+ next_page = ""
+ rows_returned = 0
+
+ if data = document.first_element_child
+ data.children.select(&.element?).each do |child|
+ case child.name
+ when "PageSize"
+ page_size = (child.content || "0").to_i
+ when "NextPageUrl"
+ uri = URI.parse URI.decode(child.content || "")
+ next_uri.query = uri.query
+ next_page = next_uri.request_target
+ when "Rows"
+ if rows = child.children.select(&.element?)
+ rows_returned = rows.size
+ rows.each do |node|
+ yield node
+ end
+ end
+ end
+ end
+ end
+
+ page_count += 1_i64
+ break if next_page.empty? || rows_returned < page_size || (page_limit && page_count >= page_limit)
+ end
+ end
+
+ # http://20.213.104.2:80/restapi/ApiVersion/v2
+ def api_version : String
+ document = check get("/ApiVersion")
+ uri = URI.parse document.first_element_child.try(&.content).as(String)
+ Path[uri.path].basename
+ end
+
+ # ===========
+ # SYSTEM INFO
+ # ===========
+
+ define_xml_type(SystemInfo, {
+ "ProductEdition" => edition : String,
+ "ProductVersion" => version : String,
+ "ProtocolVersion" => protocol : Int32,
+ })
+
+ def system_info
+ document = check get("/v2/SystemInfo")
+ extract_system_info(document)
+ end
+
+ # =======================
+ # Collection Modification
+ # =======================
+ # these are special routes for adding or removing items from collections
+ # use XML.build_fragment as errors if there is a version header:
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def add_to_collection(type : String, id : String, property_name : String, payload : String)
+ check patch("/v2/User/#{type}/#{id}/#{property_name}/addToCollection?IncludeObjectInResult=true", body: payload)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def remove_from_collection(type : String, id : String, property_name : String, payload : String)
+ check patch("/v2/User/#{type}/#{id}/#{property_name}/removeFromCollection?IncludeObjectInResult=true", body: payload)
+ end
+
+ define_xml_type(RemoveResult, {
+ "NumberOfItemsRemoved" => modified : Int32,
+ "Message" => message : String,
+ })
+
+ define_xml_type(AddResult, {
+ "NumberOfItemsAdded" => modified : Int32,
+ "Message" => message : String,
+ })
+
+ protected def modify_collection(type : String, id : String, property_name : String, payload : String, *, add : Bool = true)
+ if add
+ add_to_collection(type, id, property_name, payload)
+ else
+ remove_from_collection(type, id, property_name, payload)
+ end
+ end
+
+ struct Ref
+ include JSON::Serializable
+
+ getter type : String
+ getter id : String
+ getter partition_id : String | Int32? = nil
+
+ def initialize(@type, @id, @partition_id = nil)
+ end
+
+ def to_xml(xml)
+ xml.element("Ref", {
+ "Type" => type,
+ "PartitionID" => partition_id,
+ "ID" => id,
+ }.compact!)
+ end
+ end
+
+ # =======================
+ # Add or Update DB entry
+ # =======================
+
+ define_xml_type(AddOrUpdateResult, {
+ "ID" => id : Int64 | String,
+ "Address" => address : String?,
+ "Message" => message : String,
+ })
+
+ alias UpdateFields = Hash(String, String | Float64 | Int64 | Bool | Ref | Nil)
+
+ # This is the only way to add or update a database entry...
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def add_or_update(payload : String, return_object : Bool = false)
+ if return_object
+ check post("/v2/User/AddOrUpdate?IncludeObjectInResult=true", body: payload)
+ else
+ check post("/v2/User/AddOrUpdate", body: payload)
+ end
+ end
+
+ protected def add(type : String, return_object : Bool = false, &)
+ payload = XML.build_fragment(indent: " ") do |xml|
+ xml.element(type, {"PartitionID" => @default_partition_id.to_s}) { yield xml }
+ end
+ add_or_update payload, return_object: return_object
+ end
+
+ protected def apply_fields(xml, fields)
+ fields.each do |key, value|
+ case value
+ when Nil
+ xml.element(key)
+ when Ref
+ xml.element(key) { value.to_xml(xml) }
+ else
+ value_str = case value
+ when Bool
+ value ? "True" : "False"
+ else
+ value.to_s
+ end
+ xml.element(key) { xml.text value_str }
+ end
+ end
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def add_entry(type : String, fields : UpdateFields, return_object : Bool = false)
+ add(type, return_object) { |xml| apply_fields(xml, fields) }
+ end
+
+ protected def update(type : String, id : String, attribute : String = "Address", return_object : Bool = false, &)
+ payload = XML.build_fragment(indent: " ") do |xml|
+ xml.element(type, {
+ "PartitionID" => @default_partition_id.to_s,
+ attribute => id,
+ }) { yield xml }
+ end
+ add_or_update payload, return_object: return_object
+ end
+
+ # use this to update fields in various models, like:
+ # update_entry(type: "User", id: "U5", fields: {cf_HasMobileCredential: true})
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def update_entry(type : String, id : String, fields : UpdateFields, attribute : String = "Address", return_object : Bool = false)
+ update(type, id, attribute, return_object) { |xml| apply_fields(xml, fields) }
+ end
+
+ # =================
+ # Permission Groups
+ # =================
+
+ define_xml_type(PermissionGroup, {
+ "attr_PartitionID" => partition_id : Int32,
+ "SiteName" => site_name : String,
+ "SiteID" => site_id : Int32,
+ "ID" => id : Int64,
+ "Name" => name : String,
+ "Address" => address : String,
+ })
+
+ def permission_groups(site_id : Int32? = nil) : Array(PermissionGroup)
+ pgroups = [] of PermissionGroup
+ filter = Filter{
+ "Site.ID" => site_id,
+ }
+ paginate_request("User", "PermissionGroup", filter, summary_only: true) do |row|
+ pgroups << extract_permission_group(row)
+ end
+ pgroups
+ end
+
+ def permission_group(id : Int64 | String)
+ # we only want summaries of these, so no prop_param provided
+ document = check get("/v2/User/PermissionGroup/#{id}")
+ extract_site(document)
+ end
+
+ # =====
+ # SITES
+ # =====
+
+ define_xml_type(Site, {
+ "ID" => id : Int32,
+ "Name" => name : String,
+ "PartitionID" => partition_id : Int32,
+ }, "SiteKeyword")
+
+ # roughly analogous to buildings
+ def sites : Array(Site)
+ sites = [] of Site
+ paginate_request("User", "SiteKeyword") do |row|
+ sites << extract_site(row)
+ end
+ sites
+ end
+
+ def site(id : Int64 | String)
+ document = check get("/v2/User/SiteKeyword/#{id}?#{prop_param "SiteKeyword"}")
+ extract_site(document)
+ end
+
+ # =====
+ # AREAS
+ # =====
+
+ define_xml_type(Area, {
+ "ID" => id : Int64,
+ "Name" => name : String,
+ "Site" => site : Site,
+ })
+
+ # roughly zones in a building
+ def areas(site_id : Int32? = nil)
+ areas = [] of Area
+ filter = Filter{
+ "Site.ID" => site_id,
+ }
+ paginate_request("User", "Area", filter) do |row|
+ areas << extract_area(row)
+ end
+ areas
+ end
+
+ def area(id : Int64 | String)
+ document = check get("/v2/User/Area/#{id}?#{prop_param "Area"}")
+ extract_area(document)
+ end
+
+ # ==========
+ # Partitions
+ # ==========
+
+ define_xml_type(Partition, {
+ "ID" => id : Int32,
+ "Name" => name : String,
+ "ParentId" => parent_id : Int32,
+ "PartitionId" => partition_id : Int32,
+ "ShortName" => short_name : String,
+ })
+
+ # doors on a site
+ def partitions(parent_id : Int32? = nil)
+ partitions = [] of Partition
+ filter = Filter{
+ "ParentId" => parent_id,
+ }
+ paginate_request("User", "Partition", filter) do |row|
+ partitions << extract_partition(row)
+ end
+ partitions
+ end
+
+ def partition(id : Int64 | String)
+ document = check get("/v2/User/Partition/#{id}?#{prop_param "Partition"}")
+ extract_partition(document)
+ end
+
+ # =====
+ # Users
+ # =====
+
+ define_xml_type(User, {
+ "ID" => id : Int64,
+ "Name" => name : String,
+ "SiteID" => site_id : Int32,
+ "SiteName" => site_name : String,
+ "Address" => address : String,
+ "attr_PartitionID" => partition_id : Int32,
+ "cf_origo" => origo : Bool,
+ "cf_phone" => phone : String,
+ "cf_email" => email : String,
+ "cf_csv" => csv : String,
+ "cf_license" => license : String,
+ "PrimaryPermissionGroup" => primary_permission_group : PermissionGroup,
+ })
+
+ define_xml_type(FullUser, {
+ "ID" => id : Int64,
+ "Name" => name : String,
+ "Site" => site : Site, # ref only
+ "Address" => address : String,
+ "attr_PartitionID" => partition_id : Int32,
+ "cf_origo" => origo : Bool,
+ "cf_phone" => phone : String,
+ "cf_email" => email : String,
+ "cf_csv" => csv : String,
+ "cf_license" => license : String,
+ "PrimaryPermissionGroup" => primary_permission_group : PermissionGroup, # ref only
+ }) do
+ def site_id
+ site.id
+ end
+
+ def site_name
+ site.name
+ end
+ end
+
+ # users in a site
+ def users(site_id : Int32? = nil, email : String? = nil, first_name : String? = nil, second_name : String? = nil)
+ users = [] of User
+ filter = Filter{
+ # can't filter users by site id for some reason
+ # "SiteID" => site_id,
+ cf_email => email,
+ "FirstName" => first_name,
+ "SecondName" => second_name,
+ }
+ paginate_request("User", "User", filter) do |row|
+ users << extract_user(row)
+ end
+ if site_id
+ users.select(&.site_id.==(site_id))
+ end
+ users
+ end
+
+ def user(id : Int64 | String)
+ document = check get("/v2/User/User/#{id}?FullObject=true")
+ extract_full_user(document)
+ end
+
+ def user_id_lookup(email : String) : Array(String)
+ users(email: email).map(&.address.as(String))
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def create_user(name : String, email : String, phone : String? = nil, site_id : String | Int64? = nil, csv : String? = nil, license : String? = nil) : String
+ first_name, second_name = name.includes?(' ') ? name.split(' ', 2) : {name, "not provided"}
+
+ user = extract_add_or_update_result(add_entry("User", UpdateFields{
+ "FirstName" => first_name.strip,
+ "SecondName" => second_name.strip,
+ "Site" => Ref.new("SiteKeyword", (site_id || default_site_id).to_s),
+ cf_email => email.strip.downcase,
+ cf_phone => phone,
+ cf_csv => csv,
+ cf_license => license,
+ }.compact!))
+ user.address.as(String)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def update_user_custom(
+ user_id : String,
+ email : String? = nil,
+ phone : String? = nil,
+ origo : Bool? = nil,
+ csv : String? = nil,
+ license : String? = nil,
+ )
+ fields = UpdateFields{
+ cf_email => email.try(&.strip.downcase),
+ cf_phone => phone,
+ cf_origo => origo,
+ cf_csv => csv,
+ cf_license => license,
+ }.compact!
+
+ return nil if fields.empty?
+
+ extract_add_or_update_result(update_entry("User", user_id, fields))
+ end
+
+ # ================
+ # User Permissions
+ # ================
+
+ define_xml_type(UserPermission, {
+ "ID" => id : String,
+ # returns PartitionID="0" Address="QG4"
+ "What" => group : PermissionGroup,
+ "ManagedByActiveDirectory" => externally_managed : Bool,
+ # returns PartitionID="0" Address="U20"
+ "User" => user : User,
+
+ "Deny" => deny : Bool,
+ "Expired" => expired : Bool,
+ })
+
+ def user_permissions(user_id : String? = nil, group_id : String? = nil, externally_managed : Bool? = nil) : Array(UserPermission)
+ user_permissions = [] of UserPermission
+ filter = Filter{
+ "User.Address" => user_id,
+ "What.Address" => group_id,
+ "ManagedByActiveDirectory" => externally_managed,
+ }
+ paginate_request("User", "UserPermission", filter) do |row|
+ user_permissions << extract_user_permission(row)
+ end
+ user_permissions
+ end
+
+ def managed_users_in_group(group_address : String) : Hash(String, String)
+ user_ids = user_permissions(group_id: group_address, externally_managed: true).map do |permission|
+ permission.user.address.as(String)
+ end
+
+ email_user_id = Hash(String, String).new("", user_ids.size)
+
+ # annoyingly we need to N+1 to get all the user email addresses
+ # the default user response includes custom fields
+ user_ids.each do |user_id|
+ document = check get("/v2/User/User/#{user_id}")
+ if email = extract_user(document).@email
+ email_user_id[email.downcase] = user_id
+ end
+ end
+
+ logger.debug { "found #{email_user_id.size} user to email mappings" }
+ email_user_id
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def modify_user_permissions(
+ user_id : String,
+ group_id : String,
+ partition_id : String | Int32? = nil,
+ add : Bool = true,
+ externally_managed : Bool = true,
+ expires_at : Int64? = nil,
+ valid_from : Int64? = nil
+ )
+ payload = XML.build_fragment(indent: " ") do |xml|
+ xml.element("UserPermission") do
+ xml.element("What") do
+ Ref.new("PermissionGroup", group_id, partition_id).to_xml(xml)
+ end
+
+ if add
+ xml.element("GrantAccess") { xml.text "True" }
+ xml.element("ManagedByActiveDirectory") { xml.text "True" } if externally_managed
+
+ if expires_at
+ expiry = Time.unix(expires_at).in(@timezone).to_s("%Y-%m-%dT%H:%M:%S%:z")
+ xml.element("ExpiryDateTime") { xml.text expiry }
+ end
+
+ if valid_from
+ starting = Time.unix(valid_from).in(@timezone).to_s("%Y-%m-%dT%H:%M:%S%:z")
+ xml.element("StartDateTime") { xml.text starting }
+ end
+ end
+ end
+ end
+
+ response = modify_collection("User", user_id, "Permissions", payload, add: add)
+
+ add ? extract_add_result(response) : extract_remove_result(response)
+ end
+
+ # sets or unsets the Permission Group
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def set_user_primary_permission_group(user_id : String, permission_group_id : String?)
+ if permission_group_id
+ update_entry("User", user_id, UpdateFields{
+ "PrimaryPermissionGroup" => Ref.new("PermissionGroup", permission_group_id),
+ })
+ else
+ update_entry("User", user_id, UpdateFields{
+ "PrimaryPermissionGroup" => nil,
+ })
+ end
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def delete_permission(user_id : String, permission_id : String)
+ payload = XML.build_fragment(indent: " ") do |xml|
+ xml.element("UserPermission", {"ID" => permission_id}) do
+ xml.element("ID") { xml.text permission_id }
+ end
+ end
+ extract_remove_result modify_collection("User", user_id, "Permissions", payload, add: false)
+ end
+
+ # =====
+ # Cards
+ # =====
+
+ define_xml_type(CardFormat, {
+ "ID" => id : Int64,
+ "Name" => name : String,
+ "SiteID" => site_id : Int32,
+ "SiteName" => site_name : String,
+ "Notes" => notes : String,
+ "Address" => address : String,
+ "Site" => site : Site,
+ })
+
+ define_xml_type(CardTemplate, {
+ "ID" => id : Int64,
+ "Name" => name : String,
+ "SiteID" => site_id : Int32,
+ "SiteName" => site_name : String,
+ "Notes" => notes : String,
+ "Address" => address : String,
+ "SiteCodeNumber" => site_code : Int64, # Facility Code
+ "Site" => site : Site,
+ "Format" => format : CardFormat,
+ })
+
+ define_xml_type(Card, {
+ "ID" => id : String,
+ "Name" => name : String,
+ "CardNumberNumeric" => card_number_numeric : Int64,
+ "CardNumber" => card_number : String,
+ "CardData" => card_data_hex : String,
+ "CardSerialNumber" => card_serial_number : String,
+ "IssueNumber" => issue_number : Int32,
+ # Active, ActiveExpiring, ActiveReplacement seem to be the only active states
+ "State" => state : String,
+ "ExpiryDateTime" => expiry : String,
+ "StartDateTime" => valid_from : String,
+ "LastUsed" => last_used : String,
+ "CloudCredentialId" => cloud_credential_id : String,
+ # None or HIDMobileCredential
+ "CloudCredentialType" => cloud_credential_type : String,
+ "CloudCredentialPoolId" => cloud_credential_pool_id : String,
+ "CloudCredentialInvitationId" => cloud_credential_invite_id : String,
+ "CloudCredentialInvitationCode" => cloud_credential_invite_code : String,
+ "CloudCredentialCommunicationHandler" => cloud_credential_comms_handler : String,
+ "ManagedByActiveDirectory" => active_directory : Bool,
+ # these are Ref types so won't be fully hydrated (id and name only)
+ "Site" => site : Site,
+ "User" => user : User,
+ "CardType" => template : CardTemplate,
+ })
+
+ def cards(
+ site_id : Int32? = nil,
+ user_id : String? = nil,
+ template : String? = nil,
+ number : String? = nil,
+ )
+ cards = [] of Card
+ filter = Filter{
+ "CardNumber" => number,
+ "Site.ID" => site_id,
+ "User.Address" => user_id,
+ "CardType.Address" => template,
+ }
+ paginate_request("User", "Card", filter) do |row|
+ cards << extract_card(row)
+ end
+ cards
+ end
+
+ def card(id : String)
+ document = check get("/v2/User/Card/#{id}?#{prop_param "Card"}")
+ extract_card(document)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def create_card(
+ # facility : UInt32, is defined in the card template
+ card_number : String | Int64,
+ user_id : String? = nil,
+ partition_id : String | Int32? = nil,
+ site_id : String | Int64? = nil,
+ # card template defines the type of card + site code / facility
+ card_template : String? = nil, # TM2 for example
+ externally_managed : Bool? = nil
+ ) : String
+ # wiegand_card = Wiegand::Wiegand26.from_components(facility, card_number).wiegand.to_s
+
+ if user_id
+ user_ref = Ref.new("User", user_id, partition_id)
+ end
+
+ if site_id
+ site_ref = Ref.new("SiteKeyword", site_id.to_s)
+ end
+
+ if card_template
+ card_type = Ref.new("CardTemplate", card_template, partition_id)
+ end
+
+ card = extract_add_or_update_result(add_entry("Card", UpdateFields{
+ "CardNumber" => card_number,
+ "Site" => site_ref,
+ "User" => user_ref,
+ "CardType" => card_type,
+ "ManagedByActiveDirectory" => externally_managed,
+ }.compact!))
+ card.id.as(String)
+ end
+
+ # sets or unsets the user associated with this card
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def set_card_user(card_id : String, user_id : String?, partition_id : String | Int32? = nil)
+ if user_id
+ update_entry("Card", card_id, UpdateFields{
+ "User" => Ref.new("User", user_id, partition_id),
+ }, attribute: "ID")
+ else
+ update_entry("Card", card_id, UpdateFields{
+ "User" => nil,
+ }, attribute: "ID")
+ end
+ end
+
+ def card_templates(site_id : Int32? = nil)
+ templates = [] of CardTemplate
+ filter = Filter{
+ "Site.ID" => site_id,
+ }
+ paginate_request("User", "CardTemplate", filter) do |row|
+ templates << extract_card_template(row)
+ end
+ templates
+ end
+
+ def template(address : String)
+ document = check get("/v2/User/CardTemplate/#{address}?#{prop_param "CardTemplate"}")
+ extract_card_template(document)
+ end
+
+ # =====
+ # Doors
+ # =====
+
+ define_xml_type(IntegritiDoor, {
+ "ID" => id : Int64,
+ "Name" => name : String,
+ "Site" => site : Site,
+ }, "Door")
+
+ # doors on a site
+ def doors(site_id : Int32? = nil)
+ doors = [] of IntegritiDoor
+ filter = Filter{
+ "Site.ID" => site_id,
+ }
+ paginate_request("User", "Door", filter) do |row|
+ doors << extract_integriti_door(row)
+ end
+ doors
+ end
+
+ def door(id : Int64 | String)
+ document = check get("/v2/User/Door/#{id}?#{prop_param "Door"}")
+ extract_integriti_door(document)
+ end
+
+ # =========
+ # Review IO
+ # =========
+
+ define_xml_type(Review, {
+ "ID" => id : String,
+ "Text" => text : String,
+ "UTCTimeGenerated" => time_generated : Time,
+ "Type" => event_type : String,
+ "Transition" => transition : String,
+ }, "Review") do
+ getter time_gen_ms : String { time_generated.to_s(TIME_FORMAT) }
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def review_predefined_access(query_id : String | Int64, long_poll : Bool = false, after : String | Int64 | Time? = nil, page_limit : Int64? = nil) : Array(Review)
+ after_param = case after
+ in Int64
+ Time.unix(after)
+ in String
+ Time.parse(after, TIME_FORMAT, Time::Location::UTC)
+ in Time
+ after
+ in Nil
+ long_poll ? Time.utc : 10.minutes.ago
+ end
+
+ params = URI::Params.build do |form|
+ form.add "UTCTimeGenerated", after_param.to_s(TIME_FORMAT)
+ form.add "SortProperty", "UTCTimeGenerated"
+ form.add "SortOrder", "Descending"
+
+ if long_poll
+ form.add "LongPoll", "true"
+ form.add "LongPollTime", @long_poll_seconds.to_s
+ end
+ end
+
+ review = [] of Review
+ paginate_request("Review", "PredefinedFilter/#{query_id}", page_limit: page_limit, query: params) do |row|
+ entry = extract_review(row)
+ entry.time_gen_ms
+ review << entry
+ entry
+ end
+ review
+ end
+
+ # Type 5 = CommsEvent, 6 = UserAccess, 7 = CardInfo
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def review_access(filter : Filter, long_poll : Bool = false, after : String | Int64 | Time? = nil, page_limit : Int64? = nil) : Array(Review)
+ after_param = case after
+ in Int64
+ Time.unix(after)
+ in String
+ Time.parse(after, TIME_FORMAT, Time::Location::UTC)
+ in Time
+ after
+ in Nil
+ long_poll ? Time.utc : 10.minutes.ago
+ end
+
+ params = URI::Params.build do |form|
+ form.add "UTCTimeGenerated", after_param.to_s(TIME_FORMAT)
+ form.add "SortProperty", "UTCTimeGenerated"
+ form.add "SortOrder", "Descending"
+
+ if long_poll
+ form.add "LongPoll", "true"
+ form.add "LongPollTime", @long_poll_seconds.to_s
+ end
+ end
+
+ review = [] of Review
+ # as the enums need to be filtered by int value (very annoying)
+ filter = filter.transform_values { |val| val.is_a?(Int64) ? val.to_i32 : val }
+ paginate_request("Review", "Review", filter, page_limit: page_limit, query: params) do |row|
+ entry = extract_review(row)
+ entry.time_gen_ms
+ review << entry
+ entry
+ end
+ review
+ end
+
+ # =======================
+ # Door Security Interface
+ # =======================
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def door_list : Array(Door)
+ doors(default_site_id).map do |door|
+ Door.new(door.id.to_s, door.name)
+ end
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def unlock(door_id : String) : Bool?
+ payload = XML.build(indent: " ") do |xml|
+ xml.element("GrantAccessActionOptions") do
+ xml.element("UnlockSeconds") { xml.text default_unlock_time.to_s }
+ # If true, access will be granted even if the Door has been overridden.
+ xml.element("ForceEvenIfOverridden") { xml.text "false" }
+ end
+ end
+
+ response = post("/v2/BasicStatus/GrantAccess/#{door_id}", body: payload)
+ response.success?
+ end
+
+ # ======================
+ # Guest Access Interface
+ # ======================
+
+ include Interface::GuestBuildingAccess
+
+ class Guest < AccessDetails
+ property user_id : String
+ property permission_id : String
+ property card_number : Int64?
+ property card_facility : Int64?
+
+ def initialize(@user_id, @permission_id, @card_hex, @card_number = nil, @card_facility = nil)
+ end
+ end
+
+ PERMISSION_REGEX = /ID\:\s+(?[a-f0-9\-]+)\s+added/
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def grant_access(
+ name : String,
+ email : String,
+ group_id : String,
+ starting : Int64? = nil,
+ ending : Int64? = nil,
+ partition_id : Int32? = nil,
+ site_id : Int32? = nil,
+ externally_managed : Bool = true
+ ) : AccessDetails
+ site_id ||= @default_site_id
+ partition_id ||= @default_partition_id
+
+ # create a user in the access control system
+ email = email.downcase
+ user_id = user_id_lookup(email).first? || create_user(name: name, email: email, site_id: site_id)
+
+ # grant the user access
+ result = modify_user_permissions(
+ user_id: user_id,
+ group_id: group_id,
+ partition_id: partition_id,
+ add: true,
+ externally_managed: externally_managed,
+ expires_at: ending,
+ valid_from: starting
+ ).as(AddResult)
+
+ raise result.message unless result.modified == 1
+ matching = PERMISSION_REGEX.match(result.message)
+ raise "unable to obtain permission ID from: #{result.message}" unless matching
+
+ # returns the permission ID, can delete it using:
+ Guest.new(user_id, matching["id"], "")
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def assign_card_to_user(
+ user_id : String,
+ card_template : String,
+ card_number : String,
+ partition_id : Int32? = nil,
+ site_id : Int32? = nil,
+ externally_managed : Bool = true
+ )
+ site_id ||= @default_site_id
+ partition_id ||= @default_partition_id
+
+ if candidate = cards(template: card_template, number: card_number).first?
+ set_card_user(candidate.id, user_id)
+ else
+ card_id = create_card(
+ card_number: card_number,
+ user_id: user_id,
+ partition_id: partition_id,
+ site_id: site_id,
+ card_template: card_template,
+ externally_managed: externally_managed
+ )
+ # we have to query this way to obtain the data we need
+ candidate = cards(template: card_template, number: card_number).first
+ end
+
+ candidate
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def grant_guest_access(name : String, email : String, starting : Int64, ending : Int64) : AccessDetails
+ raise "guest access is not configured" unless guest_access_configured?
+
+ # create a user in the access control system
+ email = email.downcase
+ user_id = user_id_lookup(email).first? || create_user(name: name, email: email, site_id: @default_site_id)
+
+ # ensure the user has a card
+ card = cards(user_id: user_id).find { |card| card.template.try(&.address) == @guest_card_template }
+ card = create_guest_card(user_id) unless card
+
+ # grant the user access
+ result = modify_user_permissions(
+ user_id: user_id,
+ group_id: @guest_access_group,
+ partition_id: @default_partition_id,
+ add: true,
+ externally_managed: true,
+ expires_at: ending,
+ valid_from: starting
+ ).as(AddResult)
+
+ raise result.message unless result.modified == 1
+ matching = PERMISSION_REGEX.match(result.message)
+ raise "unable to obtain permission ID from: #{result.message}" unless matching
+
+ Guest.new(user_id, matching["id"], card.card_data_hex, card.card_number.try(&.to_i64?), guest_card_site_code)
+ end
+
+ getter guest_card_site_code : Int64 do
+ raise "guest access is not configured" unless guest_access_configured?
+ template(guest_card_template).site_code
+ end
+
+ # delete the permission from user
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def revoke_guest_access(details : JSON::Any)
+ details = Guest.from_json details.to_json
+ delete_permission(details.user_id, details.permission_id)
+ end
+
+ def guest_access_configured? : Bool
+ !@guest_access_group.presence.nil? && !@guest_card_template.presence.nil?
+ end
+
+ # interface helpers:
+
+ protected def create_guest_card(user_id : String) : Card
+ card = nil
+ template = @guest_card_template
+
+ loop do
+ # find a number we can use as a guest card
+ number = @guest_card_range.sample
+ loop do
+ excluded = false
+ @guest_exclude_ranges.each do |range|
+ excluded = range.includes?(number)
+ break if excluded
+ end
+ break unless excluded
+ number = @guest_card_range.sample
+ end
+
+ number = number.to_s
+
+ if candidate = cards(template: template, number: number).first?
+ if old_user_id = candidate.user.try(&.address)
+ # if user still using the card, look for another
+ next if user_permissions(old_user_id).any? { |permission| !permission.expired }
+ end
+
+ set_card_user(candidate.id, user_id)
+ else
+ card_id = create_card(
+ card_number: number,
+ user_id: user_id,
+ partition_id: @default_partition_id,
+ site_id: @default_site_id,
+ card_template: template,
+ externally_managed: true
+ )
+ # we have to query this way to obtain the data we need
+ candidate = cards(template: template, number: number).first
+ end
+
+ break candidate
+ end
+ end
+end
diff --git a/drivers/inner_range/integriti_booking_check_in.cr b/drivers/inner_range/integriti_booking_check_in.cr
new file mode 100644
index 00000000000..9003b7ad67a
--- /dev/null
+++ b/drivers/inner_range/integriti_booking_check_in.cr
@@ -0,0 +1,173 @@
+require "placeos-driver"
+require "place_calendar"
+require "xml"
+
+class InnerRange::IntegritiBookingCheckin < PlaceOS::Driver
+ descriptive_name "Integriti Booking Checkin"
+ generic_name :IntegritiBookingCheckin
+
+ default_settings({
+ logging_integriti: "Integriti_1",
+ _lookup_integriti: "Integriti_1",
+
+ predefined_filter: 13,
+ _filter: {
+ key: "value",
+ },
+
+ # we need to extract the users name from the event logs
+ # for desks we split on the card access string
+ booking_types: {
+ desk: " Card Access",
+ parking: " License Plate",
+ },
+ })
+
+ alias Filter = Hash(String, String | Bool | Int64 | Int32 | Float64 | Float32 | Nil)
+
+ def on_update
+ @logging_integriti = setting?(String, :logging_integriti) || "Integriti_1"
+ @lookup_integriti = setting?(String, :lookup_integriti) || @logging_integriti
+ @booking_types = setting(Hash(String, String), :booking_types)
+ time_zone_string = setting?(String, :time_zone).presence || config.control_system.not_nil!.timezone.presence || "GMT"
+ @time_zone = Time::Location.load(time_zone_string)
+ @predefined_filter = setting?(Int32, :predefined_filter)
+ @filter = setting?(Filter, :filter) || Filter.new
+ @building_id = nil
+ channel = @mutex.synchronize do
+ @channel.close
+ @channel = Channel(Nil).new
+ end
+ spawn { monitor_events(channel) }
+ end
+
+ @time_zone : Time::Location = Time::Location.load("GMT")
+ @mutex : Mutex = Mutex.new
+ @channel : Channel(Nil) = Channel(Nil).new
+ @booking_types : Hash(String, String) = {} of String => String
+ @predefined_filter : Int32? = nil
+ @filter : Filter = Filter.new
+ @logging_integriti : String = "Integriti_1"
+ @lookup_integriti : String = "Integriti_1"
+ @failed_name_lookup : Set(String) = Set(String).new
+
+ accessor staff_api : StaffAPI_1
+
+ protected def logging_integriti
+ system[@logging_integriti]
+ end
+
+ protected def lookup_integriti
+ system[@lookup_integriti]
+ end
+
+ def failed_name_lookups
+ @failed_name_lookup.to_a
+ end
+
+ getter building_id : String { get_building_id.not_nil! }
+
+ def get_building_id
+ building_setting = setting?(String, :building_zone_override)
+ return building_setting if building_setting.presence
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ nil
+ end
+
+ getter check_ins : UInt64 = 0_u64
+ getter matched_users : UInt64 = 0_u64
+
+ # event_types: transition:
+ # DoorEvent: DoorLock, DoorTimedUnlock (triggered by UserGrantedOut)
+ # "text": "L10 Main Entry Locked by (Door Logic) (D015)",
+ # "text": "L10 Main Entry Timed Unlocked for 00 h 00 min 05 s by R17: L10 COMMS ROOM (Door Logic) (D015)",
+ # UserAccess: UserGrantedIn, UserGrantedOut
+ # "text": "First LastName Card Access at into Kitchen Auto Door [Card 215]",
+ # "First LastName License Plate access at into B4 Carpark Ramp Entry ANPR Camera 897799759162 [License Plate CZG456]"
+ # "text": "Unknown User Button Access at out of L10 Main Entry",
+ # so we really only care about UserGrantedIn
+
+ struct Event
+ include JSON::Serializable
+
+ getter event_type : String # UserAccess
+ getter transition : String # UserGrantedIn
+ getter time_gen_ms : String
+ getter text : String # "First LastName Card Access at into Kitchen Auto Door [Card 215]"
+ end
+
+ TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%9N"
+
+ getter last_changed : String = Time.utc.to_s(TIME_FORMAT)
+
+ protected def monitor_events(channel)
+ predefined_filter = @predefined_filter
+ filter = @filter
+
+ while !channel.closed?
+ begin
+ events_raw = predefined_filter ? logging_integriti.review_predefined_access(predefined_filter, true, last_changed, 5).get : logging_integriti.review_access(filter, true, last_changed, 5).get
+ events = Array(Event).from_json(events_raw.to_json)
+ next if events.empty?
+
+ logger.debug { "found #{events.size} access events" }
+ @last_changed = events[0].time_gen_ms
+
+ now = Time.local(@time_zone).at_beginning_of_day
+ end_of_day = now.in(@time_zone).at_end_of_day - 2.hours
+ building = building_id
+
+ events.each do |event|
+ next unless event.transition == "UserGrantedIn"
+
+ begin
+ text = event.text
+ @booking_types.each do |booking_type, split_text|
+ next unless text.includes?(split_text)
+
+ # "First LastName Card Access at into Kitchen Auto Door [Card 2155]"
+ name = text.split(split_text, 2)[0]
+ first, last = name.split(' ', 2)
+
+ # find user email
+ if email = lookup_integriti.users(first_name: first, second_name: last.strip).get.as_a.first?.try(&.[]("email").as_s?)
+ staff_user = staff_api.user(email.strip.downcase).get rescue nil
+ if staff_user
+ email = staff_user["email"].as_s
+ end
+ @matched_users += 1_u64
+
+ # find any bookings that user may have
+ bookings = staff_api.query_bookings(now.to_unix, end_of_day.to_unix, zones: {building}, type: booking_type, email: email).get.as_a
+ logger.debug { "found #{bookings.size} of #{booking_type} for #{email}" }
+
+ bookings.each do |booking|
+ if !booking["checked_in"].as_bool?
+ logger.debug { " -- checking in #{booking_type} for #{email}" }
+ @check_ins += 1_u64
+ staff_api.booking_check_in(booking["id"], true, "integriti-access", instance: booking["instance"]?)
+ else
+ logger.debug { " -- skipping #{booking_type} for #{email} as already checked-in" }
+ end
+ end
+ else
+ @failed_name_lookup << name
+ logger.debug { "couldn't find user #{name} in integriti" }
+ end
+
+ break
+ end
+ rescue error
+ logger.warn(exception: error) { "error parsing event: #{event.text}" }
+ self[:parsing_failed] = event.text
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "failure monitoring events" }
+ end
+ end
+ end
+end
diff --git a/drivers/inner_range/integriti_booking_check_in_spec.cr b/drivers/inner_range/integriti_booking_check_in_spec.cr
new file mode 100644
index 00000000000..833f2c9515f
--- /dev/null
+++ b/drivers/inner_range/integriti_booking_check_in_spec.cr
@@ -0,0 +1,119 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "InnerRange::IntegritiBookingCheckin" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ Integriti: {IntegritiMock},
+ })
+
+ sleep 1.second
+
+ exec(:check_ins).get.should eq 1
+ exec(:last_changed).get.should eq "2025-02-18T23:13:05.000000000"
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def user(id : String)
+ raise "unknown user #{id}" unless id == "user@email.com"
+ {
+ email: "user@email.com",
+ login_name: "user@email.com",
+ }
+ end
+
+ def query_bookings(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil,
+ include_checked_out : Bool? = nil,
+ extension_data : JSON::Any? = nil,
+ deleted : Bool? = nil
+ )
+ return [] of Nil if type == "desk"
+ raise "unexpected bookings query" unless type == "parking" && zones.includes?("zone-building") && email == "user@email.com"
+
+ [{
+ id: 2345,
+ checked_in: false,
+ }]
+ end
+
+ def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil, instance : Int64? = nil)
+ raise "unexpected booking id" unless booking_id == 2345 && state
+ true
+ end
+
+ def zones(q : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0,
+ parent : String? = nil,
+ tags : Array(String) | String? = nil)
+ raise "unexpected tag" unless tags == "building"
+ [{
+ "id" => "zone-building",
+ }]
+ end
+end
+
+# :nodoc:
+class IntegritiMock < DriverSpecs::MockDriver
+ @users = {
+ "U35" => {
+ "id" => 281474976710691,
+ "name" => "Test User",
+ "site" => {
+ id: 1,
+ name: "PlaceOS",
+ },
+ "address" => "U35",
+ "partition_id" => 0,
+ "not_origo" => false, # just so the hash accepts bools
+ "email" => "user@email.com",
+ },
+ }
+
+ def users(site_id : Int32? = nil, email : String? = nil, first_name : String? = nil, second_name : String? = nil)
+ name = "#{first_name} #{second_name}"
+ @users.values.select { |user| user["name"] == name }
+ end
+
+ @responded : Bool = false
+
+ def review_predefined_access(query_id : String | Int64, long_poll : Bool = false, after : String | Int64 | Time? = nil, page_limit : Int64? = nil)
+ # this emulates the long polling behaviour of integriti
+ if @responded
+ sleep 10.seconds
+ return [] of Nil
+ end
+
+ @responded = true
+ [{
+ "id" => "0b6f2584-865a-42f6-a939-5fd2e1ed45",
+ "text" => "Test User License Plate access at into B4 Carpark Entry Roller ANPR Camera 8977993759162 [License Plate CZG152]",
+ "time_generated" => "2025-02-18T23:13:05Z",
+ "event_type" => "UserAccess",
+ "transition" => "UserGrantedIn",
+ "time_gen_ms" => "2025-02-18T23:13:05.000000000",
+ },
+ {
+ "id" => "bd436fe9-64dd-429b-a6a5-41f4e21a99",
+ "text" => "EA Kia EV6 Pool Car License Plate access at out of B8 - B4 Carpark Ramp Exit ANPR Camera 89779938759161 [License Plate 2BI8AZ]",
+ "time_generated" => "2025-02-18T23:04:52Z",
+ "event_type" => "UserAccess",
+ "transition" => "UserGrantedOut",
+ "time_gen_ms" => "2025-02-18T23:04:52.859000000",
+ }]
+ end
+end
diff --git a/drivers/inner_range/integriti_hid_virtual_pass.cr b/drivers/inner_range/integriti_hid_virtual_pass.cr
new file mode 100644
index 00000000000..57ce9b341cd
--- /dev/null
+++ b/drivers/inner_range/integriti_hid_virtual_pass.cr
@@ -0,0 +1,52 @@
+require "placeos-driver"
+
+class InnerRange::Integriti < PlaceOS::Driver
+ descriptive_name "Integriti HID Origo trigger"
+ generic_name :HID_Origo
+
+ default_settings({
+ custom_field_hid_origo: "cf_HasVirtualCard",
+ })
+
+ accessor staff_api : StaffAPI_1
+ accessor integriti : Integriti_1
+
+ def on_update
+ @cf_virtual_card = setting?(String, :custom_field_hid_origo) || "cf_HasVirtualCard"
+ end
+
+ getter cf_virtual_card : String = "cf_HasVirtualCard"
+
+ protected def get_user_email : String
+ user_id = invoked_by_user_id
+ raise "current user not known in this context" unless user_id
+ id = user_id.as(String)
+ user = staff_api.user(id).get
+ (user["login_name"]?.try(&.as_s?).presence || user["email"].as_s).downcase
+ end
+
+ protected def get_integriti_id : String
+ email = get_user_email
+ integriti.user_id_lookup(email).get[0].as_s
+ end
+
+ def request_virtual_card : Nil
+ id = get_integriti_id
+ integriti.update_entry("User", id, {cf_virtual_card => true})
+ end
+
+ def remove_virtual_card : Nil
+ id = get_integriti_id
+ integriti.update_entry("User", id, {cf_virtual_card => false})
+ end
+
+ def has_virtual_card? : Bool
+ email = get_user_email
+ integriti.users(email: email).get.dig?(0, "origo").try(&.as_bool?) || false
+ end
+
+ def has_account? : Bool
+ email = get_user_email
+ integriti.user_id_lookup(email).get.as_a.size > 0
+ end
+end
diff --git a/drivers/inner_range/integriti_hid_virtual_pass_spec.cr b/drivers/inner_range/integriti_hid_virtual_pass_spec.cr
new file mode 100644
index 00000000000..bcc411e2fca
--- /dev/null
+++ b/drivers/inner_range/integriti_hid_virtual_pass_spec.cr
@@ -0,0 +1,63 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "InnerRange::IntegritiHIDVirtualPass" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ Integriti: {IntegritiMock},
+ })
+
+ exec(:has_virtual_card?, user_id: "user-123").get.try(&.as_bool?).should_not be_true
+ exec(:request_virtual_card, user_id: "user-123").get
+ exec(:has_virtual_card?, user_id: "user-123").get.try(&.as_bool?).should be_true
+ exec(:remove_virtual_card, user_id: "user-123").get
+ exec(:has_virtual_card?, user_id: "user-123").get.try(&.as_bool?).should_not be_true
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def user(id : String)
+ {
+ email: "user@email.com",
+ login_name: "user@email.com",
+ }
+ end
+end
+
+# :nodoc:
+class IntegritiMock < DriverSpecs::MockDriver
+ @users = {
+ "U35" => {
+ "id" => 281474976710691,
+ "name" => "Isaiah Langer",
+ "site" => {
+ id: 1,
+ name: "PlaceOS",
+ },
+ "address" => "U35",
+ "partition_id" => 0,
+ "not_origo" => false, # just so the hash accepts bools
+ "email" => "user@email.com",
+ },
+ }
+
+ def user_id_lookup(email : String) : Array(String)
+ email = email.downcase
+ @users.compact_map do |(id, user)|
+ user["address"].as(String) if user["email"] == email
+ end
+ end
+
+ def users(site_id : Int32? = nil, email : String? = nil)
+ email = email.try(&.downcase)
+ @users.values.select { |user| user["email"] == email }
+ end
+
+ def update_entry(type : String, id : String, fields : Hash(String, Bool), attribute : String = "Address", return_object : Bool = false)
+ raise "unexpected type #{type}" unless type == "User"
+ user = @users[id]
+ fields.each do |field, value|
+ field = "origo" if field == "cf_HasVirtualCard"
+ user[field] = value
+ end
+ end
+end
diff --git a/drivers/inner_range/integriti_spec.cr b/drivers/inner_range/integriti_spec.cr
new file mode 100644
index 00000000000..817571ce0cd
--- /dev/null
+++ b/drivers/inner_range/integriti_spec.cr
@@ -0,0 +1,401 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "InnerRange::Integriti" do
+ # ===========
+ # SYSTEM INFO
+ # ===========
+ result = exec(:system_info)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ Integriti Professional Edition
+ 23.1.1.21454
+ 3
+
+ XML
+ end
+
+ result.get.should eq({
+ "edition" => "Integriti Professional Edition",
+ "version" => "23.1.1.21454",
+ "protocol" => 3,
+ })
+
+ # ===========
+ # API VERSION
+ # ===========
+ result = exec(:api_version)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << %(http://20.213.104.2:80/restapi/ApiVersion/v2 )
+ end
+
+ result.get.should eq "v2"
+
+ # =====
+ # Sites
+ # =====
+ result = exec(:sites)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ 1
+ 1
+ 1000
+ -1
+ http://20.213.104.2:80/restapi/v2/BasicStatus/SiteKeyword?Page=2&PageSize=1000&SortProperty=ID&SortOrder=Ascending&
+
+
+ 1
+ PlaceOS
+
+
+
+ XML
+ end
+
+ result.get.should eq([{
+ "id" => 1,
+ "name" => "PlaceOS",
+ }])
+
+ # =====
+ # Areas
+ # =====
+ result = exec(:areas)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ 1
+ 1
+ 1000
+ -1
+ http://20.213.104.2:80/restapi/v2/BasicStatus/Area?Page=2&PageSize=1000&SortProperty=ID&SortOrder=Ascending&
+
+
+ 1
+ Level 1
+
+ 1
+ PlaceOS
+
+
+
+
+ XML
+ end
+
+ result.get.should eq([{
+ "id" => 1,
+ "name" => "Level 1",
+ "site" => {
+ "id" => 1,
+ "name" => "PlaceOS",
+ },
+ }])
+
+ # =====
+ # Users
+ # =====
+ result = exec(:users)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ 12
+ 1
+ 1000
+ -1
+ http://20.213.104.2:80/restapi/v2/BasicStatus/User?Page=2&PageSize=1000&SortProperty=ID&SortOrder=Ascending&
+
+
+ PlaceOS
+ 1
+ 281474976710657
+ Installer
+
+ U1
+
+
+ PlaceOS
+ 1
+ 281474976710658
+ Master
+
+ U2
+
+
+ PlaceOS
+ 2
+ 281474976710659
+ Card 12
+
+ U3
+
+
+
+ steve@place.tech
+
+
+
+ XML
+ end
+
+ result.get.should eq([
+ {
+ "id" => 281474976710657,
+ "name" => "Installer",
+ "site_id" => 1,
+ "site_name" => "PlaceOS",
+ "address" => "U1",
+ "partition_id" => 1,
+ },
+ {
+ "id" => 281474976710658,
+ "name" => "Master",
+ "site_id" => 1,
+ "site_name" => "PlaceOS",
+ "address" => "U2",
+ "partition_id" => 0,
+ },
+ {
+ "id" => 281474976710659,
+ "name" => "Card 12",
+ "site_id" => 2,
+ "site_name" => "PlaceOS",
+ "address" => "U3",
+ "partition_id" => 2,
+ "email" => "steve@place.tech",
+ "primary_permission_group" => {
+ "partition_id" => 0,
+ "address" => "QG4",
+ },
+ },
+ ])
+
+ result = exec(:user, 281474976710659)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+
+
+
+ 281474976710659
+ Card 12
+
+ U3
+ steve@place.tech
+
+
+
+
+ XML
+ end
+
+ result.get.should eq({
+ "id" => 281474976710659,
+ "name" => "Card 12",
+ "site" => {"id" => 2, "name" => "PlaceOS"},
+ "address" => "U3",
+ "partition_id" => 2,
+ "email" => "steve@place.tech",
+ "primary_permission_group" => {"partition_id" => 0, "address" => "QG4"},
+ })
+
+ # =====
+ # Cards
+ # =====
+ result = exec(:cards)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ 10
+ 1
+ 1000
+ -1
+ http://20.213.104.2:80/restapi/v2/VirtualCardBadge/Card?Page=2&PageSize=1000&SortProperty=ID&SortOrder=Ascending&AdditionalProperties=ID,Name,CardNumberNumeric,User,CardNumber,CardSerialNumber,State,Site,ExpiryDateTime,StartDateTime,LastUsed,CardType,CloudCredentialType,CloudCredentialPoolId,ManagedByActiveDirectory&
+
+
+ c1fc4c28-0c1c-4573-a9cf-0025dbf6c8f7
+ 19
+
+
+
+
+
+
+
+ 2024-04-11T00:49:35.6588387+12:00
+ Active
+ False
+ 0001-01-01T00:00:00.0000000+00:00
+ 0001-01-01T00:00:00.0000000+00:00
+ 19
+ 19
+ None
+
+
+
+ XML
+ end
+
+ result.get.should eq([
+ {
+ "id" => "c1fc4c28-0c1c-4573-a9cf-0025dbf6c8f7",
+ "name" => "19",
+ "card_number_numeric" => 19,
+ "card_number" => "19",
+ "state" => "Active",
+ "expiry" => "0001-01-01T00:00:00.0000000+00:00",
+ "valid_from" => "0001-01-01T00:00:00.0000000+00:00",
+ "last_used" => "2024-04-11T00:49:35.6588387+12:00",
+ "cloud_credential_type" => "None",
+ "active_directory" => false,
+ "site" => {
+ "id" => 1,
+ "name" => "PlaceOS",
+ },
+ "user" => {
+ "address" => "U10",
+ "partition_id" => 0,
+ },
+ },
+ ])
+
+ # =================
+ # Permission Groups
+ # =================
+ result = exec(:permission_groups)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ 3
+ 1
+ 25
+ -1
+ http://20.213.104.2:80/restapi/v2/User/PermissionGroup?Page=2&PageSize=25&SortProperty=ID&SortOrder=Ascending&
+
+
+ PlaceOS
+ 1
+ 1970324836974593
+ Manager
+
+ QG1
+
+
+
+ XML
+ end
+
+ result.get.should eq([
+ {
+ "partition_id" => 0,
+ "site_name" => "PlaceOS",
+ "site_id" => 1,
+ "id" => 1970324836974593,
+ "name" => "Manager",
+ "address" => "QG1",
+ },
+ ])
+
+ result = exec(:modify_user_permissions, "U54", "QG4")
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ UserPermission with ID: 61a1c248-d62d-4485-a3e9-815918afac71 added to Permissions for User with ID U54
+ 1
+
+ XML
+ end
+
+ result.get.should eq({
+ "message" => "UserPermission with ID: 61a1c248-d62d-4485-a3e9-815918afac71 added to Permissions for User with ID U54",
+ "modified" => 1,
+ })
+
+ result = exec(:revoke_guest_access, {user_id: "U56", permission_id: "9ee6dfdd-9c01-4b67-b9f2-316ca7c9fdc5", card_hex: ""})
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ 1 item/s removed from UserPermission for User with ID U56
+ 1
+
+ XML
+ end
+
+ result.get.should eq({
+ "message" => "1 item/s removed from UserPermission for User with ID U56",
+ "modified" => 1,
+ })
+
+ # ==============
+ # Review History
+ # ==============
+
+ result = exec(:review_access, {} of Nil => Nil)
+
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << <<-XML
+
+ 1
+ 1
+ 25
+ -1
+ http://20.213.104.2:80/restapi/v2/Review/Review?Page=1&PageSize=25&SortProperty=UTCTimeGenerated&SortOrder=Descending&FullObject=true&LongPoll=true&UTCTimeGenerated=12/11/2024 5:52:54 AM&
+
+
+ 0b36c0d0-ab54-49d0-9681-2db34e0b6f4b
+ test - Stopped
+ 1
+ 0
+ Application Server@outlook-test
+ 0
+ 2024-12-11T18:52:54.6086141
+ 2024-12-11T05:52:54.6086141
+ 2024-12-11T05:52:54.8427098
+ 0
+ InstallerDetailed
+ None
+ CommunicationHandler
+ 0
+ 0
+ 0
+ 0
+ 0
+ CommunicationHandlerStopped
+ 0
+
+
+
+ XML
+ end
+
+ result.get.should eq([{
+ "id" => "0b36c0d0-ab54-49d0-9681-2db34e0b6f4b",
+ "text" => "test - Stopped",
+ "time_generated" => "2024-12-11T05:52:54Z",
+ "event_type" => "CommunicationHandler",
+ "transition" => "CommunicationHandlerStopped",
+ "time_gen_ms" => "2024-12-11T05:52:54.608614100",
+ }])
+end
diff --git a/drivers/inner_range/integriti_user_sync.cr b/drivers/inner_range/integriti_user_sync.cr
new file mode 100644
index 00000000000..7af9251be68
--- /dev/null
+++ b/drivers/inner_range/integriti_user_sync.cr
@@ -0,0 +1,405 @@
+require "placeos-driver"
+require "place_calendar"
+require "xml"
+require "set"
+
+require "../place/models/workplace_subscriptions"
+
+class InnerRange::IntegritiUserSync < PlaceOS::Driver
+ include Place::WorkplaceSubscription
+
+ descriptive_name "Integriti User Sync"
+ generic_name :IntegritiUserSync
+
+ default_settings({
+ user_group_id: "building@org.com",
+ sync_cron: "0 21 * * *",
+ integriti_security_group: "QG15",
+
+ _csv_sync_mappings: {
+ parking: {
+ default: "unisex with parking",
+ female: "Female with parking",
+ male: "Male with parking",
+ },
+ default: {
+ default: "unisex without parking",
+ female: "Female without parking",
+ male: "Male without parking",
+ },
+ },
+
+ # use these for enabling push notifications
+ _push_authority: "authority-GAdySsf05mL",
+ _push_notification_url: "https://placeos-dev.aca.im/api/engine/v2/notifications/office365",
+ })
+
+ accessor directory : Calendar_1
+ accessor integriti : Integriti_1
+ accessor staff_api : StaffAPI_1
+
+ @time_zone : Time::Location = Time::Location.load("GMT")
+
+ @syncing : Bool = false
+ @sync_mutex : Mutex = Mutex.new
+ @sync_requests : Int32 = 0
+
+ getter csv_sync_mappings : Hash(String, Hash(String, String))? = nil
+
+ def on_update
+ @time_zone_string = setting?(String, :time_zone).presence || config.control_system.not_nil!.timezone.presence || "GMT"
+ @time_zone = Time::Location.load(@time_zone_string)
+
+ @sync_cron = setting?(String, :sync_cron).presence || "0 21 * * *"
+ @user_group_id = setting(String, :user_group_id)
+ @integriti_security_group = setting(String, :integriti_security_group)
+
+ @csv_sync_mappings = setting?(Hash(String, Hash(String, String)), :csv_sync_mappings)
+
+ @graph_group_id = nil
+
+ schedule.clear
+ schedule.cron(@sync_cron, @time_zone) { perform_user_sync }
+
+ if setting?(String, :push_notification_url).presence
+ push_notificaitons_configure
+ end
+ end
+
+ getter graph_group_id : String do
+ if user_group_id.includes?('@')
+ directory.get_group(user_group_id).get["id"].as_s
+ else
+ user_group_id
+ end
+ end
+
+ getter time_zone_string : String = "GMT"
+ getter sync_cron : String = "0 21 * * *"
+
+ getter! user_group_id : String
+ getter! integriti_security_group : String
+
+ class ::PlaceCalendar::Member
+ property next_page : String? = nil
+ end
+
+ alias DirUser = ::PlaceCalendar::Member
+
+ protected def normalize_number_plate(plate : String, *plates)
+ new_plates = Set(String).new(plate.split(',').map(&.strip.gsub(/[^A-Za-z0-9]/, "").upcase))
+ plates.each do |existing_plate|
+ next unless existing_plate
+ new_plates.concat existing_plate.split(',')
+ end
+ new_plates.join(',')
+ end
+
+ # email => licence plate
+ def building_parking_users : Hash(String, String)
+ parking_access = Hash(String, String).new
+ users = staff_api.metadata(building_id, "parking-users").get.dig?("parking-users", "details")
+ return parking_access unless users
+
+ users.as_a.each do |user|
+ begin
+ next if user["deny"].as_bool?
+ email = user["email"].as_s.strip.downcase
+ plate = normalize_number_plate user["plate_number"].as_s
+ parking_access[email] = plate
+ rescue error
+ logger.error(exception: error) { "failed to parse user #{user}" }
+ end
+ end
+
+ parking_access
+ end
+
+ def perform_user_sync
+ return "already syncing" if @syncing
+
+ @sync_mutex.synchronize do
+ begin
+ @syncing = true
+ @sync_requests = 0
+ sync_users
+ ensure
+ @syncing = false
+ end
+ end
+
+ spawn { perform_user_sync } if @sync_requests > 0
+ end
+
+ protected def sync_users
+ # get the list of users in the integriti permissions group: (i.e. QG2)
+ email_to_user_id = integriti.managed_users_in_group(integriti_security_group).get.as_h.transform_values(&.as_s)
+ logger.debug { "Number of users in Integrity security group: #{email_to_user_id.size}" }
+
+ ad_emails = [] of String
+ new_users = [] of DirUser
+
+ # get the list of users in the active directory (page by page)
+ users = Array(DirUser).from_json directory.get_members(user_group_id).get.to_json
+ loop do
+ # keep track of users that need to be created
+ users.each do |user|
+ unless user.suspended
+ user_email = user.email.strip.downcase
+ user.email = user_email
+ username = user.username.strip.downcase
+ user.username = username
+ # handle cases where email may not equal username (and already configured in the system)
+ user_id = email_to_user_id[username]?
+ if user_id.nil? && username != user_email
+ if user_id = email_to_user_id[user_email]?
+ email_to_user_id[username] = user_id
+ end
+ end
+ ad_emails << username
+ new_users << user unless user_id
+ end
+ end
+
+ next_page = users.first?.try(&.next_page)
+ break unless next_page
+
+ # ensure we don't blow any request limits
+ logger.debug { "fetching next page..." }
+ sleep 500.milliseconds
+ users = Array(DirUser).from_json directory.get_members(user_group_id, next_page).get.to_json
+ end
+
+ logger.debug { "Number of users in Integrity security group: #{email_to_user_id.size}" }
+ logger.debug { "Number of users in Directory security group: #{ad_emails.size}" }
+
+ # find all the users that need to be removed from the group
+ removed = 0
+ removed_errors = 0
+
+ remove_emails = email_to_user_id.keys - ad_emails
+ remove_emails.each do |email|
+ begin
+ user_id = email_to_user_id[email]
+ integriti.modify_user_permissions(
+ user_id: user_id,
+ group_id: integriti_security_group,
+ add: false,
+ externally_managed: true
+ ).get
+ removed += 1
+ rescue error
+ removed_errors += 1
+ logger.warn(exception: error) { "failed to remove group #{user_group_id} from #{email}" }
+ end
+ end
+
+ logger.debug { "Removed #{removed} users from integrity security group" }
+
+ # add the users that need to be in the group
+ added = 0
+ added_errors = 0
+
+ new_users.each do |user|
+ username = user.username
+ user_email = user.email
+
+ begin
+ # check if the user exists (find by email and username)
+ users = integriti.user_id_lookup(username).get.as_a.map(&.as_s)
+ if users.empty?
+ users = integriti.user_id_lookup(user_email).get.as_a.map(&.as_s) unless user_email == username
+ if users.empty?
+ new_user_id = integriti.create_user(user.name, username, user.phone).get.as_s
+ users << new_user_id
+ email_to_user_id[username] = new_user_id
+ else
+ # we want to update the users email address to be the username
+ logger.debug { "updating user email #{user_email} to #{username}" }
+ integriti.update_user_custom(users.first, username)
+ end
+ end
+
+ # add the user permission group
+ user_id = users.first
+ integriti.modify_user_permissions(
+ user_id: user_id,
+ group_id: integriti_security_group,
+ add: true,
+ externally_managed: true
+ )
+
+ added += 1
+ rescue error
+ added_errors += 1
+ logger.warn(exception: error) { "failed to add group #{user_group_id} to #{user_email}" }
+ end
+ end
+
+ logger.debug { "Added #{added} users to integrity security group" }
+
+ # CSV array
+ csv_changed = sync_csv_field(ad_emails, email_to_user_id)
+
+ result = {
+ removed: removed,
+ removed_errors: removed_errors,
+ added: added,
+ added_errors: added_errors,
+ base_building_csv: csv_changed,
+ }
+ @last_result = result
+ self[:last_result] = result
+ logger.info { "integriti user sync results: #{result}" }
+ result
+ end
+
+ getter last_result : NamedTuple(
+ removed: Int32,
+ removed_errors: Int32,
+ added: Int32,
+ added_errors: Int32,
+ base_building_csv: String,
+ )? = nil
+
+ # ===================
+ # Group subscriptions
+ # ===================
+
+ # Create, update or delete of a member has occured
+ # TODO:: use delta links in the future so we don't have to parse the whole group membership
+ # https://learn.microsoft.com/en-us/graph/api/group-delta?view=graph-rest-1.0&tabs=http
+ protected def subscription_on_crud(notification : NotifyEvent) : Nil
+ subscription_on_missed
+ end
+
+ # Graph API failed to send us a notification or two
+ protected def subscription_on_missed : Nil
+ if !@syncing
+ # very simple debounce as we seem to get 2 notifications for each update
+ @sync_mutex.synchronize do
+ return if @sync_requests > 0
+ @sync_requests += 1
+ end
+ sleep 1
+ else
+ @sync_requests += 1
+ end
+ perform_user_sync
+ end
+
+ protected def subscription_resource(service_name : ServiceName) : String
+ case service_name
+ in .office365?
+ "/groups/#{graph_group_id}/members"
+ in .google?, Nil
+ raise "google is not supported"
+ end
+ end
+
+ # ===================
+ # CSV Mappings
+ # ===================
+
+ DEFAULT_KEY = "default"
+
+ getter building_id : String { get_building_id.not_nil! }
+
+ def get_building_id
+ building_setting = setting?(String, :building_zone_override)
+ return building_setting if building_setting.presence
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ nil
+ end
+
+ protected def sync_csv_field(ad_emails : Array(String), email_to_user_id : Hash(String, String)) : String
+ mappings = csv_sync_mappings
+ return "no CSV mappings" unless mappings && !mappings.empty?
+
+ check = mappings.keys
+ check.delete(DEFAULT_KEY)
+
+ possible_csv_strings = mappings.values.flat_map do |hash|
+ hash.values
+ end
+
+ logger.debug { "checking base building access for #{ad_emails.size} users" }
+
+ now = Time.local(@time_zone).at_beginning_of_day
+ end_of_day = 3.days.from_now.in(@time_zone).at_end_of_day
+ building = building_id
+ licence_users = building_parking_users
+
+ updated = 0
+ failed = 0
+
+ ad_emails.each do |email|
+ user_id = email_to_user_id[email]?
+ if user_id.nil?
+ logger.warn { "unable to apply CSV sync to #{email}. Possibly no matching integriti user" }
+ next
+ end
+
+ # TODO:: lookup gender
+ gender = DEFAULT_KEY
+
+ # check if the user has any of the required bookings
+ bookings = check.flat_map do |booking_type|
+ staff_api.query_bookings(now.to_unix, end_of_day.to_unix, zones: {building}, type: booking_type, email: email).get.as_a
+ end
+
+ key = if booking = bookings.first?
+ booking["booking_type"].as_s
+ else
+ DEFAULT_KEY
+ end
+
+ # attempt to find a number plate for this user
+ if book = bookings.find { |booking| booking["extension_data"]["plate_number"].as_s rescue nil }
+ number_plate = normalize_number_plate(book["extension_data"]["plate_number"].as_s, licence_users[email]?)
+ elsif number_plate = licence_users[email]?
+ # the user might have parking access without a booking
+ key = mappings["parking"]? ? "parking" : key
+ end
+
+ # TODO:: remove once we know how to handle multiple number plates
+ number_plate = number_plate.split(',').first if number_plate
+
+ # ensure appropriate security group is selected
+ csv_security_group = mappings[key][gender]
+ user = integriti.user(user_id).get
+ csv_string = user["cf_csv"]?.try(&.as_s?)
+ license_string = user["cf_license"]?.try(&.as_s?)
+
+ update_csv = false
+ update_license = number_plate.presence && number_plate != license_string
+
+ if csv_string != csv_security_group
+ if !csv_string.presence || csv_string.in?(possible_csv_strings)
+ # change the CSV string of this user
+ update_csv = true
+ else
+ logger.debug { "skipping csv update for #{email} as current mapping #{csv_string} may have been manually configured" }
+ end
+ end
+
+ if update_csv && update_license
+ integriti.update_user_custom(user_id, email: email, csv: csv_security_group, license: number_plate)
+ elsif update_csv
+ integriti.update_user_custom(user_id, email: email, csv: csv_security_group)
+ else
+ update_license
+ integriti.update_user_custom(user_id, email: email, license: number_plate)
+ end
+ updated += 1
+ rescue error
+ failed += 1
+ logger.warn(exception: error) { "failed to check csv field for #{email}" }
+ end
+
+ "updated #{updated}, failed #{failed}"
+ end
+end
diff --git a/drivers/inner_range/integriti_user_sync_spec.cr b/drivers/inner_range/integriti_user_sync_spec.cr
new file mode 100644
index 00000000000..2873b41c07f
--- /dev/null
+++ b/drivers/inner_range/integriti_user_sync_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "InnerRange::IntegritiUserSync" do
+end
diff --git a/drivers/inner_range/integriti_visitor_access.cr b/drivers/inner_range/integriti_visitor_access.cr
new file mode 100644
index 00000000000..b8263542eb5
--- /dev/null
+++ b/drivers/inner_range/integriti_visitor_access.cr
@@ -0,0 +1,218 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+
+# required models
+require "../wiegand/models"
+require "../place/visitor_models"
+
+class InnerRange::IntegritiBookingCheckin < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "Integriti Visitor Access"
+ generic_name :VisitorAccess
+
+ default_settings({
+ timezone: "GMT",
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+ visitor_access_template: "visitor_access",
+ determine_host_name_using: "calendar-driver",
+ })
+
+ @time_zone : Time::Location = Time::Location.load("GMT")
+
+ # See: https://crystal-lang.org/api/0.35.1/Time/Format.html
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+
+ @visitor_access_template : String = "visitor_access"
+ @determine_host_name_using : String = "calendar-driver"
+
+ @users_granted_access : UInt64 = 0_u64
+
+ def on_load
+ # Guest has arrived in the lobby
+ monitor("staff/guest/checkin") { |_subscription, payload| guest_checked_in(payload.gsub(/[^[:print:]]/, "")) }
+ on_update
+ end
+
+ def on_update
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+ @visitor_access_template = setting?(String, :visitor_access_template) || "visitor_access"
+ @determine_host_name_using = setting?(String, :determine_host_name_using) || "calendar-driver"
+
+ time_zone = setting?(String, :timezone).presence || config.control_system.try(&.timezone) || "GMT"
+ @time_zone = Time::Location.load(time_zone)
+
+ @control_system_zone_list = nil
+ @building_id = nil
+ @building_zone = nil
+ end
+
+ accessor locations : LocationServices_1
+ accessor integriti : Integriti_1
+ accessor staff_api : StaffAPI_1
+ accessor calendar : Calendar_1
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ getter control_system_zone_list : Array(String) do
+ config.control_system.not_nil!.zones
+ end
+
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+
+ class ZoneDetails
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property display_name : String?
+ property location : String?
+ property tags : Array(String)
+ property parent_id : String?
+ end
+
+ getter building_zone : ZoneDetails do
+ ZoneDetails.from_json staff_api.zone(building_id).get.to_json
+ end
+
+ protected def guest_checked_in(payload)
+ logger.debug { "received guest event payload: #{payload}" }
+ guest_details = Place::GuestNotification.from_json payload
+ zones = guest_details.zones
+ return unless zones
+
+ # ensure the event is for this building
+ if (config.control_system.not_nil!.zones & zones).empty?
+ logger.debug { "ignoring event as does not match any zones" }
+ return
+ end
+
+ case guest_details
+ when Place::GuestCheckin
+ grant_and_notify_access(
+ guest_details.attendee_email,
+ guest_details.attendee_name.as(String),
+ guest_details.host.as(String),
+ guest_details.event_summary,
+ guest_details.event_starting
+ )
+ self[:users_granted_access] = @users_granted_access += 1
+ else
+ logger.debug { "ignoring event as not a checkin: #{guest_details.class}" }
+ end
+ end
+
+ def grant_and_notify_access(
+ visitor_email : String,
+ visitor_name : String,
+ host_email : String,
+ event_title : String?,
+ event_start : Int64,
+ )
+ # now until end of meeting
+ local_event_start = Time.unix(event_start).in(@time_zone)
+ local_start_time = Time.local(@time_zone)
+ late_in_day = local_start_time.at_end_of_day - 7.hours
+
+ access_from = (local_start_time - 15.minutes).to_unix
+ access_until = local_start_time < late_in_day ? late_in_day : (local_start_time + 2.hours)
+ card_details = integriti.grant_guest_access(visitor_name, visitor_email, access_from, access_until.to_unix).get
+ card_facility = card_details["card_facility"].as_i64.to_u32
+ card_number = card_details["card_number"].as_i64.to_u32
+
+ # remove the 2 sign bits
+ wiegand = Wiegand::Wiegand26.from_components(facility: card_facility, card_number: card_number)
+ raw = (wiegand.wiegand & (Wiegand::Wiegand26::FACILITY_MASK | Wiegand::Wiegand26::CARD_MASK)) >> 1
+
+ # this would be the data in HEX
+ # data = raw.to_s(16).upcase.rjust(6, '0')
+
+ # payload in decimal (format expects the \r\n)
+ data = "#{raw.to_s}\r\n"
+
+ # create QR code
+ qr_png = mailer.generate_png_qrcode(text: data, size: 256).get.as_s
+ attach = [
+ {
+ file_name: "access.png",
+ content: qr_png,
+ content_id: visitor_email,
+ },
+ ]
+
+ mailer.send_template(
+ visitor_email,
+ {"visitor_invited", @visitor_access_template},
+ {
+ visitor_email: visitor_email,
+ visitor_name: visitor_name,
+ host_name: get_host_name(host_email),
+ host_email: host_email,
+ building_name: building_zone.display_name.presence || building_zone.name,
+ event_title: event_title,
+ event_start: local_event_start.to_s(@time_format),
+ event_date: local_event_start.to_s(@date_format),
+ event_time: local_event_start.to_s(@time_format),
+ },
+ attach
+ )
+ end
+
+ def template_fields : Array(TemplateFields)
+ time_now = Time.utc.in(@time_zone)
+
+ invitation_fields = [
+ {name: "visitor_email", description: "Email address of the visiting guest"},
+ {name: "visitor_name", description: "Full name of the visiting guest"},
+ {name: "host_name", description: "Name of the person hosting the visitor"},
+ {name: "host_email", description: "Email address of the host"},
+ {name: "building_name", description: "Name of the building where the visit occurs"},
+ {name: "event_title", description: "Title or purpose of the visit"},
+ {name: "event_start", description: "Start time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "event_date", description: "Date of the visit (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "event_time", description: "Time of the visit (or 'all day' for 24-hour events)"},
+ ]
+
+ [
+ TemplateFields.new(
+ trigger: {"visitor_invited", @visitor_access_template},
+ name: "Visitor invited",
+ description: "Visitor entry security email with QR code for access",
+ fields: invitation_fields
+ ),
+ ]
+ end
+
+ protected def get_host_name(host_email)
+ @determine_host_name_using == "staff-api-driver" ? get_host_name_from_staff_api_driver(host_email) : get_host_name_from_calendar_driver(host_email)
+ end
+
+ protected def get_host_name_from_calendar_driver(host_email)
+ calendar.get_user(host_email).get["name"]
+ rescue error
+ logger.error { "issue loading host details #{host_email}" }
+ return "your host"
+ end
+
+ protected def get_host_name_from_staff_api_driver(host_email, retries = 0)
+ staff_api.staff_details(host_email).get["name"].as_s.split('(')[0]
+ rescue error
+ if retries > 3
+ logger.error { "issue loading host details #{host_email}" }
+ return "your host"
+ end
+ sleep 1.second
+ get_host_name_from_staff_api_driver(host_email, retries + 1)
+ end
+end
diff --git a/drivers/inner_range/integriti_visitor_access_spec.cr b/drivers/inner_range/integriti_visitor_access_spec.cr
new file mode 100644
index 00000000000..5439c27ca6c
--- /dev/null
+++ b/drivers/inner_range/integriti_visitor_access_spec.cr
@@ -0,0 +1,36 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "InnerRange::IntegritiHIDVirtualPass" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ Integriti: {IntegritiMock},
+ })
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def zone(id : String)
+ {
+ name: "Building 1234",
+ }
+ end
+end
+
+# :nodoc:
+class IntegritiMock < DriverSpecs::MockDriver
+end
+
+# :nodoc:
+class CalendarMock < DriverSpecs::MockDriver
+end
+
+# :nodoc:
+class LocationsMock < DriverSpecs::MockDriver
+ def building_id : String
+ "building-1234"
+ end
+end
+
+# :nodoc:
+class MailerMock < DriverSpecs::MockDriver
+end
diff --git a/drivers/johnson_controls/metasys.cr b/drivers/johnson_controls/metasys.cr
new file mode 100644
index 00000000000..c0a2ef706e6
--- /dev/null
+++ b/drivers/johnson_controls/metasys.cr
@@ -0,0 +1,290 @@
+require "placeos-driver"
+require "inactive-support/args"
+require "./metasys_models"
+
+class JohnsonControls::Metasys < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Johnson Controls Metasys API v3"
+ generic_name :Control
+ uri_base "http://localhost/api/v3"
+
+ CONTENT_TYPE = "application/json"
+
+ @username : String = ""
+ @password : String = ""
+ @auth_token : String = ""
+ @auth_expiry : Time = 1.minute.ago
+ @equipment_ids_and_attributes = {} of String => Array(String)
+ @poll_interval_seconds : Int32 = 300
+ @count : Int32 = 0
+ @averages = {} of String => Float64
+
+ def on_update
+ schedule.clear
+ @username = setting?(String, :username) || ""
+ @password = setting?(String, :password) || ""
+ @equipment_ids_and_attributes = setting?(Hash(String, Array(String)), :equipment_ids_and_attributes) || {} of String => Array(String)
+ @poll_interval_seconds = setting?(Int32, :poll_interval_seconds) || 300
+ @count = 0
+ schedule.every(@poll_interval_seconds.seconds, true) { update_data }
+ end
+
+ def token_expired?
+ @auth_expiry <= Time.utc
+ end
+
+ def get_token
+ return @auth_token unless token_expired?
+
+ response = post("/login",
+ headers: {"Content-Type" => CONTENT_TYPE},
+ body: {
+ username: @username,
+ password: @password,
+ }.to_json
+ )
+
+ logger.debug { "received login response #{response.body}" }
+
+ if response.success?
+ resp = AuthResponse.from_json(response.body)
+ @auth_expiry = resp.expires
+ @auth_token = "Bearer #{resp.access_token}"
+ else
+ logger.error { "authentication failed with HTTP #{response.status_code}" }
+ raise "failed to obtain access token"
+ end
+ end
+
+ def get_token_debug
+ response = post("/login",
+ headers: {"Content-Type" => CONTENT_TYPE},
+ body: {
+ username: @username,
+ password: @password,
+ }.to_json
+ )
+
+ if response.success?
+ resp = AuthResponse.from_json(response.body)
+ @auth_expiry = resp.expires
+ @auth_token = "Bearer #{resp.access_token}"
+ else
+ parsed_json_body = begin
+ JSON.parse(response.body)
+ rescue ex : JSON::ParseException
+ ex.to_s
+ end
+
+ {
+ body: response.body,
+ parsed_json_body: parsed_json_body,
+ status_code: response.status_code,
+ }
+ end
+ end
+
+ def get_equipment_points(id : String) : EquipmentPoints
+ response = get_request("/equipment/#{id}/points")
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ EquipmentPoints.from_json(response.body)
+ end
+
+ def get_attribute_value(id : String) : Float64
+ current_time = Time.utc
+ # get a time object twice the poll interval into the past to ensure we can definitely get the latest value
+ short_while_ago = Time.utc - (@poll_interval_seconds * 2).seconds
+ # 85 is the identifier to get the presentValue of an object
+ response = get_request(
+ "/objects/#{id}/attributes/85/samples",
+ start_time: short_while_ago.to_rfc3339,
+ end_time: current_time.to_rfc3339,
+ page_size: 1, # only get 1 result which will be the latest value with help of the sort option below
+ sort: "-timestamp" # sort so that latest value shows first
+ )
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ SamplesResponse.from_json(response.body).items.first.value.actual
+ end
+
+ def lookup_object_id(fqr : String) : String
+ response = get_request("/objectIdentifiers?fqr=#{fqr}")
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ response.body.to_s
+ end
+
+ def get_network_device_children(id : String, page : Int32 = 1, page_size : Int32 = 10) : GetNetworkDeviceChildrenResponse
+ response = get_request("/networkDevices/#{id}/objects",
+ page: page,
+ page_size: page_size,
+ sort: "-timestamp" # sort so that latest value shows first
+ )
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ GetNetworkDeviceChildrenResponse.from_json(response.body)
+ end
+
+ def get_equipment_hosted_by_network_device(id : String, page : Int32 = 1, page_size : Int32 = 10) : GetEquipmentHostedByNetworkDeviceResponse
+ response = get_request("/networkDevices/#{id}/equipment",
+ page: page,
+ page_size: page_size,
+ sort: "-timestamp"
+ )
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ GetEquipmentHostedByNetworkDeviceResponse.from_json(response.body)
+ end
+
+ def get_object_attributes_with_samples(id : String) : GetObjectAttributesWithSamplesResponse
+ response = get_request("/objects/#{id}/trendedAttributes")
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ GetObjectAttributesWithSamplesResponse.from_json(response.body)
+ end
+
+ def get_single_object_presentValue(id : String) : GetSingleObjectPresentValueResponse
+ response = get_request("/objects/#{id}/attributes/presentValue")
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ GetSingleObjectPresentValueResponse.from_json(response.body)
+ end
+
+ def get_samples_for_an_object_attribute(id : String, attribute_id : String, start_time : String, end_time : String, page : Int32 = 1, page_size : Int32 = 10) : GetSamplesForAnObjectAttributeResponse
+ response = get_request("/objects/#{id}/attributes/#{attribute_id}/samples",
+ start_time: start_time,
+ end_time: end_time,
+ page: page,
+ page_size: page_size,
+ sort: "-timestamp"
+ )
+
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ GetSamplesForAnObjectAttributeResponse.from_json(response.body)
+ end
+
+ def get_commands_for_an_object(id : String) : Array(Command)
+ response = get_request("/objects/#{id}/commands")
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ Array(Command).from_json(response.body)
+ end
+
+ def send_command_to_an_object(id : String, command_id : String, body : Array(JSON::Any))
+ response = put_request("/objects/#{id}/commands/#{command_id}", body: body)
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ end
+
+ def update_data
+ debug = {} of String => Hash(String, Float64)
+ data = {} of String => Hash(String, Float64)
+ @equipment_ids_and_attributes.each do |id, attributes|
+ equipment_points = get_equipment_points(id)
+ equipment_points.points.each do |p|
+ next unless attributes.includes?(p.name)
+ data[p.equipment_name] ||= {} of String => Float64
+ debug[p.equipment_name] ||= {} of String => Float64
+ object_id = p.object_url.split('/').last
+ value = get_attribute_value(object_id)
+ data[p.equipment_name][p.name] = value
+ debug[p.equipment_name][p.object_url] = value
+ end
+ end
+
+ averages = calculate_averages(data)
+
+ {
+ data: self[:data] = data,
+ count: @count,
+ equipment_ids_and_attributes: @equipment_ids_and_attributes,
+ debug: debug,
+ averages: self[:averages] = averages,
+ }
+ end
+
+ private def calculate_averages(data)
+ sums = {} of String => Float64
+ no_of_sensors = {} of String => Int32
+
+ data.each do |_sensor_name, values|
+ values.each do |attribute_name, attribute_value|
+ no_of_sensors[attribute_name] ||= 0
+ no_of_sensors[attribute_name] += 1
+ sums[attribute_name] ||= 0
+ sums[attribute_name] += attribute_value
+ end
+ end
+
+ sums.each do |attribute_name, attribute_sum|
+ # If there are multiple sensors, divide the sum by the number of sensors
+ # This will provide the average of each sensor that has this attribute
+ sensor_avg = attribute_sum / no_of_sensors[attribute_name]
+ @averages[attribute_name] ||= 0
+ @averages[attribute_name] = ((@averages[attribute_name] * @count) + sensor_avg) / (@count + 1)
+ end
+
+ @count += 1
+ @averages
+ end
+
+ def get_data
+ {
+ data: self[:data],
+ averages: self[:averages],
+ }
+ end
+
+ private def get_request(path : String, **params)
+ if params.size > 0
+ get(path, headers: {"Authorization" => get_token}, params: stringify_params(**params))
+ else
+ get(path, headers: {"Authorization" => get_token})
+ end
+ end
+
+ private def put_request(path : String, body)
+ put(path, headers: {"Authorization" => get_token, "Content-Type" => CONTENT_TYPE}, body: body.to_json)
+ end
+
+ @[Security(Level::Support)]
+ def get_request_debug(path : String, **params)
+ response = get_request(path, **params)
+
+ parsed_json_body = begin
+ JSON.parse(response.body)
+ rescue ex : JSON::ParseException
+ ex.to_s
+ end
+
+ {
+ body: response.body,
+ parsed_json_body: parsed_json_body,
+ status_code: response.status_code,
+ }
+ end
+
+ def count
+ @count
+ end
+
+ # Stringify param keys and values so that they're valid query params
+ private def stringify_params(**params) : Hash(String, String)
+ hash = Hash(String, String).new
+ params.each do |k, v|
+ next if v.nil? # Ignore params with nil values
+
+ case k
+ when :start_epoch
+ hash["startTime"] = ISO8601.format(Time.unix(v.to_i64))
+ when :end_epoch
+ hash["endTime"] = ISO8601.format(Time.unix(v.to_i64))
+ when :id # Ignore as id will be used in the route and not as a query param
+ else
+ hash[k.to_s.camelcase(lower: true)] = v.to_s
+ end
+ end
+ hash
+ end
+end
diff --git a/drivers/johnson_controls/metasys_models.cr b/drivers/johnson_controls/metasys_models.cr
new file mode 100644
index 00000000000..3a2907ce67e
--- /dev/null
+++ b/drivers/johnson_controls/metasys_models.cr
@@ -0,0 +1,276 @@
+require "json"
+
+module JohnsonControls
+ ISO8601 = Time::Format.new("%FT%TZ")
+
+ class AuthResponse
+ include JSON::Serializable
+
+ @[JSON::Field(key: "accessToken")]
+ property access_token : String
+
+ @[JSON::Field(converter: JohnsonControls::ISO8601)]
+ property expires : Time
+ end
+
+ class NetworkDevice
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ @[JSON::Field(key: "itemReference")]
+ property item_reference : String
+
+ @[JSON::Field(key: "name")]
+ property name : String
+
+ @[JSON::Field(key: "typeUrl")]
+ property type_url : String
+
+ @[JSON::Field(key: "self")]
+ property self : String
+
+ @[JSON::Field(key: "parentUrl")]
+ property parent_url : String
+
+ @[JSON::Field(key: "objectsUrl")]
+ property objects_url : String
+
+ @[JSON::Field(key: "networkDeviceUrl")]
+ property network_device_url : String
+
+ @[JSON::Field(key: "pointsUrl")]
+ property points_url : String
+
+ @[JSON::Field(key: "trendedAttributesUrl")]
+ property trended_attributes_url : String
+
+ @[JSON::Field(key: "alarmsUrl")]
+ property alarms_url : String
+
+ @[JSON::Field(key: "auditsUrl")]
+ property audits_url : String
+ end
+
+ class Equipment
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : String
+
+ @[JSON::Field(key: "itemReference")]
+ property item_reference : String
+
+ @[JSON::Field(key: "name")]
+ property name : String
+
+ @[JSON::Field(key: "type")]
+ property type : String
+
+ @[JSON::Field(key: "self")]
+ property self : String
+
+ @[JSON::Field(key: "spacesUrl")]
+ property spaces_url : String
+
+ @[JSON::Field(key: "networkDeviceUrl")]
+ property network_device_url : String
+
+ @[JSON::Field(key: "equipmentUrl")]
+ property equipment_url : String
+
+ @[JSON::Field(key: "upstreamEquipmentUrl")]
+ property upstream_equipment_url : String
+
+ @[JSON::Field(key: "pointsUrl")]
+ property points_url : String
+ end
+
+ class Attribute
+ include JSON::Serializable
+
+ @[JSON::Field(key: "smaplesUrl")]
+ property smaples_url : String
+
+ @[JSON::Field(key: "attributeUrl")]
+ property attribute_url : String
+ end
+
+ class Sample
+ include JSON::Serializable
+
+ @[JSON::Field(key: "timestamp")]
+ property timestamp : String
+
+ @[JSON::Field(key: "isReliable")]
+ property reliable : Bool
+
+ @[JSON::Field(key: "value")]
+ property value : Hash(String, JSON::Any)
+ end
+
+ class GetSamplesForAnObjectAttributeResponse
+ include JSON::Serializable
+
+ @[JSON::Field(key: "total")]
+ property total : Int32
+
+ @[JSON::Field(key: "items")]
+ property items : Array(Sample)
+
+ @[JSON::Field(key: "next")]
+ property next : String?
+
+ @[JSON::Field(key: "previous")]
+ property previous : String?
+
+ @[JSON::Field(key: "self")]
+ property self : String
+
+ @[JSON::Field(key: "attributeUrl")]
+ property attribute_url : String
+
+ @[JSON::Field(key: "objectUrl")]
+ property object_url : String
+ end
+
+ class GetNetworkDeviceChildrenResponse
+ include JSON::Serializable
+
+ @[JSON::Field(key: "total")]
+ property total : Int32
+
+ @[JSON::Field(key: "items")]
+ property items : Array(NetworkDevice)
+
+ @[JSON::Field(key: "next")]
+ property next : String?
+
+ @[JSON::Field(key: "previous")]
+ property previous : String?
+
+ @[JSON::Field(key: "self")]
+ property self : String
+ end
+
+ class GetObjectAttributesWithSamplesResponse
+ include JSON::Serializable
+
+ @[JSON::Field(key: "total")]
+ property total : Int32
+
+ @[JSON::Field(key: "items")]
+ property items : Array(Attribute)
+
+ @[JSON::Field(key: "self")]
+ property self : String
+ end
+
+ class GetEquipmentHostedByNetworkDeviceResponse
+ include JSON::Serializable
+
+ @[JSON::Field(key: "total")]
+ property total : Int32
+
+ @[JSON::Field(key: "items")]
+ property items : Array(Equipment)
+
+ @[JSON::Field(key: "next")]
+ property next : String?
+
+ @[JSON::Field(key: "previous")]
+ property previous : String?
+
+ @[JSON::Field(key: "self")]
+ property self : String
+ end
+
+ class Command
+ include JSON::Serializable
+
+ @[JSON::Field(key: "commandId")]
+ property command_id : String
+
+ @[JSON::Field(key: "title")]
+ property title : String
+
+ @[JSON::Field(key: "type")]
+ property type : String = "array"
+
+ @[JSON::Field(key: "items")]
+ property items : Array(JSON::Any)
+
+ @[JSON::Field(key: "minItems")]
+ property minimum_items : Int32
+
+ @[JSON::Field(key: "maxItems")]
+ property maximum_items : Int32
+ end
+
+ class EquipmentPoints
+ include JSON::Serializable
+
+ @[JSON::Field(key: "items")]
+ property points : Array(Point)
+ end
+
+ class Point
+ include JSON::Serializable
+
+ @[JSON::Field(key: "label")]
+ property name : String
+
+ @[JSON::Field(key: "equipmentName")]
+ property equipment_name : String
+
+ @[JSON::Field(key: "objectUrl")]
+ property object_url : String
+ end
+
+ class GetSingleObjectPresentValueResponse
+ include JSON::Serializable
+
+ class Item
+ include JSON::Serializable
+
+ class Value
+ include JSON::Serializable
+
+ @[JSON::Field(key: "value")]
+ property value : String?
+
+ @[JSON::Field(key: "reliability")]
+ property reliability : String?
+
+ @[JSON::Field(key: "priority")]
+ property next : String?
+ end
+
+ @[JSON::Field(key: "presentValue")]
+ property presentValue : Value
+ end
+
+ @[JSON::Field(key: "item")]
+ property item : Item
+ end
+
+ class SamplesResponse
+ include JSON::Serializable
+
+ property items : Array(Item)
+ end
+
+ class Item
+ include JSON::Serializable
+
+ property value : Value
+ end
+
+ class Value
+ include JSON::Serializable
+
+ @[JSON::Field(key: "value")]
+ property actual : Float64
+ end
+end
diff --git a/drivers/johnson_controls/metasys_spec.cr b/drivers/johnson_controls/metasys_spec.cr
new file mode 100644
index 00000000000..283a4e9366f
--- /dev/null
+++ b/drivers/johnson_controls/metasys_spec.cr
@@ -0,0 +1,35 @@
+require "placeos-driver/spec"
+require "./metasys_models"
+
+DriverSpecs.mock_driver "JohnsonControls::Metasys" do
+ username = "user"
+ password = "pass"
+
+ settings({
+ username: "user",
+ password: "pass",
+ })
+
+ exec(:get_token)
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["username"].should eq username
+ body["password"].should eq password
+
+ response.status_code = 200
+ response << %({
+ "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IklFa3FIW...",
+ "expires": "#{JohnsonControls::ISO8601.format(Time.utc + 1.day)}"
+ })
+ end
+
+ exec(:get_equipment_points, "123")
+
+ expect_http_request do |request, response|
+ pp request
+
+ response.status_code = 200
+ response << %({})
+ end
+end
diff --git a/drivers/juniper/mist.cr b/drivers/juniper/mist.cr
new file mode 100644
index 00000000000..5f68162c834
--- /dev/null
+++ b/drivers/juniper/mist.cr
@@ -0,0 +1,166 @@
+require "placeos-driver"
+require "./mist_models"
+require "openssl/hmac"
+
+class Juniper::Mist < PlaceOS::Driver
+ generic_name :Mist
+ descriptive_name "Juniper Mist API"
+ description "Juniper Mist network API"
+
+ uri_base "https://api.mist.com"
+ default_settings({
+ api_token: "token",
+ org_id: "org_id",
+ webhook_secret: "secret",
+ })
+
+ @api_token : String = ""
+ @org_id : String = ""
+ @webhook_secret : String = ""
+
+ # Rate limited to 5000 requests an hour. Reset at the hourly boundry
+ # This is a bit over a request a second, but we'll try and burst these
+ @channel : Channel(Nil) = Channel(Nil).new(500)
+ @wait_time : Time::Span = 800.milliseconds
+ @queue_lock : Mutex = Mutex.new
+ @queue_size = 0
+
+ def on_load
+ spawn { rate_limiter }
+
+ # every hour we need to reset the rate limit
+ schedule.cron("0 * * * *") { reset_rate_limit }
+ on_update
+ end
+
+ def on_unload
+ @channel.close
+ end
+
+ def on_update
+ token = setting String, :api_token
+ @api_token = "Token #{token}"
+ @org_id = setting String, :org_id
+ @webhook_secret = setting?(String, :webhook_secret) || ""
+ end
+
+ # if there is a request queued then there will not be any burst request available
+ protected def reset_rate_limit
+ @queue_lock.synchronize do
+ if @queue_size == 0
+ old_channel = @channel
+ @channel = Channel(Nil).new(500)
+ old_channel.close
+ end
+ end
+ end
+
+ protected def rate_limiter
+ loop do
+ break if @channel.closed?
+ begin
+ @channel.send(nil)
+ rescue error
+ logger.error(exception: error) { "issue with rate limiter" }
+ ensure
+ sleep @wait_time
+ end
+ end
+ rescue
+ # Possible error with logging exception, restart rate limiter silently
+ spawn { rate_limiter } unless @channel.closed?
+ end
+
+ protected def request(klass : Class)
+ if (@wait_time * @queue_size) > 15.seconds
+ raise "wait time would be exceeded for API request, #{@queue_size} requests already queued"
+ end
+
+ @queue_lock.synchronize { @queue_size += 1 }
+ @channel.receive
+ @queue_lock.synchronize { @queue_size -= 1 }
+
+ headers = HTTP::Headers{
+ "Authorization" => @api_token,
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ "User-Agent" => "PlaceOS/2.0 PlaceTechnology",
+ }
+
+ response = yield headers
+
+ raise "request failed with status: #{response.status_code}\n#{response.body}" unless response.success?
+ klass.from_json(response.body)
+ end
+
+ @[Security(Level::Support)]
+ def get_request(location : String)
+ request(JSON::Any) { |headers| get(location, headers: headers) }
+ end
+
+ def sites
+ request(Array(Site)) { |headers| get("/api/v1/orgs/#{@org_id}/sites", headers: headers) }
+ end
+
+ def maps(site_id : String)
+ request(Array(Map)) { |headers| get("/api/v1/sites/#{site_id}/maps", headers: headers) }
+ end
+
+ def clients(site_id : String, map_id : String? = nil)
+ if map_id.presence
+ request(Array(Client)) { |headers| get("/api/v1/sites/#{site_id}/stats/maps/#{map_id}/clients", headers: headers) }
+ else
+ request(Array(ClientStats)) { |headers| get("/api/v1/sites/#{site_id}/stats/clients", headers: headers) }
+ end
+ end
+
+ def client(site_id : String, client_mac : String)
+ request(Client) { |headers| get("/api/v1/sites/#{site_id}/stats/clients/#{client_mac}", headers: headers) }
+ end
+
+ EMPTY_HEADERS = {} of String => String
+ SUCCESS_RESPONSE = {HTTP::Status::OK, EMPTY_HEADERS, nil}
+
+ def location_webhook(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "webhook received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+
+ # validate the data came from the expected source
+ validation = if signature = headers["X-Mist-Signature-v2"]?.try(&.first?)
+ OpenSSL::HMAC.hexdigest(OpenSSL::Algorithm::SHA256, @webhook_secret, body).downcase
+ elsif signature = headers["X-Mist-Signature"]?.try(&.first?)
+ OpenSSL::HMAC.hexdigest(OpenSSL::Algorithm::SHA1, @webhook_secret, body).downcase
+ else
+ logger.warn { "webhook called without validation signature" }
+ return {HTTP::Status::NOT_FOUND.to_i, EMPTY_HEADERS, ""}
+ end
+
+ if validation != signature.downcase
+ logger.warn { "validation failed, check webhook secret" }
+ return {HTTP::Status::UNAUTHORIZED.to_i, EMPTY_HEADERS, ""}
+ end
+
+ # Parse the data posted
+ begin
+ event_data = WebhookEvent.from_json(body)
+ logger.debug { "parsed mist webhook payload" }
+
+ # We're only interested in location data at the moment
+ if event_data.topic != "location"
+ logger.debug { "ignoring message type: #{event_data.topic}" }
+ return SUCCESS_RESPONSE
+ end
+
+ sites = Hash(String, Array(LocationEvent)).new { |hash, site| hash[site] = [] of LocationEvent }
+ event_data.events.as(Array(LocationEvent)).each do |event|
+ sites[event.site_id] << event
+ end
+ sites.each { |site, events| self[site] = events }
+ rescue e
+ logger.error(exception: e) { "failed to parse mist webhook payload" }
+ logger.debug { "failed payload body was\n#{body}" }
+ end
+
+ # Return a 200 response
+ SUCCESS_RESPONSE
+ end
+end
diff --git a/drivers/juniper/mist_location_service.cr b/drivers/juniper/mist_location_service.cr
new file mode 100644
index 00000000000..3682e6a1ad8
--- /dev/null
+++ b/drivers/juniper/mist_location_service.cr
@@ -0,0 +1,195 @@
+require "s2_cells"
+require "./mist_models"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+
+class Juniper::MistLocationService < PlaceOS::Driver
+ include Interface::Locatable
+
+ generic_name :MistLocations
+ descriptive_name "Juniper Mist Locations"
+ description "Juniper Mist location service"
+
+ default_settings({
+ floorplan_mappings: {
+ "mist_map_id" => {
+ "building": "zone-12345",
+ "level": "zone-123456",
+ "level_name": "BUILDING - L1",
+ },
+ },
+
+ # Time before a user location is considered probably too old
+ max_location_age: 6,
+ })
+
+ # accessor dashboard : Dashboard_1
+ accessor mist : MistWebsocket_1
+
+ # map_ids => data
+ @floorplan_mappings : Hash(String, Hash(String, String | Int32)) = Hash(String, Hash(String, String | Int32)).new
+ @floorplan_sizes = {} of String => MapImage
+
+ @max_location_age : Time::Span = 6.minutes
+
+ def on_update
+ @floorplan_mappings = setting?(Hash(String, Hash(String, String | Int32)), :floorplan_mappings) || @floorplan_mappings
+ @max_location_age = (setting?(UInt32, :max_location_age) || 6).minutes
+
+ schedule.clear
+ schedule.every(10.minutes) { sync_map_sizes }
+ schedule.in(20.seconds) { sync_map_sizes }
+ end
+
+ protected def sync_map_sizes
+ maps = {} of String => MapImage
+ Array(Map).from_json(mist.maps.get.to_json).each do |map|
+ unless map.is_a?(MapImage)
+ # TODO:: it might be possible to work out the size based on geo coordinates.
+ logger.warn { "mist map #{map.id} is not an image, cannot determine size" }
+ next
+ end
+ maps[map.id] = map
+ end
+ @floorplan_sizes = maps
+ end
+
+ # ============================
+ # Location Services Interface:
+ # ============================
+
+ # array of devices and their x, y coordinates, that are associated with this user
+ def locate_user(email : String? = nil, username : String? = nil)
+ clients = Array(Client).from_json mist.locate(username.presence || email.presence.not_nil!).get.to_json
+
+ ignore_older = @max_location_age.ago.to_unix
+ clients.compact_map { |client|
+ next if client.last_seen < ignore_older
+ map_id = client.map_id
+ mappings = @floorplan_mappings[map_id]?
+ next unless mappings
+
+ building = mappings["building"]?.as(String?)
+ level = mappings["level"]?.as(String?)
+ map_width, map_height = get_floorplan_size(map_id, mappings)
+
+ {
+ location: :wireless,
+ coordinates_from: "top-left",
+ x: client.x,
+ y: client.y,
+ # not sure if we can get geo coordinates...
+ # lon: lon,
+ # lat: lat,
+ # s2_cell_id: lat ? S2Cells::LatLon.new(lat.not_nil!, lon.not_nil!).to_token(@s2_level) : nil,
+ mac: client.mac,
+ variance: client.accuracy,
+ last_seen: client.last_seen,
+ map_width: map_width,
+ map_height: map_height,
+ manufacturer: client.manufacture,
+ os: client.os,
+ ssid: client.ssid,
+ building: building,
+ level: level,
+ mist_map_id: map_id,
+ }
+ }
+ end
+
+ # return an array of MAC address strings
+ # lowercase with no seperation characters abcdeffd1234 etc
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ mist.macs_assigned_to(username.presence || email.presence.not_nil!).get.as_a.map &.as_s
+ end
+
+ # Proxies the data to the mist driver
+ @[Security(PlaceOS::Driver::Level::Administrator)]
+ def mac_address_mappings(username : String, macs : Array(String), domain : String = "")
+ mist.mac_address_mappings(username, macs, domain)
+ end
+
+ # return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}`
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ lookup = format_mac(mac_address)
+ if user = mist.ownership_of(lookup).get.as_s?
+ {
+ location: "wireless",
+ assigned_to: user,
+ mac_address: lookup,
+ }
+ end
+ end
+
+ # array of devices and their x, y coordinates
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "looking up device locations in #{zone_id}" }
+ return [] of String if location.presence && location != "wireless"
+
+ # Find the floors associated with the provided zone id
+ maps = [] of String
+ @floorplan_mappings.each do |map_id, data|
+ maps << map_id if data.values.includes?(zone_id)
+ end
+ logger.debug { "found matching mist maps: #{maps}" }
+ return [] of String if maps.empty?
+
+ ignore_older = @max_location_age.ago.to_unix
+
+ # Find the devices that are on the matching floors
+ all_devices = maps.flat_map do |map_id|
+ clients = mist.status?(Array(Client), map_id) || [] of Client
+
+ mappings = @floorplan_mappings[map_id]
+ building = mappings["building"]?.as(String?)
+ level = mappings["level"]?.as(String?)
+ map_width, map_height = get_floorplan_size(map_id, mappings)
+
+ clients.compact_map do |client|
+ next if client.last_seen < ignore_older
+
+ {
+ location: :wireless,
+ coordinates_from: "top-left",
+ x: client.x,
+ y: client.y,
+ # not sure if we can get geo coordinates...
+ # lon: lon,
+ # lat: lat,
+ # s2_cell_id: lat ? S2Cells::LatLon.new(lat.not_nil!, lon.not_nil!).to_token(@s2_level) : nil,
+ mac: client.mac,
+ variance: client.accuracy,
+ last_seen: client.last_seen,
+ map_width: map_width,
+ map_height: map_height,
+ manufacturer: client.manufacture,
+ os: client.os,
+ ssid: client.ssid,
+ building: building,
+ level: level,
+ mist_map_id: map_id,
+ }
+ end
+ end
+ end
+
+ protected def get_floorplan_size(map_id, mappings)
+ map_details = @floorplan_sizes[map_id]?
+
+ map_width = -1
+ map_height = -1
+ if map_details
+ map_width = map_details.width
+ map_height = map_details.height
+ else
+ map_width = (mappings["width"]? || map_width).as(Int32)
+ map_height = (mappings["height"]? || map_width).as(Int32)
+ end
+
+ {map_width, map_height}
+ end
+
+ def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+end
diff --git a/drivers/juniper/mist_location_service_spec.cr b/drivers/juniper/mist_location_service_spec.cr
new file mode 100644
index 00000000000..801e5aa1c17
--- /dev/null
+++ b/drivers/juniper/mist_location_service_spec.cr
@@ -0,0 +1,27 @@
+require "placeos-driver/spec"
+
+# :nodoc:
+class MistWebsocketMock < DriverSpecs::MockDriver
+ def ownership_of(mac_address : String)
+ raise "expected 1234a6789b got #{mac_address}" unless mac_address == "1234a6789b"
+ "steve"
+ end
+end
+
+DriverSpecs.mock_driver "Juniper::MistLocationService" do
+ system({
+ MistWebsocket: {MistWebsocketMock},
+ })
+
+ sleep 0.5
+
+ # Should standardise the format of MAC addresses
+ exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b)
+
+ # Should return ownership of a MAC Address
+ exec(:check_ownership_of, "0x12:34:A6-789B").get.should eq({
+ "location" => "wireless",
+ "assigned_to" => "steve",
+ "mac_address" => "1234a6789b",
+ })
+end
diff --git a/drivers/juniper/mist_models.cr b/drivers/juniper/mist_models.cr
new file mode 100644
index 00000000000..7e5492ce2b8
--- /dev/null
+++ b/drivers/juniper/mist_models.cr
@@ -0,0 +1,256 @@
+require "json"
+
+module Juniper
+ class Site
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property timezone : String
+ property country_code : String
+ property id : String
+ property name : String
+ property org_id : String
+ property created_time : Int64
+ property modified_time : Int64
+ end
+
+ abstract class Map
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property name : String
+ property id : String
+
+ use_json_discriminator "type", {
+ "image" => MapImage,
+ "google" => MapGoogle,
+ }
+ end
+
+ class MapImage < Map
+ getter type : String = "image"
+ property url : String
+ property thumbnail_url : String
+
+ property site_id : String?
+ property org_id : String?
+
+ @[JSON::Field(key: "ppm")]
+ property pixels_per_meter : Float64?
+ property width : Int32
+ property height : Int32
+
+ property width_m : Float64?
+ property height_m : Float64?
+
+ # the user-annotated x origin, pixels
+ property origin_x : Float64?
+
+ # the user-annotated y origin, pixels
+ property origin_y : Float64?
+ property orientation : Int32?
+ property locked : Bool?
+ end
+
+ class MapGoogle < Map
+ getter type : String = "google"
+ property view : String
+ property origin_x : Float64
+ property origin_y : Float64
+
+ @[JSON::Field(key: "latlng_tl")]
+ property top_left_coordinates : LatLng
+
+ @[JSON::Field(key: "latlng_br")]
+ property bottom_right_coordinates : LatLng
+ end
+
+ struct LatLng
+ include JSON::Serializable
+
+ property lat : Float64
+ property lng : Float64
+ end
+
+ class Client
+ include JSON::Serializable
+
+ property mac : String
+ property last_seen : Int64
+
+ property username : String?
+ property hostname : String?
+ property os : String?
+ property manufacture : String?
+ property family : String?
+ property model : String?
+
+ @[JSON::Field(key: "ip")]
+ property ip_address : String
+ property ap_mac : String
+ property ap_id : String
+ property ssid : String
+ property wlan_id : String
+ property psk_id : String?
+
+ property map_id : String
+ # pixels
+ property x : Float64
+ property y : Float64
+ property x_m : Float64?
+ property y_m : Float64?
+ property num_locating_aps : Int32
+
+ # meters
+ @[JSON::Field(key: "accuracy")]
+ property raw_accuracy : Int32?
+
+ def accuracy
+ return raw_accuracy if raw_accuracy
+ 15 // num_locating_aps
+ end
+
+ property is_guest : Bool?
+ property guest : Guest?
+ end
+
+ struct ClientStats
+ include JSON::Serializable
+
+ property mac : String
+ property last_seen : Int64
+
+ property username : String?
+ property hostname : String?
+ property os : String?
+ property manufacture : String?
+ property family : String?
+ property model : String?
+
+ @[JSON::Field(key: "ip")]
+ property ip_address : String
+ property ap_mac : String
+ property ap_id : String
+ property ssid : String
+ property wlan_id : String
+ property psk_id : String?
+
+ property is_guest : Bool?
+ property guest : Guest?
+ end
+
+ struct ClientLocation
+ include JSON::Serializable
+
+ property mac : String
+ property map_id : String
+
+ # pixels
+ property x : Float64
+ property y : Float64
+ property x_m : Float64?
+ property y_m : Float64?
+ property num_locating_aps : Int32
+
+ # meters
+ @[JSON::Field(key: "accuracy")]
+ property raw_accuracy : Int32?
+
+ def accuracy
+ return raw_accuracy if raw_accuracy
+ 15 // num_locating_aps
+ end
+ end
+
+ class Guest
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property authorized : Bool
+ property authorized_time : Int64?
+ property authorized_expiring_time : Int64?
+ property name : String?
+ property email : String?
+ property company : String?
+ end
+
+ abstract class WebhookEvent
+ include JSON::Serializable
+
+ use_json_discriminator "topic", {
+ "location" => LocationEvents,
+ "zone" => OtherEvents,
+ "asset-raw" => OtherEvents,
+ "device-events" => OtherEvents,
+ "device-updowns" => OtherEvents,
+ "alarms" => OtherEvents,
+ "audits" => OtherEvents,
+ "client-join" => OtherEvents,
+ "client-sessions" => OtherEvents,
+ "ping" => OtherEvents,
+ "occupancy-alerts" => OtherEvents,
+ "sdkclient-scan-data" => OtherEvents,
+ }
+ end
+
+ class LocationEvents < WebhookEvent
+ getter topic : String = "location"
+ getter events : Array(LocationEvent)
+ end
+
+ # we are currently ignoring this event
+ class OtherEvents < WebhookEvent
+ getter topic : String
+ getter events : Array(JSON::Any)
+ end
+
+ abstract class LocationEvent
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property site_id : String
+ property map_id : String
+
+ property x : Float64
+ property y : Float64
+ property timestamp : Int64
+
+ use_json_discriminator "type", {
+ "sdk" => LocationSDK,
+ "wifi" => LocationWifi,
+ "asset" => LocationAsset,
+ }
+ end
+
+ class LocationSDK < LocationEvent
+ getter type : String = "sdk"
+ property name : String?
+ property id : String
+ end
+
+ class LocationWifi < LocationEvent
+ getter type : String = "wifi"
+ property mac : String
+ end
+
+ class LocationAsset < LocationEvent
+ getter type : String = "asset"
+ property mac : String
+
+ property ibeacon_uuid : String?
+ property ibeacon_major : Int64?
+ property ibeacon_minor : Int64?
+
+ property eddystone_uid_namespace : String?
+ property eddystone_uid_instance : String?
+ property eddystone_url_url : String?
+
+ # BLE manufacturing company ID
+ property mfg_company_id : Int64?
+
+ # BLE manufacturing data in hex byte-string format
+ property mfg_data : String?
+
+ property battery_voltage : Float64?
+ end
+end
diff --git a/drivers/juniper/mist_spec.cr b/drivers/juniper/mist_spec.cr
new file mode 100644
index 00000000000..30646f5fb45
--- /dev/null
+++ b/drivers/juniper/mist_spec.cr
@@ -0,0 +1,41 @@
+require "placeos-driver/spec"
+
+private macro respond_with(code, body)
+ res.headers["Content-Type"] = "application/json"
+ res.status_code = {{code}}
+ res.output << {{body}}
+end
+
+DriverSpecs.mock_driver "Juniper::Mist" do
+ sites = exec(:sites)
+ sites_data = %([
+ {
+ "timezone": "America/Los_Angeles",
+ "country_code": "US",
+ "latlng": {
+ "lat": 37.363863,
+ "lng": -121.901098
+ },
+ "id": "532e5b63-b008-4914-878c-c8f1cfac28bb",
+ "name": "Primary Site",
+ "org_id": "4f3aaa38-8c1b-4fb2-831d-0fff125b3ce7",
+ "created_time": 1635222250,
+ "modified_time": 1635222250,
+ "rftemplate_id": null,
+ "aptemplate_id": null,
+ "secpolicy_id": null,
+ "alarmtemplate_id": null,
+ "networktemplate_id": null,
+ "gatewaytemplate_id": null,
+ "tzoffset": 960
+ }
+ ])
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/orgs/org_id/sites")
+ req.headers["Authorization"]?.should eq("Token token")
+ respond_with 200, sites_data
+ end
+ sites = sites.get.not_nil!
+ sites.should eq(JSON.parse(sites_data))
+end
diff --git a/drivers/juniper/mist_websocket.cr b/drivers/juniper/mist_websocket.cr
new file mode 100644
index 00000000000..96e680b1e8e
--- /dev/null
+++ b/drivers/juniper/mist_websocket.cr
@@ -0,0 +1,313 @@
+require "placeos-driver"
+require "./mist_models"
+
+# docs: https://aca.im/driver_docs/Juniper/mist_site_api.pdf
+
+class Juniper::MistWebsocket < PlaceOS::Driver
+ generic_name :MistWebsocket
+ descriptive_name "Juniper Mist Websocket"
+ description "Juniper Mist location data using websockets"
+
+ uri_base "wss://api-ws.mist.com/api-ws/v1/stream"
+ default_settings({
+ api_token: "token",
+ site_id: "site_id",
+ ignore_usernames: ["host/"],
+ })
+
+ @api_token : String = ""
+ @site_id : String = ""
+ @connected : Bool = false
+
+ @storage_lock : Mutex = Mutex.new
+ @user_mac_mappings : PlaceOS::Driver::RedisStorage? = nil
+ @ignore_usernames : Array(String) = [] of String
+
+ protected def user_mac_mappings
+ @storage_lock.synchronize {
+ yield @user_mac_mappings.not_nil!
+ }
+ end
+
+ getter location_data : Hash(String, Hash(String, Client)) do
+ Hash(String, Hash(String, Client)).new { |hash, map_id| hash[map_id] = {} of String => Client }
+ end
+
+ getter client_data : Hash(String, Client) { {} of String => Client }
+
+ def on_load
+ # We want to store our user => mac_address mappings in redis.
+ @user_mac_mappings = PlaceOS::Driver::RedisStorage.new(module_id, "user_macs")
+
+ # debug HTTP requests
+ transport.before_request do |request|
+ logger.debug { "using proxy #{!!transport.proxy_in_use} #{transport.proxy_in_use.inspect}\nconnecting to host: #{config.uri}\nperforming request: #{request.method} #{request.path}\nheaders: #{request.headers}\n#{!request.body.nil? ? String.new(request.body.as(IO::Memory).to_slice) : nil}" }
+ end
+
+ on_update
+ end
+
+ def on_update
+ token = setting String, :api_token
+ @api_token = "Token #{token}"
+ @site_id = setting String, :site_id
+
+ # http override unless we're in a spec
+ transport.http_uri_override = URI.parse("https://api.mist.com") unless @site_id == "site_id"
+
+ @ignore_usernames = setting?(Array(String), :ignore_usernames) || [] of String
+ connected if @connected
+ end
+
+ def websocket_headers
+ HTTP::Headers{
+ "Authorization" => @api_token,
+ "User-Agent" => "PlaceOS/2.0 PlaceTechnology",
+ }
+ end
+
+ def connected
+ @connected = true
+ @location_data = nil
+ @client_data = nil
+
+ # We'll use this as the keepalive message
+ schedule.every(45.seconds, immediate: true) do
+ transport.send({subscribe: "/sites/#{@site_id}/stats/clients"}.to_json)
+ maps.each do |map|
+ transport.send({subscribe: "/sites/#{@site_id}/stats/maps/#{map.id}/clients"}.to_json)
+ end
+ end
+ sync_clients
+ schedule.every(3.seconds) { update_client_locations }
+ end
+
+ def disconnected
+ schedule.clear
+ @connected = false
+ end
+
+ protected def request(klass : Class)
+ headers = HTTP::Headers{
+ "Authorization" => @api_token,
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ "User-Agent" => "PlaceOS/2.0 PlaceTechnology",
+ }
+
+ response = yield headers
+
+ begin
+ raise "request failed with status: #{response.status_code}\n#{response.body}" unless response.success?
+ klass.from_json(response.body)
+ rescue error : JSON::SerializableError
+ logger.error { "parsing response body:\n#{response.body}" }
+ raise error
+ end
+ end
+
+ protected def update_location(client_data, location_data, client_loc) : Nil
+ client_mac = format_mac client_loc.mac
+
+ if client = client_data[client_mac]?
+ if client.map_id != client_loc.map_id
+ location_data[client.map_id].delete(client_mac)
+ end
+
+ # update details
+ client.last_seen = Time.utc.to_unix
+ client.map_id = client_loc.map_id
+ client.x = client_loc.x
+ client.y = client_loc.y
+ client.x_m = client_loc.x_m
+ client.y_m = client_loc.y_m
+ client.num_locating_aps = client_loc.num_locating_aps
+ client.raw_accuracy = client_loc.raw_accuracy
+ else
+ client = client(client_mac)
+ client.mac = client_mac
+ end
+
+ client_data[client_mac] = client
+ location_data[client.map_id][client_mac] = client
+
+ if username = client.username
+ user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) }
+ end
+ end
+
+ protected def update_stats(client_data, client_stats) : Nil
+ client_mac = format_mac client_stats.mac
+ client = client_data[client_mac]?
+
+ # we only care about clients with locations
+ return unless client
+
+ # update client
+ client.last_seen = client_stats.last_seen
+ client.ip_address = client_stats.ip_address
+ client.ap_mac = client_stats.ap_mac
+ client.ap_id = client_stats.ap_id
+ client.username = client_stats.username
+ client.hostname = client_stats.hostname
+ end
+
+ def sync_clients
+ all_clients = [] of Client
+ maps.each do |map|
+ all_clients.concat(clients(map.id).map(&.as(Client)))
+ end
+
+ loc_data = Hash(String, Hash(String, Client)).new { |hash, map_id| hash[map_id] = {} of String => Client }
+ cli_data = {} of String => Client
+
+ # build the internal representation
+ all_clients.each do |client|
+ client_mac = format_mac client.mac
+ client.mac = client_mac
+ cli_data[client_mac] = client
+ loc_data[client.map_id][client_mac] = client
+ end
+
+ @client_data = cli_data
+ @location_data = loc_data
+
+ # expose this to the world
+ loc_data.each { |map_id, clients| self[map_id] = clients.values }
+ location_data.size
+ end
+
+ # batch update redis (lowering the number of writes)
+ protected def update_client_locations
+ location_data.each { |map_id, clients| self[map_id] = clients.values }
+ end
+
+ @[Security(Level::Support)]
+ def get_request(location : String)
+ request(JSON::Any) { |headers| get(location, headers: headers) }
+ end
+
+ def site_list(org_id : String)
+ request(Array(Hash(String, JSON::Any))) { |headers| get("/api/v1/installer/orgs/#{org_id}/sites", headers: headers) }
+ end
+
+ def maps
+ # pixels_per_meter is optional
+ request(Array(Map)) { |headers| get("/api/v1/sites/#{@site_id}/maps", headers: headers) }
+ end
+
+ def clients(map_id : String? = nil)
+ if map_id.presence
+ request(Array(Client)) { |headers| get("/api/v1/sites/#{@site_id}/stats/maps/#{map_id}/clients", headers: headers) }
+ else
+ request(Array(ClientStats)) { |headers| get("/api/v1/sites/#{@site_id}/stats/clients", headers: headers) }
+ end
+ end
+
+ def client(client_mac : String)
+ request(Client) { |headers| get("/api/v1/sites/#{@site_id}/stats/clients/#{client_mac}", headers: headers) }
+ end
+
+ struct WebsocketEvent
+ include JSON::Serializable
+
+ getter event : String
+ getter channel : String
+
+ # data will be the Client class as a JSON string
+ getter data : String?
+ end
+
+ def received(data, task)
+ string = String.new(data).rstrip
+ logger.debug { "websocket sent: #{string}" }
+ event = WebsocketEvent.from_json(string)
+
+ if event_data = event.data
+ if event.channel.includes?("/maps/")
+ client_location = ClientLocation.from_json event_data
+ update_location(client_data, location_data, client_location)
+ else
+ client_stats = ClientStats.from_json event_data
+ update_stats(client_data, client_stats)
+ end
+ end
+
+ task.try &.success
+ end
+
+ def format_username(user : String)
+ if user.includes? "@"
+ user = user.split("@")[0]
+ elsif user.includes? "\\"
+ user = user.split("\\")[1]
+ end
+ user.downcase
+ end
+
+ def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+
+ def macs_assigned_to(username : String) : Array(String)
+ username = format_username(username)
+ if macs = user_mac_mappings { |s| s[username]? }
+ Array(String).from_json(macs)
+ else
+ [] of String
+ end
+ end
+
+ def ownership_of(mac_address : String)
+ mac_address = format_mac(mac_address)
+ user_mac_mappings { |storage| storage[mac_address]? }
+ end
+
+ def locate(username : String)
+ macs_assigned_to(username).compact_map { |mac| self[mac]? }
+ end
+
+ protected def map_user_mac(user_mac, user_id, storage)
+ updated_dev = false
+ new_devices = false
+ user_id = format_username(user_id)
+
+ # Check if mac mapping already exists
+ existing_user = storage[user_mac]?
+ return {false, false} if existing_user == user_id
+
+ # Remove any pervious mappings
+ if existing_user
+ updated_dev = true
+ if user_macs = storage[existing_user]?
+ macs = Array(String).from_json(user_macs)
+ macs.delete(user_mac)
+ storage[existing_user] = macs.to_json
+ end
+ else
+ new_devices = true
+ end
+
+ # Update the user mappings
+ storage[user_mac] = user_id
+ macs = if user_macs = storage[user_id]?
+ tmp_macs = Array(String).from_json(user_macs)
+ tmp_macs.unshift(user_mac)
+ tmp_macs.uniq!
+ tmp_macs[0...9]
+ else
+ [user_mac]
+ end
+ storage[user_id] = macs.to_json
+
+ {updated_dev, new_devices}
+ end
+
+ @[Security(PlaceOS::Driver::Level::Administrator)]
+ def mac_address_mappings(username : String, macs : Array(String), domain : String = "")
+ username = format_username(username)
+ user_mac_mappings do |storage|
+ macs.each { |mac| map_user_mac(format_mac(mac), username, storage) }
+ end
+ end
+end
diff --git a/drivers/juniper/mist_websocket_spec.cr b/drivers/juniper/mist_websocket_spec.cr
new file mode 100644
index 00000000000..429523fc246
--- /dev/null
+++ b/drivers/juniper/mist_websocket_spec.cr
@@ -0,0 +1,126 @@
+require "placeos-driver/spec"
+
+private macro respond_with(code, body)
+ res.headers["Content-Type"] = "application/json"
+ res.status_code = {{code}}
+ res.output << {{body}}
+end
+
+DriverSpecs.mock_driver "Juniper::MistWebsocket" do
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/sites/site_id/maps")
+ req.headers["Authorization"]?.should eq("Token token")
+ respond_with(200, %([]))
+ end
+
+ # sync on connect
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/sites/site_id/maps")
+ req.headers["Authorization"]?.should eq("Token token")
+ respond_with(200, %([{
+ "name": "Level 8",
+ "id": "map_id",
+ "type": "image",
+ "url": "https://api.mist.com/api/v1/forward/download?jwt=eyJ0eXAo",
+ "thumbnail_url": "https://api.mist.com/api/v1/forward/download?jwt=ey6k",
+ "site_id": "site_id",
+ "org_id": "org_id",
+ "width": 1040,
+ "height": 1804,
+ "width_m": 20.8,
+ "height_m": 36.08,
+ "created_time": 1718259348,
+ "modified_time": 1718751847
+ }]))
+ end
+
+ # sync on connect
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/sites/site_id/stats/maps/map_id/clients")
+ req.headers["Authorization"]?.should eq("Token token")
+ respond_with 200, "[]"
+ end
+
+ client = exec(:client, "5684dae9ac8b")
+ client_data = %({
+ "mac": "5684dae9ac8b",
+ "last_seen": 1470417522,
+
+ "username": "david@mist.com",
+ "hostname": "David-Macbook",
+ "os": "OS X 10.10.2",
+ "manufacture": "Apple",
+ "family": "iPhone",
+ "model": "6S",
+
+ "ip": "192.168.1.8",
+
+ "ap_mac": "5c5b35000010",
+ "ap_id": "0000000-0000-0000-1000-5c5b35000010",
+ "ssid": "corporate",
+ "wlan_id": "be22bba7-8e22-e1cf-5185-b880816fe2cf",
+ "psk_id": "732daf4e-f51e-8bba-06f9-b25cd0e779ea",
+
+ "uptime": 3568,
+ "idle_time": 3,
+ "power_saving": true,
+ "band": "24",
+ "proto": "a",
+ "key_mgmt": "WPA2-PSK/CCMP",
+ "dual_band": false,
+
+ "channel": 7,
+ "vlan_id": "",
+ "airespace_ifname": "",
+ "rssi": -65,
+ "snr": 31,
+ "tx_rate": 65,
+ "rx_rate": 65,
+
+ "tx_bytes": 175132,
+ "tx_bps": 6,
+ "tx_packets": 1566,
+ "tx_retries": 500,
+ "rx_bytes": 217416,
+ "rx_bps": 12,
+ "rx_packets": 2337,
+ "rx_retries": 5,
+
+ "map_id": "63eda950-c6da-11e4-a628-60f81dd250cc",
+ "x": 53.5,
+ "y": 173.1,
+ "num_locating_aps": 3,
+ "accuracy": 2,
+
+ "is_guest": false
+ })
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/sites/site_id/stats/clients/5684dae9ac8b")
+ req.headers["Authorization"]?.should eq("Token token")
+ respond_with 200, client_data
+ end
+ client = client.get.not_nil!
+ client.should eq(JSON.parse(client_data))
+
+ transmit %({
+ "event": "data",
+ "channel": "/sites/site_id/stats/maps/map_id/clients",
+ "data": #{client_data.to_json}
+ })
+
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/sites/site_id/stats/clients/5684dae9ac8b")
+ req.headers["Authorization"]?.should eq("Token token")
+ respond_with 200, client_data
+ end
+
+ # wait for the 3 second sync to complete
+ sleep 3.5
+
+ status["63eda950-c6da-11e4-a628-60f81dd250cc"].should eq(JSON.parse("[#{client_data}]"))
+end
diff --git a/drivers/kaiterra/api.cr b/drivers/kaiterra/api.cr
new file mode 100644
index 00000000000..b8f189d57f6
--- /dev/null
+++ b/drivers/kaiterra/api.cr
@@ -0,0 +1,142 @@
+require "placeos-driver"
+
+# https://www.kaiterra.com/dev/#overview
+
+class Kaiterra::API < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Kaiterra API"
+ generic_name :Control
+ uri_base "https://api.kaiterra.com/v1"
+
+ default_settings({
+ api_key: "",
+ })
+
+ @api_key : String = ""
+
+ def on_update
+ @api_key = setting(String, :api_key)
+ end
+
+ enum Param
+ Rco2 # Carbon dioxide
+ Ro3 # Ozone
+ Rpm25c # PM2.5
+ Rpm10c # PM10
+ Rhumid # Relative humidity
+ Rtemp # Temperature
+ Rtvoc # Total Volatile Organic Compounds (TVOC)
+ end
+
+ enum Unit
+ Ppm # Parts per million (volumetric concentration)
+ Ppb # Parts per billion
+ MicrogramsPerCubicMeter # µg/m³ => Micrograms per cubic meter (mass concentration)
+ MilligramsPerCubicMeter # mg/m³ => Milligrams per cubic meter
+ C # Degrees Celsius
+ F # Degrees Fahrenheit
+ X # Count of something, such as readings in a sampling interval
+ Percentage # % => Percentage, as with relative humidity
+
+ def self.parse(string)
+ case string
+ when "µg/m³"
+ Unit::MicrogramsPerCubicMeter
+ when "mg/m³"
+ Unit::MilligramsPerCubicMeter
+ when "%"
+ Unit::Percentage
+ else
+ super
+ end
+ end
+
+ def self.new(pull : JSON::PullParser)
+ parse(pull.read_string)
+ end
+
+ def to_s
+ case self
+ when Unit::MicrogramsPerCubicMeter
+ "µg/m³"
+ when Unit::MilligramsPerCubicMeter
+ "mg/m³"
+ when Unit::Percentage
+ "%"
+ else
+ super
+ end
+ end
+ end
+
+ class Response
+ include JSON::Serializable
+
+ property data : Array(Data)?
+ property errors : Array(JSON::Any::Type)?
+ end
+
+ class Data
+ include JSON::Serializable
+
+ property param : Param
+ property units : Unit
+ property source : String? # The module that captured the parameter reading
+ property span : Int64 # The sampling interval, in seconds, over which this measurement was taken
+ property points : Array(JSON::Any::Type)
+ end
+
+ def get_devices(id : String, params : Hash(String, String) = {} of String => String)
+ response = get(
+ generate_url("/devices/#{id}/top", params),
+ headers: generate_headers
+ )
+ Response.from_json(response.body)
+ end
+
+ class Request
+ include JSON::Serializable
+
+ property method : String
+ property relative_url : String
+ # headers (json, optional) - A JSON array of header description objects, each of which has a name and value object
+ property headers : Array(NamedTuple(name: String, value: String))?
+ property body : String?
+ end
+
+ class BatchResponse
+ include JSON::Serializable
+
+ property body : String
+ property code : Int64
+ end
+
+ def batch(body : Array(Request), params : Hash(String, String) = {} of String => String)
+ response = post(
+ generate_url("/batch", params),
+ body: body.to_json,
+ headers: generate_headers({
+ "Content-Type" => "application/json",
+ "Content-Encoding" => "UTF-8",
+ })
+ )
+ Array(BatchResponse).from_json(response.body)
+ end
+
+ private def generate_url(
+ path : String,
+ params : Hash(String, String) = {} of String => String
+ )
+ params["key"] = @api_key
+ encoded_params = URI::Params.encode(params)
+ "#{path}?#{encoded_params}"
+ end
+
+ private def generate_headers(
+ headers : Hash(String, String) = {} of String => String
+ )
+ # Recommended to use this header in docs
+ headers["Accept-Encoding"] = "gzip"
+ headers
+ end
+end
diff --git a/drivers/kaiterra/api_spec.cr b/drivers/kaiterra/api_spec.cr
new file mode 100644
index 00000000000..f681c4e270e
--- /dev/null
+++ b/drivers/kaiterra/api_spec.cr
@@ -0,0 +1,78 @@
+require "placeos-driver/spec"
+require "./api.cr"
+
+DriverSpecs.mock_driver "Kaiterra::API" do
+ exec(:get_devices, "00000000-0031-0101-0000-00007e57c0de")
+
+ expect_http_request do |_, response|
+ response.status_code = 200
+ response << %({
+ "id": "00000000-0031-0101-0000-00007e57c0de",
+ "data": [
+ {
+ "param": "rpm25c",
+ "units": "µg/m³",
+ "source": "km100",
+ "span": 60,
+ "points": [
+ {
+ "ts": "2020-06-17T03:40:00Z",
+ "value": 120
+ }
+ ]
+ },
+ {
+ "param": "rhumid",
+ "units": "%",
+ "span": 60,
+ "points": [
+ {
+ "ts": "2020-06-17T03:40:00Z",
+ "value": 83.58
+ }
+ ]
+ },
+ {
+ "param": "rco2",
+ "units": "ppm",
+ "span": 60,
+ "points": [
+ {
+ "ts": "2022-08-04T02:50:00Z",
+ "value": 432.9
+ }
+ ]
+ }
+ ]
+ })
+ end
+
+ body = Array(Kaiterra::API::Request).from_json(%([
+ {
+ "method": "GET",
+ "relative_url": "/devices/00000000-0001-0101-0000-00007e57c0de/top"
+ },
+ {
+ "method": "GET",
+ "relative_url": "/devices/00000000-0031-0001-0000-00007e57c0de/top"
+ }
+ ]))
+ params = {"include_headers" => "true"}
+ exec(:batch, body, params)
+
+ expect_http_request do |request, response|
+ request.body.not_nil!.gets_to_end.should eq(body.to_json)
+ request.query_params["include_headers"].should eq("true")
+ response.status_code = 200
+ response << %([
+ {
+ "body": "{\\"data\\":[{\\"param\\":\\"rhumid\\",\\"units\\":\\"%\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":54}]},{\\"param\\":\\"rpm10c\\",\\"units\\":\\"µg/m³\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":120}]},{\\"param\\":\\"rpm25c\\",\\"units\\":\\"µg/m³\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":191}]},{\\"param\\":\\"rtemp\\",\\"units\\":\\"C\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":16}]},{\\"param\\":\\"rtvoc\\",\\"units\\":\\"ppb\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":342}]}]}",
+ "code": 200
+ },
+ {
+ "body": "{\\"data\\":[{\\"param\\":\\"rco2\\",\\"units\\":\\"ppm\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":1673}]},{\\"param\\":\\"rhumid\\",\\"source\\":\\"km102\\",\\"units\\":\\"%\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":54.79}]},{\\"param\\":\\"rpm10c\\",\\"source\\":\\"km100\\",\\"units\\":\\"µg/m³\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":125}]},{\\"param\\":\\"rpm25c\\",\\"source\\":\\"km100\\",\\"units\\":\\"µg/m³\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":275}]},{\\"param\\":\\"rtemp\\",\\"source\\":\\"km102\\",\\"units\\":\\"C\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":20.57}]},{\\"param\\":\\"rtvoc\\",\\"source\\":\\"km102\\",\\"units\\":\\"ppb\\",\\"span\\":60,\\"points\\":[{\\"ts\\":\\"2020-06-17T07:05:00Z\\",\\"value\\":435.6}]}]}",
+ "code": 200
+ }
+ ])
+ end
+end
diff --git a/drivers/kaiterra/room_logic.cr b/drivers/kaiterra/room_logic.cr
new file mode 100644
index 00000000000..7a196881b1e
--- /dev/null
+++ b/drivers/kaiterra/room_logic.cr
@@ -0,0 +1,34 @@
+require "placeos-driver"
+
+class Kaiterra::RoomLogic < PlaceOS::Driver
+ descriptive_name "Room level abstraction of Kaiterra status "
+ generic_name :RoomEnvironment
+ description "Abstracts room sensors for Kaiterra"
+
+ default_settings({
+ kaiterra_room_id: "Paste Kaiterra Room ID here",
+ kaiterra_status_poll_cron: "*/5 * * * *",
+ })
+
+ accessor kaiterra : Kaiterra
+
+ @room_id : String = ""
+ @cron_string : String = "*/5 * * * *"
+
+ def on_update
+ @room_id = setting(String, :kaiterra_room_id)
+ @cron_string = setting(String, :kaiterra_status_poll_cron)
+ schedule.clear
+ schedule.cron(@cron_string) { get_measurements }
+ end
+
+ def get_measurements
+ response = kaiterra.get_devices(@room_id).get
+ return "No Data" unless results = response.as_h["data"]
+ results.as_a.each do |i|
+ name = "#{i["param"]} (#{i["units"]})"
+ value = i["points"].as_a.first["value"]
+ self[name] = value
+ end
+ end
+end
diff --git a/drivers/keycloak/rest_api.cr b/drivers/keycloak/rest_api.cr
new file mode 100644
index 00000000000..cfc6dd0e06c
--- /dev/null
+++ b/drivers/keycloak/rest_api.cr
@@ -0,0 +1,127 @@
+require "placeos-driver"
+require "link-header"
+
+class Keycloak::RestAPI < PlaceOS::Driver
+ # Discovery Information
+ generic_name :Keycloak
+ descriptive_name "Keycloak service"
+ uri_base "https://keycloak.domain.com"
+
+ description %(uses users OAuth2 tokens provided during SSO to access keycloak APIs)
+
+ default_settings({
+ place_domain: "https://placeos.org.com",
+ place_api_key: "requires users scope",
+ realm: "realm-id",
+ })
+
+ @realm : String = ""
+ @api_key : String = ""
+ @place_domain : String = ""
+
+ def on_update
+ @realm = setting(String, :realm) || ""
+ @api_key = setting(String, :place_api_key) || ""
+ @place_domain = setting(String, :place_domain) || ""
+ end
+
+ struct Role
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter id : String?
+ getter name : String?
+ getter description : String?
+ end
+
+ struct UserDetails
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter id : String?
+ getter username : String?
+ getter enabled : Bool?
+ getter email : String?
+
+ @[JSON::Field(key: "firstName")]
+ getter first_name : String?
+
+ @[JSON::Field(key: "lastName")]
+ getter last_name : String?
+
+ @[JSON::Field(key: "realmRoles")]
+ getter realm_roles : Array(String)?
+
+ @[JSON::Field(key: "clientRoles")]
+ getter client_roles : Array(Role)?
+
+ @[JSON::Field(key: "applicationRoles")]
+ getter application_roles : Array(Role)?
+ getter groups : Array(String)?
+ end
+
+ def users(
+ search : String? = nil,
+ email : String? = nil,
+ enabled_users_only : Bool = true,
+ all_pages : Bool = false,
+ auth_token : String? = nil
+ )
+ user_token = "Bearer #{auth_token.presence || get_token}"
+
+ params = URI::Params.build do |form|
+ form.add "search", search.to_s if search.presence
+ form.add "email", email.to_s if email.presence
+ form.add "enabled", enabled_users_only.to_s
+ form.add "exact", (!!email.presence).to_s
+
+ # yes it starts at index 1?
+ # https://github.com/keycloak/keycloak-community/blob/main/design/rest-api-guideline.md#pagination
+ form.add "first", "1"
+ form.add "max", "100"
+ end
+
+ # Get the existing bookings from the API to check if there is space
+ users = [] of UserDetails
+ next_request = "/admin/realms/#{@realm}/users?#{params}"
+ headers = HTTP::Headers{
+ "Accept" => "application/json",
+ "Authorization" => user_token,
+ }
+
+ logger.debug { "requesting users, all pages: #{all_pages}" }
+ page_count = 1
+
+ loop do
+ response = get(next_request, headers: headers)
+ raise "unexpected error: #{response.status_code} - #{response.body}" unless response.success?
+
+ links = LinkHeader.new(response)
+ next_request = links["next"]?
+
+ new_users = Array(UserDetails).from_json response.body
+ users.concat new_users
+ break if !all_pages || next_request.nil? || new_users.empty?
+ page_count += 1
+ end
+
+ logger.debug { "users count: #{users.size}, pages: #{page_count}" }
+
+ users
+ end
+
+ def get_token
+ user_id = invoked_by_user_id
+ raise "only supports requests directly from SSO users" unless user_id
+ get_user_token user_id
+ end
+
+ @[Security(Level::Administrator)]
+ def get_user_token(user_id : String) : String
+ response = ::HTTP::Client.post("#{@place_domain}/api/engine/v2/users/#{user_id}/resource_token", headers: HTTP::Headers{
+ "X-API-Key" => @api_key,
+ })
+ raise "failed to obtain a keycloak API key for user #{user_id}: #{response.status_code} - #{response.body}" unless response.success?
+ JSON.parse(response.body)["token"].as_s
+ end
+end
diff --git a/drivers/keycloak/rest_api_spec.cr b/drivers/keycloak/rest_api_spec.cr
new file mode 100644
index 00000000000..2c168883172
--- /dev/null
+++ b/drivers/keycloak/rest_api_spec.cr
@@ -0,0 +1,28 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Keycloak::RestAPI" do
+ settings({
+ # we grab the HTTP port that the spec is using
+ place_domain: "http://127.0.0.1:#{__get_ports__[1]}",
+ place_api_key: "key",
+ realm: "keycloak",
+ })
+
+ resp = exec(:get_token, user_id: "user1")
+ request_path = ""
+
+ # should send a HTTP to place API to obtain the token
+ expect_http_request do |request, response|
+ request_path = request.path
+ headers = request.headers
+ response.status_code = 403 unless headers["X-API-Key"]? == "key"
+ response << %({
+ "token": "a-token",
+ "expires": 123445
+ })
+ end
+
+ # What the sms function should return
+ resp.get.should eq("a-token")
+ request_path.should eq "/api/engine/v2/users/user1/resource_token"
+end
diff --git a/drivers/knx/baos_lighting.cr b/drivers/knx/baos_lighting.cr
new file mode 100644
index 00000000000..ba66dfdc653
--- /dev/null
+++ b/drivers/knx/baos_lighting.cr
@@ -0,0 +1,198 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+require "knx/object_server"
+
+class KNX::BaosLighting < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ # Discovery Information
+ descriptive_name "KNX BAOS Lighting"
+ generic_name :Lighting
+ tcp_port 12004
+
+ default_settings({
+ triggers: {
+ 1 => [
+ [161, true, "0: all on"],
+ [161, false, "1: all off"],
+ ],
+ },
+ })
+
+ INDICATOR = 0x06_u8
+
+ def on_load
+ queue.wait = false
+ queue.delay = 40.milliseconds
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.peek
+ logger.debug { "Received: 0x#{bytes.hexstring}" }
+
+ # Ensure message indicator is well-formed
+ if bytes.first != INDICATOR
+ disconnect
+ next 0
+ end
+
+ # make sure we can parse the header
+ next 0 unless bytes.size > 5
+
+ # extract the request length
+ io = IO::Memory.new(bytes)
+ header = io.read_bytes(KNX::Header)
+ header.request_length.to_i
+ end
+
+ on_update
+ end
+
+ alias AreaDetails = Hash(Int32, Array(Tuple(Int32, Bool | UInt8, String?)))
+ alias AreaLookup = Hash(Int32, Int32)
+
+ @triggers : AreaDetails = AreaDetails.new
+ @os : KNX::ObjectServer = KNX::ObjectServer.new
+ @area_lookup : AreaLookup = AreaLookup.new
+
+ def on_update
+ @triggers = setting?(AreaDetails, :triggers) || AreaDetails.new
+
+ # map the triggers to the area id
+ area_lookup = AreaLookup.new
+ @triggers.each do |area, triggers|
+ triggers.each do |trigger|
+ area_lookup[trigger[0]] = area
+ end
+ end
+ @area_lookup = area_lookup
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def connected
+ req = @os.status(1).to_slice
+ send req, priority: 0
+
+ schedule.every(1.minute) do
+ logger.debug { "Maintaining connection" }
+ send req, priority: 0
+ end
+ end
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area_id = area.try &.id
+ raise "no area id provided, area: #{area.inspect}" unless area_id
+
+ if trigger_group = @triggers[area_id]?
+ details = trigger_group[scene]? # 0, 1, 2 (array index)
+ end
+
+ if details
+ index, value, _desc = details
+ send_request index, value
+ else
+ send_request area_id, scene
+ end
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ area_id = area.try &.id
+ raise "no area id provided, area: #{area.inspect}" unless area_id
+
+ if trigger_group = @triggers[area_id]?
+ details = trigger_group[0]?
+ end
+
+ if details
+ index, value, _desc = details
+ send_query index
+ else
+ send_query area_id
+ end
+ end
+
+ LEVEL_PERCENTAGE = 0xFF / 100
+
+ # level between 0.0 and 100.0, fade in milliseconds
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area_id = area.try &.id
+ raise "no area id provided, area: #{area.inspect}" unless area_id
+
+ level = level.clamp(0.0, 100.0)
+ level_byte = (level * LEVEL_PERCENTAGE).to_u8
+
+ send_request area_id, level_byte
+ end
+
+ # return the current level
+ def lighting_level?(area : Area? = nil)
+ area_id = area.try &.id
+ raise "no area id provided, area: #{area.inspect}" unless area_id
+
+ send_query area_id
+ end
+
+ protected def send_request(index, value)
+ logger.debug { "Requesting #{index} = #{value}" }
+ req = @os.action(index, value).to_slice
+ send req, name: "index#{index}_level"
+ end
+
+ protected def send_query(num)
+ logger.debug { "Requesting value of #{num}" }
+ req = @os.status(num).to_slice
+ send req, wait: true
+ end
+
+ def received(data, task)
+ result = @os.read(data)
+
+ # report any errors
+ if !result.error.no_error?
+ logger.warn { "Error response: #{result.error} (#{result.error_code})" }
+ return task.try &.abort
+ end
+
+ items = result.data
+ logger.debug do
+ if items && items.size > 0
+ "Index: #{result.header.start_item}, Item Count: #{result.header.item_count}, Start value: 0x#{result.data[0].value.hexstring}"
+ else
+ "Received #{result.inspect}"
+ end
+ end
+
+ items.each do |item|
+ value_id = item.id
+ if area = @area_lookup[value_id]?
+ @triggers[area].each_with_index do |trigger, index|
+ if value_id == trigger[0]
+ # We need to coerce the value
+ check = trigger[1]
+ case check
+ in Bool
+ if check == (item.value[0] == 1)
+ updated = true
+ self["trigger_group_#{area}"] = index
+ break
+ end
+ in UInt8
+ if check == item.value[0]
+ updated = true
+ self["trigger_group_#{area}"] = index
+ break
+ end
+ end
+ end
+ end
+ else
+ self["area#{value_id}_level"] = item.value[0]
+ end
+ end
+
+ task.try &.success
+ end
+end
diff --git a/drivers/knx/baos_lighting_spec.cr b/drivers/knx/baos_lighting_spec.cr
new file mode 100644
index 00000000000..b9af07d46d3
--- /dev/null
+++ b/drivers/knx/baos_lighting_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "KNX::BaosLighting" do
+end
diff --git a/drivers/knx/disptach_model.cr b/drivers/knx/disptach_model.cr
new file mode 100644
index 00000000000..18fc1f9814c
--- /dev/null
+++ b/drivers/knx/disptach_model.cr
@@ -0,0 +1,19 @@
+require "bindata"
+
+class DispatchProtocol < BinData
+ endian big
+
+ enum MessageType : UInt8
+ OPENED
+ CLOSED
+ RECEIVED
+ WRITE
+ CLOSE
+ end
+
+ field message : MessageType = MessageType::RECEIVED
+ field ip_address : String
+ field id_or_port : UInt64
+ field data_size : UInt32, value: ->{ data.size }
+ field data : Bytes, length: ->{ data_size }
+end
diff --git a/drivers/knx/lighting.cr b/drivers/knx/lighting.cr
new file mode 100644
index 00000000000..d7ba164d3c0
--- /dev/null
+++ b/drivers/knx/lighting.cr
@@ -0,0 +1,199 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "placeos-driver/interface/lighting"
+
+class KNX::Lighting < PlaceOS::Driver
+ include Interface::Sensor
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ # Discovery Information
+ descriptive_name "KNX Lighting"
+ generic_name :Lighting
+
+ default_settings({
+ # these are optional but used to get feedback
+ knx_scene_group: "4/1/33",
+ knx_brightness_group: "4/1/66",
+ knx_brightness_max: 255,
+
+ knx_motion: "1.8.110",
+
+ # on and off switches like blinds
+ switch_groups: {
+ "Interactive Flat Blinds" => "2/3/19",
+ "Interactive Classroom Blinds" => "2/1/53",
+ },
+ })
+
+ accessor knx : KNX_1
+
+ def on_update
+ @knx_motion = setting?(String, :knx_motion).presence
+ @scene_group = setting?(String, :knx_scene_group).presence
+ @brightness_group = setting?(String, :knx_brightness_group).presence
+ @brightness_max = setting?(Int32, :knx_brightness_max) || 255
+ @level_percentage = @brightness_max / 100
+
+ @switch_groups = setting?(Hash(String, String), :switch_groups) || {} of String => String
+
+ subscriptions.clear
+ subscribe_datapoints
+ end
+
+ getter last_occupied : Int64 = 0_i64
+ getter occupied : Bool = false
+ getter knx_motion : String? = nil
+ getter scene_group : String? = nil
+ getter brightness_group : String? = nil
+ getter brightness_max : Int32 = 255
+ getter switch_groups : Hash(String, String) = {} of String => String
+ @level_percentage : Float64 = 255.0 / 100.0
+
+ protected def subscribe_datapoints
+ if s_group = @scene_group
+ knx.subscribe s_group do |_sub, payload|
+ self[Area.new(component: s_group)] = data_to_int(String.from_json(payload))
+ end
+ end
+
+ if b_group = @brightness_group
+ knx.subscribe b_group do |_sub, payload|
+ self[Area.new(component: b_group)] = data_scaled(String.from_json(payload))
+ end
+ end
+
+ if motion = @knx_motion
+ knx.subscribe motion do |_sub, payload|
+ @last_occupied = Time.utc.to_unix
+ @occupied = data_to_int(String.from_json(payload)) > 0
+ self[:motion] = @occupied ? 1.0 : 0.0
+ end
+
+ schedule.clear
+ schedule.every(10.seconds) { knx.status(motion) }
+ end
+
+ @switch_groups.each_value do |address|
+ knx.subscribe address do |_sub, payload|
+ # will return a payload like: %{"01"}
+ self["switch_#{address}"] = payload[-2] == '1'
+ end
+ end
+ end
+
+ protected def data_to_int(hexstring : String) : Int32
+ data = hexstring.rjust(8, '0').hexbytes
+ IO::Memory.new(data).read_bytes(Int32, IO::ByteFormat::BigEndian)
+ end
+
+ protected def data_scaled(hexstring : String) : Float64
+ data = hexstring.rjust(8, '0').hexbytes
+ int = IO::Memory.new(data).read_bytes(Int32, IO::ByteFormat::BigEndian)
+ (int.to_f / @brightness_max.to_f) * 100.0
+ end
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area = area || Area.new(component: @scene_group)
+ knx_address = area.component
+ raise "no scene area / group address provided" unless knx_address
+
+ knx.action(knx_address, scene)
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ if area
+ address = area.component
+ knx.status(address).get
+ if hexstring = knx[address]?.try(&.as_s)
+ # convert to integer and scale it into 0-100 range
+ data_to_int(hexstring)
+ end
+ elsif knx_address = @scene_group
+ self[Area.new(component: @scene_group)]?
+ else
+ raise "no brightness area / group address provided"
+ end
+ end
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area = area || Area.new(component: @brightness_group)
+ knx_address = area.component
+ raise "no brightness area / group address provided" unless knx_address
+
+ level = level.clamp(0.0, 100.0)
+ level_actual = (level * @level_percentage).round_away.to_i
+
+ knx.action(knx_address, level_actual)
+ end
+
+ # return the current level
+ def lighting_level?(area : Area? = nil)
+ if area
+ address = area.component
+ knx.status(address).get
+ if hexstring = knx[address]?.try(&.as_s)
+ # convert to integer and scale it into 0-100 range
+ data_scaled(hexstring)
+ end
+ elsif knx_address = @brightness_group
+ self[Area.new(component: @brightness_group)]?
+ else
+ raise "no brightness area / group address provided"
+ end
+ end
+
+ # helper for
+ def switch(name : String, state : Bool)
+ address = @switch_groups[name]? || name
+ knx.action(address, state)
+ end
+
+ def switch_on(name : String)
+ switch name, true
+ end
+
+ def switch_off(name : String)
+ switch name, false
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ SENSOR_TYPES = {SensorType::Presence}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH unless @knx_motion
+ return NO_MATCH if mac && mac != @knx_motion
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+
+ [get_sensor_details]
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless @knx_motion && @knx_motion == mac
+ get_sensor_details
+ end
+
+ def get_sensor_details
+ Detail.new(
+ type: SensorType::Presence,
+ value: @occupied ? 1.0 : 0.0,
+ last_seen: @last_occupied,
+ mac: @knx_motion.as(String),
+ id: nil,
+ name: "KNX motion sensor",
+ module_id: module_id,
+ binding: "motion",
+ )
+ end
+end
diff --git a/drivers/knx/lighting_spec.cr b/drivers/knx/lighting_spec.cr
new file mode 100644
index 00000000000..b9294b3bf96
--- /dev/null
+++ b/drivers/knx/lighting_spec.cr
@@ -0,0 +1,41 @@
+require "placeos-driver/spec"
+
+# :nodoc:
+class KNXMock < DriverSpecs::MockDriver
+ def action(address : String, data : Bool | Int32 | Float32 | String) : Nil
+ case data
+ in Int32
+ io = IO::Memory.new(4)
+ io.write_bytes data, IO::ByteFormat::BigEndian
+ self[address] = io.to_slice.hexstring
+ in Bool
+ self[address] = data ? "01" : "00"
+ in Float32, String
+ raise "types not being tested"
+ end
+ end
+
+ def status(address : String) : Nil
+ self[address]?
+ end
+end
+
+DriverSpecs.mock_driver "KNX::Lighting" do
+ system({
+ KNX: {KNXMock},
+ })
+
+ exec(:set_lighting_scene, 2).get
+ sleep 0.1
+ status["area_4/1/33"].should eq 2
+
+ exec(:set_lighting_level, 100).get
+ sleep 0.1
+ status["area_4/1/66"].should eq 100
+
+ exec(:lighting_level?).get.should eq 100
+ exec(:lighting_level?, {component: "4/1/66"}).get.should eq 100
+
+ exec(:lighting_scene?).get.should eq 2
+ exec(:lighting_scene?, {component: "4/1/33"}).get.should eq 2
+end
diff --git a/drivers/knx/udp_tunnel.cr b/drivers/knx/udp_tunnel.cr
new file mode 100644
index 00000000000..09e6faf6b21
--- /dev/null
+++ b/drivers/knx/udp_tunnel.cr
@@ -0,0 +1,178 @@
+require "placeos-driver"
+require "socket"
+require "./disptach_model"
+require "knx/tunnel_client"
+
+class KNX::TunnelDriver < PlaceOS::Driver
+ generic_name :KNX
+ descriptive_name "KNX Connector"
+ description %(makes KNX data available to other drivers in PlaceOS)
+
+ # Hookup dispatch to accept incoming packets from the KNX interface.
+ uri_base "ws://dispatch/api/dispatch/v1/udp_dispatch?port=3671&accept=192.168.0.1"
+
+ default_settings({
+ dispatcher_key: "secret",
+ dispatcher_ip: "192.168.0.1",
+ dispatcher_port: 3671,
+ broadcast: true,
+ repeat: false,
+ hop_count: 6,
+ })
+
+ def websocket_headers
+ dispatcher_key = setting?(String, :dispatcher_key)
+ HTTP::Headers{
+ "Authorization" => "Bearer #{dispatcher_key}",
+ "X-Module-ID" => module_id,
+ }
+ end
+
+ def on_load
+ queue.wait = false
+ on_update
+ end
+
+ protected getter! udp_socket : UDPSocket
+ protected getter! knx_client : KNX::TunnelClient
+ protected getter! knx_control : Socket::IPAddress
+ protected getter! knx_interface : Socket::IPAddress
+
+ getter? websocket_connected : Bool = false
+ getter? knx_client_connected : Bool = false
+
+ @mutex : Mutex = Mutex.new
+
+ def on_update
+ ip = setting(String, :dispatcher_ip)
+ port = setting(UInt16, :dispatcher_port)
+ params = URI.parse(config.uri.as(String)).query_params
+
+ @knx_control = control_ip = Socket::IPAddress.new(ip, port)
+ interface_ip = params["accept"].strip.split(',').first
+ @knx_interface = interface_ip = Socket::IPAddress.new(interface_ip, params["port"].to_i)
+
+ broadcast = setting?(Bool, :broadcast) || false
+ repeat = setting?(Bool, :repeat) || false
+ hop_count = setting?(UInt8, :hop_count) || 6_u8
+ source = setting?(String, :source_address) || "0.0.0"
+
+ spawn { @mutex.synchronize { establish_comms(control_ip, interface_ip, broadcast, repeat, hop_count, source) } }
+ end
+
+ protected def establish_comms(control_ip, interface_ip, broadcast, repeat, hop_count, source)
+ # cleanup old connections
+ if old_client = @knx_client
+ old_client.shutdown! rescue nil
+ @knx_client = nil
+ end
+
+ if old_socket = @udp_socket
+ @udp_socket = nil
+ old_socket.close
+ end
+
+ # establish a UDP port for sending data to the interface
+ logger.info { "connecting to #{interface_ip}" }
+ @udp_socket = udp_socket = UDPSocket.new
+ udp_socket.connect interface_ip.address, interface_ip.port
+ udp_socket.write_timeout = 200.milliseconds
+
+ # client handles the UDP virtual connection state
+ knx = ::KNX.new(broadcast: broadcast, no_repeat: !repeat, hop_count: hop_count, source: source)
+ @knx_client = client = KNX::TunnelClient.new(control_ip, knx: knx)
+ client.on_state_change(&->knx_connected_state(Bool, KNX::ConnectionError))
+ client.on_transmit(&->knx_transmit_request(Bytes))
+ client.on_message(&->knx_new_message(KNX::CEMI))
+
+ spawn { client.connect } if @websocket_connected
+ end
+
+ # this is called when we're connected to dispatcher and can receive messages
+ def connected
+ logger.debug { "Websocket connected!" }
+ @websocket_connected = true
+
+ schedule.clear
+ client = knx_client
+ schedule.every(1.minute) do
+ logger.debug { "Polling KNX connection" }
+ client.connected? ? client.query_state : client.connect
+ end
+
+ spawn { client.connected? ? client.query_state : client.connect }
+ end
+
+ def disconnected
+ logger.debug { "Websocket disconnected!" }
+ @websocket_connected = false
+ schedule.clear
+ end
+
+ # =========
+ # Callbacks
+ # =========
+
+ protected def knx_connected_state(connected : Bool, error : KNX::ConnectionError)
+ logger.debug { " connection state: #{connected} (#{error})" }
+ @knx_client_connected = connected
+ self[:connected] = connected
+
+ # attempt to reconnect
+ if !connected && websocket_connected?
+ knx_client.connect
+ end
+ end
+
+ protected def knx_transmit_request(payload : Bytes)
+ logger.debug do
+ io = IO::Memory.new(payload)
+ header = io.read_bytes(KNX::Header)
+ " transmitting #{header.request_type}: #{payload.hexstring}"
+ end
+ udp_socket.write payload
+ end
+
+ protected def knx_new_message(cemi : KNX::CEMI)
+ logger.debug { " received: #{cemi.inspect}" }
+ self[cemi.destination_address.to_s] = cemi.data.hexstring
+ end
+
+ # =========
+ # Interface
+ # =========
+
+ def action(address : String, data : Bool | Int32 | String) : Nil
+ knx_client.action(address, data)
+ end
+
+ # split out float to prevent type confusion parsing in JSON
+ def action_float(address : String, data : Float32) : Nil
+ knx_client.action(address, data)
+ end
+
+ def status(address : String) : Nil
+ knx_client.status(address)
+ end
+
+ def received(data, task)
+ protocol = IO::Memory.new(data).read_bytes(DispatchProtocol)
+ logger.debug { "received message: #{protocol.message} from #{protocol.ip_address}" }
+
+ return unless protocol.message.received?
+
+ logger.debug { "received payload: 0x#{protocol.data.hexstring}" }
+ logger.debug do
+ begin
+ io = IO::Memory.new(protocol.data)
+ header = io.read_bytes(KNX::Header)
+ "received #{header.request_type} message"
+ rescue error
+ "received bad KNX message"
+ end
+ end
+ knx_client.process(protocol.data)
+
+ task.try &.success
+ end
+end
diff --git a/drivers/knx/udp_tunnel_spec.cr b/drivers/knx/udp_tunnel_spec.cr
new file mode 100644
index 00000000000..cd6f08013d4
--- /dev/null
+++ b/drivers/knx/udp_tunnel_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "KNX::TunnelDriver" do
+end
diff --git a/drivers/kontakt_io/contact_tracing.cr b/drivers/kontakt_io/contact_tracing.cr
new file mode 100644
index 00000000000..b15501e1e64
--- /dev/null
+++ b/drivers/kontakt_io/contact_tracing.cr
@@ -0,0 +1,96 @@
+require "set"
+require "placeos-driver"
+require "./kio_cloud_models"
+
+class KontaktIO::ContactTracing < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Kontakt Contact Tracing"
+ generic_name :ContactTracing
+
+ accessor kontakt : KontaktIO_1
+ accessor location_services : LocationServices_1
+
+ def close_contacts(email : String? = nil, username : String? = nil, start_time : Int64? = nil, end_time : Int64? = nil)
+ macs = location_services.macs_assigned_to(email, username).get.as_a.map(&.as_s)
+
+ # break all the requests to kontakt into 24 hour segments to avoid requesting too much data
+ periods = [] of Tuple(Int64, Int64)
+ period_start = start_time || 2.days.ago.to_unix
+ period_end = end_time || 1.days.ago.to_unix
+ loop do
+ temp_ending = period_start + 6.hours.to_i
+ if temp_ending < period_end
+ periods << {period_start, temp_ending}
+ else
+ periods << {period_start, period_end}
+ break
+ end
+ period_start = temp_ending
+ end
+
+ # obtain the raw contact information
+ locations = [] of Tracking
+ errors = [] of Exception
+ macs.each do |mac|
+ begin
+ periods.each do |(starting, ending)|
+ raw_report = kontakt.colocations(mac, starting, ending).get.to_json
+ locations.concat Array(Tracking).from_json(raw_report)
+ end
+ rescue error
+ logger.warn(exception: error) { "locating close contacts" }
+ errors << error
+ end
+ end
+
+ raise errors[0] if locations.empty? && errors.size > 0
+
+ # find all the unique mac addresses in the results
+ macs = Set(String).new
+ locations.each { |location| macs << location.mac_address }
+
+ # map the mac addresses to people where we can (usernames in this case)
+ mac_mappings = {} of String => String
+ macs.each do |mac|
+ mac = format_mac(mac)
+ if owner = location_services.check_ownership_of(mac).get.as_h?
+ username = owner["assigned_to"]?.try(&.as_s)
+ next unless username
+ mac_mappings[mac] = username
+ end
+ end
+
+ # Generate the contact tracing results
+ contacts = {} of String => NamedTuple(
+ mac_address: String,
+ username: String?,
+ contact_time: Int64,
+ duration: Int32,
+ )
+
+ # removes duplications
+ locations.each do |location|
+ mac = format_mac(location.mac_address)
+ username = mac_mappings[mac]?
+ duration = location.duration
+
+ if current = contacts[username || mac]?
+ next if current[:duration] > duration
+ end
+
+ contacts[username || mac] = {
+ mac_address: mac,
+ username: username,
+ contact_time: location.start_time.to_unix,
+ # duration in seconds
+ duration: duration,
+ }
+ end
+
+ contacts.values
+ end
+
+ def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+end
diff --git a/drivers/kontakt_io/contact_tracing_spec.cr b/drivers/kontakt_io/contact_tracing_spec.cr
new file mode 100644
index 00000000000..0d400b3c359
--- /dev/null
+++ b/drivers/kontakt_io/contact_tracing_spec.cr
@@ -0,0 +1,71 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "KontaktIO::ContactTracing" do
+ system({
+ KontaktIO: {KontaktIOMock},
+ LocationServices: {LocationServicesMock},
+ })
+
+ report = exec(:close_contacts, "steve@place.tech", "stakach").get
+ report.should eq([
+ {
+ "mac_address" => "00fab603c01b",
+ "username" => "jmcfar",
+ "contact_time" => 1645761763,
+ "duration" => 7662,
+ }, {
+ "mac_address" => "00fab603c01e",
+ "username" => "jwest",
+ "contact_time" => 1645761763,
+ "duration" => 2386,
+ },
+ ])
+end
+
+# :nodoc:
+class KontaktIOMock < DriverSpecs::MockDriver
+ def colocations(mac_address : String, start_time : Int64? = nil, end_time : Int64? = nil)
+ JSON.parse %([
+ {
+ "trackingId": "00:fa:b6:03:c0:1b",
+ "startTime": "2022-02-25T04:02:43Z",
+ "endTime": "2022-03-02T04:02:43Z",
+ "contacts": [
+ {
+ "trackingId": "00:fa:b6:02:4b:a3",
+ "durationSec": 7662
+ }
+ ]
+ },
+ {
+ "trackingId": "00:fa:b6:03:c0:1e",
+ "startTime": "2022-02-25T04:02:43Z",
+ "endTime": "2022-03-02T04:02:43Z",
+ "contacts": [
+ {
+ "trackingId": "00:fa:b6:02:4b:a3",
+ "durationSec": 2386
+ }
+ ]
+ }
+ ])
+ end
+end
+
+# :nodoc:
+class LocationServicesMock < DriverSpecs::MockDriver
+ def macs_assigned_to(email : String? = nil, username : String? = nil)
+ ["00fab6024ba3"]
+ end
+
+ def check_ownership_of(mac_address : String)
+ case mac_address
+ when "00fab603c01b"
+ {location: "wireless", assigned_to: "jmcfar", mac_address: "00fab603c01b"}
+ when "00fab603c01e"
+ {location: "wireless", assigned_to: "jwest", mac_address: "00fab603c01e"}
+ else
+ nil
+ end
+ end
+end
diff --git a/drivers/kontakt_io/kio_cloud.cr b/drivers/kontakt_io/kio_cloud.cr
new file mode 100644
index 00000000000..02face85b16
--- /dev/null
+++ b/drivers/kontakt_io/kio_cloud.cr
@@ -0,0 +1,225 @@
+require "placeos-driver"
+require "simple_retry"
+require "./kio_cloud_models"
+
+class KontaktIO::KioCloud < PlaceOS::Driver
+ # Discovery Information
+ uri_base "https://apps.cloud.us.kontakt.io"
+ descriptive_name "Kontakt IO Cloud API"
+ generic_name :KontaktIO
+
+ default_settings({
+ kio_api_key: "Sign in to Kio Cloud > select Users > select Security > copy the Server API Key",
+ poll_every: 2,
+ })
+
+ @api_key : String = %()
+
+ def on_update
+ @api_key = setting(String, :kio_api_key)
+
+ poll_every = (setting?(Int32, :poll_every) || 2).seconds
+ schedule.clear
+ schedule.every(poll_every) { cache_occupancy_counts }
+ end
+
+ # Note:: there is a limit of 40 requests a second, however we are unlikely to hit this
+ protected def make_request(
+ method, path, body : ::HTTP::Client::BodyType = nil,
+ params : URI::Params = URI::Params.new,
+ headers : Hash(String, String) | HTTP::Headers = HTTP::Headers.new
+ ) : String
+ # handle auth
+ headers["Api-Key"] = @api_key
+ headers["Content-Type"] = "application/json"
+
+ # deal with result sizes and pagination
+ params["size"] = "500"
+ page = 0
+
+ loop do
+ params["page"] = page.to_s
+
+ path_params = "#{path}?#{params.map { |(key, value)| "#{key}=#{value}" }.join('&')}"
+ logger.debug { "requesting: #{method} #{path_params}" }
+ response = http(method, path_params, body, headers: headers)
+
+ logger.debug { "request returned:\n#{response.body}" }
+ case response.status_code
+ when 303
+ # TODO:: follow the redirect
+ when 401
+ logger.warn { "The API Key is invalid or disabled" }
+ when 403
+ logger.warn { "User who created the API no longer has access to the Kio Cloud account or their user role doesn't allow access to the endpoint. Device error if the endpoint is not available for the device model." }
+ end
+
+ unless response.success?
+ begin
+ error = JSON.parse response.body
+ message = error["message"]?.try(&.as_s) || response.body
+ raise "request #{path}?#{params} failed with #{response.status_code}:\n#{message}"
+ rescue
+ raise "request #{path}?#{params} failed with #{response.status_code}:\n#{response.body}"
+ end
+ end
+
+ if page_details = yield response.body
+ page += 1
+ next unless page >= page_details.total_pages
+ end
+ break response.body
+ end
+ end
+
+ protected def make_request(
+ method, path, body : ::HTTP::Client::BodyType = nil,
+ params : URI::Params = URI::Params.new,
+ headers : Hash(String, String) | HTTP::Headers = HTTP::Headers.new
+ ) : String
+ make_request(method, path, body, params, headers) { nil }
+ end
+
+ def colocations(mac_address : String, start_time : Int64? = nil, end_time : Int64? = nil) : Array(Tracking)
+ # max range is 21 days, we default to 20
+ ending = end_time ? Time.unix(end_time) : 10.minutes.ago
+ starting = start_time ? Time.unix(start_time) : (ending - 20.days)
+ tracking = [] of Tracking
+
+ make_request("GET", "/v3/novid/colocations", params: URI::Params{
+ # mac address needs to be uppercase and pretty formed for this request
+ "trackingId" => format_mac(mac_address).upcase.scan(/\w{2}/).map(&.to_a.first).join(':'),
+ "startTime" => starting.to_rfc3339,
+ "endTime" => ending.to_rfc3339,
+ }) do |data|
+ resp = Response(Tracking).from_json(data)
+ tracking.concat resp.content
+ resp.page
+ end
+ tracking
+ end
+
+ def find(mac_address : String) : Position?
+ data = make_request("GET", "/v2/positions", params: URI::Params{
+ # mac address needs to be lowercase for this request (according to the API)
+ "trackingId" => format_mac(mac_address),
+ })
+ Response(Position).from_json(data).content.first?
+ end
+
+ def campuses : Array(Campus)
+ campuses = [] of Campus
+ make_request("GET", "/v2/locations/campuses") do |data|
+ resp = Response(Campus).from_json(data)
+ campuses.concat resp.content
+ resp.page
+ end
+ campuses
+ end
+
+ def rooms : Array(Room)
+ rooms = [] of Room
+ make_request("GET", "/v2/locations/rooms") do |data|
+ resp = Response(Room).from_json(data)
+ rooms.concat resp.content
+ resp.page
+ end
+ rooms
+ end
+
+ def room_occupancy : Array(RoomOccupancy)
+ room_occupancy = [] of RoomOccupancy
+ make_request("GET", "/v3/occupancy/rooms") do |data|
+ resp = Response(RoomOccupancy).from_json(data)
+ room_occupancy.concat resp.content
+ resp.page
+ end
+ room_occupancy
+ end
+
+ def telemetry(tracking_ids : Array(String)) : Array(Telemetry)
+ telemetry = [] of Telemetry
+
+ params = URI::Params.new
+ params["endTime"] = Time.utc.to_rfc3339(fraction_digits: 3)
+ params["startTime"] = 2.minutes.ago.to_rfc3339(fraction_digits: 3)
+ params["trackingId"] = tracking_ids.map(&.strip.downcase).join(",") unless tracking_ids.empty?
+
+ make_request("GET", "/v3/telemetry", params: params) do |data|
+ resp = Response(Telemetry).from_json(data)
+ telemetry.concat resp.content
+ resp.page
+ end
+ telemetry
+ end
+
+ # ===================================
+ # Caching sensor data
+ # ===================================
+ getter occupancy_cache : Hash(Int64, RoomOccupancy) = {} of Int64 => RoomOccupancy
+
+ protected def cache_occupancy_counts
+ sensor_to_room = {} of String => Room
+ rooms.each do |room|
+ room.room_sensor_ids.each do |sensor_id|
+ sensor_to_room[sensor_id] = room
+ end
+ end
+
+ cache = Hash(Int64, RoomOccupancy).new(sensor_to_room.size) do |_hash, key|
+ raise KeyError.new(%(Missing hash key: "#{key}"))
+ end
+
+ # 3rd party motion sensors
+ recent_motion = 180_i64
+ sensor_to_room.keys.each_slice(10) do |keys|
+ SimpleRetry.try_to(max_attempts: 3, base_interval: 200.milliseconds) do
+ telemetry_data = telemetry(keys)
+ telemetry_data.each do |sensor|
+ seconds_since = sensor.seconds_since_motion || 3.days.total_seconds.to_i
+ room = sensor_to_room[sensor.id]
+ self["room-#{room.id}"] = cache[room.id] = room.to_room_occupancy(seconds_since <= recent_motion, sensor.timestamp)
+ end
+ end
+ end
+
+ # occupancy counters
+ occupancy = room_occupancy
+ occupancy.each { |room| self["room-#{room.room_id}"] = cache[room.room_id] = room }
+ @occupancy_cache = cache
+ self[:occupancy_cached_at] = Time.utc.to_unix
+ end
+
+ def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ end
+
+ def event_hub(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "scanning API received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+ logger.debug { body }
+ end
+
+ def create_channel(name : String, uri : String)
+ make_request("POST", "/v3/channels", body: {
+ status: :active,
+ name: name,
+ channel: {
+ type: "eventHub",
+ endpoint: uri,
+ streamName: name,
+ accessKey: "test",
+ secretKey: "test",
+ region: "test",
+ sharedAccessKeyName: "test",
+ eventHubName: "test",
+ sharedAccessKey: "test",
+ },
+ }.to_json)
+ end
+
+ def delete_channel(id : Int32 | String)
+ make_request("DELETE", "/v3/channels", params: URI::Params{
+ "id" => id.to_s,
+ })
+ end
+end
diff --git a/drivers/kontakt_io/kio_cloud_models.cr b/drivers/kontakt_io/kio_cloud_models.cr
new file mode 100644
index 00000000000..978becb3c49
--- /dev/null
+++ b/drivers/kontakt_io/kio_cloud_models.cr
@@ -0,0 +1,285 @@
+require "json"
+
+module KontaktIO
+ class Page
+ include JSON::Serializable
+
+ getter size : Int32
+ getter number : Int32 { 0 }
+
+ @[JSON::Field(key: "totalElements")]
+ getter total_elements : Int32 { 0 }
+
+ @[JSON::Field(key: "totalPages")]
+ getter total_pages : Int32 { 0 }
+ end
+
+ class Response(T)
+ include JSON::Serializable
+
+ getter content : Array(T)
+ getter page : Page?
+ end
+
+ class Tracking
+ include JSON::Serializable
+
+ @[JSON::Field(key: "entityId")]
+ getter entity_id : Int64?
+
+ @[JSON::Field(key: "entityName")]
+ getter entity_name : String?
+
+ @[JSON::Field(key: "trackingId")]
+ getter mac_address : String
+
+ @[JSON::Field(key: "startTime")]
+ getter start_time : Time
+
+ @[JSON::Field(key: "endTime")]
+ getter end_time : Time
+
+ getter contacts : Array(Contact)
+
+ def duration
+ contacts.first.duration_sec
+ end
+ end
+
+ class Contact
+ include JSON::Serializable
+
+ @[JSON::Field(key: "entityId")]
+ getter entity_id : Int64?
+
+ @[JSON::Field(key: "entityName")]
+ getter entity_name : String?
+
+ @[JSON::Field(key: "trackingId")]
+ getter mac_address : String
+
+ @[JSON::Field(key: "durationSec")]
+ getter duration_sec : Int32
+ end
+
+ class Presence
+ include JSON::Serializable
+
+ @[JSON::Field(key: "companyId")]
+ getter company_id : String
+
+ @[JSON::Field(key: "trackingId")]
+ getter mac_address : String
+
+ @[JSON::Field(key: "roomName")]
+ getter room_name : String
+
+ @[JSON::Field(key: "roomId")]
+ getter room_id : Int64
+
+ @[JSON::Field(key: "floorId")]
+ getter floor_id : Int64
+
+ @[JSON::Field(key: "floorName")]
+ getter floor_name : String
+
+ @[JSON::Field(key: "buildingId")]
+ getter building_id : Int64
+
+ @[JSON::Field(key: "buildingName")]
+ getter building_name : String
+
+ @[JSON::Field(key: "campusId")]
+ getter campus_id : Int64
+
+ @[JSON::Field(key: "campusName")]
+ getter campus_name : String
+
+ @[JSON::Field(key: "startTime")]
+ getter start_time : String
+
+ @[JSON::Field(key: "endTime")]
+ getter end_time : String
+ end
+
+ class Position
+ include JSON::Serializable
+
+ @[JSON::Field(key: "trackingId")]
+ getter mac_address : String
+
+ @[JSON::Field(key: "roomId")]
+ getter room_id : Int64?
+
+ @[JSON::Field(key: "floorId")]
+ getter floor_id : Int64?
+
+ @[JSON::Field(key: "buildingId")]
+ getter building_id : Int64?
+
+ @[JSON::Field(key: "campusId")]
+ getter campus_id : Int64?
+
+ @[JSON::Field(key: "lastUpdate")]
+ getter last_update : String?
+ getter x : Int64?
+ getter y : Int64?
+ end
+
+ class BuildingShort
+ include JSON::Serializable
+
+ getter id : Int64
+ getter name : String
+ end
+
+ class Floor
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter id : Int64
+ getter name : String
+
+ getter height : Float64? # in meters
+ getter width : Float64? # in meters
+ getter rotation : Float64? # in radians
+ getter level : Int32?
+
+ # lat lng from bottom right corner of image
+ @[JSON::Field(key: "anchorLat")]
+ getter lat : Float64?
+
+ @[JSON::Field(key: "anchorLng")]
+ getter lng : Float64?
+
+ getter building : BuildingShort? = nil
+ end
+
+ class Building
+ include JSON::Serializable
+
+ getter id : Int64
+ getter name : String
+ getter description : String?
+ getter address : String?
+ getter lat : Float64?
+ getter lng : Float64?
+
+ getter floors : Array(Floor)
+ end
+
+ class Campus
+ include JSON::Serializable
+
+ getter id : Int64
+ getter name : String
+ getter description : String?
+ getter address : String?
+
+ getter timezone : String?
+ getter lat : Float64?
+ getter lng : Float64?
+
+ getter buildings : Array(Building)
+ end
+
+ class Room
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter id : Int64
+ getter name : String
+
+ @[JSON::Field(key: "roomType")]
+ getter room_type : String
+ getter floor : Floor
+
+ @[JSON::Field(key: "roomNumber")]
+ getter room_number : Int64?
+
+ @[JSON::Field(key: "roomSensors")]
+ getter room_sensors : Array(RoomSensor) { [] of RoomSensor }
+
+ def room_sensor_ids : Array(String)
+ room_sensors.map(&.tracking_id)
+ end
+
+ def to_room_occupancy(occupied : Bool, last_update : Time)
+ RoomOccupancy.new self, occupied, last_update
+ end
+ end
+
+ struct RoomSensor
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ @[JSON::Field(key: "trackingId")]
+ getter tracking_id : String
+ end
+
+ struct RoomOccupancy
+ include JSON::Serializable
+
+ def initialize(room : Room, occupied : Bool, last_update : Time)
+ @room_id = room.id
+ @room_name = room.name
+ floor = room.floor
+ @floor_id = floor.id
+ @floor_name = floor.name
+ floor.building.try do |building|
+ @building_id = building.id
+ @building_name = building.name
+ end
+
+ @occupancy = occupied ? 1 : 0
+ @last_update = last_update
+ @pir = true
+ end
+
+ @[JSON::Field(key: "roomId")]
+ getter room_id : Int64
+
+ @[JSON::Field(key: "roomName")]
+ getter room_name : String?
+
+ @[JSON::Field(key: "floorId")]
+ getter floor_id : Int64?
+
+ @[JSON::Field(key: "floorName")]
+ getter floor_name : String?
+
+ @[JSON::Field(key: "buildingId")]
+ getter building_id : Int64? = nil
+
+ @[JSON::Field(key: "buildingName")]
+ getter building_name : String? = nil
+
+ @[JSON::Field(key: "campusId")]
+ getter campus_id : Int64? = nil
+
+ @[JSON::Field(key: "campusName")]
+ getter campus_name : String? = nil
+
+ @[JSON::Field(key: "lastUpdate")]
+ getter last_update : Time
+ getter occupancy : Int32
+
+ getter? pir : Bool = false
+ end
+
+ class Telemetry
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ @[JSON::Field(key: "trackingId")]
+ getter id : String
+
+ @[JSON::Field(key: "secondsSincePirMotion")]
+ getter seconds_since_motion : Int64?
+
+ @[JSON::Field(key: "numberOfPeopleDetected")]
+ getter number_of_people : Int32?
+
+ getter timestamp : Time
+ end
+end
diff --git a/drivers/kontakt_io/kio_cloud_spec.cr b/drivers/kontakt_io/kio_cloud_spec.cr
new file mode 100644
index 00000000000..87dbe528aea
--- /dev/null
+++ b/drivers/kontakt_io/kio_cloud_spec.cr
@@ -0,0 +1,62 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "KontaktIO::KioCloud" do
+ # Should standardise the format of MAC addresses
+ exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b)
+
+ resp = exec(:find, "0x12:34:A6-789B")
+
+ # The API Key should be included on requests
+ expect_http_request do |request, response|
+ headers = request.headers
+ if headers["Api-Key"]? == "Sign in to Kio Cloud > select Users > select Security > copy the Server API Key"
+ response.status_code = 200
+ response << %({"content": []})
+ else
+ response.status_code = 401
+ end
+ end
+
+ resp.get.should eq nil
+
+ resp = exec(:colocations, "00fab6:02:4B:A3", 1645858383, 1646204290)
+
+ # The API Key should be included on requests
+ expect_http_request do |request, response|
+ if request.query_params["trackingId"]? == "00:FA:B6:02:4B:A3"
+ response.status_code = 200
+ response << EXAMPLE_RESPONSE
+ else
+ response.status_code = 500
+ end
+ end
+
+ resp.get.should eq JSON.parse(EXAMPLE_COLOCATION)
+end
+
+EXAMPLE_COLOCATION = %([
+ {
+ "trackingId": "00:fa:b6:03:c0:1b",
+ "startTime": "2022-02-25T04:02:43Z",
+ "endTime": "2022-03-02T04:02:43Z",
+ "contacts": [
+ {
+ "trackingId": "00:fa:b6:02:4b:a3",
+ "durationSec": 7662
+ }
+ ]
+ },
+ {
+ "trackingId": "00:fa:b6:03:c0:1e",
+ "startTime": "2022-02-25T04:02:43Z",
+ "endTime": "2022-03-02T04:02:43Z",
+ "contacts": [
+ {
+ "trackingId": "00:fa:b6:02:4b:a3",
+ "durationSec": 2386
+ }
+ ]
+ }
+ ])
+
+EXAMPLE_RESPONSE = %({"content": #{EXAMPLE_COLOCATION}})
diff --git a/drivers/kontakt_io/mac_address_mappings.cr b/drivers/kontakt_io/mac_address_mappings.cr
new file mode 100644
index 00000000000..a97539554a0
--- /dev/null
+++ b/drivers/kontakt_io/mac_address_mappings.cr
@@ -0,0 +1,74 @@
+require "json"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+
+class KontaktIO::MacAddressMappings < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Kontakt IO Device MAC to Username Mapper"
+ generic_name :KontaktMacMappings
+
+ default_settings({
+ kio_api_key: "Sign in to Kio Cloud > select Users > select Security > copy the Server API Key",
+ })
+
+ def on_load
+ on_update
+ schedule.every(1.hour) { map_devices }
+ schedule.in(10.seconds) { map_devices }
+ end
+
+ @api_key : String = ""
+
+ def on_update
+ @api_key = setting(String, :kio_api_key)
+ end
+
+ class SearchMeta
+ include JSON::Serializable
+
+ @[JSON::Field(key: "nextResults")]
+ getter next_results : String
+ end
+
+ class DeviceDetails
+ include JSON::Serializable
+
+ getter alias : String?
+ getter mac : String
+ end
+
+ def map_devices
+ request = "https://api.kontakt.io/device?maxResult=500&deviceType=BEACON"
+
+ locatable = system.implementing(Interface::Locatable)
+
+ while request.presence
+ response = HTTP::Client.get(request, headers: HTTP::Headers{
+ "Api-Key" => @api_key,
+ "Content-Type" => "application/json",
+ "Accept" => "application/vnd.com.kontakt+json;version=10",
+ })
+
+ logger.debug { "request returned:\n#{response.body}" }
+ case response.status_code
+ when 303
+ # TODO:: follow the redirect
+ when 401
+ logger.warn { "The API Key is invalid or disabled" }
+ when 403
+ logger.warn { "User who created the API no longer has access to the Kio Cloud account or their user role doesn't allow access to the endpoint. Device error if the endpoint is not available for the device model." }
+ end
+
+ raise "request #{request} failed with status: #{response.status_code}" unless response.success?
+
+ result = NamedTuple(devices: Array(DeviceDetails), searchMeta: SearchMeta).from_json(response.body)
+ meta = result[:searchMeta]
+ request = meta.next_results
+
+ result[:devices].each do |device|
+ next unless device.alias.presence
+ locatable.mac_address_mappings(device.alias, {device.mac}, "").get
+ end
+ end
+ end
+end
diff --git a/drivers/kontakt_io/room_sensor.cr b/drivers/kontakt_io/room_sensor.cr
new file mode 100644
index 00000000000..e424222ffca
--- /dev/null
+++ b/drivers/kontakt_io/room_sensor.cr
@@ -0,0 +1,126 @@
+require "placeos-driver"
+require "./kio_cloud_models"
+require "placeos-driver/interface/sensor"
+
+class KontaktIO::RoomSensorDriver < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ descriptive_name "KontaktIO Room Sensor"
+ generic_name :Sensor
+
+ default_settings({
+ space_ref_id: "kontakt-room-id",
+ })
+
+ accessor kontakt_io : KontaktIO_1
+
+ @space_id : String = %()
+
+ getter! space : RoomOccupancy
+
+ def on_update
+ @space_id = setting(String, :space_ref_id)
+ subscriptions.clear
+ schedule.clear
+
+ # Level sensors
+ subscribe_to_sensor
+ end
+
+ protected def subscribe_to_sensor : Nil
+ system.subscribe(:KontaktIO, 1, "room-#{@space_id}") { |_sub, room| update_sensor_state(room) }
+ rescue error
+ schedule.in(15.seconds) { subscribe_to_sensor }
+ logger.warn(exception: error) { "attempting to bind to sensor details" }
+ self[:last_error] = error.message
+ end
+
+ protected def update_sensor_state(room_json : String)
+ @space = space = RoomOccupancy.from_json(room_json)
+ raise "space '#{@space_id}' not found" unless space
+
+ self[:last_changed] = space.last_update
+ people_count = space.occupancy
+
+ self[:presence] = people_count > 0
+ self[:people] = people_count
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+ sensor = @space
+ return NO_MATCH unless sensor
+
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+
+ if mac
+ return NO_MATCH unless mac == "kontakt-#{sensor.room_id}"
+ end
+
+ return NO_MATCH if zone_id && !system.zones.includes?(zone_id)
+
+ build_sensors(sensor, sensor_type)
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+ sensor = @space
+ return nil unless sensor
+ return nil unless mac == "kontakt-#{sensor.room_id}"
+
+ case id
+ when "people"
+ build_sensor_details(sensor, :people_count)
+ when "presence"
+ build_sensor_details(sensor, :presence)
+ end
+ end
+
+ protected def build_sensor_details(room : RoomOccupancy, sensor : SensorType) : Detail
+ id = "people"
+ value = case sensor
+ when .people_count?
+ room.occupancy.to_f64
+ when .presence?
+ id = "presence"
+ room.occupancy > 0 ? 1.0 : 0.0
+ else
+ raise "sensor type unavailable: #{sensor}"
+ end
+
+ detail = Detail.new(
+ type: sensor,
+ value: value,
+ last_seen: room.last_update.to_unix,
+ mac: "kontakt-#{room.room_id}",
+ id: id,
+ name: "#{room.floor_name} #{room.room_name} (#{room.building_name})",
+ module_id: module_id,
+ binding: id
+ )
+ detail
+ end
+
+ protected def build_sensors(room : RoomOccupancy, sensor : SensorType? = nil)
+ if sensor
+ [build_sensor_details(room, sensor)]
+ else
+ [
+ build_sensor_details(room, :people_count),
+ build_sensor_details(room, :presence),
+ ]
+ end
+ end
+end
diff --git a/drivers/kontakt_io/room_sensor_spec.cr b/drivers/kontakt_io/room_sensor_spec.cr
new file mode 100644
index 00000000000..6f3a005ac84
--- /dev/null
+++ b/drivers/kontakt_io/room_sensor_spec.cr
@@ -0,0 +1,36 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Vergesense::RoomSensor" do
+ system({
+ KontaktIO: {KontaktIOMock},
+ })
+
+ sleep 200.milliseconds
+
+ status[:presence].should eq(true)
+ status[:people].should eq(3)
+
+ sensors = exec(:sensors).get.not_nil!.as_a
+ sensors.size.should eq 2
+
+ sensor = exec(:sensor, sensors[0]["mac"], sensors[0]["id"]).get
+ sensors[0].should eq sensor
+end
+
+# :nodoc:
+class KontaktIOMock < DriverSpecs::MockDriver
+ def on_load
+ self["room-kontakt-room-id"] = {
+ "roomId" => 195835,
+ "roomName" => "Open Pod",
+ "floorId" => 195528,
+ "floorName" => "Lower ground floor",
+ "buildingId" => 193637,
+ "buildingName" => "Showroom",
+ "campusId" => 193296,
+ "campusName" => "Showroom",
+ "lastUpdate" => "2022-04-21T21:55:56.751Z",
+ "occupancy" => 3,
+ }
+ end
+end
diff --git a/drivers/kontakt_io/sensor_service.cr b/drivers/kontakt_io/sensor_service.cr
new file mode 100644
index 00000000000..46128a8d272
--- /dev/null
+++ b/drivers/kontakt_io/sensor_service.cr
@@ -0,0 +1,247 @@
+require "placeos-driver"
+require "./kio_cloud_models"
+require "placeos-driver/interface/sensor"
+require "placeos-driver/interface/locatable"
+
+class KontaktIO::SensorService < PlaceOS::Driver
+ include Interface::Sensor
+ include Interface::Locatable
+
+ descriptive_name "KontaktIO Sensor Service"
+ generic_name :KontaktSensors
+ description %(collects room occupancy data from KontaktIO)
+
+ accessor kontakt_io : KontaktIO_1
+ bind KontaktIO_1, :occupancy_cached_at, :update_cache
+
+ accessor staff_api : StaffAPI_1
+ accessor location_service : LocationServices_1
+
+ default_settings({
+ floor_mappings: {
+ "KontaktIO_floor_id": {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+
+ return_empty_spaces: true,
+ })
+
+ @floor_mappings : Hash(String, NamedTuple(building_id: String?, level_id: String)) = {} of String => NamedTuple(building_id: String?, level_id: String)
+ @zone_lookup : Hash(String, Array(Int64)) = {} of String => Array(Int64)
+
+ def on_update
+ @return_empty_spaces = setting?(Bool, :return_empty_spaces) || false
+ @floor_mappings = setting(Hash(String, NamedTuple(building_id: String?, level_id: String)), :floor_mappings)
+
+ lookup = Hash(String, Array(Int64)).new { |hash, key| hash[key] = [] of Int64 }
+ @floor_mappings.each do |kontakt_floor_id, zones|
+ begin
+ kontakt_id = kontakt_floor_id.to_i64
+ if building_id = zones[:building_id]
+ lookup[building_id] << kontakt_id
+ end
+ lookup[zones[:level_id]] << kontakt_id
+ rescue error
+ logger.warn(exception: error) { "invalid floor mapping #{kontakt_floor_id}" }
+ end
+ end
+ @zone_lookup = lookup
+ end
+
+ # ===================================
+ # Caching sensor data
+ # ===================================
+ @occupancy_cache : Hash(Int64, RoomOccupancy) = {} of Int64 => RoomOccupancy
+
+ protected def update_cache(_sub, _event)
+ @occupancy_cache = Hash(Int64, RoomOccupancy).from_json kontakt_io.occupancy_cache.get.to_json
+ end
+
+ # System id => Map ID
+ getter system_map_ids : Hash(String, String) do
+ building_zone = location_service.building_id.get.as_s
+ map_ids = {} of String => String
+ staff_api.systems(zone_id: building_zone).get.as_a.each do |sys|
+ map_id = sys["map_id"]?.try(&.as_s?)
+ next unless map_id
+ map_ids[sys["id"].as_s] = map_id
+ end
+ map_ids
+ end
+
+ # KIO room id => Map ID
+ getter map_ids : Hash(Int64, String) do
+ ids = {} of Int64 => String
+ system_map_ids.each do |sys_id, map_id|
+ resp = staff_api.system_settings(sys_id, "space_ref_id").get
+ value = resp.as_s?.try(&.to_i64?) || resp.as_i64?
+ next unless value
+ ids[value] = map_id
+ end
+ ids
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "sensor incapable of tracking #{mac_address}" }
+ nil
+ end
+
+ LOCATION = {"desk", "area"}
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+ floor_ids = @zone_lookup[zone_id]?
+ return [] of Nil unless floor_ids && floor_ids.size > 0
+ return [] of Nil if location && !LOCATION.includes?(location)
+
+ loc = LOCATION
+ cache = @occupancy_cache
+ cache.compact_map do |(room_id, space)|
+ next unless space.floor_id.in?(floor_ids)
+ people_count = space.occupancy
+
+ if @return_empty_spaces || people_count && people_count > 0
+ # TODO:: attach space environment conditions in the future
+ # if env = space.environment
+ # humidity = env.humidity.value
+ # temperature = env.temperature.value
+ # iaq = env.iaq.try &.value
+ # end
+ if space.pir?
+ capacity = 1
+ loc_type = loc[1]
+ else
+ loc_type = loc[0]
+ capacity = nil
+ end
+
+ if map_id = map_ids[space.room_id]?
+ capacity = 1
+ loc_type = loc[1]
+ else
+ map_id = "room-#{space.room_id}"
+ end
+
+ {
+ location: loc_type,
+ at_location: people_count,
+ map_id: map_id,
+ level: zone_id,
+ building: @floor_mappings[space.floor_id.to_s]?.try(&.[](:building_id)),
+ capacity: capacity,
+
+ kontakt_io_room: space.room_name,
+ }
+ end
+ end
+ end
+
+ # ===================================
+ # Sensor Interface functions
+ # ===================================
+ def sensor(mac : String, id : String? = nil) : Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id && mac.starts_with?("kontakt-")
+
+ room = @occupancy_cache[mac.lchop("kontakt-").to_i64?]?
+ return nil unless room
+
+ case id
+ when "people"
+ return nil if room.pir?
+ build_sensor_details(room, :people_count)
+ when "presence"
+ build_sensor_details(room, :presence)
+ end
+ rescue error
+ logger.warn(exception: error) { "checking for sensor" }
+ nil
+ end
+
+ SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+
+ if mac
+ return NO_MATCH unless mac.starts_with?("kontakt-")
+ room = @occupancy_cache[mac.lchop("kontakt-").to_i64?]?
+ end
+
+ if zone_id
+ levels = @zone_lookup[zone_id]?
+ end
+
+ rooms = if room
+ {room}
+ elsif levels
+ @occupancy_cache.values.select do |r|
+ floor_id = r.floor_id
+ floor_id.in?(levels) || @floor_mappings[floor_id.to_s]?.nil?
+ end
+ else
+ @occupancy_cache.values
+ end
+ rooms.flat_map { |r| build_sensors(r, sensor_type) }
+ end
+
+ protected def build_sensor_details(room : RoomOccupancy, sensor : SensorType) : Detail
+ id = "people"
+ value = case sensor
+ when .people_count?
+ room.occupancy.to_f64
+ when .presence?
+ id = "presence"
+ room.occupancy > 0 ? 1.0 : 0.0
+ else
+ raise "sensor type unavailable: #{sensor}"
+ end
+
+ detail = Detail.new(
+ type: sensor,
+ value: value,
+ last_seen: room.last_update.to_unix,
+ mac: "kontakt-#{room.room_id}",
+ id: id,
+ name: "#{room.floor_name} #{room.room_name} (#{room.building_name})",
+ )
+ if zones = @floor_mappings[room.floor_id.to_s]?
+ detail.level = zones[:level_id]
+ detail.building = zones[:building_id]
+ end
+ detail
+ end
+
+ protected def build_sensors(room : RoomOccupancy, sensor : SensorType? = nil)
+ if sensor
+ [build_sensor_details(room, sensor)]
+ else
+ [
+ build_sensor_details(room, :people_count),
+ build_sensor_details(room, :presence),
+ ]
+ end
+ end
+end
diff --git a/drivers/kontakt_io/sensor_service_spec.cr b/drivers/kontakt_io/sensor_service_spec.cr
new file mode 100644
index 00000000000..b01db015faa
--- /dev/null
+++ b/drivers/kontakt_io/sensor_service_spec.cr
@@ -0,0 +1,90 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "KontaktIO::SensorService" do
+ system({
+ KontaktIO: {KontaktIOMock},
+ LocationServices: {LocationServicesMock},
+ StaffAPI: {StaffAPIMock},
+ })
+ settings({
+ floor_mappings: {
+ "195528" => {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+ })
+
+ # give it a moment to grab the cache
+ sleep 1
+
+ # lookup a sensor value
+ resp = exec(:sensor, "kontakt-195835", "people").get.not_nil!
+ resp["mac"].should eq "kontakt-195835"
+ resp["id"].should eq "people"
+ resp["value"].should eq 3.0
+ resp["level"].should eq "zone-level"
+ resp["building"].should eq "zone-building"
+
+ resp = exec(:device_locations, "zone-level").get.not_nil!
+ resp.should eq([{
+ "location" => "desk",
+ "at_location" => 3,
+ "map_id" => "room-195835",
+ "level" => "zone-level",
+ "building" => "zone-building",
+ "capacity" => nil,
+ "kontakt_io_room" => "Open Pod",
+ }])
+end
+
+# :nodoc:
+class KontaktIOMock < DriverSpecs::MockDriver
+ def on_load
+ self[:occupancy_cached_at] = Time.utc.to_unix
+ end
+
+ def occupancy_cache
+ {
+ 195835 => {
+ "roomId" => 195835,
+ "roomName" => "Open Pod",
+ "floorId" => 195528,
+ "floorName" => "Lower ground floor",
+ "buildingId" => 193637,
+ "buildingName" => "Showroom",
+ "campusId" => 193296,
+ "campusName" => "Showroom",
+ "lastUpdate" => "2022-04-21T21:55:56.751Z",
+ "occupancy" => 3,
+ },
+ }
+ end
+end
+
+# :nodoc:
+class LocationServicesMock < DriverSpecs::MockDriver
+ def building_id : String
+ "zone-building"
+ end
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def systems(
+ q : String? = nil,
+ zone_id : String? = nil,
+ capacity : Int32? = nil,
+ bookable : Bool? = nil,
+ features : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0
+ )
+ [] of String
+ end
+
+ def system_settings(id : String, key : String)
+ nil
+ end
+end
diff --git a/drivers/kramer/rc_308_panel.cr b/drivers/kramer/rc_308_panel.cr
new file mode 100644
index 00000000000..028b949e7ed
--- /dev/null
+++ b/drivers/kramer/rc_308_panel.cr
@@ -0,0 +1,129 @@
+require "placeos-driver"
+
+class Kramer::RC308Panel < PlaceOS::Driver
+ # Discovery Information
+ tcp_port 50000
+ descriptive_name "Kramer RC-308 Key Pad"
+ generic_name :KeyPad
+
+ default_settings({
+ button_count: 8,
+ default_light: {
+ red: 255,
+ green: 0,
+ blue: 0,
+ },
+ })
+
+ record(DefaultLight,
+ red : UInt8,
+ green : UInt8,
+ blue : UInt8
+ ) do
+ include JSON::Serializable
+ end
+
+ @default : DefaultLight = DefaultLight.new(255_u8, 0_u8, 0_u8)
+ @button_count : UInt8 = 8_u8
+
+ # \r\n 0D0A
+ DELIMITER = "\r\n"
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ on_update
+
+ (0..@button_count).each do |idx|
+ self["button#{idx}_state"] = ButtonAction::Released
+ end
+ end
+
+ def on_update
+ @default = setting?(DefaultLight, :default_light) || DefaultLight.new(255_u8, 0_u8, 0_u8)
+ @button_count = setting?(UInt8, :button_count) || 8_u8
+ end
+
+ def connected
+ schedule.clear
+ schedule.every(1.minute, true) { query_state }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def query_state
+ (1_u8..@button_count).each do |idx|
+ button_state? idx
+ end
+ end
+
+ def button_state(index : UInt8, light : Bool, red : UInt8? = nil, green : UInt8? = nil, blue : UInt8? = nil)
+ data = "#RGB #{index},#{red || @default.red},#{green || @default.green},#{blue || @default.blue},#{light ? '1' : '0'}\r"
+ send data, name: "button#{index}"
+ end
+
+ def button_state?(index : UInt8, priority : Int32 = 0)
+ send "#RGB? #{index}\r", priority: priority
+ end
+
+ enum ButtonAction
+ Pressed
+ Released
+ HeldDown
+
+ def self.check(type : String)
+ case type.downcase
+ when "p"
+ ButtonAction::Pressed
+ when "r"
+ ButtonAction::Released
+ when "h"
+ ButtonAction::HeldDown
+ else
+ raise "unknown button action type: #{type}"
+ end
+ end
+ end
+
+ def received(data, task)
+ # Remove the delimiter
+ data = String.new(data).strip
+ logger.debug { "Kramer sent: #{data.inspect}" }
+
+ # error feedback: ~01@ ERR 002\x0D\x0A
+ # Button press feedback: ~01@BTN 1,1,p\x0D\x0A
+ # Light query response: ~01@RGB 6,64,64,64,0\x0D\x0A
+
+ # check we're getting some button feedback
+ parts = data.split('@', 2)[1].strip.split(' ')
+ component = parts[0].upcase
+ details = parts[1]
+ success = parts[2]?
+
+ case component
+ when "BTN"
+ light_on, button_index, button_action = details.split(',')
+ self["button#{button_index}_light"] = light_on == "1"
+ self["button#{button_index}_state"] = ButtonAction.check(button_action)
+ when "RGB"
+ button_index, red, green, blue, light_on = details.split(',')
+ self["button#{button_index}_rgb"] = {red.to_u8, green.to_u8, blue.to_u8}
+ self["button#{button_index}_light"] = light_on == "1"
+ when "ERR"
+ logger.warn { "request failed with error code: #{details}" }
+ return task.try &.abort("error code: #{details}")
+ else
+ logger.warn { "unknown button component #{component}" }
+ return
+ end
+
+ if task
+ if task.name
+ task.success if success
+ else
+ task.success
+ end
+ end
+ end
+end
diff --git a/drivers/kramer/rc_308_panel_spec.cr b/drivers/kramer/rc_308_panel_spec.cr
new file mode 100644
index 00000000000..d03149e9688
--- /dev/null
+++ b/drivers/kramer/rc_308_panel_spec.cr
@@ -0,0 +1,61 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Kramer::RC308Panel" do
+ should_send("#RGB? 1\r")
+ responds("~01@RGB 1,64,63,62,0\r\n")
+
+ should_send("#RGB? 2\r")
+ responds("~01@RGB 2,64,63,62,0\r\n")
+
+ should_send("#RGB? 3\r")
+ responds("~01@RGB 3,64,63,62,0\r\n")
+
+ should_send("#RGB? 4\r")
+ responds("~01@RGB 4,64,63,62,0\r\n")
+
+ should_send("#RGB? 5\r")
+ responds("~01@RGB 5,64,63,62,0\r\n")
+
+ should_send("#RGB? 6\r")
+ responds("~01@RGB 6,64,63,62,0\r\n")
+
+ should_send("#RGB? 7\r")
+ responds("~01@RGB 7,64,63,62,0\r\n")
+
+ should_send("#RGB? 8\r")
+ responds("~01@RGB 8,64,63,62,0\r\n")
+
+ status["button1_light"].should be_false
+ status["button2_light"].should be_false
+ status["button3_light"].should be_false
+ status["button4_light"].should be_false
+ status["button5_light"].should be_false
+ status["button6_light"].should be_false
+ status["button7_light"].should be_false
+ status["button8_light"].should be_false
+
+ # Query button state
+ resp = exec :button_state?, 1
+ should_send("#RGB? 1\r")
+ responds("~01@RGB 1,64,63,62,1\r\n")
+ resp.get
+
+ status["button1_rgb"].should eq [64, 63, 62]
+ status["button1_light"].should be_true
+
+ # Test button press
+ transmit "~01@BTN 1,3,p\r\n"
+ sleep 0.2
+ status["button3_light"].should be_true
+ status["button3_state"].should eq "Pressed"
+
+ # Test setting button state
+ resp = exec :button_state, 2, true, 3, 4, 5
+ should_send("#RGB 2,3,4,5,1\r")
+ responds("~01@RGB 2,3,4,5,1\r\n")
+ responds("~01@RGB 2,3,4,5,1 OK\r\n")
+ resp.get
+
+ status["button2_rgb"].should eq [3, 4, 5]
+ status["button2_light"].should be_true
+end
diff --git a/drivers/lenel/open_access.cr b/drivers/lenel/open_access.cr
new file mode 100644
index 00000000000..69d22fadcd7
--- /dev/null
+++ b/drivers/lenel/open_access.cr
@@ -0,0 +1,333 @@
+require "placeos-driver"
+require "time"
+
+class Lenel::OpenAccess < PlaceOS::Driver; end
+
+require "./open_access/client"
+
+class Lenel::OpenAccess < PlaceOS::Driver
+ include OpenAccess::Models
+
+ generic_name :Security
+ descriptive_name "Lenel OpenAccess"
+ description "Bindings for Lenel OnGuard physical security system"
+ uri_base "https://example.com/api/access/onguard/openaccess"
+ default_settings({
+ application_id: "",
+ directory_id: "",
+ username: "",
+ password: "",
+ })
+
+ private getter client : OpenAccess::Client do
+ app_id = setting String, :application_id
+ OpenAccess::Client.new transport_wrapper, app_id
+ end
+
+ private getter transport_wrapper : PlaceOS::HTTPClient do
+ wrapper = PlaceOS::HTTPClient.new self
+ transport.before_request do |request|
+ wrapper.before_lenel_request.try &.each &.call(request)
+ end
+ wrapper
+ end
+
+ def on_load
+ schedule.every 5.minutes, &->check_comms
+ end
+
+ def on_update
+ logger.debug { "settings updated" }
+ client.app_id = setting String, :application_id
+ authenticate!
+ end
+
+ def connected
+ logger.debug { "connected" }
+ authenticate! if client.token.nil?
+ end
+
+ def disconnected
+ logger.debug { "disconnected" }
+ client.token = nil
+ end
+
+ private def authenticate! : Nil
+ username = setting String, :username
+ password = setting String, :password
+ directory = setting?(String, :directory_id).presence
+
+ logger.debug { "requesting access token for #{username}" }
+
+ begin
+ auth = client.login username, password, directory
+ client.token = auth[:session_token]
+
+ renewal_time = auth[:token_expiration_time] - 5.minutes
+ schedule.at renewal_time, &->authenticate!
+
+ logger.info { "authenticated - renews at #{renewal_time}" }
+
+ set_connected_state true
+ rescue e
+ logger.error { "authentication failed: #{e.message}" }
+ set_connected_state false
+ end
+ end
+
+ # Test service connectivity.
+ @[Security(Level::Support)]
+ def check_comms
+ logger.debug { "checking service connectivity" }
+ if client.token
+ client.keepalive
+ logger.info { "client online and authenticated" }
+ else
+ client.version
+ logger.warn { "service reachable, no active auth session" }
+ authenticate!
+ end
+ rescue e : OpenAccess::Error
+ logger.error { e.message }
+ set_connected_state false
+ end
+
+ # Query the directories available for auth.
+ @[Security(Level::Support)]
+ def list_directories
+ client.directories
+ end
+
+ # Gets the version of the attached OnGuard system.
+ @[Security(Level::Support)]
+ def version
+ client.version
+ end
+
+ # Query the available badge types.
+ #
+ # Badge types contain default configuration that is applied to any badge
+ # created under them. This includes items such as access areas, activation
+ # windows and other bulk config. These may then be override on individual
+ # badge instances.
+ @[Security(Level::Support)]
+ def badge_types
+ client.lookup BadgeType
+ end
+
+ # List badges belonging to a cardholder
+ @[Security(Level::Support)]
+ def list_badges(personid : Int32)
+ client.lookup Badge, filter: %(personid = #{personid})
+ end
+
+ # Get badge by badgekey (instead of id)
+ # Note: id is the number in the QR data or burnt to the swipe card. badgekey is Lenel's primary key for badges
+ @[Security(Level::Support)]
+ def lookup_badge_key(badgekey : Int32)
+ badges = client.lookup Badge, filter: %(badgekey = #{badgekey})
+ if badges.size > 1
+ logger.warn { "duplicate records exist for #{badgekey}" }
+ end
+ badges.first?
+ end
+
+ # Get badge by id (instead of badgekey)
+ @[Security(Level::Support)]
+ def lookup_badge_id(id : Int64)
+ badges = client.lookup Badge, filter: %(id = #{id})
+ if badges.size > 1
+ logger.warn { "duplicate records exist for #{id}" }
+ end
+ badges.first?
+ end
+
+ # Creates a new badge of the specied *type*, belonging to *personid* with a
+ # specific *id*.
+ #
+ # Note: 'id' is the physical badge number (e.g. the ID written to an NFC chip)
+ @[Security(Level::Administrator)]
+ def create_badge(
+ type : Int32,
+ id : Int64,
+ personid : Int32,
+ uselimit : Int32? = nil,
+ activate : Time? = nil,
+ deactivate : Time? = nil
+ )
+ logger.debug { "creating badge for cardholder #{personid}" }
+ client.create Badge, **args
+ end
+
+ def create_badge_epoch(
+ type : Int32,
+ id : Int64,
+ personid : Int32,
+ activate_epoch : Int32,
+ deactivate_epoch : Int32,
+ uselimit : Int32? = nil
+ )
+ activate = Time.unix(activate_epoch)
+ deactivate = Time.unix(deactivate_epoch)
+
+ create_badge(
+ type: type,
+ id: id,
+ personid: personid,
+ activate: activate,
+ deactivate: deactivate,
+ uselimit: uselimit
+ )
+ end
+
+ @[Security(Level::Administrator)]
+ def update_badge(
+ badgekey : Int32,
+ id : Int64? = nil,
+ uselimit : Int32? = nil,
+ activate : Time? = nil,
+ deactivate : Time? = nil
+ )
+ logger.debug { "Updating badge #{badgekey}" }
+ client.update Badge, **args
+ end
+
+ @[Security(Level::Administrator)]
+ def update_badge_epoch(
+ badgekey : Int32,
+ activate_epoch : Int32,
+ deactivate_epoch : Int32,
+ id : Int64? = nil,
+ uselimit : Int32? = nil
+ )
+ activate = Time.unix(activate_epoch)
+ deactivate = Time.unix(deactivate_epoch)
+
+ update_badge(
+ badgekey: badgekey,
+ id: id,
+ activate: activate,
+ deactivate: deactivate,
+ uselimit: uselimit
+ )
+ end
+
+ # Deletes a badge with the specified *badgekey*.
+ @[Security(Level::Administrator)]
+ def delete_badge(badgekey : Int32) : Nil
+ logger.debug { "deleting badge #{badgekey}" }
+ client.delete Badge, **args
+ end
+
+ def delete_badges(badgekeys : Array(Int32)) : Int32
+ badgekeys.count do |badge_key|
+ begin
+ delete_badge(badge_key)
+ 1
+ rescue OpenAccess::Error
+ logger.debug { "failed to delete badge #{badge_key}" }
+ 0
+ end
+ end
+ end
+
+ # Lookup a cardholder by *email* address.
+ @[Security(Level::Support)]
+ def lookup_cardholder(email : String)
+ cardholders = client.lookup Cardholder, filter: %(email = "#{email}")
+ if cardholders.size > 1
+ logger.warn { "duplicate records exist for #{email}" }
+ end
+ cardholders.first?
+ end
+
+ @[Security(Level::Support)]
+ def lookup_cardholders(email : String)
+ client.lookup Cardholder, filter: %(email = "#{email}")
+ end
+
+ # Lookup a cardholder by ID
+ @[Security(Level::Support)]
+ def lookup_cardholder_id(id : Int32)
+ cardholders = client.lookup Cardholder, filter: %(id = #{id})
+ if cardholders.size > 1
+ logger.warn { "duplicate records exist for #{id}" }
+ end
+ cardholders.first?
+ end
+
+ # Creates a new cardholder.
+ #
+ # An error will be returned if an existing cardholder exists for the specified
+ # *email* address.
+ @[Security(Level::Support)]
+ def create_cardholder(
+ email : String,
+ firstname : String,
+ lastname : String
+ )
+ logger.debug { "creating cardholder record for #{email}" }
+ unless client.count(Cardholder, filter: %(email = "#{email}")).zero?
+ raise ArgumentError.new "record already exists for #{email}"
+ end
+ client.create Cardholder, **args
+ end
+
+ # Deletes a cardholed by their person *id*.
+ @[Security(Level::Administrator)]
+ def delete_cardholder(id : Int32) : Nil
+ logger.debug { "deleting cardholder #{id}" }
+ client.delete Cardholder, **args
+ end
+
+ # List card readers matching a given filter
+ @[Security(Level::Support)]
+ def search_readers(filter : String)
+ client.lookup Reader, filter
+ end
+
+ # List Logged Events
+ @[Security(Level::Support)]
+ def list_events(filter : String, page_number : Int32? = nil)
+ client.get_logged_events(filter, page_number)
+ end
+
+ @[Security(Level::Support)]
+ # List events that occured during a given time window. Default to past 24h.
+ def list_events_in_range(
+ filter : String,
+ from : Time? = nil,
+ til : Time? = nil
+ )
+ til ||= Time.local
+ from ||= til - 1.day
+ client.get_logged_events(filter + %( AND timestamp >= "#{from.to_s}" AND timestamp <= "#{til.to_s}"))
+ end
+
+ @[Security(Level::Support)]
+ def search(type_name : String, filter : String? = nil)
+ client.raw_lookup type_name, filter
+ end
+end
+
+################################################################################
+# The intent below is to provide a `HTTP::Client`-ish object that uses the
+# underlying queue and config. This provides a familiar interface for users, but
+# importantly also allows it to be passed as a compatible object to client libs
+# that may already exist for the service being integrated.
+#
+
+class PlaceOS::HTTPClient < HTTP::Client
+ def initialize(@driver : PlaceOS::Driver)
+ @host = ""
+ @port = -1
+ end
+
+ delegate get, post, put, patch, delete, to: @driver
+
+ getter before_lenel_request : Array(HTTP::Request ->) = [] of (HTTP::Request ->)
+
+ def before_request(&callback : HTTP::Request ->)
+ @before_lenel_request << callback
+ end
+end
diff --git a/drivers/lenel/open_access/client.cr b/drivers/lenel/open_access/client.cr
new file mode 100644
index 00000000000..5c6eaba61cc
--- /dev/null
+++ b/drivers/lenel/open_access/client.cr
@@ -0,0 +1,197 @@
+require "http/client"
+require "http/params"
+require "responsible"
+require "uri"
+require "inactive-support/args"
+
+require "./models"
+require "./error"
+
+# Lenel OpenAccess API wrapper.
+#
+# Provides thin abstractions over API endpoints. Requests are executed on the
+# pased transport. This can be a `PlaceOS::Driver`, `HTTP::Client` or other type
+# supporting the same set of base HTTP request methods.
+class Lenel::OpenAccess::Client
+ private getter transport : HTTP::Client
+
+ property app_id : String
+
+ property token : String?
+
+ def initialize(@transport, @app_id)
+ transport.before_request do |req|
+ req.headers["Application-Id"] = app_id
+ req.headers["Content-Type"] = "application/json"
+ req.headers["Session-Token"] = token.not_nil! unless token.nil?
+ end
+ end
+
+ Responsible.on_server_error do |response|
+ raise OpenAccess::Error.from_response response
+ end
+
+ Responsible.on_client_error do |response|
+ raise OpenAccess::Error.from_response response
+ end
+
+ # Gets the version of the attached OnGuard system.
+ def version
+ ~transport.get(
+ path: "/version?version=1.0",
+ ) >> NamedTuple(
+ product_name: String,
+ product_version: String,
+ )
+ end
+
+ # Enumerates the directories available for auth.
+ def directories
+ (~transport.get(
+ path: "/directories?version=1.0"
+ ) >> NamedTuple(
+ total_items: Int32,
+ item_list: Array({property_value_map: {ID: String, Name: String, directory_type: Int32}}),
+ ))[:item_list].map { |item| item[:property_value_map] }
+ end
+
+ # Creates a new auth session.
+ def login(
+ username user_name : String,
+ password : String,
+ directory_id : String?
+ )
+ ~transport.post(
+ path: "/authentication?version=1.0",
+ body: args.to_h.compact.to_json,
+ ) >> NamedTuple(
+ session_token: String,
+ token_expiration_time: Time,
+ )
+ end
+
+ # Removes an auth session.
+ def logout : Nil
+ ~transport.delete(
+ path: "/authentication?version=1.0",
+ )
+ end
+
+ # Request a connection keepalive to prevent session timeout.
+ def keepalive : Nil
+ ~transport.get(
+ path: "/keepalive?version=1.0",
+ )
+ end
+
+ # Creates a new instance of *entity*.
+ #
+ # API create responses return a partial object, which is provided here as an
+ # untyped return. This includes the object's database key (which varies
+ # between object types - ID, BADGEKEY etc), however contents of this is
+ # unspecified. The partial object is provided here, in full, with keys
+ # transformed to match how they appear in a type-safe model.
+ def create(entity : T.class, **props) forall T
+ ~transport.post(
+ path: "/instances?version=1.0",
+ body: {
+ type_name: T.type_name,
+ property_value_map: T.partial(**props),
+ }.to_json
+ ) >> Models::Untyped
+ end
+
+ # Retrieves instances of a particular *entity*.
+ #
+ # The search criteria specified in *filter* is a subset of SQL. This supports
+ # operations such as as:
+ # + exclusion `LastName != "Lake"`
+ # + wildcards `LastName like "La%"`
+ # + boolean operators `LastName = "Lake" OR FirstName = "Lisa"`
+ def lookup(
+ entity type_name : T.class,
+ filter : String? = nil,
+ page_number : Int32? = nil,
+ page_size : Int32? = nil,
+ order_by : String? = nil
+ ) : Array(T) forall T
+ params = HTTP::Params.new
+ args.merge(type_name: T.type_name).each do |key, val|
+ params.add key.to_s, val unless val.nil?
+ end
+ (~transport.get(
+ path: "/instances?version=1.0{params}",
+ ) >> NamedTuple(
+ page_number: Int32?,
+ page_size: Int32?,
+ total_pages: Int32,
+ total_items: Int32,
+ count: Int32,
+ item_list: Array(T),
+ ))[:item_list]
+ end
+
+ def raw_lookup(
+ type_name : String,
+ filter : String? = nil,
+ page_number : Int32? = nil,
+ page_size : Int32? = 100,
+ order_by : String? = nil
+ )
+ params = HTTP::Params.new
+ args.each do |key, val|
+ params.add key.to_s, val.to_s unless val.nil?
+ end
+ response = transport.get(path: "/instances?version=1.0{params}")
+ response.body
+ end
+
+ # Counts the number of instances of *entity*.
+ #
+ # *filter* may optionally be used to specify a subset of these.
+ def count(entity type_name : T.class, filter : String? = nil) forall T
+ params = HTTP::Params.encode args.merge type_name: T.type_name
+ (~transport.get(
+ path: "/count?version=1.0{params}"
+ ) >> NamedTuple(total_items: Int32))[:total_items]
+ end
+
+ # Updates a record of *entity*. Passed properties must include the types key and
+ # any fields to update.
+ def update(entity : T.class, **props) : T forall T
+ ~transport.put(
+ path: "/instances?version=1.0",
+ body: {
+ type_name: T.type_name,
+ property_value_map: T.partial(**props),
+ }.to_json
+ ) >> T
+ end
+
+ # Deletes an instance of *entity*.
+ def delete(entity : T.class, **props) : Nil forall T
+ ~transport.delete(
+ path: "/instances?version=1.0",
+ body: {
+ type_name: T.type_name,
+ property_value_map: T.partial(**props),
+ }.to_json
+ )
+ end
+
+ # Retrieve a list of logged events from Onguard
+ # See Onguard 7.6 OpenAccess User Gude > Chapter 4 REST API > Manage Instances > get logged_events
+ def get_logged_events(
+ filter : String? = nil,
+ page_number : Int32? = nil,
+ page_size : Int32? = 100,
+ order_by : String? = nil
+ )
+ params = HTTP::Params.new
+ args.each do |key, val|
+ params.add key.to_s, val.to_s unless val.nil?
+ end
+ response = transport.get(path: "/logged_events?version=1.0{params}")
+ response.body
+ end
+end
diff --git a/drivers/lenel/open_access/error.cr b/drivers/lenel/open_access/error.cr
new file mode 100644
index 00000000000..aaa8d5d8adc
--- /dev/null
+++ b/drivers/lenel/open_access/error.cr
@@ -0,0 +1,24 @@
+require "json"
+
+class Lenel::OpenAccess::Error < Exception
+ alias Info = {error: {code: String, message: String?}}
+
+ def self.from_response(response)
+ # Although the API docs specify this is being in an "error" header, this
+ # appars as JSON within the response body when tested with OpenAccess 7.5
+ error = Error::Info.from_json response.body
+ new **error[:error]
+ rescue
+ new response.status.to_s
+ end
+
+ getter code
+
+ def initialize(@code : String, message : String? = nil)
+ if message
+ super "#{message} (#{code})"
+ else
+ super code
+ end
+ end
+end
diff --git a/drivers/lenel/open_access/models.cr b/drivers/lenel/open_access/models.cr
new file mode 100644
index 00000000000..86c3c57a5ba
--- /dev/null
+++ b/drivers/lenel/open_access/models.cr
@@ -0,0 +1,195 @@
+require "json"
+
+# Ensure that UTC time strings provide the offset as "+00:00" instead of "Z", as required by Openaccess
+module Lenel::TimeConverter
+ def self.to_json(value : Time, json : JSON::Builder)
+ json.string(value.to_s("%FT%T%:z"))
+ end
+end
+
+# DTO's for OpenAccess entities.
+#
+# These are intentionally lightweight. In cases where a entity holds a
+# relationship to another, these are _not_ auto-resolved. Original ID references
+# are kept in place. Types here a simply a thin wrapper for JSON serialization.
+module Lenel::OpenAccess::Models
+ PROPERTIES_KEY = "property_value_map"
+
+ # Base type for Lenel data objects.
+ abstract struct Element
+ include JSON::Serializable
+
+ # Name of the type as expected by the OpenAccess API endpoints.
+ def self.type_name
+ "Lnl_#{name.rpartition("::").last}"
+ end
+
+ # The Lenel API 'features' multiple case conventions, with varying
+ # consistency. It appears to be non-case sensitive for requests sent to it,
+ # however as response parsing _is_ more strict raw keys should come via first.
+ protected def normalise(key : String) : String
+ key.downcase
+ end
+
+ # Override the default JSON::Serializable behaviour to make keys case
+ # inensitive when deserialising.
+ def initialize(*, __pull_for_json_serializable pull : ::JSON::PullParser)
+ {% begin %}
+ {% properties = {} of Nil => Nil %}
+ {% for ivar in @type.instance_vars %}
+ {% ann = ivar.annotation(::JSON::Field) %}
+ {% unless ann && ann[:ignore] %}
+ {% properties[ivar.id] = ivar.type %}
+ %var{ivar.id} = nil
+ {% end %}
+ {% end %}
+
+ # All entities come wrapeed inside a standard key...
+ pull.on_key! PROPERTIES_KEY do
+
+ pull.read_begin_object
+ until pull.kind.end_object?
+ %key_location = pull.location
+ key = normalise pull.read_object_key
+ case key
+ {% for name, type in properties %}
+ when {{name.stringify}}
+ %var{name} = ::Union({{type}}).new pull
+ {% end %}
+ else
+ on_unknown_json_attribute(pull, key, %key_location)
+ end
+ end
+ pull.read_next
+
+ end
+
+ {% for name, type in properties %}
+ @{{name}} = %var{name}.as {{type}}
+ {% end %}
+ {% end %}
+ end
+
+ # Provide a compile-time check to ensure *properties* is a subset of *self*.
+ def self.partial(**properties : **T) : T forall T
+ {% for key in T.keys %}
+ {% raise %(no "#{key}" property on #{@type.name}) unless @type.has_method? key %}
+ {% end %}
+ properties
+ end
+ end
+
+ struct Untyped < Element
+ include JSON::Serializable::Unmapped
+ forward_missing_to json_unmapped
+ end
+
+ struct Event < Element
+ getter serial_number : Int32?
+ getter timestamp : Time?
+ getter description : String?
+ getter controller_id : Int32
+ getter device_id : Int32
+ getter subdevice_id : Int32?
+ getter segment_id : Int32?
+ getter event_type : Int32
+ getter event_subtype : Int32?
+ getter event_text : String?
+ getter badge_id : Int32?
+ getter badge_id_str : String?
+ getter badge_extended_id : String?
+ getter badge_issue_code : Int32?
+ getter asset_id : Int32?
+ getter cardholder_key : Int32?
+ # getter alarm_priority : Int32?
+ # getter alarm_ack_blue_channel : Int32?
+ # getter alarm_ack_green_channel : Int32?
+ # getter alarm_ack_red_channel : Int32?
+ # getter alarm_blue_channel : Int32?
+ # getter alarm_green_channel : Int32?
+ # getter alarm_red_channel : Int32?
+ getter access_result : Int32?
+ getter cardholder_entered : Bool?
+ getter duress : Bool?
+ getter controller_name : String?
+ getter event_source_name : String?
+ getter cardholder_first_name : String?
+ getter cardholder_last_name : String?
+ getter device_name : String?
+ getter subdevice_name : String?
+ # getter must_acknowledge : Bool?
+ # getter must_mark_in_progress : Bool?
+ end
+
+ abstract struct Person < Element
+ getter id : Int32
+ getter firstname : String?
+ getter lastname : String?
+ end
+
+ struct Badge < Element
+ getter badgekey : Int32
+
+ @[JSON::Field(converter: Lenel::TimeConverter)]
+ getter activate : Time?
+
+ @[JSON::Field(converter: Lenel::TimeConverter)]
+ getter deactivate : Time?
+
+ getter id : Int64?
+ getter personid : Int32?
+ getter status : Int32?
+ getter type : Int32?
+ getter uselimit : Int32?
+ end
+
+ struct BadgeType < Element
+ enum BadgeTypeClass
+ Standard
+ Temporary
+ Visitor
+ Guest
+ SpecialPurpose
+ end
+ getter id : Int32
+ getter name : String
+ getter badgetypeclass : BadgeTypeClass
+ getter usemobilecredential : Bool
+ end
+
+ struct Cardholder < Person
+ getter email : String?
+ end
+
+ struct Reader < Element
+ getter accessMode : Int32?
+ getter address : Int32?
+ getter controlType : Int32?
+ getter extendedOpenTime : Int32?
+ getter extendedStrikeTime : Int32?
+ getter gatewayAddress : Int32?
+ getter gatewayIPPort : Int32?
+ getter offlineMode : Int32?
+ getter mode : Int32?
+ getter openTime : Int32?
+ getter panelID : Int32?
+ getter portNumber : Int32?
+ getter readerID : Int32?
+ getter readerNumber : Int32?
+ getter slaveID : Int32?
+ getter strikeTime : Int32?
+ getter timeAttendanceType : Int32?
+ getter aux1Name : String?
+ getter aux2Name : String?
+ getter aux3Name : String?
+ getter friendlyName : String?
+ getter gatewayHostName : String?
+ getter hostName : String?
+ getter name : String?
+ getter out1Name : String?
+ getter out2Name : String?
+ getter panelTypeName : String?
+ getter isPairedMaster : Bool?
+ getter isPairedSlave : Bool?
+ end
+end
diff --git a/drivers/lenel/open_access_spec.cr b/drivers/lenel/open_access_spec.cr
new file mode 100644
index 00000000000..6f9f30ca761
--- /dev/null
+++ b/drivers/lenel/open_access_spec.cr
@@ -0,0 +1,172 @@
+require "placeos-driver/spec"
+
+private macro respond_with(code, body)
+ res.headers["Content-Type"] = "application/json"
+ res.status_code = {{code}}
+ res.output << {{body}}.to_json
+end
+
+DriverSpecs.mock_driver "Lenel::OpenAccess" do
+ # Auth on connect
+ expect_http_request do |req, res|
+ req.method.should eq("POST")
+ req.path.should eq("/authentication")
+ respond_with 200, {
+ session_token: "abc123",
+ token_expiration_time: "#{(Time.utc + 2.weeks).to_rfc3339}",
+ }
+ end
+
+ # Re-auth on creds update
+ settings({
+ username: "foo",
+ password: "bar",
+ directory_id: "baz",
+ application_id: "",
+ })
+ expect_http_request do |req, res|
+ req.method.should eq("POST")
+ req.path.should eq("/authentication")
+ body = JSON.parse req.body.not_nil!
+ body["user_name"].should eq("foo")
+ body["password"].should eq("bar")
+ body["directory_id"].should eq("baz")
+ respond_with 200, {
+ session_token: "abc123",
+ token_expiration_time: "#{(Time.utc + 2.weeks).to_rfc3339}",
+ }
+ end
+
+ # Version lookup
+ version = exec(:version)
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/version")
+ req.headers["Session-Token"]?.should eq("abc123")
+ respond_with 200, {
+ product_name: "OnGuard 7.6",
+ product_version: "7.6.001",
+ version: "1.0",
+ }
+ end
+ version = version.get.not_nil!
+ version["product_version"].should eq("7.6.001")
+
+ # Error handling
+ failing_request = exec(:version)
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/version")
+ respond_with 401, {
+ error: {
+ code: "openaccess.general.invalidapplicationid",
+ message: "You are not licensed for OpenAccess.",
+ },
+ }
+ end
+ expect_raises(PlaceOS::Driver::RemoteException) do
+ failing_request.get
+ end
+
+ # Cardholder CRUD
+
+ example_cardholder = {
+ email: "sales@vandelayindustries.com",
+ firstname: "Kel",
+ lastname: "Varnsen",
+ }
+
+ created_cardholder = exec(:create_cardholder, **example_cardholder)
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/count")
+ req.query_params["type_name"]?.should eq("Lnl_Cardholder")
+ req.query_params["filter"]?.should eq(%(email = "sales@vandelayindustries.com"))
+ respond_with 200, {total_items: 0}
+ end
+ expect_http_request do |req, res|
+ req.method.should eq("POST")
+ req.path.should eq("/instances")
+ body = JSON.parse req.body.not_nil!
+ body["type_name"]?.should eq("Lnl_Cardholder")
+ body["property_value_map"]?.try do |prop|
+ prop["email"].should eq("sales@vandelayindustries.com")
+ prop["firstname"].should eq("Kel")
+ prop["lastname"].should eq("Varnsen")
+ end
+ respond_with 200, {
+ type_name: "Lnl_Cardholder",
+ property_value_map: {
+ ID: 1,
+ },
+ }
+ end
+ created_cardholder = created_cardholder.get.not_nil!
+ created_cardholder["id"]?.should eq(1)
+
+ queried_cardholder = exec(:lookup_cardholder, email: "sales@vandelayindustries.com")
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/instances")
+ req.query_params["type_name"]?.should eq("Lnl_Cardholder")
+ req.query_params["filter"]?.should eq(%(email = "sales@vandelayindustries.com"))
+ respond_with 200, {
+ total_pages: 1,
+ total_items: 1,
+ count: 1,
+ type_name: "Lnl_Cardholder",
+ item_list: [{
+ property_value_map: {
+ ID: 1,
+ EMAIL: "sales@vandelyindustries.com",
+ FIRSTNAME: "Kel",
+ LASTNAME: "Varnsen",
+ },
+ }],
+ }
+ end
+ queried_cardholder = queried_cardholder.get.not_nil!
+ queried_cardholder["id"]?.should eq(1)
+ queried_cardholder["firstname"]?.should eq("Kel")
+
+ exec(:delete_cardholder, id: 1)
+ expect_http_request do |req, res|
+ req.method.should eq("DELETE")
+ req.path.should eq("/instances")
+ body = JSON.parse req.body.not_nil!
+ body["type_name"]?.should eq("Lnl_Cardholder")
+ body.dig("property_value_map", "id").should eq(1)
+ res.status_code = 200
+ end
+
+ created_badge = exec(:create_badge, type: 5, personid: 1, id: 123)
+ expect_http_request do |req, res|
+ req.method.should eq("POST")
+ req.path.should eq("/instances")
+ body = JSON.parse req.body.not_nil!
+ body["type_name"]?.should eq("Lnl_Badge")
+ body["property_value_map"]?.try do |prop|
+ prop["type"].should eq(5)
+ prop["personid"].should eq(1)
+ prop["id"].should eq(123)
+ end
+ respond_with 200, {
+ type_name: "Lnl_Badge",
+ property_value_map: {
+ BADGEKEY: 1,
+ },
+ }
+ end
+ created_badge = created_badge.get.not_nil!
+ created_badge["badgekey"]?.should eq(1)
+
+ exec(:delete_badge, badgekey: 1)
+ expect_http_request do |req, res|
+ req.method.should eq("DELETE")
+ req.path.should eq("/instances")
+ body = JSON.parse req.body.not_nil!
+ body["type_name"]?.should eq("Lnl_Badge")
+ body.dig("property_value_map", "badgekey").should eq(1)
+ res.status_code = 200
+ end
+end
diff --git a/drivers/leviton/acquisuite.cr b/drivers/leviton/acquisuite.cr
new file mode 100644
index 00000000000..a350d2aa84e
--- /dev/null
+++ b/drivers/leviton/acquisuite.cr
@@ -0,0 +1,183 @@
+require "http"
+require "placeos-driver"
+require "csv"
+require "action-controller/body_parser"
+require "compress/gzip"
+
+class Leviton::Acquisuite < PlaceOS::Driver
+ descriptive_name "Leviton Acquisuite Webhook"
+ generic_name :Leviton
+ description %(provide an endpoint for the Leviton webhook to send logfiles)
+
+ default_settings({
+ device_list: {"loggerconfig.ini" => {"X", "0000-00-00 00:00:00"}},
+ manifest_list: [] of String,
+ config_list: {} of String => Array(Hash(String, Float64 | String | Nil)),
+ debug_webhook: false,
+ })
+ #
+ @debug_webhook : Bool = false
+ @device_list : Hash(String, Tuple(String, String)) = {} of String => Tuple(String, String)
+ @manifest_list : Array(String) = [] of String
+ @config_list : Hash(String, Array(Hash(String, Float64 | String | Nil))) = {} of String => Array(Hash(String, Float64 | String | Nil))
+
+ def on_update
+ @debug_webhook = setting?(Bool, :debug_webhook) || false
+ @device_list = setting(Hash(String, Tuple(String, String)), :device_list)
+ @manifest_list = setting(Array(String), :manifest_list)
+ @config_list = setting(Hash(String, Array(Hash(String, Float64 | String | Nil))), :config_list)
+ end
+
+ def receive_webhook(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.warn do
+ "Received Webhook\n" +
+ "Method: #{method.inspect}\n" +
+ "Headers:\n#{headers.inspect}\n" +
+ "Body:\n#{body.inspect}"
+ end if @debug_webhook
+ decoded = Base64.decode_string(body)
+ case method.downcase
+ when "post"
+ new_headers = HTTP::Headers.new
+ headers.each { |k, v| new_headers[k] = v }
+ request = HTTP::Request.new("POST", "/request", new_headers, decoded)
+ files, form_data = ActionController::BodyParser.extract_form_data(request, "multipart/form-data", request.query_params)
+ form_data = form_data.not_nil!
+ case form_data["MODE"]
+ # This is the server checking the status of our webhook so just 200 back
+ when "STATUS"
+ return {HTTP::Status::OK.to_i, {} of String => String, "SUCCESS"}
+ # This is the server asking for a list of devices which we need the config files
+ when "CONFIGFILEMANIFEST"
+ return {HTTP::Status::OK.to_i, {} of String => String, device_to_manifest.join("\n")}
+ # This is the server sending us an actual config file from the previously provided list
+ when "CONFIGFILEUPLOAD"
+ files = files.not_nil!
+ return config_file_upload(files, form_data)
+ # Finally, this is an actual log file from a device that we should already have the config file for
+ when "LOGFILEUPLOAD"
+ files = files.not_nil!
+ return log_file_upload(files, form_data)
+ else
+ {HTTP::Status::INTERNAL_SERVER_ERROR.to_i, {"Content-Type" => "application/json"}, "FAILURE: Invalid mode passed. Either STATUS, CONFIGFILEMANIFEST, CONFIGFILEUPLOAD or LOGFILEUPLOAD required. Got #{form_data["MODE"]}"}
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "processing webhook request: #{body.inspect}" }
+ self[:last_error] = error.inspect_with_backtrace
+ self[:error_payload] = body
+ {HTTP::Status::INTERNAL_SERVER_ERROR.to_i, {"Content-Type" => "application/json"}, "FAILURE: #{error.message.to_s}"}
+ end
+
+ protected def log_file_upload(files : Hash(String, Array(ActionController::BodyParser::FileUpload)), form_data : URI::Params)
+ log_file, log_contents = get_file(files, "LOGFILE")
+ # Check whether we have the config for this log file device type
+ modbus_index = form_data["MODBUSDEVICE"].to_i
+ if !@device_list.any? { |device, config| device.includes?("mb-%03d" % modbus_index) && config[0] != "X" }
+ # Add this device to our device list
+ @device_list["mb-%03d.ini" % modbus_index] = {"X", "0000-00-00 00:00:00"}
+ define_setting(:device_list, @device_list)
+ return {HTTP::Status::NOT_ACCEPTABLE.to_i, {} of String => String, "FAILURE: Device list invalid"}
+ end
+ return if log_contents.nil?
+ csv = CSV.new(log_contents, headers: true)
+ # NOTE: This csv.next structure assumes that there will be a header row we don't need
+ # if this is not the case we should add logic to check for a header
+ while csv.next
+ data = [] of Hash(String, (String | Int64 | Float64))
+ @config_list[form_data["MODBUSDEVICE"]].each_with_index do |conf, i|
+ next if @config_list[form_data["MODBUSDEVICE"]][i]["NAME"] == "-\r"
+ # Disregard the first 4 columns of the csv
+ csv_index = i + 4
+ next if csv[csv_index].rstrip == ""
+ time = Time.parse(csv[0].gsub("'", "").strip, "%Y-%m-%d %H:%M:%S", Time::Location::UTC).to_unix
+ reading = {
+ "time" => time,
+ "name" => @config_list[form_data["MODBUSDEVICE"]][i]["NAME"].as(String).rstrip,
+ "units" => @config_list[form_data["MODBUSDEVICE"]][i]["UNITS"].as(String).rstrip,
+ } of String => (String | Int64 | Float64)
+ begin
+ reading["value"] = csv[csv_index].rstrip.to_f
+ rescue ex : ArgumentError
+ reading["reading"] = csv[csv_index].rstrip
+ end
+ data << reading
+ end
+ self["mb-%03d" % modbus_index] = {
+ value: data,
+ ts_hint: "complex",
+ ts_timestamp: "time",
+ measurement: "acquisuite",
+ }
+ end
+ {HTTP::Status::OK.to_i, {} of String => String, "SUCCESS"}
+ end
+
+ protected def config_file_upload(files : Hash(String, Array(ActionController::BodyParser::FileUpload)), form_data : URI::Params)
+ config_file, config_contents = get_file(files, "CONFIGFILE")
+
+ # First update our manifest with the new config data
+ @device_list["mb-%03d.ini" % form_data["MODBUSDEVICE"].to_i] = {form_data["MD5CHECKSUM"], form_data["FILETIME"]}
+
+ # Below is an alternative way of saving that we can only really verify once real data is hooked up
+ # @device_list[config_contents.filename] = {form_data["MD5CHECKSUM"],form_data["FILETIME"]}
+
+ define_setting(:device_list, @device_list)
+
+ # Now update our config list with the new config
+ store_config(form_data["MODBUSDEVICE"], config_contents) unless config_contents.nil?
+ {HTTP::Status::OK.to_i, {} of String => String, "SUCCESS"}
+ end
+
+ protected def get_file(files : Hash(String, Array(ActionController::BodyParser::FileUpload)), name : String)
+ file = files.not_nil!
+ file_object = file[name][0]
+ file_contents = file_object.body.gets_to_end
+ # If the file is gzipped then unzip it
+ file_name = file_object.filename
+ if file_name && file_name[-3..-1] == ".gz"
+ file_unzipped = Compress::Gzip::Reader.open(IO::Memory.new(file_contents)) do |gzip|
+ gzip.gets_to_end
+ end
+ else
+ file_unzipped = file_contents
+ end
+ {file_object, file_unzipped}
+ end
+
+ def device_list
+ @device_list
+ end
+
+ protected def store_config(modbusid : String, config : String)
+ index_max = config.split("\n").map { |line|
+ reg = /POINT(?\d+)(?.*)=(?.*)/.match(line)
+ reg[1].to_i if reg
+ }.compact.sort.pop
+
+ configs = Array.new(index_max + 1, {} of String => (Float64 | String | Nil))
+
+ config.split("\n").each do |line|
+ reg = /POINT(?\d+)(?.*)=(?.*)/.match(line)
+ if reg
+ config_index = reg[1].to_i
+ column_header = reg[2]
+ column_value = reg[3]
+ begin
+ column_value = column_value.to_f64
+ rescue
+ end
+ new_obj = configs[config_index].dup
+ new_obj[column_header] = column_value
+ configs[config_index] = new_obj
+ end
+ end
+ @config_list[modbusid] = configs
+ define_setting(:config_list, @config_list)
+ end
+
+ # Converts the device list to the starting manifest format
+ protected def device_to_manifest
+ @device_list.map { |name, data| "CONFIGFILE,modbus/#{name},#{data[0]},#{data[1]}" }
+ end
+end
diff --git a/drivers/leviton/acquisuite_spec.cr b/drivers/leviton/acquisuite_spec.cr
new file mode 100644
index 00000000000..5405f10a4f2
--- /dev/null
+++ b/drivers/leviton/acquisuite_spec.cr
@@ -0,0 +1,118 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Leviton::Acquisuite" do
+ headers = {"Content-Type" => ["multipart/form-data; boundary=MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY"]}
+ test_devices = ["mb-001"]
+
+ # First, we need to receive some failed LOGFILEUPLOAD requests to work out our list of devices
+ test_devices.each do |device_name|
+ dev_log = File.read("/app/repositories/local/drivers/leviton/#{device_name}.63BD5AFD_2.log.gz")
+ body = create_request(
+ "LOGFILEUPLOAD",
+ "Temp Inputs / Branch Circuits",
+ device_name[-1].to_s,
+ "9a6d278642b64db73c754271de733758",
+ "2022-09-12 21:25:55",
+ "LOGFILE",
+ "modbus/#{device_name}.63BD5AFD_2.log.gz",
+ nil
+ )
+ body = body.gsub("\n", "\r\n")
+ body = body.gsub("fileplaceholder", dev_log)
+ resp = exec(:receive_webhook, "POST", headers, Base64.encode(body)).get
+
+ res = exec(:device_list).get
+ res = res.not_nil!
+ res = Hash(String, Array(String)).from_json(res.to_json)
+ res.keys.any? { |device| device.includes?(device_name) }.should be_true if !res.nil?
+ end
+
+ # Next we receive a CONFIGFILEMANIFEST webhook asking for the config files we want
+ body = <<-BODY
+
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY
+ Content-Disposition: form-data; name="MODE"
+
+ CONFIGFILEMANIFEST
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY--
+ BODY
+
+ body = body.gsub("\n", "\r\n")
+ resp = exec(:receive_webhook, "POST", headers, Base64.encode(body)).get
+
+ # We should expect the driver to respond with a manifest containing the list of devices
+ resp = resp.not_nil!
+ # resp[2].to_s.split("\n").size.should eq device_list.size if !resp.nil?
+
+ dev_config = File.read("/app/repositories/local/drivers/leviton/mb-001.ini")
+
+ # Then we receive a CONFIGFILEMANIFEST webhook asking for the config files we want
+ body = create_request(
+ "CONFIGFILEUPLOAD",
+ "Temp Inputs / Branch Circuits",
+ "1",
+ "9a6d278642b64db73c754271de733758",
+ "2022-09-12 21:25:55",
+ "CONFIGFILE",
+ "modbus/mb-001.ini",
+ dev_config
+ )
+
+ body = body.gsub("\n", "\r\n")
+ resp = exec(:receive_webhook, "POST", headers, Base64.encode(body)).get
+
+ dev_log = File.read("/app/repositories/local/drivers/leviton/mb-001.63BD5AFD_2.log.gz")
+
+ # Now, finally, send an actual log file
+ body = create_request(
+ "LOGFILEUPLOAD",
+ "Temp Inputs / Branch Circuits",
+ "1",
+ "9a6d278642b64db73c754271de733758",
+ "2022-09-12 21:25:55",
+ "LOGFILE",
+ "mb-001.63BD5AFD_2.log.gz",
+ nil
+ )
+ body = body.gsub("\n", "\r\n")
+ body = body.gsub("fileplaceholder", dev_log)
+ resp = exec(:receive_webhook, "POST", headers, Base64.encode(body)).get
+ # TODO:: Should really parse the JSON and make sure it is the below
+ # status[test_devices[0]].should be_a(Hash(String, Array(Hash(String, String)) | Int32))
+ status[test_devices[0]].should be_a(JSON::Any)
+end
+
+# Some of these fields may not be present in every request but
+# having them there doesn't hurt anything so why bother removing them
+def create_request(mode : String, device_name : String, modbus_device : String, md5 : String, file_time : String, file_descriptor : String, file_name : String, file : String?)
+ file = "fileplaceholder" if file.nil?
+ <<-BODY
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY
+ Content-Disposition: form-data; name="MODE"
+
+ #{mode}
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY
+ Content-Disposition: form-data; name="MODBUSDEVICENAME"
+
+ #{device_name}
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY
+ Content-Disposition: form-data; name="MODBUSDEVICE"
+
+ #{modbus_device}
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY
+ Content-Disposition: form-data; name="MD5CHECKSUM"
+
+ #{md5}
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY
+ Content-Disposition: form-data; name="FILETIME"
+
+ #{file_time}
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY
+ Content-Disposition: form-data; name="#{file_descriptor}"; filename="#{file_name}"
+ Content-Type: application/octet-stream;
+
+ #{file}
+ --MIME_BOUNDRY_MIME_BOUNDRY_MIME_BOUNDRY--
+
+ BODY
+end
diff --git a/drivers/leviton/mb-001.63BD5AFD_2.log.gz b/drivers/leviton/mb-001.63BD5AFD_2.log.gz
new file mode 100644
index 00000000000..3ff80fcfff8
Binary files /dev/null and b/drivers/leviton/mb-001.63BD5AFD_2.log.gz differ
diff --git a/drivers/leviton/mb-001.ini b/drivers/leviton/mb-001.ini
new file mode 100644
index 00000000000..02dd9672d88
--- /dev/null
+++ b/drivers/leviton/mb-001.ini
@@ -0,0 +1,150 @@
+DEVCLASS=67
+TYPE=Obvius, A7810, Internal Pulse Inputs
+TYPENUMBER=48
+GATEWAYIP=127.0.0.1
+GATEWAYPORT=502
+GATEWAYMETER=250
+ENDPOINT=MB
+LOGFILEPREFIX=mb-250
+NUMPOINTS=20
+NAME=Internal I/O
+POINT01LOW=0.000
+POINT01HIGH=0.000
+POINT01NAME=Room 123 Demand
+POINT01UNITS=kW
+POINT01CONFIG=0,0,
+POINT02LOW=0.000
+POINT02HIGH=0.000
+POINT02NAME=Room 123 Instantaneous
+POINT02UNITS=kW
+POINT02CONFIG=0,0,
+POINT03LOW=0.000
+POINT03HIGH=0.000
+POINT03NAME=Room 123 Min
+POINT03UNITS=kW
+POINT03CONFIG=0,0,
+POINT04LOW=0.000
+POINT04HIGH=0.000
+POINT04NAME=Room 123 Max
+POINT04UNITS=kW
+POINT04CONFIG=0,0,
+POINT06LOW=0.000
+POINT06HIGH=0.000
+POINT06NAME=Room 124 Demand
+POINT06UNITS=kW
+POINT06CONFIG=0,0,
+POINT07LOW=0.000
+POINT07HIGH=0.000
+POINT07NAME=Room 124 Instantaneous
+POINT07UNITS=kW
+POINT07CONFIG=0,0,
+POINT08LOW=0.000
+POINT08HIGH=0.000
+POINT08NAME=Room 124 Min
+POINT08UNITS=kW
+POINT08CONFIG=0,0,
+POINT09LOW=0.000
+POINT09HIGH=0.000
+POINT09NAME=Room 124 Max
+POINT09UNITS=kW
+POINT09CONFIG=0,0,
+POINT11LOW=0.000
+POINT11HIGH=0.000
+POINT11NAME=Room 137 Demand
+POINT11UNITS=kW
+POINT11CONFIG=0,0,
+POINT12LOW=0.000
+POINT12HIGH=0.000
+POINT12NAME=Room 137 Instantaneous
+POINT12UNITS=kW
+POINT12CONFIG=0,0,
+POINT13LOW=0.000
+POINT13HIGH=0.000
+POINT13NAME=Room 137 Min
+POINT13UNITS=kW
+POINT13CONFIG=0,0,
+POINT14LOW=0.000
+POINT14HIGH=0.000
+POINT14NAME=Room 137 Max
+POINT14UNITS=kW
+POINT14CONFIG=0,0,
+POINT16LOW=0.000
+POINT16HIGH=0.000
+POINT16NAME=Room E202 Demand
+POINT16UNITS=kW
+POINT16CONFIG=0,0,
+POINT17LOW=0.000
+POINT17HIGH=0.000
+POINT17NAME=Room E202 Instantaneous
+POINT17UNITS=kW
+POINT17CONFIG=0,0,
+POINT18LOW=0.000
+POINT18HIGH=0.000
+POINT18NAME=Room E202 Min
+POINT18UNITS=kW
+POINT18CONFIG=0,0,
+POINT19LOW=0.000
+POINT19HIGH=0.000
+POINT19NAME=Room E202 Max
+POINT19UNITS=kW
+POINT19CONFIG=0,0,
+LOGPOINTS=0
+POINT00TYPE=(custom)
+POINT00SCALEMIN=0.000
+POINT00SCALEMAX=100.000
+POINT00MULT=10.000
+POINT00INPUTMODE=6
+POINT00TIMERANGE=60
+POINT00CURVESCALE=
+POINT00LOW=0.000
+POINT00HIGH=0.000
+POINT00NAME=Room 123
+POINT00UNITS=kWh
+POINT00CONFIG=0,16384,a8812io
+POINT05TYPE=(custom)
+POINT05SCALEMIN=0.000
+POINT05SCALEMAX=100.000
+POINT05MULT=10.000
+POINT05INPUTMODE=6
+POINT05TIMERANGE=60
+POINT05CURVESCALE=
+POINT05LOW=0.000
+POINT05HIGH=0.000
+POINT05NAME=Room 124
+POINT05UNITS=kWh
+POINT05CONFIG=0,16384,a8812io
+POINT10TYPE=(custom)
+POINT10SCALEMIN=0.000
+POINT10SCALEMAX=100.000
+POINT10MULT=10.000
+POINT10INPUTMODE=6
+POINT10TIMERANGE=60
+POINT10CURVESCALE=
+POINT10LOW=0.000
+POINT10HIGH=0.000
+POINT10NAME=Room 137
+POINT10UNITS=kWh
+POINT10CONFIG=0,16384,a8812io
+POINT15TYPE=(custom)
+POINT15SCALEMIN=0.000
+POINT15SCALEMAX=100.000
+POINT15MULT=10.000
+POINT15INPUTMODE=6
+POINT15TIMERANGE=60
+POINT15CURVESCALE=
+POINT15LOW=0.000
+POINT15HIGH=0.000
+POINT15NAME=Room E202
+POINT15UNITS=kWh
+POINT15CONFIG=0,16384,a8812io
+SERIALNUMBER=001EC60137F7
+FIRMWARENUM=114
+FIRMWARE=v1.14
+CONTACTCLOSURETHRESHOLD=1000
+CONTACTOPENTHRESHOLD=65535
+LOGINSTHISTORY=5
+HARDWARE=7810, pcb rev A, part rev A
+SUPPORTEDMODESBITMAP=225
+SUPPORTEDCONTACTMAXFREQ=2
+DEVICEBAUD=0
+CONTACTMAXFREQ=0
\ No newline at end of file
diff --git a/drivers/leviton/mb-001.log b/drivers/leviton/mb-001.log
new file mode 100644
index 00000000000..b6c81e044a6
--- /dev/null
+++ b/drivers/leviton/mb-001.log
@@ -0,0 +1,27 @@
+time(utc),error,low alarm,high alarm,'ION6200 (KWh)','ION6200 demand (kW)','ION6200 rate (instantaneous) (kW)','ION6200 rate min (kW)','ION6200 rate max (kW)','ION6200Reactive (KVARh)','ION6200Reactive demand (kVAR)','ION6200Reactive rate (instantan (kVAR)','ION6200Reactive rate min (kVAR)','ION6200Reactive rate max (kVAR)','Apparent Power (demand) (KVA)','Apparent Power (instantaneous) (KVA)','Power Factor (demand)','Power Factor (instantaneous)','Water Meter (Gallons)','Water Meter rate (Gpm)','Water Meter rate (instantaneous (Gpm)','Water Meter rate min (Gpm)','Water Meter rate max (Gpm)','Gas Meter (CF)','Gas Meter rate (CFm)','Gas Meter rate (instantaneous) (CFm)','Gas Meter rate min (CFm)','Gas Meter rate max (CFm)','-','-','-','-'
+'2004-05-12 15:45:00',0,0,0,141713,,123.288,122.449,123.288,27841,,24.194,24.129,24.194,,125.639,,0.981,0,,,,,0,,,,,,,,
+'2004-05-12 16:00:00',0,0,0,141743,120,123.288,121.622,124.138,27847,24,24.194,24.161,24.194,122.376,125.639,0.981,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 16:15:00',0,0,0,141774,124,123.288,122.449,124.138,27853,24,24.194,24.161,24.194,126.301,125.639,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 16:30:00',0,0,0,141805,124,123.288,121.622,123.288,27859,24,24.161,24.161,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 16:45:00',0,0,0,141836,124,123.288,122.449,123.288,27865,24,24.194,24.161,24.194,126.301,125.639,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 17:00:00',0,0,0,141867,124,123.288,122.449,123.288,27871,24,24.161,24.161,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 17:15:00',0,0,0,141897,120,122.449,122.449,123.288,27877,24,24.161,24.161,24.194,122.376,124.81,0.981,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 17:30:00',0,0,0,141928,124,123.288,122.449,123.288,27883,24,24.194,24.129,24.194,126.301,125.639,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 17:45:00',0,0,0,141959,124,123.288,121.622,123.288,27889,24,24.161,24.161,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 18:00:00',0,0,0,141990,124,123.288,121.622,123.288,27895,24,24.161,24.161,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 18:15:00',0,0,0,142020,120,123.288,122.449,123.288,27901,24,24.194,24.129,24.194,122.376,125.639,0.981,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 18:30:00',0,0,0,142051,124,122.449,122.449,123.288,27907,24,24.161,24.129,24.194,126.301,124.81,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 18:45:00',0,0,0,142082,124,122.449,121.622,123.288,27913,24,24.161,24.161,24.194,126.301,124.81,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 19:00:00',0,0,0,142113,124,123.288,122.449,123.288,27919,24,24.194,24.129,24.194,126.301,125.639,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 19:15:00',0,0,0,142144,124,123.288,121.622,123.288,27925,24,24.161,24.161,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 19:30:00',0,0,0,142174,120,122.449,122.449,123.288,27932,28,24.129,24.129,24.194,123.223,124.804,0.974,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 19:45:00',0,0,0,142205,124,122.449,122.449,123.288,27938,24,24.161,24.161,24.194,126.301,124.81,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 20:00:00',0,0,0,142236,124,123.288,121.622,123.288,27944,24,24.161,24.161,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 20:15:00',0,0,0,142267,124,123.288,122.449,123.288,27950,24,24.161,24.161,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 20:30:00',0,0,0,142297,120,123.288,122.449,123.288,27956,24,24.161,24.161,24.161,122.376,125.633,0.981,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 20:45:00',0,0,0,142328,124,123.288,122.449,123.288,27962,24,24.129,24.129,24.194,126.301,125.627,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 21:00:00',0,0,0,142359,124,122.449,122.449,123.288,27968,24,24.194,24.161,24.194,126.301,124.816,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 21:15:00',0,0,0,142390,124,123.288,121.622,123.288,27974,24,24.194,24.129,24.194,126.301,125.639,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 21:30:00',0,0,0,142421,124,123.288,121.622,123.288,27980,24,24.161,24.129,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 21:45:00',0,0,0,142451,120,123.288,122.449,123.288,27986,24,24.194,24.161,24.194,122.376,125.639,0.981,0.981,0,0,,,,0,0,,,,,,,
+'2004-05-12 22:00:00',0,0,0,142482,124,123.288,121.622,123.288,27992,24,24.161,24.129,24.194,126.301,125.633,0.982,0.981,0,0,,,,0,0,,,,,,,
\ No newline at end of file
diff --git a/drivers/leviton/mb-002.63BD5AFD_2.log.gz b/drivers/leviton/mb-002.63BD5AFD_2.log.gz
new file mode 100644
index 00000000000..8ca40528742
Binary files /dev/null and b/drivers/leviton/mb-002.63BD5AFD_2.log.gz differ
diff --git a/drivers/leviton/mb-002.ini b/drivers/leviton/mb-002.ini
new file mode 100644
index 00000000000..d03fc7de941
--- /dev/null
+++ b/drivers/leviton/mb-002.ini
@@ -0,0 +1,16 @@
+DEVCLASS=1
+TYPE=Veris HXO/T-485-M, Outdoor Humidity/Temperature
+TYPENUMBER=100
+NAME=Loading Dock Temp
+POINT00LOW=60.00
+POINT00HIGH=82.00
+POINT00NAME=Temperature F
+POINT00UNITS=Degrees Fahrenheit
+POINT01LOW=0.00
+POINT01HIGH=0.00
+POINT01NAME=Temperature C
+POINT01UNITS=Degrees Celsius
+POINT02LOW=0.00
+POINT02HIGH=0.00
+POINT02NAME=Relative Humidity
+POINT02UNITS=%
diff --git a/drivers/leviton/mb-002.log b/drivers/leviton/mb-002.log
new file mode 100644
index 00000000000..fdf7aaee94a
--- /dev/null
+++ b/drivers/leviton/mb-002.log
@@ -0,0 +1,27 @@
+time(utc),error,low alarm,high alarm,'ION6200 (KWh)','ION6200 demand (kW)'
+'2004-05-12 15:45:00',0,0,0,141713,
+'2004-05-12 16:00:00',0,0,0,141743,120
+'2004-05-12 16:15:00',0,0,0,141774,124
+'2004-05-12 16:30:00',0,0,0,141805,124
+'2004-05-12 16:45:00',0,0,0,141836,124
+'2004-05-12 17:00:00',0,0,0,141867,124
+'2004-05-12 17:15:00',0,0,0,141897,120
+'2004-05-12 17:30:00',0,0,0,141928,124
+'2004-05-12 17:45:00',0,0,0,141959,124
+'2004-05-12 18:00:00',0,0,0,141990,124
+'2004-05-12 18:15:00',0,0,0,142020,120
+'2004-05-12 18:30:00',0,0,0,142051,124
+'2004-05-12 18:45:00',0,0,0,142082,124
+'2004-05-12 19:00:00',0,0,0,142113,124
+'2004-05-12 19:15:00',0,0,0,142144,124
+'2004-05-12 19:30:00',0,0,0,142174,120
+'2004-05-12 19:45:00',0,0,0,142205,124
+'2004-05-12 20:00:00',0,0,0,142236,124
+'2004-05-12 20:15:00',0,0,0,142267,124
+'2004-05-12 20:30:00',0,0,0,142297,120
+'2004-05-12 20:45:00',0,0,0,142328,124
+'2004-05-12 21:00:00',0,0,0,142359,124
+'2004-05-12 21:15:00',0,0,0,142390,124
+'2004-05-12 21:30:00',0,0,0,142421,124
+'2004-05-12 21:45:00',0,0,0,142451,120
+'2004-05-12 22:00:00',0,0,0,142482,124
\ No newline at end of file
diff --git a/drivers/lg/displays/ls5.cr b/drivers/lg/displays/ls5.cr
new file mode 100644
index 00000000000..0b753f0070b
--- /dev/null
+++ b/drivers/lg/displays/ls5.cr
@@ -0,0 +1,302 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+class Lg::Displays::Ls5 < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Input
+ Dvi = 0x70
+ Hdmi = 0xA0
+ HdmiDtv = 0x90
+ Hdmi2 = 0xA1
+ Hdmi2Dtv = 0x91
+ DisplayPort = 0xD0
+ DisplayPortDtv = 0xC0
+ end
+
+ include Interface::InputSelection(Input)
+
+ # Discovery Information
+ tcp_port 9761
+ descriptive_name "LG WebOS LCD Monitor"
+ generic_name :Display
+ # This device does not hold the connection open. Must be configured as makebreak
+ makebreak!
+
+ default_settings({
+ rs232_control: false,
+ display_id: 1,
+ })
+
+ @display_id : Int32 = 0
+ @id_num : Int32 = 1
+ @rs232 : Bool = false
+ @id : String = ""
+ @last_broadcast : String? = nil
+ @connected : Bool = false
+
+ DELIMITER = 0x78_u8 # 'x'
+
+ def on_load
+ # Communication settings
+ queue.delay = 150.milliseconds
+ transport.tokenizer = Tokenizer.new(Bytes[DELIMITER])
+ on_update
+ end
+
+ def on_update
+ @rs232 = setting(Bool, :rs232_control)
+ @id_num = setting(Int32, :display_id)
+ @id = @id_num.to_s.rjust(2, '0')
+ end
+
+ def connected
+ @connected = true
+ self[:connected] = true
+ wake_on_lan
+ no_signal_off
+ auto_off
+ local_button_lock
+ pm_mode
+ schedule.every(50.seconds, true) do
+ do_poll
+ end
+ end
+
+ def disconnected
+ @connected = false
+ self[:connected] = false
+ schedule.clear
+ end
+
+ enum Command
+ Power = 0x61 # 'a'
+ Input = 0x62 # 'b'
+ AspectRatio = 0x63 # 'c'
+ ScreenMute = 0x64 # 'd'
+ VolumeMute = 0x65 # 'e'
+ Volume = 0x66 # 'f'
+ Contrast = 0x67 # 'g'
+ Brightness = 0x68 # 'h'
+ Sharpness = 0x6B # 'k'
+ AutoOff = 0x6E # 'n'
+ LocalButtonLock = 0x6F # 'o'
+ WakeOnLan = 0x77 # 'w'
+ NoSignalOff = 0x67 # 'g'
+ PmMode = 0x6E # 'n'
+ end
+ {% for name in Command.constants %}
+ @[Security(Level::Administrator)]
+ def {{name.id.underscore}}?(priority : Int32 = 0)
+ do_send(Command::{{name.id}}, 0xFF, priority: priority, name: {{name.id.underscore.stringify}} + "_status")
+ end
+ {% end %}
+
+ def power(state : Bool, broadcast : String? = nil)
+ if state
+ if @rs232
+ do_send(Command::Power, 1, name: "power", priority: 99)
+ else
+ wake(broadcast || @last_broadcast)
+ end
+ end
+ # To power on, unmute the display
+ # To power off, mute the display
+ mute(!state) if @connected
+ end
+
+ def hard_off
+ do_send(Command::Power, 0, name: "power", priority: 99, clear_queue: true)
+ end
+
+ def switch_to(input : Input, **options)
+ do_send(Command::Input, input.value, 'x', name: "input", delay: 2.seconds)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ if layer.video? || layer.audio_video?
+ do_send(Command::ScreenMute, state ? 1 : 0, name: "mute_video")
+ end
+
+ if (layer.audio? || layer.audio_video?) && (self[:audio_mute]?.try &.as_bool) != state
+ do_send(Command::VolumeMute, state ? 0 : 1, name: "mute_audio")
+ end
+
+ state
+ end
+
+ enum Ratio
+ Square = 0x01
+ Wide = 0x02
+ Zoom = 0x04
+ Scan = 0x09
+ Program = 0x06
+ end
+
+ def aspect_ratio(ratio : Ratio)
+ do_send(Command::AspectRatio, ratio.value, name: "aspect_ratio", delay: 1.second)
+ end
+
+ def do_poll
+ if @rs232
+ power?
+ if self[:hard_power]?.try &.as_bool
+ screen_mute?
+ input?
+ volume_mute?
+ volume?
+ end
+ elsif @connected
+ screen_mute?
+
+ if @id_num == 1
+ input?
+ volume_mute?
+ volume?
+ end
+ elsif self[:power_target]?.try &.as_bool
+ power(true)
+ end
+ end
+
+ def input?(priority : Int32 = 0)
+ do_send(Command::Input, 0xFF, 'x', priority: priority)
+ end
+
+ {% for name in ["Volume", "Contrast", "Brightness", "Sharpness"] %}
+ @[Security(Level::Administrator)]
+ def {{name.id.downcase}}(value : Float64 | Int32)
+ val = value.to_f.clamp(0.0, 100.0).round_away.to_i
+ do_send(Command::{{name.id}}, val, name: {{name.id.downcase.stringify}})
+ end
+ {% end %}
+
+ # This is only necessary for Command::PmMode and Command::NoSignalOff
+ # Both the responses for contrast/no_signal_off will have data[0] == 'g'
+ # Same thing for auto_off/pm_mode with data[0] == 'n'
+ # We will use the send and callback method to ensure these responses are processed properly
+ private def process_response(data, task)
+ if (resp_value = get_response_value(data)) == -1
+ task.abort
+ else
+ self[task.name] = task.name == "pm_mode" ? resp_value : resp_value == 1
+ task.success
+ end
+ end
+
+ def pm_mode(mode : Int32 = 3)
+ command = build_command(Command::PmMode, mode, 's')
+ send(command, name: "pm_mode") { |data, task| process_response(data, task) }
+ end
+
+ def no_signal_off(state : Bool = false)
+ val = state ? 1 : 0
+ command = build_command(Command::NoSignalOff, val, 'f')
+ send(command, name: "no_signal_off") { |data, task| process_response(data, task) }
+ end
+
+ # 0 = Off, 1 = lock all except Power buttons, 2 = lock all buttons. Default to 2 as power off from local button results in network offline
+ def local_button_lock(state : Bool = true)
+ val = state ? 2 : 0
+ do_send(Command::LocalButtonLock, val, 't', name: "local_button_lock")
+ end
+
+ def auto_off(state : Bool = false)
+ val = state ? 1 : 0
+ do_send(Command::AutoOff, val, 'm', name: "disable_auto_off")
+ end
+
+ def wake_on_lan(state : Bool = true)
+ val = state ? 1 : 0
+ do_send(Command::WakeOnLan, val, 'f', name: "enable_wake_on_lan")
+ end
+
+ def wake(broadcast : String? = nil)
+ if mac = setting?(String, :mac_address)
+ # config is the database model representing this device
+ wake_device(mac, broadcast)
+ logger.debug {
+ info = "Wake on Lan for MAC #{mac}"
+ if b = broadcast
+ info += " directed to VLAN #{b}"
+ end
+ info
+ }
+ else
+ logger.warn { "No MAC address provided" }
+ end
+ end
+
+ private def get_response_value(response : Bytes)
+ logger.debug { "LG sent #{response}" }
+ resp = String.new(response).split(' ').last
+ # Default to -1 which means an error
+ resp_value = -1
+ if resp[0..1] == "OK" # Extract the response value
+ # Special case for PM Mode
+ if resp[2..3] == "0c"
+ resp_value = resp[4..-2].to_i(16)
+ else
+ resp_value = resp[2..-2].to_i(16)
+ end
+ end
+ resp_value
+ end
+
+ def received(data, task)
+ return task.try &.abort if (resp_value = get_response_value(data)) == -1
+ command = Command.from_value(data[0])
+ logger.debug { "Received command #{command}" }
+
+ case command
+ when .power?
+ self[:hard_power] = resp_value == 1
+ self[:power] = false unless self[:hard_power].as_bool
+ when .input?
+ self[:input] = Input.from_value(resp_value)
+ when .aspect_ratio?
+ self[:aspect_ratio] = Ratio.from_value(resp_value)
+ when .screen_mute?
+ self[:power] = resp_value == 0
+ when .volume_mute?
+ self[:audio_mute] = resp_value == 0
+ when .contrast?, .brightness?, .sharpness?, .volume?
+ self[command.to_s.underscore] = resp_value
+ when .wake_on_lan?, .auto_off?
+ self[command.to_s.underscore] = resp_value == 1
+ when .local_button_lock?
+ self[:local_button_lock] = resp_value == 2
+ else
+ return task.try &.retry
+ end
+
+ task.try &.success
+ end
+
+ # From manual
+ # [Command1]: identifies between the factory setting and the user setting modes.
+ # Default c1 to 'k' which appears to be for user settings
+ # and which most commands use (e.g. Mute, Screen off, Volume, Brightness)
+ # Note: this is not a Command instance method as this needs access to @id
+ private def build_command(command : Command, data : Int, c1 : Char = 'k')
+ # Command::PmMode and Command::AutoOff both are equal to 0x6E == 'n'
+ # However, PmMode has c1 == 's' while AutoOff has c1 == 'm'
+ # So this is how we can differentiate whether the command we want to send is PmMode
+ if command.pm_mode? && c1 == 's'
+ "#{c1}#{command.value.chr} #{@id} 0c #{data.to_s(16, upcase: true).rjust(2, '0')}\r"
+ else
+ "#{c1}#{command.value.chr} #{@id} #{data.to_s(16, upcase: true).rjust(2, '0')}\r"
+ end
+ end
+
+ private def do_send(command : Command, data : Int, c1 : Char = 'k', **options)
+ send(build_command(command, data, c1), **options)
+ end
+end
diff --git a/drivers/lg/displays/ls5_spec.cr b/drivers/lg/displays/ls5_spec.cr
new file mode 100644
index 00000000000..eff4027f469
--- /dev/null
+++ b/drivers/lg/displays/ls5_spec.cr
@@ -0,0 +1,72 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Lg::Displays::Ls5" do
+ # Execute a command (triggers the connection)
+ exec(:power?)
+ expect_reconnect
+
+ # connected
+ # wake_on_lan(true)
+ should_send("fw 01 01\r")
+ responds("w 01 OK01x")
+ status[:wake_on_lan].should eq(true)
+ # no_signal_off(false)
+ should_send("fg 01 00\r")
+ responds("g 01 OK00x")
+ status[:no_signal_off].should eq(false)
+ # auto_off(false)
+ should_send("mn 01 00\r")
+ responds("n 01 OK00x")
+ status[:auto_off].should eq(false)
+ # local_button_lock(true)
+ should_send("to 01 02\r")
+ responds("o 01 OK02x")
+ status[:local_button_lock].should eq(true)
+ # pm_mode(3)
+ should_send("sn 01 0c 03\r")
+ responds("n 01 OK0c03x")
+ status[:pm_mode].should eq(3)
+ # do_poll && self[:connected] == true && @id_num == 1
+ # screen_mute?
+ should_send("kd 01 FF\r")
+ responds("d 01 OK01x")
+ status[:power].should eq(false)
+ # input?
+ should_send("xb 01 FF\r")
+ responds("b 01 OKA0x")
+ status[:input].should eq("Hdmi")
+ # volume_mute?
+ should_send("ke 01 FF\r")
+ responds("e 01 OK00x")
+ status[:audio_mute].should eq(true)
+ # volume?
+ should_send("kf 01 FF\r")
+ responds("f 01 OK08x")
+ status[:volume].should eq(8)
+
+ exec(:switch_to, "dvi")
+ should_send("xb 01 70\r")
+ responds("b 01 OK70x")
+ status[:input].should eq("Dvi")
+
+ exec(:power, true)
+ sleep 2 # since switch_to has 2 seconds of delay
+ # mute_video(false)
+ should_send("kd 01 00\r")
+ responds("d 01 OK00x")
+ status[:power].should eq(true)
+ # mute_audio(false)
+ should_send("ke 01 01\r")
+ responds("e 01 OK01x")
+ status[:audio_mute].should eq(false)
+
+ exec(:power, false)
+ # mute_video(true)
+ should_send("kd 01 01\r")
+ responds("d 01 OK01x")
+ status[:power].should eq(false)
+ # mute_audio(true)
+ should_send("ke 01 00\r")
+ responds("e 01 OK00x")
+ status[:audio_mute].should eq(true)
+end
diff --git a/drivers/lumens/dc193.cr b/drivers/lumens/dc193.cr
new file mode 100644
index 00000000000..5581664fb4f
--- /dev/null
+++ b/drivers/lumens/dc193.cr
@@ -0,0 +1,245 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/zoomable"
+
+# Documentation: https://aca.im/driver_docs/Lumens/DC193-Protocol.pdf
+# RS232 controlled device
+
+class Lumens::DC193 < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Zoomable
+
+ # Discovery Information
+ descriptive_name "Lumens DC 193 Document Camera"
+ generic_name :DocCam
+
+ # Global Cache Port
+ tcp_port 4999
+
+ def on_load
+ # Communication settings
+ queue.delay = 100.milliseconds
+ transport.tokenizer = Tokenizer.new(6)
+
+ # Ensure range is roughly accurate
+ @zoom_range = 0..@zoom_max
+ end
+
+ def connected
+ schedule.every(50.seconds) { query_status }
+ query_status
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def query_status
+ # Responses are JSON encoded
+ if power?.get == "true"
+ lamp?
+ zoom?
+ frozen?
+ max_zoom?
+ picture_mode?
+ end
+ end
+
+ def power(state : Bool)
+ state = state ? 0x01_u8 : 0x00_u8
+ send Bytes[0xA0, 0xB0, state, 0x00, 0x00, 0xAF], name: :power
+ power?
+ end
+
+ def power?
+ # item 58 call system status
+ send Bytes[0xA0, 0xB7, 0x00, 0x00, 0x00, 0xAF], priority: 0
+ end
+
+ def lamp(state : Bool, head_led : Bool = false)
+ return false if @frozen
+
+ lamps = if state && head_led
+ 1_u8
+ elsif state
+ 2_u8
+ elsif head_led
+ 3_u8
+ else
+ 0_u8
+ end
+
+ send Bytes[0xA0, 0xC1, lamps, 0x00, 0x00, 0xAF], name: :lamp
+ end
+
+ def lamp?
+ send Bytes[0xA0, 0x50, 0x00, 0x00, 0x00, 0xAF], priority: 0
+ end
+
+ def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0)
+ position = position.clamp(0.0, 100.0)
+ percentage = position / 100.0
+ position = (percentage * @zoom_max.to_f).to_i
+
+ low = (position & 0xFF).to_u8
+ high = ((position >> 8) & 0xFF).to_u8
+ auto_focus = auto_focus ? 0x1F_u8 : 0x13_u8
+ send Bytes[0xA0, auto_focus, low, high, 0x00, 0xAF], name: :zoom_to
+ end
+
+ def zoom(direction : ZoomDirection, index : Int32 | String = 1)
+ return false if @frozen
+
+ case direction
+ when ZoomDirection::Stop
+ send Bytes[0xA0, 0x10, 0x00, 0x00, 0x00, 0xAF]
+ # Ensures this request is at the normal priority and ordering is preserved
+ zoom?(priority: queue.priority)
+ # This prevents the auto-focus if someone starts zooming again
+ auto_focus(name: "zoom")
+ when ZoomDirection::In
+ send Bytes[0xA0, 0x11, 0x00, 0x00, 0x00, 0xAF], name: :zoom
+ when ZoomDirection::Out
+ send Bytes[0xA0, 0x11, 0x01, 0x00, 0x00, 0xAF], name: :zoom
+ end
+ end
+
+ def auto_focus(name : String = "auto_focus")
+ return false if @frozen
+
+ send Bytes[0xA0, 0xA3, 0x01, 0x00, 0x00, 0xAF], name: name
+ end
+
+ def zoom?(priority : Int32 = 0)
+ send Bytes[0xA0, 0x60, 0x00, 0x00, 0x00, 0xAF], priority: priority
+ end
+
+ def freeze(state : Bool)
+ state = state ? 1_u8 : 0_u8
+ send Bytes[0xA0, 0x2C, state, 0x00, 0x00, 0xAF], name: :freeze
+ end
+
+ def frozen?
+ send Bytes[0xA0, 0x78, 0x00, 0x00, 0x00, 0xAF], priority: 0
+ end
+
+ def picture_mode(state : String)
+ return false if @frozen
+
+ mode = case state.downcase
+ when "photo"
+ 0x00_u8
+ when "text"
+ 0x01_u8
+ when "greyscale", "grayscale"
+ 0x02_u8
+ else
+ raise ArgumentError.new("unknown picture mode #{state}")
+ end
+ send Bytes[0xA0, 0xA7, mode, 0x00, 0x00, 0xAF], name: :picture_mode
+ end
+
+ def picture_mode?
+ send Bytes[0xA0, 0x51, 0x00, 0x00, 0x00, 0xAF], priority: 0
+ end
+
+ def max_zoom?
+ send Bytes[0xA0, 0x8A, 0x00, 0x00, 0x00, 0xAF], priority: 0
+ end
+
+ @[Flags]
+ enum Status
+ Error
+ Ignored
+ Reserved1
+ Reserved2
+ Focusing
+ Zooming
+ Iris
+ Reserved3
+ end
+
+ COMMANDS = {
+ 0xC1_u8 => :lamp,
+ 0xB0_u8 => :power,
+ 0xB7_u8 => :power_staus,
+ 0xA7_u8 => :picture_mode,
+ 0xA3_u8 => :auto_focus,
+ 0x8A_u8 => :max_zoom,
+ 0x78_u8 => :frozen_status,
+ 0x60_u8 => :zoom_staus,
+ 0x51_u8 => :picture_mode_staus,
+ 0x50_u8 => :lamp_staus,
+ 0x2C_u8 => :freeze,
+ 0x1F_u8 => :zoom_direct_auto_focus,
+ 0x13_u8 => :zoom_direct,
+ 0x11_u8 => :zoom,
+ 0x10_u8 => :zoom_stop,
+ }
+
+ @ready : Bool = true
+ @power : Bool = false
+ @zoom_max : Int32 = 864
+ @lamp : Bool = false
+ @head_led : Bool = false
+ @frozen : Bool = false
+
+ PICTURE_MODES = {:photo, :test, :greyscale}
+
+ def received(data, task)
+ logger.debug { "Lumens sent: #{data.hexstring}" }
+
+ status = Status.from_value(data[4].to_i)
+ self[:zooming] = status.zooming?
+ self[:focusing] = status.focusing?
+ self[:iris_adjusting] = status.iris?
+
+ return task.try &.abort("bad request") if status.error?
+ return task.try &.retry("device busy") if status.ignored?
+
+ result = case COMMANDS[data[1]]?
+ when :power
+ data[2] == 0x01_u8
+ when :power_staus
+ @ready = data[2] == 0x01_u8
+ @power = data[3] == 0x01_u8
+ logger.debug { "System power: #{@power}, ready: #{@ready}" }
+ self[:ready] = @ready
+ self[:power] = @power
+ when :max_zoom
+ @zoom_max = data[2].to_i + (data[3].to_i << 8)
+ @zoom_range = 0..@zoom_max
+ self[:zoom_range] = {min: 0, max: @zoom_max}
+ when :frozen_status, :freeze
+ self[:frozen] = @frozen = data[2] == 1_u8
+ when :zoom_staus, :zoom_direct_auto_focus, :zoom_direct
+ value = data[2].to_i + (data[3].to_i << 8)
+ self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f)
+ when :picture_mode_staus, :picture_mode
+ self[:picture_mode] = PICTURE_MODES[data[2].to_i]
+ when :lamp_staus, :lamp
+ case data[2]
+ when 0_u8
+ @head_led = @lamp = false
+ when 1_u8
+ @head_led = @lamp = true
+ when 2_u8
+ @head_led = false
+ @lamp = true
+ when 3_u8
+ @head_led = true
+ @lamp = false
+ end
+ self[:head_led] = @head_led
+ self[:lamp] = @lamp
+ when :auto_focus
+ # Can ignore this response
+ else
+ error = "Unknown command #{data[1]}"
+ logger.debug { error }
+ return task.try &.abort(error)
+ end
+
+ task.try &.success(result)
+ end
+end
diff --git a/drivers/lumens/dc193_spec.cr b/drivers/lumens/dc193_spec.cr
new file mode 100644
index 00000000000..ae5bca16e9c
--- /dev/null
+++ b/drivers/lumens/dc193_spec.cr
@@ -0,0 +1,10 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Lumens::DC193" do
+ # On connect it queries the state of the device
+ should_send(Bytes[0xA0, 0xB7, 0x00, 0x00, 0x00, 0xAF])
+ transmit(Bytes[0xA0, 0xB7, 0x01, 0x00, 0x00, 0xAF])
+
+ status[:ready].should be_true
+ status[:power].should be_false
+end
diff --git a/drivers/lutron/lighting.cr b/drivers/lutron/lighting.cr
new file mode 100644
index 00000000000..c820d28fc9e
--- /dev/null
+++ b/drivers/lutron/lighting.cr
@@ -0,0 +1,219 @@
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/Lutron/lutron-lighting.pdf
+
+# Device defaults
+# Login #1: nwk
+# Login #2: nwk2
+
+# Login: lutron
+# Password: integration
+
+class Lutron::Lighting < PlaceOS::Driver
+ # Discovery Information
+ tcp_port 23
+ descriptive_name "Lutron Lighting Gateway"
+ generic_name :Lighting
+
+ def on_load
+ # Communication settings
+ queue.wait = false
+ queue.delay = 100.milliseconds
+ transport.tokenizer = Tokenizer.new("\r\n")
+
+ on_update
+ end
+
+ @trigger_type : String = "area"
+ @login : String = "nwk"
+
+ def on_update
+ @login = setting?(String, :login) || "nwk"
+ @trigger_type = setting?(String, :trigger) || "area"
+ end
+
+ def connected
+ send "#{@login}\r\n", priority: 9999
+
+ schedule.every(40.seconds) do
+ logger.debug { "-- Polling Lutron" }
+ scene? 1
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def restart
+ send_cmd "RESET", 0
+ end
+
+ # on or off
+ def lighting(device : Int32, state : Bool, action : Int32 = 1)
+ level = state ? 100 : 0
+ light_level(device, level)
+ end
+
+ # ===============
+ # OUTPUT COMMANDS
+ # ===============
+
+ # dimmers, CCOs, or other devices in a system that have a controllable output
+ def level(
+ device : Int32,
+ level : Int32,
+ rate : Int32 = 1000,
+ component : String = "output"
+ )
+ level = level.clamp(0, 100)
+ seconds = rate / 1000
+ min = seconds / 60
+ seconds -= min * 60
+ time = "#{min.to_s.rjust(2, '0')}:#{seconds.to_s.rjust(2, '0')}"
+ send_cmd component.upcase, device, 1, level, time
+ end
+
+ def blinds(device : String, action : String, component : String = "shadegrp")
+ case action.downcase
+ when "raise", "up"
+ send_cmd component.upcase, device, 3
+ when "lower", "down"
+ send_cmd component.upcase, device, 2
+ when "stop"
+ send_cmd component.upcase, device, 4
+ end
+ end
+
+ # =============
+ # AREA COMMANDS
+ # =============
+ def scene(area : Int32, scene : Int32, component : String = "area")
+ send_cmd(component.upcase, area, 6, scene).get
+ scene?(area, component)
+ end
+
+ def scene?(area : Int32, component : String = "area")
+ send_query component.upcase, area, 6
+ end
+
+ def occupancy?(area : Int32)
+ send_query "AREA", area, 8
+ end
+
+ def daylight_mode?(area : Int32)
+ send_query "AREA", area, 7
+ end
+
+ def daylight(area : Int32, mode : Bool)
+ val = mode ? 1 : 2
+ send_cmd "AREA", area, 7, val
+ end
+
+ # ===============
+ # DEVICE COMMANDS
+ # ===============
+ def button_press(area : Int32, button : Int32)
+ send_cmd "DEVICE", area, button, 3
+ end
+
+ def led(area : Int32, device : Int32, state : Int32 | Bool)
+ val = if state.is_a?(Int32)
+ state
+ else
+ state ? 1 : 0
+ end
+
+ send_cmd "DEVICE", area, device, 9, val
+ end
+
+ def led?(area : Int32, device : Int32)
+ send_query "DEVICE", area, device, 9
+ end
+
+ # =============
+ # COMPATIBILITY
+ # =============
+ def trigger(area : Int32, scene : Int32)
+ scene(area, scene, @trigger_type)
+ end
+
+ def light_level(area : Int32, level : Int32, component : String? = nil, fade : Int32 = 1000)
+ if component
+ level(area, level, fade, component)
+ else
+ level(area, level, fade, "area")
+ end
+ end
+
+ Errors = {
+ "1" => "Parameter count mismatch",
+ "2" => "Object does not exist",
+ "3" => "Invalid action number",
+ "4" => "Parameter data out of range",
+ "5" => "Parameter data malformed",
+ "6" => "Unsupported Command",
+ }
+
+ Occupancy = {
+ "1" => "unknown",
+ "2" => "inactive",
+ "3" => "occupied",
+ "4" => "unoccupied",
+ }
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Lutron sent: #{data}" }
+
+ parts = data.split(",")
+ component = parts[0][1..-1].downcase
+
+ case component
+ when "area", "output", "shadegrp"
+ area = parts[1]
+ action = parts[2].to_i
+ param = parts[3]
+
+ case action
+ when 1 # level
+ self["#{component}#{area}_level"] = param.to_f
+ when 6 # Scene
+ self["#{component}#{area}"] = param.to_i
+ when 7
+ self["#{component}#{area}_daylight"] = param == "1"
+ when 8
+ self["#{component}#{area}_occupied"] = Occupancy[param]
+ end
+ when "device"
+ area = parts[1]
+ device = parts[2]
+ action = parts[3].to_i
+
+ case action
+ when 7 # Scene
+ self["device#{area}_#{device}"] = parts[4].to_i
+ when 9 # LED state
+ self["device#{area}_#{device}_led"] = parts[4].to_i
+ end
+ when "error"
+ error = "error #{parts[1]}: #{Errors[parts[1]]}"
+ logger.warn { error }
+ return task.try &.abort(error)
+ end
+
+ task.try &.success
+ end
+
+ protected def send_cmd(*command)
+ cmd = "##{command.join(",")}"
+ logger.debug { "Requesting: #{cmd}" }
+ send("#{cmd}\r\n")
+ end
+
+ protected def send_query(*command)
+ cmd = "?#{command.join(",")}"
+ logger.debug { "Querying: #{cmd}" }
+ send("#{cmd}\r\n")
+ end
+end
diff --git a/drivers/lutron/lighting_spec.cr b/drivers/lutron/lighting_spec.cr
new file mode 100644
index 00000000000..5934c3af497
--- /dev/null
+++ b/drivers/lutron/lighting_spec.cr
@@ -0,0 +1,36 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Lutron::Lighting" do
+ # Module waits for this text to become ready
+ transmit "login: "
+ should_send "nwk\r\n"
+ transmit "connection established\r\n"
+
+ # Perform actions
+ response = exec(:scene?, area: 1)
+ should_send("?AREA,1,6\r\n")
+ responds("~AREA,1,6,2\r\n")
+ response.get
+ status[:area1].should eq(2)
+
+ transmit "~DEVICE,1,6,9,1\r\n"
+ status[:device1_6_led].should eq(1)
+
+ transmit "~AREA,1,6,1\r\n"
+ status[:area1].should eq(1)
+
+ transmit "~OUTPUT,53,1,100.00\r\n"
+ status[:output53_level].should eq(100.00)
+
+ transmit "~SHADEGRP,26,1,100.00\r\n"
+ status[:shadegrp26_level].should eq(100.00)
+
+ exec(:scene, area: 1, scene: 3)
+ should_send("#AREA,1,6,3\r\n")
+ responds("\r\n")
+
+ should_send("?AREA,1,6\r\n")
+ transmit "~AREA,1,6,3\r\n"
+
+ status[:area1].should eq(3)
+end
diff --git a/drivers/lutron/quantum.cr b/drivers/lutron/quantum.cr
new file mode 100644
index 00000000000..83e58ec68f9
--- /dev/null
+++ b/drivers/lutron/quantum.cr
@@ -0,0 +1,60 @@
+require "placeos-driver"
+require "quantum"
+
+class Lutron::Quantum < PlaceOS::Driver
+ descriptive_name "Lutron Quantum Gateway"
+ generic_name :Lighting
+ uri_base "https://engineeringwebdemo01.lutron.com/"
+
+ alias Client = ::Quantum::Client
+
+ default_settings({
+ api_key: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ device_key: "ab22c585-14e6-4c6b-b418-166728bcc608",
+ })
+
+ protected getter! client : Client
+
+ def on_update
+ host_name = URI.parse(config.uri.not_nil!).host
+ api_key = setting(String, :api_key)
+ device_key = setting(String, :device_key)
+
+ @client = Client.new(host_name: host_name.not_nil!, api_key: api_key, device_key: device_key)
+ end
+
+ def level?(id : Int32)
+ status = client.zone.get_status(id)
+ self["area#{id}_level"] = status["Level"]
+ end
+
+ def level(id : Int32, level : String)
+ client.zone.set_status_level(id: id, level: level)
+ self["area#{id}_level"] = level
+ end
+
+ def scene(id : Int32, scene : Int32)
+ client.area.set_scene(id: id, scene: scene)
+ self["area#{id}"] = scene
+ end
+
+ def scene?(id : Int32)
+ status = client.area.get_status(id: id)
+ self["area#{id}"] = status["CurrentScene"]
+ end
+
+ def occupancy_status?(id : Int32)
+ occupancy_status = client.area.get_occupancy_status(id: id)
+ self["area#{id}_occupancy"] = occupancy_status
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def scenes(id : Int32)
+ client.area.get_scenes(id: id)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def root
+ client.area.root
+ end
+end
diff --git a/drivers/lutron/room_logic.cr b/drivers/lutron/room_logic.cr
new file mode 100644
index 00000000000..686fdfcdcaf
--- /dev/null
+++ b/drivers/lutron/room_logic.cr
@@ -0,0 +1,30 @@
+require "placeos-driver"
+
+# Currently this logic driver is designed for Quantum only but ideally there should be just one Lutron room logic that handles all Lutron APIs
+class Lutron::RoomLogic < PlaceOS::Driver
+ descriptive_name "Lutron Room level status "
+ generic_name :RoomLighting
+ description "Exposes the room's lighting state"
+
+ default_settings({
+ lutron_area_id: 0,
+ lutron_status_poll_cron: "*/5 * * * *",
+ })
+
+ accessor lutron : Lutron
+
+ @area_id : Int32 = 0
+ @cron_string : String = "*/5 * * * *"
+
+ def on_update
+ @area_id = setting(Int32, :lutron_area_id)
+ @cron_string = setting(String, :lutron_status_poll_cron)
+ schedule.clear
+ schedule.cron(@cron_string) { get_state }
+ end
+
+ def get_state
+ self["lighting_scene"] = lutron.scene?(@area_id).get
+ self["occupancy"] = lutron.occupancy_status?(@area_id).get
+ end
+end
diff --git a/drivers/lutron/vive_bacnet.cr b/drivers/lutron/vive_bacnet.cr
new file mode 100644
index 00000000000..079c2205461
--- /dev/null
+++ b/drivers/lutron/vive_bacnet.cr
@@ -0,0 +1,128 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+
+# Documentation: https://aca.im/driver_docs/Lutron/BACnet-PIC-Statementfor-VIVE.pdf
+
+class Lutron::ViveBacnet < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ descriptive_name "Lutron Vive BACnet"
+ generic_name :Lighting
+
+ default_settings({
+ device_id: 389999,
+ })
+
+ accessor bacnet : BACnet_1
+
+ @device_id : UInt32 = 0_u32
+ @last_updated : Int64 = 0_i64
+ @occupancy : Bool? = nil
+
+ def on_update
+ @device_id = setting(UInt32, :device_id)
+ subscriptions.clear
+
+ # Light level
+ system.subscribe(:BACnet, 1, "#{@device_id}.AnalogValue[2]") { |_sub, value| self[:lighting_level] = value.to_f }
+
+ # Total Power (in watts)
+ system.subscribe(:BACnet, 1, "#{@device_id}.AnalogValue[18]") { |_sub, value| self[:power_usage] = value.to_f }
+
+ # Max Power (in watts)
+ system.subscribe(:BACnet, 1, "#{@device_id}.AnalogValue[19]") { |_sub, value| self[:max_power_usage] = value.to_f }
+
+ # lighting on / off
+ system.subscribe(:BACnet, 1, "#{@device_id}.BinaryValue[3]") { |_sub, value| self[:lighting] = value == "1" }
+
+ # occupancy disabled
+ system.subscribe(:BACnet, 1, "#{@device_id}.BinaryValue[7]") { |_sub, value| self[:occupancy_disabled] = value == "1" }
+
+ # occupancy state
+ system.subscribe(:BACnet, 1, "#{@device_id}.MultiStateValue[8]") do |_sub, value|
+ @occupancy = case value
+ when "1"
+ false
+ when "2"
+ true
+ else
+ nil
+ end
+ self[:occupancy] = @occupancy
+ self[:occupancy_sensor] = @occupancy.nil? ? nil : (@occupancy ? 1.0 : 0.0)
+ @last_updated = Time.utc.to_unix
+ end
+
+ schedule.clear
+ schedule.every((4 + rand(3)).seconds) do
+ bacnet.update_value(@device_id, 2, "AnalogValue").get
+ bacnet.update_value(@device_id, 18, "AnalogValue").get
+ bacnet.update_value(@device_id, 19, "AnalogValue").get
+ bacnet.update_value(@device_id, 3, "BinaryValue").get
+ bacnet.update_value(@device_id, 8, "MultiStateValue").get
+ end
+ end
+
+ def level(percentage : Float32)
+ percentage = 0.0_f32 if percentage < 0.0_f32
+ percentage = 100.0_f32 if percentage > 100.0_f32
+ bacnet.write_real(@device_id, 2, percentage).get
+ self[:lighting_level] = percentage
+ end
+
+ def lighting(state : Bool)
+ bacnet.write_binary(@device_id, 3, state).get
+ self[:lighting] = state
+ end
+
+ def disable_occupancy(state : Bool)
+ bacnet.write_binary(@device_id, 7, state).get
+ self[:occupancy_disabled] = state
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if type && type != "Presence"
+ return NO_MATCH if mac && mac != @device_id.to_s
+ return NO_MATCH if zone_id && !system.zones.includes?(zone_id)
+
+ [
+ Interface::Sensor::Detail.new(
+ type: SensorType::Presence,
+ value: @occupancy ? 1.0 : 0.0,
+ last_seen: @last_updated,
+ mac: @device_id.to_s,
+ id: "occupancy",
+ name: "#{system.name}: occupancy",
+ module_id: module_id,
+ binding: "occupancy_sensor"
+ ),
+ ]
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id == "occupancy"
+ return nil unless mac == @device_id.to_s
+ return nil if @last_updated == 0_i64
+
+ Interface::Sensor::Detail.new(
+ type: SensorType::Presence,
+ value: @occupancy ? 1.0 : 0.0,
+ last_seen: @last_updated,
+ mac: @device_id.to_s,
+ id: "occupancy",
+ name: "#{system.name}: occupancy",
+ module_id: module_id,
+ binding: "occupancy_sensor"
+ )
+ end
+end
diff --git a/drivers/lutron/vive_bacnet_spec.cr b/drivers/lutron/vive_bacnet_spec.cr
new file mode 100644
index 00000000000..9085ba1fde3
--- /dev/null
+++ b/drivers/lutron/vive_bacnet_spec.cr
@@ -0,0 +1,19 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Lutron::ViveBacnet" do
+ system({
+ BACnet: {BACnetMock},
+ })
+
+ level = exec(:level, 120.0).get
+ level.should eq(100.0)
+ status[:lighting_level].should eq(100.0)
+end
+
+# :nodoc:
+class BACnetMock < DriverSpecs::MockDriver
+ def write_real(device_id : UInt32, instance_id : UInt32, value : Float32, object_type : String = "AnalogValue")
+ raise "over 100!" if value > 100.0
+ self["#{device_id}.#{object_type}[#{instance_id}]"] = value
+ end
+end
diff --git a/drivers/lutron/vive_leap.cr b/drivers/lutron/vive_leap.cr
new file mode 100644
index 00000000000..5fca69ec019
--- /dev/null
+++ b/drivers/lutron/vive_leap.cr
@@ -0,0 +1,253 @@
+require "placeos-driver"
+require "./vive_leap_models"
+require "placeos-driver/interface/sensor"
+
+class Lutron::ViveLeap < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ descriptive_name "Lutron Vive LEAP"
+ generic_name :Lighting
+
+ # Requires TLS negotiation (max 10 connections)
+ tcp_port 8081
+
+ default_settings({
+ username: "user",
+ password: "pass",
+ })
+
+ def on_load
+ transport.tokenizer = Tokenizer.new do |io|
+ length, unpaired = 0, 0
+ loop do
+ case io.read_char
+ when '{' then unpaired += 1
+ when '}' then unpaired -= 1
+ when Nil then break
+ end
+
+ length += 1
+ break if unpaired.zero?
+ end
+ unpaired.zero? && length > 0 ? length : -1
+ end
+
+ on_update
+ end
+
+ @username : String = ""
+ @password : String = ""
+
+ # area_id => presence, update time
+ @sensors : Hash(String, Tuple(Bool, Int64)) = {} of String => Tuple(Bool, Int64)
+
+ def on_update
+ @username = setting(String, :username)
+ @password = setting(String, :password)
+ end
+
+ def disconnected
+ @sensors.clear
+ schedule.clear
+ end
+
+ def connected
+ # this request needs to be made before anything else to negotiate protocol version
+ request = Request.new("/clientsetting", :update_request, {
+ ClientSetting: {
+ ClientMajorVersion: 1,
+ },
+ })
+ send request.to_json, priority: 99, name: request.name?
+
+ schedule.every(1.minute) { ping }
+ end
+
+ # This is called after the protocol version is negotiated
+ protected def authenticate
+ request = Request.new("/login", :update_request, {
+ Login: {
+ ContextType: "Application",
+ LoginId: @username,
+ Password: @password,
+ },
+ })
+ send request.to_json, priority: 99, name: request.name?
+ end
+
+ def ping
+ request = Request.new("/server/status/ping")
+ send request.to_json, priority: 0, name: request.name?
+ end
+
+ # gets the status of all areas
+ def area_status?
+ request = Request.new("/area/status")
+ send request.to_json, name: request.name?
+ end
+
+ protected def subscribe_areas
+ request = Request.new("/area/status", :subscribe_request)
+ send request.to_json, name: :subscribe_area_status
+ end
+
+ # get the status of all zones
+ def zone_status?
+ request = Request.new("/zone/status")
+ send request.to_json, name: request.name?
+ end
+
+ protected def subscribe_zones
+ request = Request.new("/zone/status", :subscribe_request)
+ send request.to_json, name: :subscribe_zone_status
+ end
+
+ def zone_level(zone_id : String | Int32, level : Float64)
+ request = Request.new("/zone/#{zone_id}/commandprocessor", :create_request, {
+ Command: {
+ CommandType: "GoToDimmedLevel",
+ DimmedLevelParameters: {
+ Level: level,
+ },
+ },
+ })
+ send request.to_json, name: request.name?
+ end
+
+ def zone_lighting(zone_id : String | Int32, state : Bool)
+ request = Request.new("/zone/#{zone_id}/commandprocessor", :create_request, {
+ Command: {
+ CommandType: "GoToSwitchedLevel",
+ SwitchedLevelParameters: {
+ SwitchedLevel: state ? "On" : "Off",
+ },
+ },
+ })
+ send request.to_json, name: request.name?
+ end
+
+ def zone_contact_closure(zone_id : String | Int32, state : Bool)
+ request = Request.new("/zone/#{zone_id}/commandprocessor", :create_request, {
+ Command: {
+ CommandType: "GoToCCOLevel",
+ CCOLevelParameters: {
+ CCOLevel: state ? "Closed" : "Open",
+ },
+ },
+ })
+ send request.to_json, name: request.name?
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Lutron sent: #{data}" }
+ request = Request.from_json(data)
+
+ url = request["Url"]?
+ http_status = request["StatusCode"]? || "200 OK"
+ message_type = request["MessageBodyType"]?
+
+ # process the message based on its type by preference
+ case message_type
+ when "OneClientSettingDefinition"
+ setting = ClientSetting.from_json request.body
+ logger.debug { "protocol version negotiated #{setting.protocol.version}, authenticating" }
+ authenticate
+ when "MultipleAreaStatus"
+ statuses = MultipleAreaStatus.from_json request.body
+ timestamp = Time.utc.to_unix
+
+ statuses.states.each do |status|
+ base_key = status.status_key
+ self["#{base_key}_level"] = status.level if status.level
+
+ if status.occupancy
+ self["#{base_key}_occupied"] = status.occupancy
+ @sensors[base_key] = {status.occupancy.try(&.occupied?) || false, timestamp}
+ end
+ end
+ when "MultipleZoneStatus"
+ statuses = MultipleZoneStatus.from_json request.body
+ statuses.states.each { |status| set_zone(status) }
+ when "OneZoneStatus"
+ set_zone(OneZoneStatus.from_json(request.body).status)
+ when "ExceptionDetail"
+ # get status code
+ code, status = http_status.split(" ", 2)
+ details = ExceptionDetail.from_json request.body
+ error_message = "operation #{url} failed with #{code}: #{status}, #{details.message} [#{details.error_code}]"
+ logger.warn { error_message }
+ if task && task.name == url
+ task.abort error_message
+ else
+ # ignore the current task
+ return
+ end
+ when nil
+ case url
+ when "/server/status/ping"
+ logger.debug { "got ping response" }
+ end
+ else
+ logger.debug { "unknown message type #{message_type}" }
+ end
+
+ task.try &.success
+ end
+
+ protected def set_zone(status)
+ base_key = status.status_key
+ self["#{base_key}"] = status.switched_level.try(&.on?) if status.switched_level
+ self["#{base_key}_level"] = status.level if status.level
+ self["#{base_key}_availability"] = status.availability if status.availability
+ self["#{base_key}_contact_closure"] = status.contact_closure if status.contact_closure
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if type && type != "Presence"
+ return NO_MATCH if mac && mac != config.ip
+
+ @sensors.map do |area_id, (presence, timestamp)|
+ Interface::Sensor::Detail.new(
+ type: SensorType::Presence,
+ value: presence ? 1.0 : 0.0,
+ last_seen: timestamp,
+ mac: config.ip.not_nil!,
+ id: area_id,
+ name: "#{system.name} #{area_id} occupancy",
+ module_id: module_id,
+ binding: "#{area_id}_occupied"
+ )
+ end
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless mac == config.ip
+ return nil unless id
+
+ sensor_found = @sensors[id]?
+ return nil unless sensor_found
+ presence, timestamp = sensor_found
+
+ Interface::Sensor::Detail.new(
+ type: SensorType::Presence,
+ value: presence ? 1.0 : 0.0,
+ last_seen: timestamp,
+ mac: mac,
+ id: id,
+ name: "#{system.name} #{id} occupancy",
+ module_id: module_id,
+ binding: "#{id}_occupied"
+ )
+ end
+end
diff --git a/drivers/lutron/vive_leap_models.cr b/drivers/lutron/vive_leap_models.cr
new file mode 100644
index 00000000000..864df7940b2
--- /dev/null
+++ b/drivers/lutron/vive_leap_models.cr
@@ -0,0 +1,187 @@
+require "json"
+
+module Lutron
+ macro upper_enum(name)
+ {% if name.type.resolve.nilable? %} @{{name.var}} : String? {% else %} @{{name.var}} : String {% end %}
+ {% enum_type = name.type.resolve.union_types.reject(&.nilable?).first %}
+
+ def {{name.var}} : {{name.type}}
+ if value = @{{name.var}}
+ {{enum_type}}.parse(value)
+ else
+ nil
+ end
+ end
+
+ def {{name.var}}=(value : {{name.type}}) : {{name.type}}
+ @{{name.var}} = value.try &.to_s
+ value
+ end
+ end
+
+ enum CommuniqueType
+ ReadRequest
+ ReadResponse
+ UpdateRequest
+ UpdateResponse
+ SubscribeRequest
+ SubscribeResponse
+ DeleteRequest
+ DeleteResponse
+ CreateRequest
+ CreateResponse
+ UnsubscribeRequest
+ UnsubscribeResponse
+ ExceptionResponse
+ end
+
+ class Request
+ include JSON::Serializable
+
+ @[JSON::Field(key: "CommuniqueType")]
+ Lutron.upper_enum type : CommuniqueType
+
+ @[JSON::Field(key: "Header")]
+ property header : Hash(String, String)
+
+ @[JSON::Field(key: "Body", converter: String::RawConverter)]
+ property body : String { "" }
+
+ delegate :[], :[]?, :[]=, to: @header
+
+ def name?
+ header["Url"]?
+ end
+
+ def initialize(
+ url : String,
+ req_type : CommuniqueType = CommuniqueType::ReadRequest,
+ body = nil,
+ @header = {} of String => String
+ )
+ @type = req_type.to_s
+ @body = case body
+ when String, Nil
+ body
+ else
+ body.to_json
+ end
+ header["Url"] = url
+ end
+ end
+
+ struct ClientSetting
+ include JSON::Serializable
+
+ @[JSON::Field(key: "ClientSetting")]
+ getter protocol : ClientVersion
+ end
+
+ struct ClientVersion
+ include JSON::Serializable
+
+ @[JSON::Field(key: "ClientMajorVersion")]
+ getter major_version : Int32
+
+ @[JSON::Field(key: "ClientMinorVersion")]
+ getter minor_version : Int32
+
+ def version
+ "#{major_version}.#{minor_version}.0"
+ end
+ end
+
+ struct ExceptionDetail
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Message")]
+ getter message : String
+
+ @[JSON::Field(key: "ErrorCode")]
+ getter error_code : Int32?
+ end
+
+ struct MultipleAreaStatus
+ include JSON::Serializable
+
+ @[JSON::Field(key: "AreaStatuses")]
+ getter states : Array(AreaStatus)
+ end
+
+ enum OccupancyStatus
+ Occupied
+ Unoccupied
+ Unknown
+ end
+
+ struct AreaStatus
+ include JSON::Serializable
+
+ # /area/3/status
+ getter href : String
+
+ @[JSON::Field(key: "Level")]
+ getter level : Float64?
+
+ @[JSON::Field(key: "OccupancyStatus")]
+ Lutron.upper_enum occupancy : OccupancyStatus?
+
+ def status_key
+ _blank, component, area_id, status = href.split("/", 4)
+ "#{component}#{area_id}"
+ end
+ end
+
+ struct MultipleZoneStatus
+ include JSON::Serializable
+
+ @[JSON::Field(key: "ZoneStatuses")]
+ getter states : Array(ZoneStatus)
+ end
+
+ struct OneZoneStatus
+ include JSON::Serializable
+
+ @[JSON::Field(key: "ZoneStatus")]
+ getter status : ZoneStatus
+ end
+
+ enum SwitchedLevel
+ On
+ Off
+ end
+
+ enum ContactClosureState
+ Open
+ Closed
+ end
+
+ enum Availability
+ Available
+ Unavailable
+ Unknown
+ end
+
+ struct ZoneStatus
+ include JSON::Serializable
+
+ getter href : String
+
+ @[JSON::Field(key: "Level")]
+ getter level : Float64?
+
+ @[JSON::Field(key: "SwitchedLevel")]
+ Lutron.upper_enum switched_level : SwitchedLevel?
+
+ @[JSON::Field(key: "Availability")]
+ Lutron.upper_enum availability : Availability?
+
+ @[JSON::Field(key: "CCOLevel")]
+ Lutron.upper_enum contact_closure : ContactClosureState?
+
+ def status_key
+ _blank, component, zone_id, status = href.split("/", 4)
+ "#{component}#{zone_id}"
+ end
+ end
+end
diff --git a/drivers/lutron/vive_leap_spec.cr b/drivers/lutron/vive_leap_spec.cr
new file mode 100644
index 00000000000..667e49b547b
--- /dev/null
+++ b/drivers/lutron/vive_leap_spec.cr
@@ -0,0 +1,86 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Lutron::ViveLeap" do
+ puts "---- Confirming protocol version"
+ should_send %({
+ "CommuniqueType":"UpdateRequest",
+ "Header":{
+ "Url":"/clientsetting"
+ },
+ "Body":{
+ "ClientSetting":{
+ "ClientMajorVersion":1
+ }
+ }
+ }).gsub(/\s/, "")
+ transmit %({
+ "CommuniqueType":"UpdateResponse",
+ "Header": {
+ "MessageBodyType":"OneClientSettingDefinition",
+ "StatusCode":"200 OK",
+ "Url":"/clientsetting"
+ },
+ "Body":{
+ "ClientSetting":{
+ "href":"/clientsetting",
+ "ClientMajorVersion":1,
+ "ClientMinorVersion":3
+ }
+ }
+ })
+
+ puts "---- Logging on"
+ should_send %({
+ "CommuniqueType":"UpdateRequest",
+ "Header": {"Url":"/login"},
+ "Body": {
+ "Login":{
+ "ContextType":"Application",
+ "LoginId":"user",
+ "Password":"pass"
+ }
+ }
+ }).gsub(/\s/, "")
+ transmit %({
+ "CommuniqueType":"UpdateResponse",
+ "Header": {
+ "StatusCode":"200 OK",
+ "Url":"/login"
+ }
+ })
+
+ puts "---- Testing API"
+ status["zonez45"]?.should eq(nil)
+ level = exec(:zone_lighting, "z45", true)
+
+ should_send %({
+ "CommuniqueType":"CreateRequest",
+ "Header":{ "Url":"/zone/z45/commandprocessor" },
+ "Body":{
+ "Command":{
+ "CommandType":"GoToSwitchedLevel",
+ "SwitchedLevelParameters":{
+ "SwitchedLevel":"On"
+ }
+ }
+ }
+ }).gsub(/\s/, "")
+ transmit %({
+ "CommuniqueType": "CreateResponse",
+ "Header":{
+ "MessageBodyType":"OneZoneStatus",
+ "StatusCode":"200 Created",
+ "Url":"/zone/z45/commandprocessor"
+ },
+ "Body":{
+ "ZoneStatus":{
+ "href":"/zone/z45/status",
+ "SwitchedLevel":"On",
+ "Zone":{ "href":"/zone/z45" }
+ }
+ }
+ })
+
+ level.get
+ status["zonez45"].should eq(true)
+end
diff --git a/drivers/message_media/sms.cr b/drivers/message_media/sms.cr
new file mode 100644
index 00000000000..33cf52ed93f
--- /dev/null
+++ b/drivers/message_media/sms.cr
@@ -0,0 +1,58 @@
+require "placeos-driver"
+
+# Documentation: https://developers.messagemedia.com/code/messages-api-documentation/
+require "placeos-driver/interface/sms"
+
+class MessageMedia::SMS < PlaceOS::Driver
+ include Interface::SMS
+
+ # Discovery Information
+ generic_name :SMS
+ descriptive_name "MessageMedia SMS service"
+ uri_base "https://api.messagemedia.com"
+
+ default_settings({
+ basic_auth: {
+ username: "srvc_acct",
+ password: "password!",
+ },
+ })
+
+ def on_update
+ end
+
+ def send_sms(
+ phone_numbers : String | Array(String),
+ message : String,
+ format : String? = "SMS",
+ source : String? = nil
+ )
+ phone_numbers = [phone_numbers] unless phone_numbers.is_a?(Array)
+
+ # Could be MMS etc
+ format = format || "SMS"
+
+ numbers = phone_numbers.map do |number|
+ payload = {
+ :content => message,
+ :destination_number => number,
+ :format => format,
+ }
+ if source
+ payload[:source_number] = source.to_s
+ payload[:source_number_type] = "ALPHANUMERIC"
+ end
+ payload
+ end
+
+ response = post("/v1/messages", body: {
+ messages: numbers,
+ }.to_json, headers: {
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ })
+
+ raise "request failed with #{response.status_code}" unless response.status_code == 202
+ nil
+ end
+end
diff --git a/drivers/message_media/sms_spec.cr b/drivers/message_media/sms_spec.cr
new file mode 100644
index 00000000000..b027a14555e
--- /dev/null
+++ b/drivers/message_media/sms_spec.cr
@@ -0,0 +1,29 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "MessageMedia::SMS" do
+ # Send the request
+ retval = exec(:send_sms,
+ phone_numbers: "+61418419954",
+ message: "hello steve"
+ )
+
+ # sms should send a HTTP request
+ expect_http_request do |request, response|
+ headers = request.headers
+ io = request.body
+ if io
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request["messages"][0]["content"] == "hello steve" && headers["Authorization"]? == "Basic #{Base64.strict_encode("srvc_acct:password!")}"
+ response.status_code = 202
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include dialing details #{request.inspect}"
+ end
+ end
+
+ # What the sms function should return
+ retval.get.should eq(nil)
+end
diff --git a/drivers/microsoft/FindMe Service API.TXT b/drivers/microsoft/FindMe Service API.TXT
new file mode 100644
index 00000000000..d0a726c550e
--- /dev/null
+++ b/drivers/microsoft/FindMe Service API.TXT
@@ -0,0 +1,58 @@
+*List of available buildings and levels. Returns number of people "findable" on each level*
+
+GET /FindMeService/api/MeetingRooms/BuildingLevelsWithMeetingRooms
+
+[{"Building":"SYDNEY","Level":"0","Online":13},{"Building":"SYDNEY","Level":"2","Online":14},{"Building":"SYDNEY","Level":"3","Online":18}]
+
+*List of meeting rooms in a building/level*
+
+GET /FindMeService/api/MeetingRooms/Level/SYDNEY/2
+
+[{"Alias":"cf2020","Name":"Minogue","Building":"SYDNEY","Level":"2","LocationDescription":"2020","X":null,"Y":null,"Capacity":4,"Features":null,"CanBeBooked":true,"PhotoUrl":null,"HasAV":false,"HasDeskPhone":true,"HasSpeakerPhone":false,"HasWhiteboard":true}]
+
+You need to honour the CanBeBooked flag - don't try to book room that can't be booked!
+
+*Meetings for all rooms on a given level*
+
+Due to the design of our kiosk and web site we always get all meetings for all rooms.
+
+GET /FindMeService/api/MeetingRooms/Meetings/SYDNEY/2/2015-11-12T02:11:41/2015-11-15T02:11:41
+GET /FindMeService/api/MeetingRooms/Meetings/Building/Level/StartDate/EndDate
+
+[{"ConferenceRoomAlias":"cfsydinx","Start":"2015-11-11T23:30:00+00:00","End":"2015-11-12T00:00:00+00:00","Subject":"","Location":"Pty MR Syd L2 INXS (10) RT Int","BookingUserAlias":null,"StartTimeZoneName":null,"EndTimeZoneName":null},{"ConferenceRoomAlias":"cfsydinx","Start":"2015-11-12T23:00:00+00:00","End":"2015-11-13T00:00:00+00:00","Subject":"","Location":"Pty MR Syd L2 INXS (10) RT Int","BookingUserAlias":null,"StartTimeZoneName":null,"EndTimeZoneName":null},{"ConferenceRoomAlias":"cfsydsky","Start":"2015-11-13T01:00:00+00:00","End":"2015-11-13T03:00:00+00:00","Subject":"","Location":"Sydney team: Pty MR Syd L2 Skyhooks (10) RT","BookingUserAlias":null,"StartTimeZoneName":null,"EndTimeZoneName":null}]
+
+*Schedule a Meeting*
+
+POST /FindMeService/api/MeetingRooms/ScheduleMeeting
+
+{"ConferenceRoomAlias":"cf2205","Start":"2015-11-13T18:00:00","End":"2015-11-13T18:30:00","Subject":" String?,
+ headers : Hash(String, String) | HTTP::Headers = HTTP::Headers.new
+ ) : String
+ logger.debug { "requesting: #{method} #{path}" }
+
+ headers["Authorization"] = @auth_token unless @auth_token.empty?
+ response = http(method, path, body, params, headers)
+
+ logger.debug { "authentication required: #{response.status_code == 401}" }
+
+ if response.status_code == 401 && response.headers["WWW-Authenticate"]?
+ supported = response.headers.get("WWW-Authenticate")
+ raise "doesn't support NTLM auth: #{supported}" unless supported.includes?("NTLM")
+
+ # Negotiate NTLM
+ headers["Authorization"] = NTLM.negotiate_http(@domain)
+ response = http(method, path, body, params, headers)
+
+ # Extract the challenge
+ raise "unexpected response #{response.status_code}" unless response.status_code == 401 && response.headers["WWW-Authenticate"]?
+ challenge = response.headers["WWW-Authenticate"]
+
+ # Authenticate the client
+ @auth_token = NTLM.authenticate_http(challenge, @username, @password)
+ headers["Authorization"] = @auth_token
+
+ logger.debug { "authenticated" }
+ response = http(method, path, body, params, headers)
+ end
+
+ logger.debug { "request returned:\n#{response.body}" }
+ raise "request #{path} failed with status: #{response.status_code}" unless response.success?
+
+ response.body
+ end
+
+ def levels
+ data = make_request("GET", "/FindMeService/api/MeetingRooms/BuildingLevelsWithMeetingRooms")
+
+ levels = Array(Microsoft::Level).from_json(data)
+ buildings = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String }
+ levels.each { |level| buildings[level.building] << level.name }
+
+ buildings
+ end
+
+ def user_details(usernames : String | Array(String))
+ users = usernames.is_a?(String) ? [usernames] : usernames
+ data = make_request("GET", "/FindMeService/api/ObjectLocation/Users/#{users.join(",")}?getExtendedData=true")
+
+ Array(Microsoft::Location).from_json(data).reject { |loc| {"NoRecentData", "NoData"}.includes?(loc.status) }
+ end
+
+ def users_on(building : String, level : String)
+ # Same response as above with or without ExtendedUserData
+ uri = "/FindMeService/api/ObjectLocation/Level/#{building}/#{level}"
+ # uri += "?getExtendedData=true" if extended_data
+
+ data = make_request("GET", uri)
+
+ begin
+ Array(Microsoft::Location).from_json(data).reject { |loc| {"NoRecentData", "NoData"}.includes?(loc.status) }
+ rescue error
+ logger.debug { "failed to parse location data\n#{data}" }
+ raise error
+ end
+ end
+end
diff --git a/drivers/microsoft/find_me_location_service.cr b/drivers/microsoft/find_me_location_service.cr
new file mode 100644
index 00000000000..dac654d4e83
--- /dev/null
+++ b/drivers/microsoft/find_me_location_service.cr
@@ -0,0 +1,193 @@
+require "json"
+require "oauth2"
+require "s2_cells"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "./find_me_models"
+
+class Microsoft::FindMeLocationService < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "FindMe Location Service"
+ generic_name :FindMeLocationService
+ description %(collects desk usage and wireless locations for visualising on a map)
+
+ accessor findme : FindMe_1
+
+ default_settings({
+ map_id_prefix: "table-",
+
+ floor_mappings: {
+ "zone-id": {
+ building: "SYDNEY",
+ level: "L14",
+ },
+ },
+
+ building_zone: "zone-building",
+ s2_level: 21,
+ })
+
+ @building_zone : String = ""
+ @floor_mappings : Hash(String, NamedTuple(building: String, level: String)) = {} of String => NamedTuple(building: String, level: String)
+ @zone_filter : Array(String) = [] of String
+ @map_id_prefix : String = "table-"
+ @s2_level : Int32 = 21
+
+ def on_update
+ @map_id_prefix = setting?(String, :map_id_prefix).presence || "table-"
+
+ @building_zone = setting(String, :building_zone)
+ @floor_mappings = setting(Hash(String, NamedTuple(building: String, level: String)), :floor_mappings)
+ @zone_filter = @floor_mappings.keys
+ @s2_level = setting?(Int32, :s2_level) || 21
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "searching for #{email}, #{username}" }
+
+ locations_raw = findme.user_details(username).get.to_json
+ locations = Array(Microsoft::Location).from_json locations_raw
+
+ locations = locations.compact_map do |location|
+ coords = location.coordinates
+ next unless coords
+
+ level = findme_building = findme_level = ""
+ @floor_mappings.each do |zone, details|
+ findme_building = details[:building]
+ findme_level = details[:level]
+
+ if findme_building == coords.building && findme_level == coords.level
+ level = zone
+ break
+ end
+ end
+
+ next if level.empty?
+
+ build_location_response(location, level, findme_building, findme_level)
+ end
+
+ locations
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "listing MAC addresses assigned to #{email}, #{username}" }
+
+ active_users_raw = findme.user_details(username || email).get.to_json
+ active_users = Array(Microsoft::Location).from_json active_users_raw
+
+ found = [] of String
+ if user_details = active_users[0]?
+ found << user_details.username
+ end
+ found
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "searching for owner of #{mac_address}" }
+
+ active_users_raw = findme.user_details(mac_address).get.to_json
+ active_users = Array(Microsoft::Location).from_json active_users_raw
+
+ if user_details = active_users[0]?
+ {
+ location: user_details.located_using == "FixedLocation" ? "desk" : "wireless",
+ assigned_to: user_details.user_data.not_nil!.email_address || "",
+ mac_address: mac_address,
+ }
+ end
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching devices in zone #{zone_id}" }
+ return [] of Nil unless @zone_filter.includes?(zone_id)
+
+ findme_details = @floor_mappings[zone_id]?
+ return [] of Nil unless findme_details
+
+ findme_building = findme_details[:building]
+ findme_level = findme_details[:level]
+ active_users_raw = findme.users_on(findme_building, findme_level).get.to_json
+ active_users = Array(Microsoft::Location).from_json active_users_raw
+
+ locations = active_users.compact_map do |loc|
+ build_location_response(loc, zone_id, findme_building, findme_level, location)
+ end
+
+ locations
+ end
+
+ protected def build_location_response(location, zone_id, findme_building, findme_level, loc_type = nil)
+ case location.located_using
+ when "FixedLocation"
+ return if loc_type.presence && loc_type != "desk"
+
+ location_id = "#{@map_id_prefix}#{location.location_id}"
+
+ loc = {
+ location: :desk,
+ at_location: 1,
+ map_id: location_id,
+ level: zone_id,
+ building: @building_zone,
+ mac: location.username,
+ last_seen: location.last_update.to_unix,
+ capacity: 1,
+
+ findme_building: findme_building,
+ findme_level: findme_level,
+ findme_status: location.status,
+ findme_type: location.type,
+ }
+
+ loc
+ when "WiFi"
+ return if loc_type.presence && loc_type != "wireless"
+
+ coordinates = location.coordinates
+ return unless coordinates
+
+ if gps = location.gps
+ lat = gps.latitude
+ lon = gps.longitude
+ end
+
+ # Based on the confidence % and a max variance of 20m
+ variance = 20 - (20 * (location.confidence / 100))
+
+ loc = {
+ location: :wireless,
+ coordinates_from: "top-left",
+ x: coordinates.x,
+ y: coordinates.y,
+ # x,y coordinates are % based so map width and height are out of 100
+ map_width: 100,
+ # by not returning map height, it indicates that a relative height should be calculated
+ # map_height: 100,
+ lon: lon,
+ lat: lat,
+ s2_cell_id: lat ? S2Cells.at(lat.not_nil!, lon.not_nil!).parent(@s2_level).to_token : nil,
+
+ mac: location.username,
+ variance: variance,
+
+ last_seen: location.last_update.to_unix,
+ level: zone_id,
+ building: @building_zone,
+
+ findme_building: findme_building,
+ findme_level: findme_level,
+ findme_status: location.status,
+ findme_type: location.type,
+ }
+ else
+ logger.info { "unexpected location type #{location.located_using}" }
+ nil
+ end
+ end
+end
diff --git a/drivers/microsoft/find_me_location_service_spec.cr b/drivers/microsoft/find_me_location_service_spec.cr
new file mode 100644
index 00000000000..dd192c16835
--- /dev/null
+++ b/drivers/microsoft/find_me_location_service_spec.cr
@@ -0,0 +1,86 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Microsoft::FindMeLocationService" do
+ system({
+ FindMe: {FindMeMock},
+ })
+
+ now = Time.local
+ start = now.at_beginning_of_day.to_unix
+ ending = now.at_end_of_day.to_unix
+
+ resp = exec(:device_locations, "zone-id").get
+ puts resp
+ resp.should eq([
+ {
+ "location" => "wireless",
+ "coordinates_from" => "top-left",
+ "x" => 76.0,
+ "y" => 29.0,
+ "map_width" => 100,
+ "lon" => 151.1382508278,
+ "lat" => -33.796597429,
+ "s2_cell_id" => "6b12a5f8f0c4",
+ "mac" => "dwatson",
+ "variance" => 0.0,
+ "last_seen" => 1447295150,
+ "level" => "zone-id",
+ "building" => "zone-building",
+ "findme_building" => "SYDNEY",
+ "findme_level" => "L14",
+ "findme_status" => "Located",
+ "findme_type" => "Person",
+ }, {
+ "location" => "desk",
+ "at_location" => 1,
+ "map_id" => "table-11.097",
+ "level" => "zone-id",
+ "building" => "zone-building",
+ "mac" => "acorder003",
+ "last_seen" => 1608185586,
+ "capacity" => 1,
+ "findme_building" => "SYDNEY",
+ "findme_level" => "L14",
+ "findme_status" => "NoRecentData",
+ "findme_type" => "Person",
+ },
+ ])
+end
+
+# :nodoc:
+class FindMeMock < DriverSpecs::MockDriver
+ def user_details(usernames : String | Array(String))
+ JSON.parse %([{"Alias":"dwatson","LastUpdate":"2015-11-12T02:25:50.017Z","Confidence":100,
+ "Coordinates":{"Building":"SYDNEY","Level":"L14","X":76,"Y":29,"LocationDescription":"2140","MapByLocationId":true},
+ "GPS":{"Latitude":-33.796597429,"Longitude":151.1382508278,"Accuracy":0.0,"LocationDescription":null},
+ "LocationIdentifier":null,"Status":"Located","LocatedUsing":"FixedLocation","Type":"Person","Comments":null,
+ "ExtendedUserData":{"Alias":"dwatson","DisplayName":"David Watson","EmailAddress":"David.Watson@microsoft.com","LyncSipAddress":"dwatson@microsoft.com"}}])
+ end
+
+ def users_on(building : String, level : String)
+ # Wireless and a desk
+ JSON.parse %([{"Alias":"dwatson","LastUpdate":"2015-11-12T02:25:50.017Z","Confidence":100,
+ "Coordinates":{"Building":"SYDNEY","Level":"L14","X":76,"Y":29,"LocationDescription":"2140","MapByLocationId":true},
+ "GPS":{"Latitude":-33.796597429,"Longitude":151.1382508278,"Accuracy":0.0,"LocationDescription":null},
+ "LocationIdentifier":null,"Status":"Located","LocatedUsing":"WiFi","Type":"Person","Comments":null,
+ "ExtendedUserData":{"Alias":"dwatson","DisplayName":"David Watson","EmailAddress":"David.Watson@microsoft.com","LyncSipAddress":"dwatson@microsoft.com"}},
+
+ {
+ "Alias": "acorder003",
+ "LastUpdate": "2020-12-17T06:13:06.797Z",
+ "CurrentUntil": "2020-12-17T06:16:06.797Z",
+ "Confidence": 100,
+ "Coordinates": null,
+ "GPS": null,
+ "LocationIdentifier": "11.097",
+ "Status": "NoRecentData",
+ "LocatedUsing": "FixedLocation",
+ "Type": "Person",
+ "Comments": null,
+ "ExtendedUserData": null,
+ "WiFiScale": 1.00,
+ "userTypes": []
+ }
+ ])
+ end
+end
diff --git a/drivers/microsoft/find_me_models.cr b/drivers/microsoft/find_me_models.cr
new file mode 100644
index 00000000000..a1a4c84999f
--- /dev/null
+++ b/drivers/microsoft/find_me_models.cr
@@ -0,0 +1,108 @@
+require "json"
+
+module Microsoft
+ class Level
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Building")]
+ getter building : String
+
+ @[JSON::Field(key: "Level")]
+ getter name : String
+
+ @[JSON::Field(key: "Online")]
+ getter online : Int32
+ end
+
+ class Coordinates
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Building")]
+ getter building : String
+
+ @[JSON::Field(key: "Level")]
+ getter level : String
+
+ @[JSON::Field(key: "X")]
+ getter x : Float64
+
+ @[JSON::Field(key: "Y")]
+ getter y : Float64
+ end
+
+ class GPS
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Latitude")]
+ getter latitude : Float64
+
+ @[JSON::Field(key: "Longitude")]
+ getter longitude : Float64
+ end
+
+ class UserData
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Alias")]
+ getter username : String?
+
+ @[JSON::Field(key: "DisplayName")]
+ getter display_name : String?
+
+ @[JSON::Field(key: "EmailAddress")]
+ getter email_address : String?
+ end
+
+ # Example Response:
+ # [{"Alias":"dwatson","LastUpdate":"2015-11-12T02:25:50.017Z","Confidence":100,
+ # "Coordinates":{"Building":"SYDNEY","Level":"2","X":76,"Y":29,"LocationDescription":"2140","MapByLocationId":true},
+ # "GPS":{"Latitude":-33.796597429,"Longitude":151.1382508278,"Accuracy":0.0,"LocationDescription":null},
+ # "LocationIdentifier":null,"Status":"Located","LocatedUsing":"FixedLocation","Type":"Person","Comments":null,
+ # "ExtendedUserData":{"Alias":"dwatson","DisplayName":"David Watson","EmailAddress":"David.Watson@microsoft.com","LyncSipAddress":"dwatson@microsoft.com"}}]
+ class Location
+ include JSON::Serializable
+
+ module RFC3339Converter
+ def self.from_json(value : JSON::PullParser) : Time
+ Time::Format::RFC_3339.parse(value.read_string)
+ end
+
+ def self.to_json(value : Time, json : JSON::Builder)
+ json.string(Time::Format::RFC_3339.format(value, 1))
+ end
+ end
+
+ @[JSON::Field(key: "Alias")]
+ getter username : String
+
+ @[JSON::Field(
+ key: "LastUpdate",
+ converter: Microsoft::Location::RFC3339Converter
+ )]
+ getter last_update : Time
+
+ @[JSON::Field(key: "Confidence")]
+ getter confidence : Float64
+
+ @[JSON::Field(key: "Coordinates")]
+ getter coordinates : Coordinates?
+
+ @[JSON::Field(key: "GPS")]
+ getter gps : GPS?
+
+ @[JSON::Field(key: "LocationIdentifier")]
+ getter location_id : String?
+
+ @[JSON::Field(key: "Status")]
+ getter status : String
+
+ @[JSON::Field(key: "LocatedUsing")]
+ getter located_using : String?
+
+ @[JSON::Field(key: "Type")]
+ getter type : String?
+
+ @[JSON::Field(key: "ExtendedUserData")]
+ getter user_data : UserData?
+ end
+end
diff --git a/drivers/microsoft/find_me_spec.cr b/drivers/microsoft/find_me_spec.cr
new file mode 100644
index 00000000000..5cc1e938bd9
--- /dev/null
+++ b/drivers/microsoft/find_me_spec.cr
@@ -0,0 +1,35 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Microsoft::FindMe" do
+ # Send the request
+ retval = exec(:levels)
+
+ # sms should send a HTTP request
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << %([{"Building":"SYDNEY","Level":"0","Online":13},{"Building":"SYDNEY","Level":"2","Online":14}])
+ end
+
+ # What the sms function should return
+ retval.get.should eq({
+ "SYDNEY" => ["0", "2"],
+ })
+
+ # Send the request
+ retval = exec(:user_details, "mbenz")
+ details_response = %([{"Alias":"mbenz","LastUpdate":"2020-12-15T13:22:00.8675244Z","CurrentUntil":"0001-01-01T00:00:00","Confidence":0,"Coordinates":null,"GPS":null,"LocationIdentifier":null,"Status":"NoData","LocatedUsing":null,"Type":null,"Comments":null,"ExtendedUserData":null,"WiFiScale":0.0,"userTypes":null}])
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << details_response
+ end
+ retval.get.should eq([] of JSON::Any)
+
+ # Check the time format works
+ retval = exec(:user_details, "mbenz")
+ details_response = %([{"Alias":"mbenz","LastUpdate":"2020-12-15T13:22:00Z","CurrentUntil":"0001-01-01T00:00:00","Confidence":0,"Coordinates":null,"GPS":null,"LocationIdentifier":null,"Status":"NoData","LocatedUsing":null,"Type":null,"Comments":null,"ExtendedUserData":null,"WiFiScale":0.0,"userTypes":null}])
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << details_response
+ end
+ retval.get.should eq([] of JSON::Any)
+end
diff --git a/drivers/microsoft/graph_api.cr b/drivers/microsoft/graph_api.cr
new file mode 100644
index 00000000000..fa96d497cf2
--- /dev/null
+++ b/drivers/microsoft/graph_api.cr
@@ -0,0 +1,28 @@
+require "../place/calendar_common"
+
+class Microsoft::GraphAPI < PlaceOS::Driver
+ include Place::CalendarCommon
+
+ # update to trigger build.
+ descriptive_name "Microsoft Graph API"
+ generic_name :Calendar
+
+ uri_base "https://graph.microsoft.com"
+
+ default_settings({
+ calendar_service_account: "service_account@email.address",
+ calendar_config: {
+ tenant: "",
+ client_id: "",
+ client_secret: "",
+ conference_type: nil, # This can be set to "teamsForBusiness" to add a Teams link to EVERY created Event
+ },
+
+ # defaults to calendar_service_account if not configured
+ mailer_from: "email_or_office_userPrincipalName",
+ email_templates: {visitor: {checkin: {
+ subject: "%{name} has arrived",
+ text: "for your meeting at %{time}",
+ }}},
+ })
+end
diff --git a/drivers/microsoft/graph_api_advanced.cr b/drivers/microsoft/graph_api_advanced.cr
new file mode 100644
index 00000000000..6d6ce04290e
--- /dev/null
+++ b/drivers/microsoft/graph_api_advanced.cr
@@ -0,0 +1,92 @@
+require "placeos-driver"
+require "office365"
+
+class Microsoft::GraphAPIAdvanced < PlaceOS::Driver
+ descriptive_name "Direct Access to Microsoft Graph API"
+ generic_name :MSGraphAPI
+
+ uri_base "https://graph.microsoft.com/"
+
+ default_settings({
+ credentials: {
+ tenant: "",
+ client_id: "",
+ client_secret: "",
+ },
+
+ })
+
+ alias GraphParams = NamedTuple(
+ tenant: String,
+ client_id: String,
+ client_secret: String,
+ )
+
+ def on_update
+ credentials = setting(GraphParams, :credentials)
+ @client = Office365::Client.new(**credentials)
+ end
+
+ private def get(path : String, query_params : URI::Params? = nil)
+ @client.not_nil!.graph_request(
+ @client.not_nil!.graph_http_request(
+ request_method: "GET",
+ path: path,
+ query: query_params
+ )
+ )
+ end
+
+ @[Security(Level::Support)]
+ def get_request(path : String)
+ get(path)
+ end
+
+ private def post(path : String, query_params : URI::Params? = nil, body : String? = nil)
+ @client.not_nil!.graph_request(
+ @client.not_nil!.graph_http_request(
+ request_method: "POST",
+ path: path,
+ data: body,
+ query: query_params
+ )
+ )
+ end
+
+ @[Security(Level::Support)]
+ def post_request(path : String)
+ post(path)
+ end
+
+ private def put(path : String, query_params : URI::Params? = nil, body : String? = nil)
+ @client.not_nil!.graph_request(
+ @client.not_nil!.graph_http_request(
+ request_method: "PUT",
+ path: path,
+ data: body,
+ query: query_params
+ )
+ )
+ end
+
+ @[Security(Level::Support)]
+ def put_request(path : String)
+ put(path)
+ end
+
+ def list_managed_devices(filter_device_name : String? = nil)
+ query_params = filter_device_name ? URI::Params{"filter" => "deviceName eq #{filter_device_name}"} : nil
+ response = get(
+ "/v1.0/deviceManagement/managedDevices",
+ query_params
+ )
+ response.body["value"]
+ end
+
+ def list_users_managed_devices(user_id : String)
+ response = get(
+ "/v1.0/users/#{user_id}/managedDevices"
+ )
+ response.body["value"]
+ end
+end
diff --git a/drivers/middle_atlantic/rack_link.cr b/drivers/middle_atlantic/rack_link.cr
new file mode 100644
index 00000000000..fb3dd3d38ff
--- /dev/null
+++ b/drivers/middle_atlantic/rack_link.cr
@@ -0,0 +1,152 @@
+require "placeos-driver"
+require "./rack_link_protocol"
+
+# docs: https://res.cloudinary.com/avd/image/upload/v133928820/Resources/Middle%20Atlantic/Power/Firmware/I-00472-Series-Protocol.pdf
+
+class MiddleAtlantic::RackLink < PlaceOS::Driver
+ descriptive_name "RackLink Power Controller."
+ generic_name :PowerController
+
+ tcp_port 60000
+
+ default_settings({
+ username: "user",
+ password: "password",
+ outlets: 8,
+ sequence_delay: 3,
+ })
+
+ @username : String = "user"
+ @password : String = "password"
+ @outlet_count : Int32 = 8
+ @sequence_delay : Int32 = 3
+ @outlets = {} of UInt8 => Bool
+
+ def on_load
+ queue.delay = 100.milliseconds
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.to_slice
+
+ next -1 if bytes.size < 2
+
+ # Expecting structure: 0xFE 0xFF
+ # Length is the count of escaped data bytes (excluding header + length)
+ expected = 4 + bytes[1].to_i
+ bytes.size >= expected ? expected : -1
+ end
+
+ on_update
+ end
+
+ def on_update
+ @username = setting?(String, :username) || "user"
+ @password = setting?(String, :password) || "password"
+ @outlet_count = setting?(Int32, :outlets) || 8
+ @sequence_delay = setting?(Int32, :sequence_delay) || 3
+ end
+
+ def connected
+ authenticate
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ protected def authenticate
+ do_send RackLinkProtocol.login_packet(@username, @password), name: "authenticate", priority: 99
+ end
+
+ enum NACK
+ BadCRC = 1
+ BadLength
+ BadEscape
+ InvalidCommand
+ InvalidSubCommand
+ IncorrectByteCount
+ InvalidDataBytes
+ InvalidCredentials
+ UnknownError = 0x10
+ AccessDenied
+ end
+
+ def received(data : Bytes, task)
+ logger.debug { "received: 0x#{data.hexstring}" }
+
+ command = data[3]
+ subcommand = data[4]
+
+ case {command, subcommand}
+ when {0x02, 0x10} # login response
+ if data[5] == 0x01
+ logger.info { "Login successful" }
+ self[:connected] = true
+ schedule.every(50.seconds) { query_all_outlets }
+ else
+ logger.error { "Login failed" }
+ self[:connected] = false
+ schedule.in(30.seconds) { authenticate }
+ end
+ when {0x01, 0x01} # received ping
+ logger.debug { "Received ping, replying with pong" }
+ do_send RackLinkProtocol.pong_response, wait: false
+ when {0x20, 0x10}, {0x20, 0x12}
+ outlet = data[5]
+ state = data[6] == 0x01
+ @outlets[outlet] = state
+ self["outlet_#{outlet}"] = state
+ when {0x10, 0x10}
+ error_code = data[5]
+ error = NACK.from_value(error_code) rescue NACK::UnknownError
+ last_error = "Error #{error_code}: #{error}"
+ logger.error { last_error }
+
+ if error.invalid_credentials?
+ logger.error { "Login failed" }
+ self[:connected] = false
+ schedule.in(30.seconds) { authenticate }
+ end
+
+ return task.try &.abort
+ else
+ logger.debug { "Unhandled command #{command.to_s(16)} subcommand #{subcommand.to_s(16)}" }
+ end
+
+ task.try &.success
+ end
+
+ def query_all_outlets
+ 1.upto(@outlet_count) do |id|
+ do_send RackLinkProtocol.query_outlet(id.to_u8)
+ end
+ end
+
+ def power_on(id : Int32)
+ do_send RackLinkProtocol.set_outlet(id.to_u8, 0x01_u8)
+ end
+
+ def power_off(id : Int32)
+ do_send RackLinkProtocol.set_outlet(id.to_u8, 0x00_u8)
+ end
+
+ def power_cycle(id : Int32, seconds : Int32 = 5)
+ do_send RackLinkProtocol.cycle_outlet(id.to_u8, seconds)
+ end
+
+ def outlet_status(id : Int32) : Bool
+ @outlets[id.to_u8]? || false
+ end
+
+ def sequence_up
+ do_send RackLinkProtocol.build(Bytes[0x00, 0x36, 0x01, 0x01] + sprintf("%04d", @sequence_delay).to_slice)
+ end
+
+ def sequence_down
+ do_send RackLinkProtocol.build(Bytes[0x00, 0x36, 0x01, 0x03] + sprintf("%04d", @sequence_delay).to_slice)
+ end
+
+ protected def do_send(bytes : Bytes, **opts)
+ logger.debug { "sending: 0x#{bytes.hexstring}" }
+ send bytes, **opts
+ end
+end
diff --git a/drivers/middle_atlantic/rack_link_protocol.cr b/drivers/middle_atlantic/rack_link_protocol.cr
new file mode 100644
index 00000000000..24e200fa023
--- /dev/null
+++ b/drivers/middle_atlantic/rack_link_protocol.cr
@@ -0,0 +1,56 @@
+module MiddleAtlantic::RackLinkProtocol
+ HEADER = 0xFE_u8
+ TAIL = 0xFF_u8
+ ESCAPE = 0xFD_u8
+ PROTECTED = Set{HEADER, TAIL, ESCAPE}
+
+ def self.checksum(payload : Bytes) : UInt8
+ sum = payload.reduce(0_u16) { |s, b| s + b } & 0x7F
+ sum.to_u8
+ end
+
+ def self.escape(bytes : Bytes) : Bytes
+ output = IO::Memory.new
+ bytes.each do |byte|
+ if PROTECTED.includes?(byte)
+ output.write_byte(ESCAPE)
+ output.write_byte(~byte)
+ else
+ output.write_byte(byte)
+ end
+ end
+ output.to_slice
+ end
+
+ def self.build(command : Bytes) : Bytes
+ command = escape(command)
+ length = command.size.to_u8
+ frame = Bytes[HEADER, length] + command
+ checksum = checksum(frame)
+ frame + Bytes[checksum, TAIL]
+ end
+
+ def self.login_packet(user : String, pass : String) : Bytes
+ data = Bytes[0x00, 0x02, 0x01] + "#{user}|#{pass}".to_slice
+ build(data)
+ end
+
+ def self.pong_response : Bytes
+ build(Bytes[0x00, 0x01, 0x10])
+ end
+
+ def self.query_outlet(outlet : UInt8) : Bytes
+ build(Bytes[0x00, 0x20, 0x02, outlet])
+ end
+
+ def self.set_outlet(outlet : UInt8, state : UInt8) : Bytes
+ data = Bytes[0x00, 0x20, 0x01, outlet, state] + "0000".to_slice
+ build(data)
+ end
+
+ def self.cycle_outlet(outlet : UInt8, seconds : Int32 = 5) : Bytes
+ cycle = sprintf("%04d", seconds).to_slice
+ data = Bytes[0x00, 0x20, 0x01, outlet, 0x02] + cycle
+ build(data)
+ end
+end
diff --git a/drivers/middle_atlantic/rack_link_spec.cr b/drivers/middle_atlantic/rack_link_spec.cr
new file mode 100644
index 00000000000..a32790c7b7e
--- /dev/null
+++ b/drivers/middle_atlantic/rack_link_spec.cr
@@ -0,0 +1,50 @@
+require "placeos-driver/spec"
+require "./rack_link_protocol"
+
+DriverSpecs.mock_driver "MiddleAtlantic::RackLink" do
+ login = MiddleAtlantic::RackLinkProtocol.login_packet("user", "password")
+ pong = MiddleAtlantic::RackLinkProtocol.pong_response
+
+ # data from protocol doc
+ login_hex = "fe10000201" + "757365727c70617373776f7264" + "3FFF"
+ login.should eq login_hex.hexbytes
+
+ # Login
+ should_send login
+ responds Bytes[0xFE, 0x04, 0x00, 0x02, 0x10, 0x01, 0x15, 0xFF] # login accepted
+
+ # simulate ping
+ transmit Bytes[0xFE, 0x03, 0x00, 0x01, 0x01, 0x03, 0xFF] # PING
+ should_send pong
+
+ # query outlet states (assume 8 outlets).
+ exec :query_all_outlets
+ 1.upto(8) do |id|
+ query = MiddleAtlantic::RackLinkProtocol.query_outlet(id.to_u8)
+ should_send query
+ # outlet alternating states
+ responds Bytes[0xFE, 0x09, 0x00, 0x20, 0x10, id.to_u8, (id % 2).to_u8, 0x30, 0x30, 0x30, 0x30, 0x00, 0xFF]
+ status["outlet_#{id}"].should eq(id.odd?)
+ end
+
+ # Test ON
+ exec(:power_on, 2)
+ should_send MiddleAtlantic::RackLinkProtocol.set_outlet(2_u8, 1_u8)
+ responds Bytes[0xFE, 0x09, 0x00, 0x20, 0x10, 0x02, 0x01, 0x30, 0x30, 0x30, 0x30, 0x00, 0xFF]
+ status["outlet_2"].should eq(true)
+
+ # Test OFF
+ exec(:power_off, 2)
+ should_send MiddleAtlantic::RackLinkProtocol.set_outlet(2_u8, 0_u8)
+ responds Bytes[0xFE, 0x09, 0x00, 0x20, 0x10, 0x02, 0x00, 0x30, 0x30, 0x30, 0x30, 0x00, 0xFF]
+ status["outlet_2"].should eq(false)
+
+ # Cycle
+ exec(:power_cycle, 1, 5)
+ should_send MiddleAtlantic::RackLinkProtocol.cycle_outlet(1_u8, 5)
+
+ # NACK Handling
+ responds Bytes[0xFE, 0x04, 0x00, 0x10, 0x10, 0x08, 0x3F, 0xFF] # NACK - invalid credentials
+
+ sleep 4.seconds
+end
diff --git a/drivers/mulesoft/booking_api.cr b/drivers/mulesoft/booking_api.cr
new file mode 100644
index 00000000000..8c22b2f05ef
--- /dev/null
+++ b/drivers/mulesoft/booking_api.cr
@@ -0,0 +1,175 @@
+require "placeos-driver"
+require "./models"
+
+class MuleSoft::BookingsAPI < PlaceOS::Driver
+ descriptive_name "MuleSoft Bookings API"
+ generic_name :Bookings
+ description %(Retrieves and creates bookings using the MuleSoft API)
+ uri_base "https://api.sydney.edu.au"
+
+ default_settings({
+ venue_code: "venue code",
+ base_path: "/usyd-edu-timetable-exp-api-v1/v1/",
+ polling_cron: "*/30 7-20 * * *",
+ time_zone: "Australia/Sydney",
+ ssl_key: "private key",
+ ssl_cert: "certificate",
+ ssl_auth_enabled: false,
+ username: "basic auth username",
+ password: "basic auth password",
+ basic_auth_enabled: true,
+ running_a_spec: false,
+ })
+
+ @username : String = ""
+ @password : String = ""
+ @base_path : String = ""
+ @context : OpenSSL::SSL::Context::Client = OpenSSL::SSL::Context::Client.new
+ @host : String = ""
+ @venue_code : String = ""
+ @bookings : Array(Booking) = [] of Booking
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+ @ssl_auth_enabled : Bool = false
+ @basic_auth_enabled : Bool = false
+ @runing_a_spec : Bool = false
+
+ def on_update
+ schedule.clear
+ @running_a_spec = !!setting(Bool, :running_a_spec)
+
+ @username = setting(String, :username)
+ @password = setting(String, :password)
+ @basic_auth_enabled = !!setting?(Bool, :basic_auth_enabled)
+ logger.debug { "basic_auth_enabled is #{@basic_auth_enabled}" }
+
+ @base_path = setting(String, :base_path)
+ @venue_code = setting(String, :venue_code)
+
+ @host = URI.parse(config.uri.not_nil!).host.not_nil!
+
+ time_zone = setting?(String, :time_zone).presence
+ @time_zone = Time::Location.load(time_zone) if time_zone
+
+ @ssl_auth_enabled = !!setting?(Bool, :ssl_auth_enabled)
+ save_ssl_credentials if @ssl_auth_enabled
+ logger.debug { "ssl_auth_enabled is #{@ssl_auth_enabled}" }
+
+ schedule.in(Random.rand(60).seconds + Random.rand(1000).milliseconds) { poll_bookings }
+
+ cron_string = setting?(String, :polling_cron).presence || "*/30 7-20 * * *"
+ schedule.cron(cron_string, @time_zone) { poll_bookings(random_delay: true) }
+ end
+
+ def poll_bookings(random_delay : Bool = false)
+ now = Time.local @time_zone
+ from = now - 1.week
+ to = now + 1.week
+
+ logger.debug { "polling bookings #{@venue_code}, from #{from}, to #{to}, in #{@time_zone.name}" }
+ if random_delay
+ logger.debug { "random delay of <30seconds to reduce instantaneous Mulesoft API load" }
+ sleep Random.rand(30.0)
+ end
+ query_bookings(@venue_code, from, to)
+
+ check_current_booking
+ end
+
+ def check_current_booking
+ now = Time.utc.to_unix
+ previous_booking = nil
+ current_booking = nil
+ next_booking = Int32::MAX
+
+ @bookings.each_with_index do |event, index|
+ starting = event.event_start
+
+ # All meetings are in the future
+ if starting > now
+ next_booking = index
+ previous_booking = index - 1 if index > 0
+ break
+ end
+
+ # Calculate event end time
+ ending_unix = event.event_end
+
+ # Event ended in the past
+ next if ending_unix < now
+
+ # We've found the current event
+ if starting <= now && ending_unix > now
+ current_booking = index
+ previous_booking = index - 1 if index > 0
+ next_booking = index + 1
+ break
+ end
+ end
+
+ if next_booking >= (@bookings.size - 1)
+ next_booking = nil
+ end
+
+ self[:previous_booking] = previous_booking ? @bookings[previous_booking].to_placeos : nil
+ self[:current_booking] = current_booking ? @bookings[current_booking].to_placeos : nil
+ self[:next_booking] = next_booking ? @bookings[next_booking].to_placeos : nil
+ end
+
+ def query_bookings(venue_code : String, starts_at : Time = Time.local.at_beginning_of_day, ends_at : Time = Time.local.at_end_of_day)
+ client = HTTP::Client.new(host: @host, tls: (@ssl_auth_enabled ? @context : nil))
+
+ params = {
+ "startDateTime" => starts_at.to_s("%FT%T"),
+ "endDateTime" => ends_at.to_s("%FT%T"),
+ }.join('&') { |k, v| "#{k}=#{v}" }
+
+ headers = HTTP::Headers{
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ }
+
+ if @basic_auth_enabled
+ headers.add("Authorization", "Basic #{Base64.strict_encode("#{@username}:#{@password}")}")
+ end
+
+ if @running_a_spec
+ response = get("#{@base_path}/venues/#{venue_code}/bookings?#{params}", headers: headers)
+ else
+ response = client.get("#{@base_path}/venues/#{venue_code}/bookings?#{params}", headers: headers)
+ end
+
+ raise "request failed with #{response.status_code}: #{response.body}" unless (200...300).includes?(response.status_code)
+
+ # when there's no results, it seems to return just an empty response rather than an empty array?
+ if response.body.presence != nil
+ results = BookingResults.from_json(response.body)
+
+ self[:venue_code] = results.venue_code
+ self[:venue_name] = results.venue_name
+
+ @bookings = results.bookings.sort { |a, b| a.event_start <=> b.event_start }
+ self[:bookings] = @bookings.map(&.to_placeos)
+ else
+ self[:venue_code] = nil
+ self[:venue_name] = nil
+ self[:bookings] = nil
+ end
+ end
+
+ def query_bookings_epoch(venue_code : String, starts_at : Int32, ends_at : Int32)
+ query_bookings(venue_code, Time.unix(starts_at), Time.unix(ends_at))
+ end
+
+ protected def save_ssl_credentials
+ [:ssl_key, :ssl_cert].each do |key|
+ raise "Required setting #{key} left blank" unless setting(String, key).presence
+
+ File.open("./pkey-#{module_id}.#{key}", "w") do |cert|
+ cert.puts setting(String, key)
+ end
+ end
+
+ @context.private_key = "./pkey-#{module_id}.ssl_key"
+ @context.certificate_chain = "./pkey-#{module_id}.ssl_cert"
+ end
+end
diff --git a/drivers/mulesoft/booking_api_spec.cr b/drivers/mulesoft/booking_api_spec.cr
new file mode 100644
index 00000000000..2d00bbe71a7
--- /dev/null
+++ b/drivers/mulesoft/booking_api_spec.cr
@@ -0,0 +1,52 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "MuleSoft::API" do
+ settings({
+ venue_code: "venue code",
+ base_path: "/usyd-edu-timetable-exp-api-v1/v1/",
+ polling_period: 5,
+ time_zone: "Australia/Sydney",
+ ssl_key: "private key",
+ ssl_cert: "certificate",
+ ssl_auth_enabled: false,
+ username: "basic auth username",
+ password: "basic auth password",
+ basic_auth_enabled: false,
+ running_a_spec: true,
+ })
+
+ resp = exec(:query_bookings, "A14.02.K2.05")
+
+ expect_http_request do |_request, response|
+ starts_at = Time.local - 30.minutes
+ ends_at = starts_at + 1.hour
+
+ response.status_code = 200
+ response << <<-RESPONSE
+ {
+ "count": 1,
+ "timeTableBookingsCount": 1,
+ "casualBookingsCount": 0,
+ "venueCode": "A14.02.K2.05",
+ "venueName": "A14.02.K2.05.The Quadrangle.The Quad General Lecture Theatre K2.05",
+ "bookings": [
+ {
+ "unitCode": "HSTY2630",
+ "unitName": "Panics and Pandemics",
+ "activityName": "HSTY2630-S1C-ND-CC/TUT/01",
+ "activityType": "Tutorial",
+ "activityDescription": "Tutorial",
+ "startDateTime": "#{starts_at.to_s("%FT%T")}",
+ "endDateTime": "#{ends_at.to_s("%FT%T")}",
+ "location": "Social Sciences Building - SSB Seminar Room 210",
+ "bookingType": "timeTable"
+ }
+ ]
+ }
+ RESPONSE
+ end
+
+ resp.get
+
+ exec(:check_current_booking).get
+end
diff --git a/drivers/mulesoft/calendar_exporter.cr b/drivers/mulesoft/calendar_exporter.cr
new file mode 100644
index 00000000000..0d2cca62b80
--- /dev/null
+++ b/drivers/mulesoft/calendar_exporter.cr
@@ -0,0 +1,145 @@
+require "placeos-driver"
+require "place_calendar"
+require "./models"
+
+class MuleSoft::CalendarExporter < PlaceOS::Driver
+ descriptive_name "MuleSoft Bookings to Calendar Events Exporter"
+ generic_name :Bookings
+ description %(Retrieves and creates bookings using the MuleSoft API)
+
+ default_settings({
+ calendar_time_zone: "Australia/Sydney",
+ })
+
+ accessor calendar : Calendar_1
+
+ @time_zone_string : String | Nil = "Australia/Sydney"
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+ @bookings : Array(Hash(String, Int64 | String | Nil)) = [] of Hash(String, Int64 | String | Nil)
+ @existing_events : Array(JSON::Any) = [] of JSON::Any
+ @deleted_events : Int32 = 0
+ # An array of Attendee that has only the system (room) email address. Generally static
+ @just_this_system : NamedTuple(email: String, name: String) = {email: "", name: ""}
+
+ def on_load
+ @just_this_system = {
+ "email": system.email.not_nil!,
+ "name": system.name,
+ }
+ on_update
+ end
+
+ def on_update
+ subscriptions.clear
+
+ @time_zone_string = setting?(String, :calendar_time_zone).presence
+ @time_zone = Time::Location.load(@time_zone_string.not_nil!) if @time_zone_string
+ self[:timezone] = Time.local.to_s
+
+ subscription = system.subscribe(:Bookings_1, :bookings) do |_subscription, mulesoft_bookings|
+ logger.debug { "DETECTED changed in Mulesoft Bookings.." }
+ latest_bookings : Array(Hash(String, Int64 | String | Nil)) = [] of Hash(String, Int64 | String | Nil)
+ latest_bookings = Array(Hash(String, Int64 | String | Nil)).from_json(mulesoft_bookings)
+ logger.debug { "#{latest_bookings.size} bookings in total" }
+
+ # determine which bookings are no longer present (note that this includes bookings that still exist in Mulesoft but are no longer inside the query range of Bookings_1)
+ removed_bookings = @bookings - latest_bookings
+
+ # filter out bookings that have already ended (no point in deleting their Calendar event)
+ now = Time.utc.to_unix
+ deleted_bookings = removed_bookings.reject { |b| b["event_end"].not_nil!.to_i64 < now }
+
+ update_events
+
+ # delete the events of any deleted bookings
+ deleted_bookings.each { |b| delete_matching_event(b) }
+
+ # export any new bookings
+ @bookings = latest_bookings
+ @bookings.each { |b| export_booking(b) }
+ end
+ end
+
+ def status
+ {
+ "bookings": @bookings,
+ "events": @existing_events,
+ "deleted_events": @deleted_events,
+ }
+ end
+
+ def update_events
+ logger.debug { "FETCHING existing Calendar events..." }
+ @existing_events = fetch_events()
+ logger.debug { "#{@existing_events.size} events in total" }
+ end
+
+ protected def fetch_events(past_span : Time::Span = 14.days, future_span : Time::Span = 14.days)
+ now = Time.local @time_zone
+ from = now - past_span
+ til = now + future_span
+
+ calendar.list_events(
+ calendar_id: system.email.not_nil!,
+ period_start: from.to_unix,
+ period_end: til.to_unix
+ ).get.as_a
+ end
+
+ protected def export_booking(booking : Hash(String, Int64 | String | Nil))
+ # Mulesoft booking titles are often nil. Use the body instead in this case
+ booking["title"] = booking["body"] if booking["title"].nil?
+ booking["title"] = "#{booking["recurring_master_id"]} #{booking["title"]}"
+ logger.debug { "Checking for existing events that match: #{booking}" }
+
+ unless event_already_exists?(booking, @existing_events)
+ new_event = {
+ title: booking["title"],
+ event_start: booking["event_start"],
+ event_end: booking["event_end"],
+ timezone: @time_zone_string,
+ description: booking["body"].to_s + "\n\nCreated by PlaceOS Mulesoft Bookings API Exporter",
+ user_id: system.email.not_nil!,
+ attendees: [@just_this_system],
+ location: system.name.not_nil!,
+ }
+ logger.debug { ">>> EXPORTING booking #{new_event}" }
+ calendar.create_event(**new_event)
+ end
+ end
+
+ protected def delete_matching_event(booking : Hash(String, Int64 | String | Nil))
+ # Mulesoft booking titles are often nil. Use the body instead in this case
+ booking["title"] = booking["body"] if booking["title"].nil?
+ booking["title"] = "#{booking["recurring_master_id"]} #{booking["title"]}"
+ logger.debug { "Checking for existing events that match DELETED: #{booking}" }
+
+ if event_id = event_already_exists?(booking, @existing_events)
+ logger.debug { ">>> DELETING event #{event_id}" }
+ calendar.delete_event(calendar_id: system.email.not_nil!, event_id: event_id)
+ self[:deleted_events] = @deleted_events += 1
+ else
+ logger.debug { ">>> NO EXISTING events for DELETED booking #{booking["title"]}" }
+ end
+ end
+
+ protected def event_already_exists?(new_event : Hash(String, Int64 | String | Nil), existing_events : Array(JSON::Any))
+ existing_events.each do |existing_event|
+ return existing_event["id"] if events_match?(new_event, existing_event.as_h)
+ end
+ false
+ end
+
+ protected def events_match?(event_a : Hash(String, Int64 | String | Nil), event_b : Hash(String, JSON::Any))
+ event_a.select("event_start", "event_end") == event_b.select("event_start", "event_end")
+ end
+
+ def delete_all_events(past_days : Int32 = 14, future_days : Int32 = 14)
+ events = fetch_events(past_span: past_days.days, future_span: future_days.days)
+ event_ids = events.map { |e| e["id"] }
+ event_ids.each do |event_id|
+ calendar.delete_event(calendar_id: system.email.not_nil!, event_id: event_id)
+ end
+ "Deleted #{event_ids.size} events"
+ end
+end
diff --git a/drivers/mulesoft/models.cr b/drivers/mulesoft/models.cr
new file mode 100644
index 00000000000..b25c66c4027
--- /dev/null
+++ b/drivers/mulesoft/models.cr
@@ -0,0 +1,61 @@
+module MuleSoft
+ class Booking
+ include JSON::Serializable
+
+ @[JSON::Field(key: "unitName")]
+ property title : String?
+
+ @[JSON::Field(key: "activityType")]
+ property body : String
+
+ @[JSON::Field(key: "unitCode")]
+ property recurring_master_id : String?
+
+ @[JSON::Field(key: "startDateTime", converter: MuleSoft::DateTimeConvertor)]
+ property event_start : Int64
+
+ @[JSON::Field(key: "endDateTime", converter: MuleSoft::DateTimeConvertor)]
+ property event_end : Int64
+
+ property location : String
+
+ # we need this method to create an intermediary hash
+ # otherwise when to_json is called all the field names revert to the MuleSoft ones
+ def to_placeos
+ value = {
+ "title" => @title,
+ "body" => @body,
+ "recurring_master_id" => @recurring_master_id,
+ "event_start" => @event_start,
+ "event_end" => @event_end,
+ "location" => @location,
+ }
+ end
+ end
+
+ class BookingResults
+ include JSON::Serializable
+
+ property count : Int64
+
+ @[JSON::Field(key: "venueCode")]
+ property venue_code : String
+
+ @[JSON::Field(key: "venueName")]
+ property venue_name : String
+
+ property bookings : Array(Booking)
+ end
+
+ module DateTimeConvertor
+ extend self
+
+ def to_json(value, json : JSON::Builder)
+ json.string(Time.unix(value).to_local.to_s("%FT%T"))
+ end
+
+ def from_json(pull : JSON::PullParser)
+ Time.parse(pull.read_string, "%FT%T", Time::Location.local).to_unix
+ end
+ end
+end
diff --git a/drivers/nec/display.cr b/drivers/nec/display.cr
new file mode 100644
index 00000000000..77353cf2aba
--- /dev/null
+++ b/drivers/nec/display.cr
@@ -0,0 +1,312 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+class Nec::Display < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::AudioMuteable
+
+ enum Input
+ Vga = 1
+ Rgbhv = 2
+ Dvi = 3
+ HdmiSet = 4
+ Video1 = 5
+ Video2 = 6
+ Svideo = 7
+ Tuner = 9
+ Tv = 10
+ Dvd1 = 12
+ Option = 13
+ Dvd2 = 14
+ DisplayPort = 15
+ Hdmi = 17
+ Hdmi2 = 18
+ Hdmi3 = 130
+ Usb = 135
+ end
+ include PlaceOS::Driver::Interface::InputSelection(Input)
+
+ # Discovery Information
+ tcp_port 7142
+ descriptive_name "NEC Display"
+ generic_name :Display
+
+ DELIMITER = 0x0D_u8
+
+ def on_load
+ # Communication settings
+ queue.delay = 120.milliseconds
+ queue.timeout = 5.seconds
+ transport.tokenizer = Tokenizer.new(Bytes[DELIMITER])
+ end
+
+ def connected
+ schedule.clear
+ schedule.every(50.seconds, true) do
+ do_poll
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ # Do nothing if already in desired state
+ return if self[:power]? == state
+
+ if state
+ logger.debug { "requested to power on" }
+ # 1 = Power On
+ data = MsgType::Command.build(Command::SetPower, 1)
+ send(data, name: "power", delay: 5.seconds)
+ else
+ logger.debug { "requested to power off" }
+ # 4 = Power Off
+ data = MsgType::Command.build(Command::SetPower, 4)
+ send(data, name: "power", delay: 10.seconds, timeout: 10.seconds)
+ end
+ end
+
+ def power?(**options) : Bool
+ data = MsgType::Command.build(Command::PowerQuery)
+ send(data, **options, name: "power?").get
+ self[:power].as_bool
+ end
+
+ def switch_to(input : Input)
+ logger.debug { "requested to switch to: #{input}" }
+ data = MsgType::SetParameter.build(Command::VideoInput, input.value)
+ send(data, name: "input", delay: 6.seconds)
+ end
+
+ enum Audio
+ Audio1 = 1
+ Audio2 = 2
+ Audio3 = 3
+ Hdmi = 4
+ Tv = 6
+ DisplayPort1 = 7
+ DisplayPort2 = 8
+ Hdmi2 = 10
+ Hdmi3 = 11
+ MultiPicture = 13
+ ComputeModule = 14
+ end
+
+ def switch_audio(input : Audio)
+ logger.debug { "requested to switch audio to: #{input}" }
+ data = MsgType::SetParameter.build(Command::AudioInput, input.value)
+ send(data, name: "audio")
+ end
+
+ def auto_adjust
+ data = MsgType::SetParameter.build(Command::AutoSetup, 1)
+ send(data, name: "auto_adjust")
+ end
+
+ def brightness(val : Int32)
+ data = MsgType::SetParameter.build(Command::BrightnessStatus, val.clamp(0, 100))
+ send(data, name: "brightness")
+ send(MsgType::Command.build(Command::Save), name: "save", priority: 0)
+ end
+
+ def contrast(val : Int32)
+ data = MsgType::SetParameter.build(Command::ContrastStatus, val.clamp(0, 100))
+ send(data, name: "contrast")
+ send(MsgType::Command.build(Command::Save), name: "save", priority: 0)
+ end
+
+ def volume(val : Int32 | Float64)
+ val = val.to_f.clamp(0.0, 100.0).round_away.to_i
+ data = MsgType::SetParameter.build(Command::VolumeStatus, val)
+ send(data, name: "volume")
+ send(MsgType::Command.build(Command::Save), name: "save", priority: 0)
+ end
+
+ def volume_up
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume + 5.0)
+ end
+
+ def volume_down
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume - 5.0)
+ end
+
+ def mute_audio(state : Bool = true, index : Int32 | String = 0)
+ logger.debug { "requested to update mute to #{state}" }
+ data = MsgType::SetParameter.build(Command::MuteStatus, state ? 1 : 0)
+ resp = send(data, name: "mute_audio")
+
+ resp
+ end
+
+ def do_poll
+ current_power = power?(priority: 0)
+ logger.debug { "Polling, power = #{current_power}" }
+
+ if current_power
+ mute_status
+ video_input
+ end
+ end
+
+ def received(data, task)
+ logger.debug { "NEC sent: 0x#{data.hexstring}" }
+
+ header = data[0..6]
+ message = data[7..-3]
+ checksum = data[-2]
+
+ # checksum is often incorrect so we'll just ignore it
+ # unless checksum == data[1..-3].reduce { |a, b| a ^ b }
+ # return task.try &.retry("invalid checksum in device response")
+ # end
+
+ begin
+ case MsgType.from_value header[4]
+ when .command_reply?
+ parse_command_reply message
+ when .get_parameter_reply?, .set_parameter_reply?
+ parse_response message
+ else
+ raise "unknown message type"
+ end
+ rescue e
+ logger.warn(exception: e) { "processing response" }
+ task.try &.abort e.message
+ else
+ task.try &.success
+ end
+ end
+
+ # Command replies each use a different packet structure
+ private def parse_command_reply(message : Bytes)
+ # Don't do any processing if this is the response for the save command
+ string = String.new(message[1..-2])
+ return if {"000C", "00C"}.includes?(string)
+ response = string.hexbytes
+
+ if response[1..3] == Bytes[0xC2, 0x03, 0xD6] # Set power
+ result_code = response[0]
+ raise "unsupported operation" unless result_code == 0
+ self[:power] = response[5] == 1
+ elsif response[2..3] == Bytes[0xD6, 0x00] # Power query
+ result_code = response[1]
+ raise "unsupported operation" unless result_code == 0
+ self[:power] = response[7] == 1
+ else
+ logger.warn { "unhandled command reply: #{message}" }
+ end
+ end
+
+ @audio_mute : Bool = false
+
+ # Get and set parameter replies share common structure
+ private def parse_response(message : Bytes)
+ response = String.new(message[1..-2]).hexbytes
+
+ result_code = response[0]
+ raise "unsupported operation" unless result_code == 0
+
+ op_code = response[1].to_u16 << 8 | response[2]
+ value = response[6].to_u16 << 8 | response[7]
+
+ case Command.from_value op_code
+ when .video_input?
+ self[:input] = Input.from_value(value)
+ when .audio_input?
+ self[:audio] = Audio.from_value(value)
+ when .volume_status?
+ return if @audio_mute
+ self[:volume] = value
+ self[:audio_mute] = value == 0
+ when .brightness_status?
+ self[:brightness] = value
+ when .contrast_status?
+ self[:contrast] = value
+ when .mute_status?
+ self[:audio_mute] = @audio_mute = value == 1
+ if value == 1
+ self[:volume] = 0
+ else
+ volume_status
+ end
+ when .auto_setup?
+ # auto_setup
+ # nothing needed to do here (we are delaying the next command by 4 seconds)
+ else
+ logger.warn { "unhandled device response: #{message}" }
+ end
+ end
+
+ enum Command
+ VideoInput = 0x0060
+ AudioInput = 0x022E
+ VolumeStatus = 0x0062
+ MuteStatus = 0x008D
+ PowerOnDelay = 0x02D8
+ ContrastStatus = 0x0012
+ BrightnessStatus = 0x0010
+ AutoSetup = 0x001E
+ PowerQuery = 0x01D6
+ Save = 0x0C
+ SetPower = 0xC203D6
+
+ def to_s : String
+ case self
+ when .save?
+ length = 2
+ when .set_power?
+ length = 6
+ else
+ length = 4
+ end
+ value.to_s(16, upcase: true).rjust(length, '0')
+ end
+ end
+
+ {% for name in Command.constants %}
+ @[Security(Level::Administrator)]
+ def {{name.id.underscore}}(priority : Int32 = 0)
+ send(MsgType::GetParameter.build(Command::{{name.id}}), priority: priority, name: {{name.id.underscore.stringify}})
+ end
+ {% end %}
+
+ # Types of messages sent to and from the LCD
+ enum MsgType : UInt8
+ Command = 0x41 # 'A'
+ CommandReply = 0x42 # 'B'
+ GetParameter = 0x43 # 'C'
+ GetParameterReply = 0x44 # 'D'
+ SetParameter = 0x45 # 'E'
+ SetParameterReply = 0x46 # 'F'
+
+ def build(command : Nec::Display::Command, data : Int? = nil)
+ command = command.to_s
+
+ message = String.build do |str|
+ str << "0*0"
+ str.write_byte self.value # Type
+
+ message_length = command.size + 2
+ message_length += 4 if data # If there is data, add 4 to the message length
+ str << message_length.to_s(16, upcase: true).rjust(2, '0') # Message length
+ str.write_byte 0x02 # Start of messsage
+ str << command # Message
+ str << data.to_s(16, upcase: true).rjust(4, '0') if data # Data if required
+ str.write_byte 0x03 # End of message
+ end
+
+ String.build do |str|
+ str.write_byte 0x01 # SOH
+ str << message # Message
+ str.write_byte message.each_byte.reduce { |a, b| a ^ b } # Checksum
+ str.write_byte DELIMITER # Delimiter
+ end
+ end
+ end
+end
diff --git a/drivers/nec/display_spec.cr b/drivers/nec/display_spec.cr
new file mode 100644
index 00000000000..6a205b296e4
--- /dev/null
+++ b/drivers/nec/display_spec.cr
@@ -0,0 +1,73 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Nec::Display" do
+ # do_poll
+ # power?
+ should_send("\x010*0A06\x0201D6\x03\x1F\x0D")
+ responds("\x0100*B12\x020200D60000040001\x03\x1F\x0D")
+ status[:power].should eq(true)
+ # mute_status
+ should_send("\x010*0C06\x02008D\x03\x12\x0D")
+ responds("\x0100*D12\x0200008D0000000002\x03\x12\x0D")
+ status[:audio_mute].should eq(false)
+ # video_input
+ should_send("\x010*0C06\x020060\x03\x68\x0D")
+ responds("\x0100*D12\x020000600000000011\x03\x6A\x0D")
+ status[:input].should eq("Hdmi")
+ # volume_status
+ should_send("\x010*0C06\x020062\x03\x6A\x0D")
+ responds("\x0100*D12\x020000620000000032\x03\x69\x0D")
+ status[:volume].should eq(50)
+
+ exec(:mute_audio)
+ should_send("\x010*0E0A\x02008D0001\x03\x62\x0D")
+ responds("\x0100*F12\x0200008D0000000001\x03\x13\x0D")
+ status[:audio_mute].should eq(true)
+ status[:volume].should eq(0)
+
+ exec(:unmute_audio)
+ should_send("\x010*0E0A\x02008D0000\x03\x63\x0D")
+ responds("\x0100*F12\x0200008D0000000000\x03\x12\x0D")
+ status[:audio_mute].should eq(false)
+
+ # volume_status
+ should_send("\x010*0C06\x020062\x03\x6A\x0D")
+ responds("\x0100*D12\x020000620000000032\x03\x69\x0D")
+ status[:volume].should eq(50)
+
+ exec(:volume, 25)
+ should_send("\x010*0E0A\x0200620019\x03\x13\x0D")
+ responds("\x0100*F12\x020000620000640019\x03\x60\x0D")
+ should_send("\x010*0A04\x020C\x03\x1D\x0D")
+ responds("\x0100*B06\x0200C\x03\x2C\x0D")
+ status[:audio_mute].should eq(false)
+ status[:volume].should eq(25)
+
+ exec(:brightness_status)
+ should_send("\x010*0C06\x020010\x03\x6F\x0D")
+ responds("\x0100*D12\x020000100000000000\x03\x6D\x0D")
+ status[:brightness].should eq(0)
+
+ exec(:brightness, 100)
+ should_send("\x010*0E0A\x0200100064\x03\x1C\x0D")
+ responds("\x0100*F12\x020000100000640064\x03\x6F\x0D")
+ should_send("\x010*0A04\x020C\x03\x1D\x0D")
+ responds("\x0100*B06\x0200C\x03\x2C\x0D")
+ status[:brightness].should eq(100)
+
+ exec(:switch_to, "tv")
+ should_send("\x010*0E0A\x020060000A\x03\x68\x0D")
+ responds("\x0100*F12\x02000060000000000A\x03\x19\x0D")
+ status[:input].should eq("Tv")
+
+ exec(:switch_audio, "audio_2")
+ sleep 6 # since switch_to has 6 seconds of delay
+ should_send("\x010*0E0A\x02022E0002\x03\x68\x0D")
+ responds("\x0100*F12\x0200022E0000000002\x03\x19\x0D")
+ status[:audio].should eq("Audio2")
+
+ exec(:power, false)
+ should_send("\x010*0A0C\x02C203D60004\x03\x1D\x0D")
+ responds("\x0100*B0E\x0200C203D60004\x03\x18\x0D")
+ status[:power].should eq(false)
+end
diff --git a/drivers/nec/np_series.cr b/drivers/nec/np_series.cr
new file mode 100644
index 00000000000..e349bbbf545
--- /dev/null
+++ b/drivers/nec/np_series.cr
@@ -0,0 +1,482 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+class Nec::Projector < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Input
+ VGA = 0x01
+ RGBHV = 0x02
+ Composite = 0x06
+ SVideo = 0x0B
+ Component = 0x10
+ Component2 = 0x11
+ HDMI = 0x1A
+ HDMI2 = 0x1B
+ DisplayPort = 0xA6
+ LAN = 0x20
+ Viewer = 0x1F
+ end
+
+ include Interface::InputSelection(Input)
+
+ # Discovery Information
+ tcp_port 7142
+ descriptive_name "NEC Projector"
+ generic_name :Display
+
+ default_settings({
+ volume_min: 0,
+ volume_max: 63,
+ })
+
+ @power_target : Bool? = nil
+ @input_target : Input? = nil
+ @volume_min : Int32 = 0
+ @volume_max : Int32 = 63
+
+ def on_load
+ # Communication settings
+ queue.delay = 100.milliseconds
+ self[:error] = [] of String
+ on_update
+ end
+
+ def on_update
+ @power_target = nil
+ @input_target = nil
+ @volume_min = setting(Int32, :volume_min)
+ @volume_max = setting(Int32, :volume_max)
+ end
+
+ def connected
+ schedule.every(50.seconds, true) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ # Disconnect often occurs on power off
+ # We may have not received a status response before the disconnect occurs
+ self[:power] = false
+ end
+
+ # Command Listing
+ # Second byte used to detect command type
+ COMMAND = {
+ # Mute controls
+ mute_picture: Bytes[0x02, 0x10, 0x00, 0x00, 0x00, 0x12],
+ unmute_picture: Bytes[0x02, 0x11, 0x00, 0x00, 0x00, 0x13],
+ mute_audio_cmd: Bytes[0x02, 0x12, 0x00, 0x00, 0x00, 0x14],
+ unmute_audio_cmd: Bytes[0x02, 0x13, 0x00, 0x00, 0x00, 0x15],
+ mute_onscreen: Bytes[0x02, 0x14, 0x00, 0x00, 0x00, 0x16],
+ unmute_onscreen: Bytes[0x02, 0x15, 0x00, 0x00, 0x00, 0x17],
+
+ freeze_picture: Bytes[0x01, 0x98, 0x00, 0x00, 0x01, 0x01],
+ unfreeze_picture: Bytes[0x01, 0x98, 0x00, 0x00, 0x01, 0x02],
+
+ lamp?: Bytes[0x00, 0x81, 0x00, 0x00, 0x00, 0x81], # Running sense (ret 81)
+ input?: Bytes[0x00, 0x85, 0x00, 0x00, 0x01, 0x02], # Input status (ret 85)
+ mute?: Bytes[0x00, 0x85, 0x00, 0x00, 0x01, 0x03], # MUTE STATUS REQUEST (Check 10H on byte 5)
+ error?: Bytes[0x00, 0x88, 0x00, 0x00, 0x00, 0x88], # ERROR STATUS REQUEST (ret 88)
+ model?: Bytes[0x00, 0x85, 0x00, 0x00, 0x01, 0x04], # Request model name (both of these are related)
+
+ # lamp hours / remaining info
+ lamp_info: Bytes[0x03, 0x8A, 0x00, 0x00, 0x00, 0x8D], # LAMP INFORMATION REQUEST
+ filter_info: Bytes[0x03, 0x8A, 0x00, 0x00, 0x00, 0x8D],
+ projector_info: Bytes[0x03, 0x8A, 0x00, 0x00, 0x00, 0x8D],
+
+ # TODO: figure out where these are in the docs as they conflict with audio_switch
+ background_black: Bytes[0x03, 0xB1, 0x00, 0x00, 0x02, 0x0B, 0x01], # set mute to be a black screen
+ background_blue: Bytes[0x03, 0xB1, 0x00, 0x00, 0x02, 0x0B, 0x00], # set mute to be a blue screen
+ background_logo: Bytes[0x03, 0xB1, 0x00, 0x00, 0x02, 0x0B, 0x02], # set mute to be the company logo
+ }
+
+ {% for name, data in COMMAND %}
+ def {{name.id}}(**options)
+ do_send(COMMAND[{{name.id.stringify}}], **options, name: {{name.id.stringify}})
+ end
+ {% end %}
+
+ def volume(vol : Int32 | Float64)
+ vol = vol.to_f.clamp(0.0, 100.0)
+ percentage = vol / 100.0
+ vol_actual = (percentage * @volume_max.to_f).round_away.to_i
+
+ # volume base command D1 D2 D3 D4 D5
+ command = Bytes[0x03, 0x10, 0x00, 0x00, 0x05, 0x05, 0x00, 0x00, vol, 0x00]
+ # D3 = 00 (absolute vol) or 01 (relative vol)
+ # D4 = value (lower bits 0 to 63)
+ # D5 = value (higher bits always 00h)
+
+ do_send(command)
+ end
+
+ def volume_up
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume + 5.0)
+ end
+
+ def volume_down
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume - 5.0)
+ end
+
+ # Mutes both audio/video
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ if layer.video? || layer.audio_video?
+ if state
+ mute_picture
+ mute_onscreen
+ else
+ unmute_picture
+ end
+ end
+
+ if layer.audio? || layer.audio_video?
+ state ? mute_audio_cmd : unmute_audio_cmd
+ end
+ end
+
+ def switch_to(input : Input)
+ logger.debug { "-- NEC projector, requested to switch to: #{input}" }
+ @input_target = input
+ command = Bytes[0x02, 0x03, 0x00, 0x00, 0x02, 0x01, input.value]
+ do_send(command, name: "input")
+ end
+
+ enum Audio
+ HDMI
+ VGA # Computer in docs
+ end
+
+ def switch_audio(input : Audio)
+ # C0 == HDMI Audio
+ command = Bytes[0x03, 0xB1, 0x00, 0x00, 0x02, 0xC0, input.value]
+ do_send(command, name: "switch_audio")
+ end
+
+ def power(state : Bool)
+ @power_target = state
+
+ if state
+ command = Bytes[0x02, 0x00, 0x00, 0x00, 0x00]
+ do_send(command, name: "power", timeout: 15.seconds, delay: 1.second)
+ else
+ command = Bytes[0x02, 0x01, 0x00, 0x00, 0x00]
+ # Jump ahead of any other queued commands as they are no longer important
+ do_send(
+ command,
+ name: "power",
+ timeout: 60.seconds, # don't want retries occuring very fast
+ delay: 30.seconds,
+ clear_queue: true,
+ priority: 100,
+ )
+ end
+ end
+
+ def power?(**options) : Bool
+ do_send(COMMAND[:lamp?], **options, name: "power?").get
+ !!self[:power]?.try(&.as_bool)
+ end
+
+ def switch_to(input : Input)
+ @input_target = input
+ command = Bytes[0x02, 0x03, 0x00, 0x00, 0x02, 0x01, input.value]
+ do_send(command, name: "input")
+ end
+
+ def do_poll
+ if power?(priority: 0)
+ mute?(priority: 0)
+ background_black(priority: 0)
+ lamp_info(priority: 0)
+ end
+ end
+
+ private def checksum_valid?(data : Bytes)
+ checksum = data[0..-2].sum(0) & 0xFF
+ logger.debug { "Error: checksum should be 0x#{checksum.to_s(16, upcase: true)}" } unless result = checksum == data[-1]
+ result
+ end
+
+ private def do_send(command : Bytes, **options)
+ req = Bytes.new(command.size + 1)
+ req.copy_from(command)
+ req[-1] = (command.sum(0) & 0xFF).to_u8
+ logger.debug { "Nec proj sending 0x#{req.hexstring}" }
+ send(req, **options) { |data, task| process_response(data, task, req) }
+ end
+
+ # TODO: add responses for freeze commands if we need to process them
+ enum Response : UInt16
+ Power = 8321 # [0x20,0x81]
+ InputOrMuteQuery = 8325 # [0x20,0x85]
+ Error = 8328 # [0x20,0x88]
+ InputSwitch = 8707 # [0x22,0x03]
+ Lamp = 8704 # [0x22,0x00]
+ Lamp2 = 8705 # [0x22,0x01]
+ PictureMuteOn = 8720 # [0x22,0x10]
+ PictureMuteOff = 8721 # [0x22,0x11]
+ AudioMuteOn = 8722 # [0x22,0x12]
+ AudioMuteOff = 8723 # [0x22,0x13]
+ OnscreenMuteOn = 8724 # [0x22,0x14]
+ OnscreenMuteOff = 8725 # [0x22,0x15]
+ VolumeOrImageAdjust = 8976 # [0x23,0x10]
+ Info = 9098 # [0x23,0x8A]
+ AudioSwitch = 9137 # [0x23,0xB1]
+
+ def self.from_bytes?(response)
+ value = IO::Memory.new(response[0..1]).read_bytes(UInt16, IO::ByteFormat::BigEndian)
+ Response.from_value?(value)
+ end
+ end
+
+ private def process_response(data, task, req = nil)
+ logger.debug { "NEC projector sent: 0x#{data.hexstring}" }
+
+ # Command failed
+ if (data[0] & 0xA0) == 0xA0
+ # We were changing power state at time of failure we should keep trying
+ if req && (0..1).includes?(req[1])
+ # command[:delay_on_receive] = 6000
+ power?
+ return task.try(&.success)
+ end
+ return task.try(&.abort("-- NEC projector, sent fail code for command: 0x#{req.try(&.hexstring) || "unknown"}"))
+ end
+
+ # Verify checksum
+ unless checksum_valid?(data)
+ return task.try(&.abort("-- NEC projector, checksum failed for command: 0x#{req.try(&.hexstring) || "unknown"}"))
+ end
+
+ # Only process response if successful
+ # Otherwise return success to prevent retries on commands we were not expecting
+ unless resp = Response.from_bytes?(data)
+ return task.try(&.success("-- NEC projector, no status updates defined for response for command: 0x#{req.try(&.hexstring) || "unknown"}"))
+ end
+
+ case resp
+ when .power?
+ process_power_status(data)
+ when .input_or_mute_query?
+ # Return if we can't work out what was requested initially
+ return task.try(&.success) unless req && (2..3).includes?(req[-2])
+ process_input_state(data) if req[-2] == 2
+ process_mute_state(data) if req[-2] == 3
+ when .error?
+ process_error_status(data)
+ when .input_switch?
+ return process_input_switch(data, task, req)
+ when .lamp?, .lamp2?
+ process_lamp_command(data, req)
+ when .picture_mute_on?, .picture_mute_off?
+ self[:mute] = self[:picture_mute] = resp.picture_mute_on?
+ when .audio_mute_on?, .audio_mute_off?
+ self[:audio_mute] = resp.audio_mute_on?
+ when .onscreen_mute_on?, .onscreen_mute_off?
+ self[:onscreen_mute] = resp.onscreen_mute_on?
+ when .volume_or_image_adjust?
+ if req && data[-3] == 5 && data[-2] == 0
+ vol_percent = (req[-3].to_f / @volume_max.to_f) * 100.0
+ self[:volume] = vol_percent
+ end
+ # We don't care about image adjust
+ when .info?
+ process_projector_info(data)
+ when .audio_switch? # TODO: also seems to the seem as setting background response
+ self[:audio_input] = Audio.from_value(data[-2]) if data[-3] == 0xC0
+ end
+
+ task.try(&.success)
+ end
+
+ def received(data, task)
+ process_response(data, task)
+ end
+
+ # Process the lamp status response
+ # Intimately entwined with the power power command
+ # (as we need to control ensure we are in the correct target state)
+ private def process_power_status(data)
+ logger.debug { "-- NEC projector sent a response to a power status command" }
+
+ self[:power] = (data[-2] & 0b10) > 0
+
+ # Projector cooling || power on off processing
+ if (data[-2] & 0b100000) > 0 || (data[-2] & 0b10000000) > 0
+ if @power_target
+ self[:cooling] = false
+ self[:warming] = true
+ logger.debug { "power warming..." }
+ else
+ self[:warming] = false
+ self[:cooling] = true
+ logger.debug { "power cooling..." }
+ end
+
+ schedule.in(3.seconds) { power? }
+ # Signal processing
+ elsif (data[-2] & 0b1000000) > 0
+ schedule.in(3.seconds) { power? }
+ else # We are in a stable state!
+ if power_target = @power_target
+ if self[:power] == power_target
+ @power_target = nil
+ else # We are in an undesirable state and will try to correct it
+ logger.debug { "NEC projector in an undesirable power state... (Correcting)" }
+ power(power_target)
+ end
+ else
+ logger.debug { "NEC projector is in a good power state..." }
+ self[:warming] = self[:cooling] = false
+ # Ensure the input is in the correct state if power/lamp is on
+ input? if self[:power].as_bool # Calls status mute
+ end
+ end
+
+ logger.debug { "Current state {power: #{self[:power]}, warming: #{self[:warming]}, cooling: #{self[:cooling]}}" }
+ end
+
+ # NEC has different values for the input status when compared to input selection
+ INPUT_MAP = {
+ 0x01 => {
+ 0x01 => Input::VGA,
+ 0x02 => Input::Composite,
+ 0x03 => Input::SVideo,
+ 0x06 => Input::HDMI,
+ 0x07 => Input::Viewer,
+ 0x21 => Input::HDMI,
+ 0x22 => Input::DisplayPort,
+ },
+ 0x02 => {
+ 0x01 => Input::RGBHV,
+ 0x04 => Input::Component2,
+ 0x06 => Input::HDMI2,
+ 0x07 => Input::LAN,
+ 0x21 => Input::HDMI2,
+ },
+ 0x03 => {
+ 0x04 => Input::Component,
+ },
+ }
+
+ private def process_input_state(data)
+ return unless self[:power]?.try(&.as_bool) && (first = INPUT_MAP[data[-15]])
+
+ logger.debug { "-- NEC projector sent a response to an input state command" }
+
+ self[:input] = current_input = first[data[-14]] || "unknown"
+ if data[-17] == 0x01
+ # TODO: figure out how to write in crystal and if needed
+ # command[:delay_on_receive] = 3000 # still processing signal
+ input?
+ else # TODO: figure out if this is needed from old ruby driver
+ # mute? # get mute status one signal has settled
+ end
+
+ logger.debug { "The input selected was: #{current_input}" }
+
+ # Notify of bad input selection for debugging
+ # We ensure at the very least power state and input are always correct
+ if (input_target = @input_target)
+ # If we have reached the input_target, clear @input_target so input can be set again
+ if current_input == input_target
+ @input_target = nil
+ else
+ logger.debug { "-- NEC input state may not be correct, desired: #{input_target} current: #{current_input}" }
+ switch_to(input_target)
+ end
+ end
+ end
+
+ private def process_mute_state(data)
+ logger.debug { "-- NEC projector responded to mute state command" }
+ self[:mute] = self[:picture_mute] = data[-17] == 0x01
+ self[:audio_mute] = data[-16] == 0x01
+ self[:onscreen_mute] = data[-15] == 0x01
+ end
+
+ private def process_input_switch(data, task, req)
+ logger.debug { "-- NEC projector responded to switch input command" }
+ if data[-2] != 0xFF
+ input? # Double check with a status update
+ return task.try(&.success)
+ end
+ task.try(&.retry("-- NEC projector failed to switch input with command: #{req.try(&.hexstring) || "unknown"}"))
+ end
+
+ private def process_lamp_command(data, req)
+ logger.debug { "-- NEC projector sent a response to a power command" }
+ # Ensure a change of power state was the last command sent
+ if req && (0..1).includes?(req[1])
+ power? # Queues the status power command
+ end
+ end
+
+ # Provide all the error info required
+ ERROR_CODES = [{
+ 0b1 => "Lamp cover error",
+ 0b10 => "Temperature error (Bimetal)",
+ # 0b100 => not used
+ 0b1000 => "Fan Error",
+ 0b10000 => "Fan Error",
+ 0b100000 => "Power Error",
+ 0b1000000 => "Lamp Error",
+ 0b10000000 => "Lamp has reached its end of life",
+ }, {
+ 0b1 => "Lamp has been used beyond its limit",
+ 0b10 => "Formatter error",
+ 0b100 => "Lamp no.2 Error",
+ }, {
+ # 0b1 => "not used"
+ 0b10 => "FPGA error",
+ 0b100 => "Temperature error (Sensor)",
+ 0b1000 => "Lamp housing error",
+ 0b10000 => "Lamp data error",
+ 0b100000 => "Mirror cover error",
+ 0b1000000 => "Lamp no.2 has reached its end of life",
+ 0b10000000 => "Lamp no.2 has been used beyond its limit",
+ }, {
+ 0b1 => "Lamp no.2 housing error",
+ 0b10 => "Lamp no.2 data error",
+ 0b100 => "High temperature due to dust pile-up",
+ 0b1000 => "A foreign object sensor error",
+ }]
+
+ private def process_error_status(data)
+ logger.debug { "-- NEC projector sent a response to an error status command" }
+ errors = [] of String
+ # Run through each byte
+ data[5..8].each_with_index do |byte, byte_no|
+ # If there is an error
+ if byte > 0
+ # Go through each individual bit
+ ERROR_CODES[byte_no].each_key do |bit_check|
+ # Add the error if the bit corresponding to it is set
+ errors.push(ERROR_CODES[byte_no][bit_check]) if (bit_check & byte) > 0
+ end
+ end
+ end
+ self[:error] = errors
+ end
+
+ private def process_projector_info(data)
+ logger.debug { "-- NEC projector sent a response to a projector info command" }
+ # Calculate lamp/filter usage in seconds
+ lamp = data[87..90].each_with_index.sum { |byte, index| byte.to_i << (index * 8) }
+ filter = data[91..94].each_with_index.sum { |byte, index| byte.to_i << (index * 8) }
+ # Convert seconds to hours
+ self[:lamp_usage] = lamp / 3600
+ self[:filter_usage] = filter / 3600
+ logger.debug { "lamp usage is #{self[:lamp_usage]} hours, filter usage is #{self[:filter_usage]} hours" }
+ end
+end
diff --git a/drivers/nec/np_series_spec.cr b/drivers/nec/np_series_spec.cr
new file mode 100644
index 00000000000..c9f3dba5cd4
--- /dev/null
+++ b/drivers/nec/np_series_spec.cr
@@ -0,0 +1,85 @@
+require "placeos-driver/spec"
+
+# NOTES
+# (*1) Projector ID
+# (*2) Model code: "xxH" inscription
+# (*3) Checksum: "CKS" inscription
+# (*4) Response error number
+# (*5) Term “RGB” and “COMPUTER”
+# (*6) Term “DVI” and “COMPUTER”
+
+DriverSpecs.mock_driver "Nec::Projector" do
+ p_id = 0x00_u8 # Projector ID
+ mdlc = 0x10_u8 # Model code
+
+ # do_poll
+ # power?
+ should_send(Bytes[0x00, 0x81, 0x00, 0x00, 0x00, 0x81, 0x02])
+ responds(Bytes[0x20, 0x81, p_id, mdlc, 0x10, 0b0000_0010, 0xC3])
+ status[:power].should eq(true)
+ # input?
+ should_send(Bytes[0x00, 0x85, 0x00, 0x00, 0x01, 0x02, 0x88])
+ responds(Bytes[0x20, 0x85, p_id, mdlc, 0x10,
+ # Data, simplified for sanity
+ # We only care about the ones with 0x
+ # -17 -15 -14
+ 0x00, 2, 0x01, 0x06, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
+ 0x4C]) # Checksum
+ status[:input].should eq("HDMI")
+ # mute?
+ should_send(Bytes[0x00, 0x85, 0x00, 0x00, 0x01, 0x03, 0x89])
+ responds(Bytes[0x20, 0x85, p_id, mdlc, 0x10,
+ # -17 -16 -15
+ 0x00, 0x00, 0x00, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
+ 0x47]) # Checksum
+ status[:mute].should eq(false)
+ status[:picture_mute].should eq(false)
+ status[:audio_mute].should eq(false)
+ status[:onscreen_mute].should eq(false)
+ # background_black
+ should_send(Bytes[0x03, 0xB1, 0x00, 0x00, 0x02, 0x0B, 0x01, 0xC2])
+ responds(Bytes[0x23, 0xB1, p_id, mdlc, 0x02, 0x0B, 0xF1])
+ # lamp_info
+ should_send(Bytes[0x03, 0x8A, 0x00, 0x00, 0x00, 0x8D, 0x1A])
+ # 5 for header, 1 for checksum and 98 for data
+ response = Bytes.new(104)
+ response.copy_from(Bytes[0x23, 0x8A, p_id, mdlc, 0x62, 0x0B]) # header
+ # data
+ # lamp usage
+ response[87] = 0xC0
+ response[88] = 0x65
+ response[89] = 0x52
+ # filter usage
+ response[92] = 0xE4
+ response[93] = 0x57
+ # checksum
+ response[-1] = 0xDC
+ responds(response)
+ status[:lamp_usage].should eq(1500)
+ status[:filter_usage].should eq(1600)
+
+ exec(:volume, 100)
+ should_send(Bytes[0x03, 0x10, 0x00, 0x00, 0x05, 0x05, 0x00, 0x00, 0x3F, 0x00, 0x5C])
+ responds(Bytes[0x23, 0x10, p_id, mdlc, 0x05, 0x00, 0x48])
+ status[:volume].should eq(63)
+
+ exec(:mute)
+ # mute_picture
+ should_send(Bytes[0x02, 0x10, 0x00, 0x00, 0x00, 0x12, 0x24])
+ responds(Bytes[0x22, 0x10, p_id, mdlc, 0x32, 0x00, 0x74])
+ status[:mute] = true
+ status[:picture_mute] = true
+ # mute_onscreen
+ should_send(Bytes[0x02, 0x14, 0x00, 0x00, 0x00, 0x16, 0x2C])
+ responds(Bytes[0x22, 0x14, p_id, mdlc, 0x00, 0x46])
+ status[:onscreen_mute] = true
+ # mute_audio
+ should_send(Bytes[0x02, 0x12, 0x00, 0x00, 0x00, 0x14, 0x28])
+ responds(Bytes[0x22, 0x12, p_id, mdlc, 0x00, 0x44])
+ status[:audio_mute] = true
+
+ exec(:switch_audio, "VGA")
+ should_send(Bytes[0x03, 0xB1, 0x00, 0x00, 0x02, 0xC0, 0x01, 0x77])
+ responds(Bytes[0x23, 0xB1, p_id, mdlc, 0xC0, 0x01, 0xA5])
+ status[:audio_input].should eq("VGA")
+end
diff --git a/drivers/office_rnd/models.cr b/drivers/office_rnd/models.cr
new file mode 100644
index 00000000000..f6943dffc4d
--- /dev/null
+++ b/drivers/office_rnd/models.cr
@@ -0,0 +1,202 @@
+require "json"
+
+# OfficeRnD Data Models
+module OfficeRnd
+ abstract struct Data
+ include JSON::Serializable
+ end
+
+ struct TokenResponse < Data
+ include JSON::Serializable
+ property access_token : String
+ property token_type : String
+ property expires_in : Int32
+ property scope : String
+ end
+
+ struct Office < Data
+ @[JSON::Field(key: "_id")]
+ getter id : String
+ getter name : String
+ getter country : String?
+ getter state : String?
+ getter city : String?
+ getter address : String?
+ getter timezone : String?
+ getter image : String?
+ @[JSON::Field(key: "isOpen")]
+ getter is_open : Bool?
+ end
+
+ struct BookingTime < Data
+ @[JSON::Field(key: "dateTime")]
+ getter time : Time
+
+ def initialize(@time : Time); end
+ end
+
+ struct Fee < Data
+ getter name : String
+ getter price : Int32
+ getter quantity : Int32 = 1
+ getter date : Time
+ @[JSON::Field(key: "team")]
+ getter team_id : String?
+ @[JSON::Field(key: "office")]
+ getter office_id : String
+ @[JSON::Field(key: "member")]
+ getter member_id : String?
+ @[JSON::Field(key: "plan")]
+ getter plan_id : String?
+ getter refundable : Bool?
+ @[JSON::Field(key: "billInAdvance")]
+ getter bill_in_advance : Bool?
+ @[JSON::Field(key: "isPersonal")]
+ getter is_personal : Bool?
+ end
+
+ struct BookingFee < Data
+ getter date : Time
+ getter fee : Fee?
+ @[JSON::Field(key: "extraFees")]
+ getter extra_fees : Array(JSON::Any?)
+ getter credits : Array(Credit)
+ end
+
+ struct Booking < Data
+ @[JSON::Field(key: "start")]
+ getter booking_start : BookingTime
+ @[JSON::Field(key: "end")]
+ getter booking_end : BookingTime
+ getter timezone : String = "Australia/Sydney"
+ getter source : String?
+ getter summary : String?
+ @[JSON::Field(key: "resourceId")]
+ getter resource_id : String
+ @[JSON::Field(key: "plan")]
+ getter plan_id : String = ""
+ @[JSON::Field(key: "team")]
+ getter team_id : String?
+ @[JSON::Field(key: "member")]
+ getter member_id : String?
+ getter description : String?
+ getter tentative : Bool?
+ getter free : Bool?
+ getter fees : Array(::OfficeRnd::BookingFee) = [] of ::OfficeRnd::BookingFee
+ getter extras : JSON::Any = JSON::Any.new("")
+
+ def initialize(
+ @resource_id : String,
+ booking_start : Time,
+ booking_end : Time,
+ @summary : String? = nil,
+ @team_id : String? = nil,
+ @member_id : String? = nil,
+ @description : String? = nil,
+ @tentative : Bool? = nil,
+ @free : Bool? = nil
+ )
+ unless @member_id || @team_id
+ raise "Booking requires at least one of team_id or member_id"
+ end
+ @booking_start = BookingTime.new(booking_start)
+ @booking_end = BookingTime.new(booking_end)
+ end
+
+ def overlaps?(time_span : Range(Time, Time))
+ starting, ending = booking_start.time, booking_end.time
+ within = time_span.includes?(starting) || time_span.includes?(ending)
+ covers = starting < time_span.begin && ending > time_span.end
+
+ within || covers
+ end
+ end
+
+ struct Credit < Data
+ getter count : Int32
+ getter credit : String
+ end
+
+ struct Rate < Data
+ @[JSON::Field(key: "_id")]
+ getter id : String
+ getter name : String
+ getter price : Int32
+ @[JSON::Field(key: "cancellationPolicy")]
+ getter cancellation_policy : CancellationPolicy
+ getter extras : Array(Extra)
+ @[JSON::Field(key: "maxDuration")]
+ getter max_duration : Int32
+
+ struct CancellationPolicy < Data
+ @[JSON::Field(key: "minimumPeriod")]
+ property minimum_period : Int32
+ end
+
+ struct Extra < Data
+ @[JSON::Field(key: "_id")]
+ getter id : String
+ getter name : String
+ getter price : Int32
+ end
+ end
+
+ struct Resource < Data
+ getter name : String
+ @[JSON::Field(key: "rate")]
+ getter rate_id : String?
+ @[JSON::Field(key: "office")]
+ getter office_id : String
+ @[JSON::Field(key: "room")]
+ getter floor_id : String
+ getter type : Type
+
+ MAPPING = {
+ Type::MeetingRoom => "meeting_room",
+ Type::PrivateOffices => "team_room",
+ Type::PrivateOfficeDesk => "desk_tr",
+ Type::DedicatedDesks => "desk",
+ Type::HotDesks => "hotdesk",
+ }
+
+ enum Type
+ MeetingRoom
+ PrivateOffices
+ PrivateOfficeDesk
+ DedicatedDesks
+ HotDesks
+
+ def to_s
+ Resource::MAPPING[self]
+ end
+
+ def to_json(json : JSON::Builder)
+ json.string(self.to_s)
+ end
+
+ def self.parse(type : String)
+ parsed = Resource::MAPPING.key_for?(type)
+ raise ArgumentError.new("Unrecognised Resource::Type '#{type}'") unless parsed
+ parsed
+ end
+
+ def self.valid?(type : String)
+ !!(Resource::MAPPING.key_for?(type))
+ end
+ end
+ end
+
+ struct Floor < Data
+ @[JSON::Field(key: "_id")]
+ getter id : String
+ getter floor : String?
+ getter name : String
+ @[JSON::Field(key: "office")]
+ getter office_id : String
+ getter area : Int32?
+ @[JSON::Field(key: "isOpen")]
+ getter is_open : Bool?
+ @[JSON::Field(key: "targetRevenue")]
+ getter target_revenue : Int32?
+ end
+end
diff --git a/drivers/office_rnd/office_rnd_api.cr b/drivers/office_rnd/office_rnd_api.cr
new file mode 100644
index 00000000000..7a528fa657e
--- /dev/null
+++ b/drivers/office_rnd/office_rnd_api.cr
@@ -0,0 +1,275 @@
+require "uri"
+require "uuid"
+require "placeos-driver"
+require "./models"
+
+module OfficeRnd
+ class OfficeRndAPI < PlaceOS::Driver
+ # Discovery Information
+ generic_name :OfficeRnd
+ descriptive_name "OfficeRnD REST API"
+
+ default_settings({
+ client_id: "10000000",
+ client_secret: "c5a6adc6-UUID-46e8-b72d-91395bce9565",
+ scopes: ["officernd.api.read", "officernd.api.write"],
+ test_auth: true,
+ })
+
+ @client_id : String = ""
+ @client_secret : String = ""
+ @scopes : Array(String) = [] of String
+
+ @test_auth : Bool = false
+ @auth_token : String = ""
+ @auth_expiry : Time = 1.minute.ago
+
+ def on_load
+ on_update
+ @test_auth = setting(Bool, :test_auth)
+ end
+
+ def on_update
+ @client_id = setting(String, :client_id)
+ @client_secret = setting(String, :client_secret)
+ @scopes = setting(Array(String), :scopes)
+ end
+
+ def expire_token!
+ @auth_expiry = 1.minute.ago
+ end
+
+ def token_expired?
+ @auth_expiry < Time.utc
+ end
+
+ def get_token
+ return @auth_token unless token_expired?
+ auth_route = @test_auth ? "http://localhost:17839/oauth/token" : "https://identity.officernd.com/oauth/token"
+ params = HTTP::Params.encode({
+ "client_id" => @client_id,
+ "client_secret" => @client_secret,
+ "grant_type" => "client_credentials",
+ "scope" => @scopes.join(' '),
+ })
+ headers = HTTP::Headers{
+ "Content-Type" => "application/x-www-form-urlencoded",
+ "Accept" => "application/json",
+ }
+ response = HTTP::Client.post(
+ url: auth_route,
+ headers: headers,
+ body: params,
+ )
+ body = response.body
+ logger.debug { "received login response: #{body}" }
+
+ if response.success?
+ resp = TokenResponse.from_json(body)
+ @auth_expiry = Time.utc + (resp.expires_in - 5).seconds
+ @auth_token = "Bearer #{resp.access_token}"
+ else
+ logger.error { "authentication failed with HTTP #{response.status_code}" }
+ raise "failed to obtain access token"
+ end
+ end
+
+ def get_header
+ headers = {
+ "Accept" => "application/json",
+ "Authorization" => get_token,
+ }
+ end
+
+ # Floor
+ ###########################################################################
+
+ # Get a floor
+ #
+ def floor(floor_id : String)
+ path = "/floors/#{floor_id}"
+ get_request(path, Floor)
+ end
+
+ # Get floors
+ #
+ def floors(office_id : String?, name : String?)
+ params = HTTP::Params.new
+ params["office"] = office_id if office_id
+ params["name"] = name if name
+ query_string = params.to_s
+ path = query_string.empty? ? "/floors" : "/floors?#{query_string}"
+ get_request(path, Array(Floor))
+ end
+
+ # Booking
+ ###########################################################################
+
+ # Get bookings for a resource for a given time span
+ #
+ def resource_bookings(
+ resource_id : String,
+ range_start : Time = Time.utc - 5.minutes,
+ range_end : Time = Time.utc + 24.hours,
+ office_id : String? = nil,
+ member_id : String? = nil,
+ team_id : String? = nil
+ ) : Array(Booking)
+ time_span = (range_start..range_end)
+ bookings(
+ office_id: office_id,
+ member_id: member_id,
+ team_id: team_id,
+ ).select! do |booking|
+ booking.resource_id == resource_id && booking.overlaps?(time_span)
+ end
+ end
+
+ # Get a booking
+ #
+ def booking(booking_id : String)
+ get_request("/bookings/#{booking_id}", Booking)
+ end
+
+ # Get bookings
+ #
+ def bookings(
+ office_id : String? = nil,
+ member_id : String? = nil,
+ team_id : String? = nil
+ )
+ params = HTTP::Params.new
+ params["office"] = office_id if office_id
+ params["member"] = member_id if member_id
+ params["team"] = team_id if team_id
+ query_string = params.to_s
+ path = query_string.empty? ? "/bookings" : "/bookings?#{query_string}"
+ get_request(path, Array(Booking))
+ end
+
+ # Delete a booking
+ #
+ def delete_booking(booking_id : String)
+ !!(delete_request("/bookings/#{booking_id}"))
+ end
+
+ # Make a booking
+ #
+ def create_bookings(bookings : Array(Booking))
+ post_request("/bookings", body: bookings.to_json)
+ end
+
+ # Create a booking
+ #
+ def create_booking(
+ resource_id : String,
+ booking_start : Time,
+ booking_end : Time,
+ summary : String? = nil,
+ team_id : String? = nil,
+ member_id : String? = nil,
+ description : String? = nil,
+ tentative : Bool? = nil,
+ free : Bool? = nil
+ )
+ create_bookings [Booking.new(
+ resource_id: resource_id,
+ booking_start: booking_start,
+ booking_end: booking_end,
+ summary: summary,
+ team_id: team_id,
+ member_id: member_id,
+ description: description,
+ tentative: tentative,
+ free: free,
+ )]
+ end
+
+ alias BookingArgument = NamedTuple(
+ resource_id: String,
+ booking_start: Time,
+ booking_end: Time,
+ summary: String?,
+ team_id: String?,
+ member_id: String?,
+ description: String?,
+ tentative: Bool?,
+ free: Bool?,
+ )
+
+ def create_bookings(bookings : Array(BookingArgument))
+ create_bookings(bookings.map { |booking| Booking.new(**booking) })
+ end
+
+ # Office
+ ###########################################################################
+
+ # List offices
+ #
+ def offices
+ path = "/offices"
+ get_request(path, Array(Office))
+ end
+
+ # Retrieve office
+ #
+ def office(name : String)
+ path = "/offices/#{name}"
+ get_request(path, Array(Office))
+ end
+
+ # Resource
+ ###########################################################################
+
+ # Get available rooms (resources) by
+ # - type
+ # - date range (available_from, available_to)
+ # - office (office_id)
+ # - resource name (name)
+ def resources(
+ type : (Resource::Type | String)? = nil,
+ name : String? = nil,
+ office_id : String? = nil,
+ available_from : Time? = nil,
+ available_to : Time? = nil
+ )
+ type = Resource::Type.parse(type) if type.is_a?(String)
+ params = HTTP::Params.new
+ params["type"] = type.to_s if type
+ params["name"] = name if name
+ params["office"] = office_id if office_id
+ params["availableFrom"] = available_from.to_s if available_from
+ params["availableTo"] = available_to.to_s if available_to
+ query_string = params.to_s
+ path = query_string.empty? ? "/resources" : "/resources?#{query_string}"
+ get_request(path, Array(Resource))
+ end
+
+ # Internal Helpers
+ #############################################################################
+
+ private def parse_response(response : HTTP::Client::Response)
+ expire_token! if response.status_code == 401
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ private def get_request(path, result_type)
+ response = get(path, get_header)
+ parse_response(response)
+ end
+
+ private def delete_request(path)
+ response = delete(path, headers: get_header)
+ parse_response(response)
+ end
+
+ {% for method in %w(post put) %}
+ private def {{method.id}}_request(path, body : JSON::Any | String)
+ header = get_header
+ header["Content-Type"] = "application/json"
+ response = {{method.id}}(path, body, header)
+ parse_response(response)
+ end
+ {% end %}
+ end
+end
diff --git a/drivers/office_rnd/office_rnd_api_spec.cr b/drivers/office_rnd/office_rnd_api_spec.cr
new file mode 100644
index 00000000000..c81fd96dd55
--- /dev/null
+++ b/drivers/office_rnd/office_rnd_api_spec.cr
@@ -0,0 +1,41 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "OfficeRnd::OfficeRndApi" do
+ # Send the request
+ retval = exec(:get_token)
+ token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJSRUFEIiwiV1JJVEUiXSwiZXhwIjoxNTc0MjMzNjEyLCJhdXRob3JpdGllcyI6WyJST0xFX1RSVVNURURfQ0xJRU5UIl0sImp0aSI6IjM1ZjkxYjlkLTVmZmMtNDJkYy05YWZkLTJiZTE0YjI1MmE1NCIsImNsaWVudF9pZCI6IjEwMDAwMjEzIn0.Wzrsaey5z3ShAFYKOaWmgfoRZNsk-PclSK9IRtYf4b8"
+
+ expect_http_request do |request, response|
+ case request.path
+ when "/oauth/token"
+ data = request.body
+ .try(&.gets_to_end)
+ .try(&->HTTP::Params.parse(String))
+ # The request is param encoded
+ if data && data["grant_type"] == "client_credentials" && data["client_secret"] == "c5a6adc6-UUID-46e8-b72d-91395bce9565"
+ response.status_code = 200
+ response.output.puts %({
+ "access_token": "#{token}",
+ "token_type": "Bearer",
+ "expires_in": 3599,
+ "scope": "officernd.api.read officernd.api.write"
+ })
+ else
+ response.status_code = 401
+ response.output.puts ""
+ end
+ when .starts_with?("/bookings")
+ case request.method
+ when "POST"
+ # TODO: Create bookings mock response
+ when "GET"
+ # TODO: Get bookings mock response
+ when "DELETE"
+ # TODO: Delete booking mock response
+ end
+ end
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq("Bearer #{token}")
+end
diff --git a/drivers/open_ai/gpt.cr b/drivers/open_ai/gpt.cr
new file mode 100644
index 00000000000..2a4b2f31f2a
--- /dev/null
+++ b/drivers/open_ai/gpt.cr
@@ -0,0 +1,86 @@
+require "placeos-driver"
+require "./models/*"
+
+class OpenAI::GPT < PlaceOS::Driver
+ descriptive_name "OpenAI GPT Gateway"
+ generic_name :LLM
+ uri_base "https://api.openai.com"
+
+ default_settings({
+ openai_key: "8537d5c8-a85c-4657-bc6b-7c35b1405464",
+ openai_org: "856b5b85d3eb4697369",
+ })
+
+ def on_update
+ openai_key = setting(String, :openai_key)
+ openai_org = setting?(String, :openai_org)
+
+ transport.before_request do |request|
+ logger.debug { "requesting #{request.method} #{request.path}?#{request.query}\n#{request.headers}\n#{request.body}" }
+
+ request.headers["Authorization"] = "Bearer #{openai_key}"
+ request.headers["OpenAI-Organization"] = openai_org if openai_org
+ request.headers["Content-Type"] = "application/json"
+ end
+
+ if usage = setting?(Usage, :token_usage)
+ @total_tokens = usage.total_tokens
+ @prompt_tokens = usage.prompt_tokens
+ @completion_tokens = usage.completion_tokens
+ end
+ end
+
+ @write_lock = Mutex.new
+ @writing_stats = false
+ getter total_tokens : Int64 = 0
+ getter prompt_tokens : Int64 = 0
+ getter completion_tokens : Int64 = 0
+
+ protected def check(response)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ response
+ end
+
+ # we only need a rough details on usage so if we miss one or two requests
+ # that's fine, but generally should be eventually consistent
+ protected def write_stats(usage : Usage)
+ @write_lock.synchronize do
+ return if @writing_stats
+ @writing_stats = true
+ end
+ define_setting(:token_usage, usage)
+ ensure
+ @write_lock.synchronize { @writing_stats = false }
+ end
+
+ protected def update_token(usage : Usage)
+ @total_tokens += usage.total_tokens
+ @prompt_tokens += usage.prompt_tokens
+ @completion_tokens += usage.completion_tokens
+ usage = Usage.new(@total_tokens, @prompt_tokens, @completion_tokens)
+ spawn { write_stats(usage) }
+ self[:usage] = usage
+ end
+
+ # returns the available models for the current key
+ def models
+ response = check get("/v1/models")
+ List(Model).from_json(response.body).data
+ end
+
+ # returns the details of the provided model id
+ def model(id : String)
+ response = check get("/v1/models/#{id}")
+ Model.from_json response.body
+ end
+
+ # creates a completion for the chat message
+ def chat(model : String, message : Message | Array(Message))
+ messages = message.is_a?(Array) ? message : [message]
+ chat = CreateChatCompletion.new(model, messages)
+ response = check post("/v1/chat/completions", body: chat.to_json)
+ chat = ChatCompletion.from_json response.body
+ update_token chat.usage
+ chat.choices
+ end
+end
diff --git a/drivers/open_ai/gpt_spec.cr b/drivers/open_ai/gpt_spec.cr
new file mode 100644
index 00000000000..46b5dd8570f
--- /dev/null
+++ b/drivers/open_ai/gpt_spec.cr
@@ -0,0 +1,5 @@
+require "placeos-driver/spec"
+require "./models/*"
+
+DriverSpecs.mock_driver "OpenAI::GPT" do
+end
diff --git a/drivers/open_ai/models/chat_completion.cr b/drivers/open_ai/models/chat_completion.cr
new file mode 100644
index 00000000000..ac35bbbb37c
--- /dev/null
+++ b/drivers/open_ai/models/chat_completion.cr
@@ -0,0 +1,98 @@
+require "./model"
+
+module OpenAI
+ enum Role
+ # Can be generated by the end users of an application, or set by a developer as an instruction.
+ User
+ # The system message helps set the behavior of the assistant.
+ # GPT 3 does not always pay strong attention to system messages
+ System
+ # The assistant messages help store prior responses. They can also be written by a developer to help give examples of desired behavior.
+ Assistant
+ end
+
+ # Typically, a conversation is formatted with a system message first,
+ # followed by alternating user and assistant messages.
+ struct Message
+ include JSON::Serializable
+
+ def initialize(@role : Role, @content : String)
+ end
+
+ getter role : Role
+ getter content : String
+ end
+
+ # POST https://api.openai.com/v1/chat/completions
+ class CreateChatCompletion
+ include JSON::Serializable
+
+ def initialize(@model, @messages)
+ end
+
+ # the model id
+ property model : String
+
+ property messages : Array(Message)
+
+ # What sampling temperature to use, between 0 and 2.
+ # Higher values like 0.8 will make the output more random,
+ # while lower values like 0.2 will make it more focused and deterministic.
+ property temperature : Float64 = 1.0
+
+ # An alternative to sampling with temperature, called nucleus sampling,
+ # where the model considers the results of the tokens with top_p probability mass.
+ # So 0.1 means only the tokens comprising the top 10% probability mass are considered.
+ # Alter this or temperature but not both.
+ property top_p : Float64 = 1.0
+
+ # How many completions to generate for each prompt.
+ @[JSON::Field(key: "n")]
+ property num_completions : Int32 = 1
+
+ # Whether to stream back partial progress.
+ property stream : Bool = false
+
+ # Up to 4 sequences where the API will stop generating further tokens.
+ # The returned text will not contain the stop sequence.
+ property stop : String | Array(String)? = nil
+
+ # Number between -2.0 and 2.0.
+ # Positive values penalize new tokens based on whether they appear in the text so far,
+ # increasing the model's likelihood to talk about new topics.
+ property presence_penalty : Float64 = 0.0
+
+ # Number between -2.0 and 2.0.
+ # Positive values penalize new tokens based on their existing frequency in the text so far,
+ # decreasing the model's likelihood to repeat the same line verbatim.
+ property frequency_penalty : Float64 = 0.0
+
+ # Modify the likelihood of specified tokens appearing in the completion.
+ # You can use this [tokenizer tool](https://platform.openai.com/tokenizer?view=bpe) (which works for both GPT-2 and GPT-3) to convert text to token IDs
+ property logit_bias : Hash(String, Float64)? = nil
+
+ # A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
+ property user : String? = nil
+ end
+
+ struct MessageChoice
+ include JSON::Serializable
+
+ getter index : Int32
+ getter message : Message
+ getter finish_reason : String
+ end
+
+ struct ChatCompletion
+ include JSON::Serializable
+
+ getter id : String
+ getter object : String
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ getter created : Time
+
+ getter choices : Array(MessageChoice)
+ getter usage : Usage
+ end
+end
diff --git a/drivers/open_ai/models/edit_completion.cr b/drivers/open_ai/models/edit_completion.cr
new file mode 100644
index 00000000000..1d5bdb7e30f
--- /dev/null
+++ b/drivers/open_ai/models/edit_completion.cr
@@ -0,0 +1,34 @@
+# re-uses the TextCompletion responses
+require "./text_completion"
+
+module OpenAI
+ # POST https://api.openai.com/v1/edits
+ class CreateEditCompletion
+ include JSON::Serializable
+
+ # the model id
+ # You can use the text-davinci-edit-001 or code-davinci-edit-001 model with this endpoint.
+ property model : String
+
+ # The input text to use as a starting point for the edit.
+ property input : String
+
+ # The instruction that tells the model how to edit the prompt.
+ property instruction : String
+
+ # What sampling temperature to use, between 0 and 2.
+ # Higher values like 0.8 will make the output more random,
+ # while lower values like 0.2 will make it more focused and deterministic.
+ property temperature : Float64 = 1.0
+
+ # An alternative to sampling with temperature, called nucleus sampling,
+ # where the model considers the results of the tokens with top_p probability mass.
+ # So 0.1 means only the tokens comprising the top 10% probability mass are considered.
+ # Alter this or temperature but not both.
+ property top_p : Float64 = 1.0
+
+ # How many completions to generate for each prompt.
+ @[JSON::Field(key: "n")]
+ property num_completions : Int32 = 1
+ end
+end
diff --git a/drivers/open_ai/models/model.cr b/drivers/open_ai/models/model.cr
new file mode 100644
index 00000000000..42deea30ce5
--- /dev/null
+++ b/drivers/open_ai/models/model.cr
@@ -0,0 +1,34 @@
+require "json"
+
+module OpenAI
+ struct List(Type)
+ include JSON::Serializable
+
+ getter object : String
+ getter data : Array(Type)
+ end
+
+ struct Usage
+ include JSON::Serializable
+
+ def initialize(@total_tokens, @prompt_tokens, @completion_tokens)
+ end
+
+ getter total_tokens : Int64
+ getter prompt_tokens : Int64
+ getter completion_tokens : Int64
+ end
+
+ # GET https://api.openai.com/v1/models
+ struct Model
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter id : String
+ getter object : String
+ getter owned_by : String
+
+ # Serializable::Unmapped
+ # permission: [...]
+ end
+end
diff --git a/drivers/open_ai/models/text_completion.cr b/drivers/open_ai/models/text_completion.cr
new file mode 100644
index 00000000000..ac0d35a543c
--- /dev/null
+++ b/drivers/open_ai/models/text_completion.cr
@@ -0,0 +1,94 @@
+require "./model"
+
+module OpenAI
+ # POST https://api.openai.com/v1/completions
+ class CreateTextCompletion
+ include JSON::Serializable
+
+ # the model id
+ property model : String
+
+ # The prompt(s) to generate completions for
+ property prompt : String | Array(String)? = "<|endoftext|>"
+
+ # The suffix that comes after a completion of inserted text.
+ property suffix : String? = nil
+
+ # The maximum number of tokens to generate in the completion.
+ # Most models have a context length of 2048 tokens (except for the newest models, which support 4096).
+ # The token count of your prompt plus max_tokens cannot exceed the model's context length.
+ property max_tokens : Int32 = 16
+
+ # What sampling temperature to use, between 0 and 2.
+ # Higher values like 0.8 will make the output more random,
+ # while lower values like 0.2 will make it more focused and deterministic.
+ property temperature : Float64 = 1.0
+
+ # An alternative to sampling with temperature, called nucleus sampling,
+ # where the model considers the results of the tokens with top_p probability mass.
+ # So 0.1 means only the tokens comprising the top 10% probability mass are considered.
+ # Alter this or temperature but not both.
+ property top_p : Float64 = 1.0
+
+ # How many completions to generate for each prompt.
+ @[JSON::Field(key: "n")]
+ property num_completions : Int32 = 1
+
+ # Whether to stream back partial progress.
+ property stream : Bool = false
+
+ # Include the log probabilities on the logprobs most likely tokens, as well the chosen tokens.
+ property logprobs : Int32? = nil
+
+ # Echo back the prompt in addition to the completion
+ property echo : Bool = false
+
+ # Up to 4 sequences where the API will stop generating further tokens.
+ # The returned text will not contain the stop sequence.
+ property stop : String | Array(String)? = nil
+
+ # Number between -2.0 and 2.0.
+ # Positive values penalize new tokens based on whether they appear in the text so far,
+ # increasing the model's likelihood to talk about new topics.
+ property presence_penalty : Float64 = 0.0
+
+ # Number between -2.0 and 2.0.
+ # Positive values penalize new tokens based on their existing frequency in the text so far,
+ # decreasing the model's likelihood to repeat the same line verbatim.
+ property frequency_penalty : Float64 = 0.0
+
+ # Generates best_of completions server-side and returns the "best" (the one with the highest log probability per token). Results cannot be streamed.
+ # best_of must be greater than num_completions
+ property best_of : Int32 = 1
+
+ # Modify the likelihood of specified tokens appearing in the completion.
+ # You can use this [tokenizer tool](https://platform.openai.com/tokenizer?view=bpe) (which works for both GPT-2 and GPT-3) to convert text to token IDs
+ property logit_bias : Hash(String, Float64)? = nil
+
+ # A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
+ property user : String? = nil
+ end
+
+ struct TextChoice
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter text : String
+ getter index : Int32
+ getter finish_reason : String?
+ end
+
+ struct TextCompletion
+ include JSON::Serializable
+
+ getter id : String?
+ getter model : String?
+ getter object : String
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ getter created : Time
+
+ getter choices : Array(TextChoice)
+ getter usage : Usage
+ end
+end
diff --git a/drivers/open_ai/voice_control.cr b/drivers/open_ai/voice_control.cr
new file mode 100644
index 00000000000..5c09d791020
--- /dev/null
+++ b/drivers/open_ai/voice_control.cr
@@ -0,0 +1,74 @@
+require "placeos-driver"
+require "./models/*"
+
+# A Voice interface that should be able to:
+# * request
+class OpenAI::VoiceControlInterface < PlaceOS::Driver
+ descriptive_name "Voice Control Interface"
+ generic_name :VoiceControl
+
+ accessor language_model : LLM_1
+
+ default_settings({
+ llm_model_id: "gpt-3.5-turbo",
+
+ # [{ role: "user", content: "ensure devices are powered on before use" }]
+ custom_prompts: [] of OpenAI::Message,
+ })
+
+ def on_update
+ @llm_model_id = setting(String, :llm_model_id)
+ @custom_prompts = setting?(Array(OpenAI::Message), :custom_prompts) || [] of OpenAI::Message
+ end
+
+ getter llm_model_id : String = "gpt-3.5-turbo"
+ getter custom_prompts : Array(OpenAI::Message) = [] of OpenAI::Message
+
+ PROMPT = OpenAI::Message.new(
+ :user,
+ <<-MESSAGE
+
+ MESSAGE
+ )
+
+ def request(text : String)
+ messages = [PROMPT] + custom_prompts + [OpenAI::Message.new(:user, "The Request: #{text}")]
+ choices = Array(MessageChoice).from_json language_model.chat(llm_model_id, messages).get.to_json
+ # select choice (typically just the first one)
+ # parse the response (prompt should ensure it responds using JSON)
+ # perform request actions:
+ # => loop provide any errors to the LLM and request fixes (limit 3)
+ # provide text response to user as well as success or failure
+ end
+
+ alias Metadata = PlaceOS::Driver::DriverModel::Metadata
+
+ def system_metadata
+ # Display_1 => {interface: {}, notes: ""}
+ metadata = {} of String => Metadata
+
+ sys = system
+ sys.modules.each do |module_name|
+ 1.upto(sys.count(module_name)) do |index|
+ mod = sys.get(module_name, index)
+ metadata["#{module_name}_#{index}"] = mod.__metadata__.llm_interface
+ end
+ end
+
+ # module functions are described by JSON schema
+ # modules["Display_1"] #=> {
+ # "interface": {"function": {"param": {"type": "string", "default": "value"}}},
+ # "notes": "small display near the door, on the left"
+ # }
+ {
+ name: sys.name,
+ description: sys.description,
+ modules: metadata,
+ }
+ end
+
+ # returns a hash of status values
+ def module_status(module_id : String) : Hash(String, String)
+ system[module_id].__status__
+ end
+end
diff --git a/drivers/optergy/p864.cr b/drivers/optergy/p864.cr
new file mode 100644
index 00000000000..ef75c1ca0f6
--- /dev/null
+++ b/drivers/optergy/p864.cr
@@ -0,0 +1,336 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "bacnet"
+require "jwt"
+
+require "./p864_models"
+
+class Optergy::P864 < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ generic_name :BMS
+ descriptive_name "Optergy P864 BMS"
+ uri_base "https://bms.org.com"
+
+ default_settings({
+ username: "admin",
+ password: "password",
+
+ # grab unit names from: https://github.com/spider-gazelle/crunits
+ # Sensor type list: https://github.com/PlaceOS/driver/blob/master/src/placeos-driver/interface/sensor.cr#L8
+ unit_mappings: {
+ 1 => {SensorType::Temperature, "Cel"},
+ },
+ })
+
+ @username : String = ""
+ @password : String = ""
+
+ @auth_token : String = ""
+ @auth_expiry : Time = 1.minute.ago
+
+ alias Mapping = Hash(Int32, Tuple(SensorType, String))
+ @unit_mappings : Mapping = Mapping.new
+
+ def on_load
+ on_update
+
+ schedule.every(1.minutes) { version }
+ transport.before_request do |req|
+ logger.debug { "requesting #{req.method} #{req.path}?#{req.query}\n#{req.headers}\n#{req.body}" }
+ end
+ end
+
+ def on_update
+ @username = setting(String, :username)
+ @password = setting(String, :password)
+ @unit_mappings = setting?(Mapping, :unit_mappings) || Mapping.new
+ end
+
+ def version
+ response = get("/version", headers: auth_headers)
+ NamedTuple(version: String).from_json(check response)[:version]
+ end
+
+ def configuration
+ response = get("/api/device/config", headers: auth_headers)
+ Config.from_json(check response)
+ end
+
+ TYPES = {"value", "input", "output"}
+
+ {% begin %}
+ {% for type in TYPES %}
+ {% type_id = type.id %}
+ {% url = "/api/a#{type.chars[0].id}/" %}
+
+ def analog_{{ type.id }}s
+ response = get({{url}}, headers: auth_headers)
+ Array(AnalogValue).from_json(check response)
+ end
+
+ def analog_{{ type.id }}(instance : Int32)
+ path = String.build do |str|
+ str << {{url}}
+ instance.to_s(str)
+ end
+ response = get(path, headers: auth_headers)
+ AnalogValue.from_json(check response)
+ end
+
+ {% if type != "input" %}
+ @[Security(Level::Administrator)]
+ def write_analog_{{ type.id }}(instance : Int32, value : Float64, priority : Int32 = 8)
+ path = String.build do |str|
+ str << {{url}}
+ instance.to_s(str)
+ end
+ response = post(path, headers: auth_headers, body: {
+ value: value.to_s,
+ arrayIndex: priority,
+ property: "presentValue",
+ }.to_json)
+ AnalogValue.from_json(check response)
+ end
+ {% end %}
+ {% end %}
+ {% end %}
+
+ {% begin %}
+ {% for type in TYPES %}
+ {% type_id = type.id %}
+ {% url = "/api/b#{type.chars[0].id}/" %}
+
+ def binary_{{ type.id }}s
+ response = get({{url}}, headers: auth_headers)
+ Array(BinaryValue).from_json(check response)
+ end
+
+ def binary_{{ type.id }}(instance : Int32)
+ path = String.build do |str|
+ str << {{url}}
+ instance.to_s(str)
+ end
+ response = get(path, headers: auth_headers)
+ BinaryValue.from_json(check response)
+ end
+
+ {% if type != "input" %}
+ @[Security(Level::Administrator)]
+ def write_binary_{{ type.id }}(instance : Int32, value : Bool, priority : Int32 = 8)
+ path = String.build do |str|
+ str << {{url}}
+ instance.to_s(str)
+ end
+ response = post(path, headers: auth_headers, body: {
+ value: value ? "Active" : "Inactive",
+ arrayIndex: priority,
+ property: "presentValue",
+ }.to_json)
+ BinaryValue.from_json(check response)
+ end
+ {% end %}
+ {% end %}
+ {% end %}
+
+ @[Security(Level::Administrator)]
+ def set_input_mode(instance : Int32, mode : String)
+ response = post("/api/ai/#{instance}/mode", headers: auth_headers, body: {
+ mode: mode,
+ }.to_json)
+ ModeResponse.from_json(check response)
+ end
+
+ # ==============
+ # Authentication
+ # ==============
+
+ def token_expired?
+ @auth_expiry < Time.utc
+ end
+
+ record TokenResponse, token : String do
+ include JSON::Serializable
+ end
+
+ @[Security(Level::Administrator)]
+ def get_token
+ return @auth_token unless token_expired?
+
+ response = post("/authorize", headers: HTTP::Headers{
+ "Accept" => "application/json",
+ "Content-Type" => "application/json",
+ }, body: {
+ username: @username,
+ password: @password,
+ }.to_json)
+
+ body = response.body
+ now = Time.utc
+ logger.debug { "received login response: #{body}" }
+
+ if response.success?
+ set_connected_state true
+ token = TokenResponse.from_json(body).token
+ payload, header = JWT.decode(token, verify: false, validate: false)
+
+ # time is relative in this JWT (non standard)
+ issued = payload["iat"].as_i64
+ expires = payload["exp"].as_i64
+ expires_at = now + (expires - issued - 3).seconds
+
+ @auth_expiry = expires_at
+ @auth_token = "Bearer #{token}"
+ else
+ set_connected_state false
+ logger.error { "authentication failed with HTTP #{response.status_code}" }
+ raise "failed to obtain access token"
+ end
+ end
+
+ @[Security(Level::Administrator)]
+ def auth_headers
+ HTTP::Headers{
+ "Accept" => "application/json",
+ "Content-Type" => "application/json",
+ "Authorization" => get_token,
+ }
+ end
+
+ macro check(response)
+ %resp = {{response}}
+ logger.debug { "received: #{%resp.body}" }
+ raise "error response: #{%resp.status} (#{%resp.status_code})\n#{%resp.body}" unless %resp.success?
+ %resp.body
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ protected def to_sensor(object, mac, filter_type = nil) : Interface::Sensor::Detail?
+ unit_number = object.units
+ unit_lookup = unit_number ? BACnet::Unit.from_value(unit_number) : nil
+ sensor_type = case unit_lookup
+ when Nil, .no_units?
+ if mapping = @unit_mappings[object.instance]?
+ unit = mapping[1]
+ mapping[0]
+ end
+ when .degrees_fahrenheit?, .degrees_celsius?, .degrees_kelvin?
+ SensorType::Temperature
+ when .percent_relative_humidity?
+ SensorType::Humidity
+ when .pounds_force_per_square_inch?
+ SensorType::Pressure
+ when .volts?, .millivolts?, .kilovolts?, .megavolts?
+ SensorType::Voltage
+ when .milliamperes?, .amperes?
+ SensorType::Current
+ when .millimeters_of_water?, .centimeters_of_water?, .inches_of_water?, .cubic_feet?, .cubic_meters?, .imperial_gallons?, .milliliters?, .liters?, .us_gallons?
+ SensorType::Volume
+ when .milliwatts?, .watts?, .kilowatts?, .megawatts?, .watt_hours?, .kilowatt_hours?, .megawatt_hours?
+ SensorType::Power
+ when .hertz?, .kilohertz?, .megahertz?
+ SensorType::Frequency
+ when .cubic_feet_per_second?, .cubic_feet_per_minute?, .cubic_feet_per_hour?, .cubic_meters_per_second?, .cubic_meters_per_minute?, .cubic_meters_per_hour?, .imperial_gallons_per_minute?, .milliliters_per_second?, .liters_per_second?, .liters_per_minute?, .liters_per_hour?, .us_gallons_per_minute?, .us_gallons_per_hour?
+ SensorType::Flow
+ when .percent?
+ SensorType::Level
+ end
+ return nil unless sensor_type
+ return nil if filter_type && sensor_type != filter_type
+
+ unit = unit || case unit_lookup
+ when Nil
+ when .degrees_fahrenheit? then "[degF]"
+ when .degrees_celsius? then "Cel"
+ when .degrees_kelvin? then "K"
+ when .pounds_force_per_square_inch? then "[psi]"
+ when .volts? then "V"
+ when .millivolts? then "mV"
+ when .kilovolts? then "kV"
+ when .megavolts? then "MV"
+ when .milliamperes? then "mA"
+ when .amperes? then "A"
+ when .cubic_feet? then "[cft_i]"
+ when .cubic_meters? then "m3"
+ when .imperial_gallons? then "[gal_br]"
+ when .milliliters? then "ml"
+ when .liters? then "l"
+ when .us_gallons? then "[gal_us]"
+ when .milliwatts? then "mW"
+ when .watts? then "W"
+ when .kilowatts? then "kW"
+ when .megawatts? then "MW"
+ when .watt_hours? then "Wh"
+ when .kilowatt_hours? then "kWh"
+ when .megawatt_hours? then "MWh"
+ when .hertz? then "Hz"
+ when .kilohertz? then "kHz"
+ when .megahertz? then "MHz"
+ when .cubic_feet_per_second? then "[cft_i]/s"
+ when .cubic_feet_per_minute? then "[cft_i]/min"
+ when .cubic_feet_per_hour? then "[cft_i]/h"
+ when .cubic_meters_per_second? then "m3/s"
+ when .cubic_meters_per_minute? then "m3/min"
+ when .cubic_meters_per_hour? then "m3/h"
+ when .imperial_gallons_per_minute? then "[gal_br]/min"
+ when .milliliters_per_second? then "ml/s"
+ when .liters_per_second? then "l/s"
+ when .liters_per_minute? then "l/min"
+ when .liters_per_hour? then "l/h"
+ when .us_gallons_per_minute? then "[gal_us]/min"
+ when .us_gallons_per_hour? then "[gal_us]/h"
+ end
+
+ value = object.value
+
+ Interface::Sensor::Detail.new(
+ type: sensor_type,
+ value: value,
+ last_seen: Time.utc.to_unix,
+ mac: mac,
+ id: object.instance.to_s,
+ name: object.name,
+ module_id: module_id,
+ # binding: object_binding(device_id, object),
+ unit: unit,
+ status: object.out_of_service? ? Interface::Sensor::Status::OutOfService : Interface::Sensor::Status::Normal
+ )
+ end
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ this_mac = device_mac
+ return NO_MATCH if mac && mac != this_mac
+ filter = type ? Interface::Sensor::SensorType.parse?(type) : nil
+ analog_values.compact_map { |obj| to_sensor(obj, this_mac, filter) }
+ rescue error
+ logger.warn(exception: error) { "searching for sensors" }
+ NO_MATCH
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+
+ this_mac = device_mac
+ return nil if mac != this_mac
+ return nil unless id
+ instance = id.to_i?
+ return nil unless instance
+
+ device = (analog_value(instance) rescue nil)
+ return nil unless device
+
+ to_sensor(device, this_mac)
+ end
+
+ protected def device_mac
+ URI.parse(config.uri.not_nil!).host.as(String)
+ end
+end
diff --git a/drivers/optergy/p864_models.cr b/drivers/optergy/p864_models.cr
new file mode 100644
index 00000000000..6fc02e0bc5d
--- /dev/null
+++ b/drivers/optergy/p864_models.cr
@@ -0,0 +1,75 @@
+require "json"
+
+module Optergy
+ enum Units
+ Metric
+ Imperial
+ end
+
+ struct Config
+ include JSON::Serializable
+
+ getter units : Units
+ getter id : Int64
+ end
+
+ struct AnalogValue
+ include JSON::Serializable
+
+ @[JSON::Field(key: "objectName")]
+ getter name : String { "" }
+ getter description : String { "" }
+
+ @[JSON::Field(key: "presentValue")]
+ getter value_str : String
+ getter instance : Int32
+
+ @[JSON::Field(key: "outOfService")]
+ getter? out_of_service : Bool
+
+ getter units : Int32?
+
+ getter value : Float64 do
+ value_str.to_f? || 0.0
+ end
+ end
+
+ struct BinaryValue
+ include JSON::Serializable
+
+ @[JSON::Field(key: "objectName")]
+ getter name : String { "" }
+ getter description : String { "" }
+
+ @[JSON::Field(key: "presentValue")]
+ getter value_str : String
+ getter instance : Int32
+
+ @[JSON::Field(key: "outOfService")]
+ getter? out_of_service : Bool
+
+ getter units : Int32?
+
+ getter value : Bool do
+ value_str == "Active"
+ end
+ end
+
+ ANALOG_INPUT_MODE = {
+ "2" => "10k-2 sensor",
+ "6" => "Dry Contact",
+ "4|10" => "Pulse 10 per pulse",
+ "3|0|100" => "4-20 ma 0 to 100",
+ "5" => "3K sensor",
+ }
+
+ struct ModeResponse
+ include JSON::Serializable
+
+ getter mode : String
+ getter instance : Int32
+
+ @[JSON::Field(key: "objectType")]
+ getter object_type : Int32
+ end
+end
diff --git a/drivers/optergy/p864_spec.cr b/drivers/optergy/p864_spec.cr
new file mode 100644
index 00000000000..edbd23d6054
--- /dev/null
+++ b/drivers/optergy/p864_spec.cr
@@ -0,0 +1,147 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Optergy::P864" do
+ # authenticate
+ retval = exec(:version)
+
+ expect_http_request do |request, response|
+ io = request.body
+ if io
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request["username"] == "admin" && request["password"] == "password"
+ response.status_code = 200
+ {
+ token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjozLCJpYXQiOjI1ODgwNCwiZXhwIjoyODc2MDQsImlzcyI6Ik9wdGVyZ3kiLCJzdWIiOiIyIn0.NqQ4z7RL6rOTYwxc4-VYvxj_11-6YMcS4UeUzFZ3gWc",
+ }.to_json(response)
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include authentication details #{request.inspect}"
+ end
+ end
+
+ expect_http_request do |request, response|
+ if request.headers["Authorization"]? == "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjozLCJpYXQiOjI1ODgwNCwiZXhwIjoyODc2MDQsImlzcyI6Ik9wdGVyZ3kiLCJzdWIiOiIyIn0.NqQ4z7RL6rOTYwxc4-VYvxj_11-6YMcS4UeUzFZ3gWc"
+ response.status_code = 200
+ {
+ version: "1.1.7",
+ }.to_json(response)
+ else
+ response.status_code = 401
+ end
+ end
+
+ retval.get.should eq("1.1.7")
+
+ # =================
+ # Analog Values
+ # =================
+
+ retval = exec(:analog_values)
+ expect_http_request do |request, response|
+ if request.path == "/api/av/"
+ response.status_code = 200
+ response << %([{
+ "presentValue": "26.0",
+ "instance": 1,
+ "eventState": "normal",
+ "outOfService": false,
+ "description": "Light 1",
+ "objectName": "Analog Value 1",
+ "priorityArray": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ "26.0",
+ null,
+ null
+ ],
+ "units": 95,
+ "tags": [
+ ""
+ ],
+ "objectType": 2,
+ "relinquishDefault": "0.0"
+ }])
+ else
+ response.status_code = 500
+ response << "GOT PATH: #{request.path}"
+ end
+ end
+
+ retval.get.should eq([{
+ "objectName" => "Analog Value 1",
+ "description" => "Light 1",
+ "presentValue" => "26.0",
+ "instance" => 1,
+ "outOfService" => false,
+ "units" => 95,
+ }])
+
+ # =================
+ # Analog Value
+ # =================
+
+ retval = exec(:analog_value, 1)
+ expect_http_request do |request, response|
+ if request.path == "/api/av/1"
+ response.status_code = 200
+ response << %({
+ "presentValue": "26.0",
+ "instance": 1,
+ "eventState": "normal",
+ "outOfService": false,
+ "description": "Light 1",
+ "objectName": "Analog Value 1",
+ "priorityArray": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ "26.0",
+ null,
+ null
+ ],
+ "units": 95,
+ "tags": [
+ ""
+ ],
+ "objectType": 2,
+ "relinquishDefault": "0.0"
+ })
+ else
+ response.status_code = 500
+ response << "GOT PATH: #{request.path}"
+ end
+ end
+
+ retval.get.should eq({
+ "objectName" => "Analog Value 1",
+ "description" => "Light 1",
+ "presentValue" => "26.0",
+ "instance" => 1,
+ "outOfService" => false,
+ "units" => 95,
+ })
+end
diff --git a/drivers/panasonic/camera/he_series.cr b/drivers/panasonic/camera/he_series.cr
new file mode 100644
index 00000000000..4fa2ada2e42
--- /dev/null
+++ b/drivers/panasonic/camera/he_series.cr
@@ -0,0 +1,307 @@
+require "placeos-driver"
+require "placeos-driver/interface/camera"
+require "placeos-driver/interface/powerable"
+
+# Documentation: https://aca.im/driver_docs/Panasonic/Camera%20Specifications%20V1.03E.pdf
+# for a live view: http:///cgi-bin/mjpeg?stream=1
+
+class Panasonic::Camera::HESeries < PlaceOS::Driver
+ include Interface::Camera
+ include Interface::Powerable
+
+ # Discovery Information
+ generic_name :Camera
+ descriptive_name "Panasonic PTZ Camera HE40/50/60"
+ uri_base "http://192.168.0.12"
+
+ default_settings({
+ basic_auth: {
+ username: "admin",
+ password: "12345",
+ },
+ invert_controls: false,
+ presets: {
+ name: {pan: 1, tilt: 1, zoom: 1},
+ },
+ })
+
+ @pan : Int32 = 0
+ @tilt : Int32 = 0
+ @zoom_raw : Int32 = 0
+
+ def on_load
+ # delay between sending commands
+ queue.delay = 130.milliseconds
+ schedule.every(1.minute) { do_poll }
+ on_update
+ end
+
+ @invert : Bool = false
+ @default_movement_speed : Int32 = 12
+ @presets = {} of String => NamedTuple(pan: Int32, tilt: Int32, zoom: Float64)
+
+ def on_update
+ @default_movement_speed = setting?(Int32, :default_movement_speed) || 12
+ self[:inverted] = @invert = setting?(Bool, :invert_controls) || false
+ @presets = setting?(Hash(String, NamedTuple(pan: Int32, tilt: Int32, zoom: Float64)), :presets) || {} of String => NamedTuple(pan: Int32, tilt: Int32, zoom: Float64)
+ self[:presets] = @presets.keys
+ end
+
+ # ===================
+ # Powerable interface
+
+ def power(state : Bool)
+ delay = 6.seconds if state
+ request("O", state ? 1 : 0, delay: delay) { |resp| parse_power resp }
+ end
+
+ def power?
+ parse_power query("O")
+ end
+
+ protected def parse_power(response : String)
+ case response
+ when "p0" then self[:power] = false
+ when "p1", "p3" then self[:power] = true
+ end
+ end
+
+ # ================
+ # Camera interface
+
+ MOVEMENT_STOPPED = 50
+
+ protected def joyspeed(speed : Float64)
+ speed = speed.clamp(-100.0, 100.0)
+ negative = speed < 0.0
+ speed = speed.abs if negative
+
+ percentage = speed / 100.0
+ value = (percentage * 49.0).round.to_i
+ value = -value if negative
+ value
+ end
+
+ def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0)
+ tilt_speed = -tilt_speed if @invert
+
+ pan = (MOVEMENT_STOPPED + joyspeed(pan_speed)).to_s.rjust(2, '0')
+ tilt = (MOVEMENT_STOPPED + joyspeed(tilt_speed)).to_s.rjust(2, '0')
+
+ # check if we want to stop panning
+ if pan_speed == "50" && tilt_speed == "50"
+ options = {
+ retries: 4,
+ priority: queue.priority + 50,
+ clear_queue: true,
+ name: :joystick,
+ }
+ else
+ options = {
+ retries: 1,
+ priority: queue.priority,
+ clear_queue: false,
+ name: :joystick,
+ }
+ end
+
+ request("PTS", "#{pan}#{tilt}", **options) do |resp|
+ pan, tilt = resp[3..-1].scan(/.{2}/).flat_map(&.to_a)
+ self[:pan_speed] = pan.not_nil!.to_i - MOVEMENT_STOPPED
+ self[:tilt_speed] = tilt.not_nil!.to_i - MOVEMENT_STOPPED
+ end
+ end
+
+ def recall(position : String, index : Int32 | String = 0)
+ preset = @presets[position]?
+ if preset
+ pantilt preset[:pan], preset[:tilt]
+ zoom_to preset[:zoom]
+ else
+ raise "unknown preset #{position}"
+ end
+ end
+
+ def save_position(name : String, index : Int32 | String = 0)
+ do_poll
+ @presets[name] = {pan: @pan, tilt: @tilt, zoom: self[:zoom].as_f}
+ define_setting(:presets, @presets)
+ self[:presets] = @presets.keys
+ end
+
+ def remove_position(name : String, index : Int32 | String = 0)
+ @presets.delete name
+ define_setting(:presets, @presets)
+ self[:presets] = @presets.keys
+ end
+
+ # ================
+ # Moveable interface
+
+ def move(position : MoveablePosition, index : Int32 | String = 0)
+ case position
+ in .open?, .close?
+ # iris not supported
+ in .down?, .up?
+ joystick(
+ pan_speed: 0,
+ tilt_speed: position.down? ? @default_movement_speed : -@default_movement_speed
+ )
+ in .left?, .right?
+ joystick(
+ pan_speed: position.left? ? -@default_movement_speed : @default_movement_speed,
+ tilt_speed: 0
+ )
+ in .in?, .out?
+ zoom(position.in? ? ZoomDirection::In : ZoomDirection::Out)
+ end
+ end
+
+ # ================
+ # Zoomable interface
+
+ ZOOM_MIN = 0x555
+ ZOOM_MAX = 0xFFF
+ ZOOM_RANGE = (ZOOM_MAX - ZOOM_MIN).to_f
+
+ def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0)
+ position = position.clamp(0.0, 100.0)
+ percentage = position / 100.0
+ zoom_value = (percentage * ZOOM_RANGE).to_i + ZOOM_MIN # (zoom range is 0x555 => 0xFFF)
+
+ request("AXZ", zoom_value.to_s(16).upcase.rjust(3, '0')) do |resp|
+ self[:zoom] = resp[3..-1].to_i(16)
+ end
+ end
+
+ def zoom?
+ resp = query("GZ")
+ if resp.includes?("--")
+ message = "camera in standby, operation unavailable"
+ logger.debug { message }
+ message
+ else
+ @zoom_raw = resp[2..-1].to_i(16)
+ self[:zoom] = (@zoom_raw - ZOOM_MIN).to_f * (100.0 / ZOOM_RANGE)
+ end
+ end
+
+ def zoom(direction : ZoomDirection, index : Int32 | String = 0)
+ case direction
+ in .in?
+ move_zoom(@default_movement_speed // 2)
+ in .out?
+ move_zoom(-@default_movement_speed)
+ in .stop?
+ move_zoom(0)
+ end
+ end
+
+ protected def move_zoom(speed : Int32, **options)
+ speed = MOVEMENT_STOPPED + speed
+ request("Z", speed.to_s.rjust(2, '0'), **options) do |resp|
+ self[:zoom_speed] = resp[2..-1].to_i - MOVEMENT_STOPPED
+ end
+ end
+
+ # ================
+ # Stoppable interface
+
+ def stop(index : Int32 | String = 0, emergency : Bool = false)
+ move_zoom(0, priority: 100)
+ joystick(0, 0)
+ end
+
+ # ======================
+ # Other camera functions
+
+ enum Installation
+ Desk
+ Ceiling
+ end
+
+ def installation(position : Installation)
+ request("INS", position.desk? ? 0 : 1) { |resp| parse_installation resp }
+ end
+
+ def installation?
+ parse_installation query("INS")
+ end
+
+ protected def parse_installation(response : String)
+ case response
+ when "ins0" then self[:installation] = Installation::Desk
+ when "ins1" then self[:installation] = Installation::Ceiling
+ end
+ end
+
+ def pantilt(pan : Int32, tilt : Int32)
+ pan_val = pan.to_s(16).upcase.rjust(4, '0')
+ tilt_val = tilt.to_s(16).upcase.rjust(4, '0')
+ request("APC", "#{pan_val}#{tilt_val}", name: :pantilt) { |resp| parse_pantilt resp }
+ end
+
+ def pantilt?
+ parse_pantilt query("APC")
+ end
+
+ protected def parse_pantilt(response : String)
+ pan, tilt = response[3..-1].scan(/.{4}/).flat_map(&.to_a).compact_map(&.try &.to_i(16))
+ self[:pan] = @pan = pan
+ self[:tilt] = @tilt = tilt
+ end
+
+ def do_poll
+ if power?
+ zoom?
+ pantilt?
+ end
+ end
+
+ protected def request(cmd : String, data, **options, &callback : String -> _)
+ request_string = "/cgi-bin/aw_ptz?cmd=%23#{cmd}#{data}&res=1"
+
+ queue.add(**options) do |task|
+ logger.debug { "requesting #{options[:name]?}: #{request_string}" }
+ response = get(request_string)
+
+ if response.success?
+ body = response.body.downcase
+ if body.starts_with?("er")
+ case body[2]
+ when '1' then task.abort("unsupported command #{cmd}: #{body}")
+ when '2' then task.retry("camera busy, requested #{cmd}: #{body}")
+ when '3' then task.abort("query outside acceptable range, requested #{cmd}: #{body}")
+ end
+ else
+ begin
+ logger.debug { "received: #{body}" }
+ task.success callback.call(body)
+ rescue error
+ logger.error(exception: error) { "error processing response" }
+ task.abort error.message
+ end
+ end
+ else
+ logger.error { "request failed with #{response.status_code}: #{response.body}" }
+ task.abort "request failed"
+ end
+ end
+ end
+
+ protected def query(cmd : String)
+ request_string = "/cgi-bin/aw_ptz?cmd=%23#{cmd}&res=1"
+ logger.debug { "querying: #{request_string}" }
+ response = get(request_string)
+ raise "request failed with #{response.status_code}: #{response.body}" unless response.success?
+ body = response.body.downcase
+ if body.starts_with?("er")
+ case body[2]
+ when '1' then raise "unsupported command #{cmd}: #{body}"
+ when '2' then raise "camera busy, requested #{cmd}: #{body}"
+ when '3' then raise "query outside acceptable range, requested #{cmd}: #{body}"
+ end
+ end
+ body
+ end
+end
diff --git a/drivers/panasonic/camera/he_series_spec.cr b/drivers/panasonic/camera/he_series_spec.cr
new file mode 100644
index 00000000000..a3856ad439f
--- /dev/null
+++ b/drivers/panasonic/camera/he_series_spec.cr
@@ -0,0 +1,21 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Panasonic::Camera::HESeries" do
+ # Send the request
+ retval = exec(:zoom?)
+
+ # sms should send a HTTP request
+ expect_http_request do |request, response|
+ cmd = request.query_params["cmd"]
+ if cmd == "#GZ"
+ response.status_code = 200
+ response.write "gZFFF".to_slice
+ else
+ raise "expected request: #{cmd}"
+ end
+ end
+
+ # What the sms function should return
+ retval.get.should eq(100.0)
+ status[:zoom].should eq 100
+end
diff --git a/drivers/panasonic/display/protocol2.cr b/drivers/panasonic/display/protocol2.cr
new file mode 100644
index 00000000000..852ab2d1d89
--- /dev/null
+++ b/drivers/panasonic/display/protocol2.cr
@@ -0,0 +1,282 @@
+require "digest/md5"
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+# Based off the very similar https://github.com/PlaceOS/drivers/blob/master/drivers/panasonic/display/nt_control.cr
+
+# How the display expects you interact with it:
+# ===============================================
+# 1. New connection required for each command sent (hence makebreak!)
+# 2. On connect, the display sends you a string of characters to use as a password salt
+# 3. Encode your message using the salt and send it to the display
+# 4. Display responds with a value
+# 5. You have to disconnect explicitly, display won't close the connection
+
+class Panasonic::Display::Protocol2 < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Inputs
+ HDMI
+ HDMI2
+ VGA
+ DVI
+ end
+
+ include Interface::InputSelection(Inputs)
+
+ # Discovery Information
+ tcp_port 1024
+ descriptive_name "Panasonic Display Protocol 2"
+ generic_name :Display
+
+ default_settings({
+ username: "admin1",
+ password: "panasonic",
+ })
+
+ makebreak!
+
+ def on_load
+ # Communication settings
+ transport.tokenizer = Tokenizer.new("\r")
+
+ schedule.every(60.seconds) { do_poll }
+
+ on_update
+ end
+
+ def disconnected
+ @channel.close unless @channel.closed?
+ end
+
+ @username : String = "admin1"
+ @password : String = "panasonic"
+
+ # used to coordinate the display password hash
+ @channel : Channel(String) = Channel(String).new
+ @power_target : Bool? = nil
+
+ def on_update
+ @username = setting?(String, :username) || "dispadmin"
+ @password = setting?(String, :password) || "@Panasonic"
+ end
+
+ COMMANDS = {
+ power_on: "PON",
+ power_off: "POF",
+ power_query: "QPW",
+ input: "IMS",
+ volume: "AVL",
+ volume_query: "QAV",
+ audio_mute: "AMT",
+ }
+ RESPONSES = COMMANDS.to_h.invert
+
+ def power(state : Bool)
+ @power_target = state
+
+ if state
+ logger.debug { "requested to power on" }
+ do_send(:power_on, retries: 10, name: :power, delay: 8.seconds)
+ else
+ logger.debug { "requested to power off" }
+ do_send(:power_off, retries: 10, name: :power, delay: 8.seconds)
+ end
+ power?
+ end
+
+ def power?(**options) : Bool
+ do_send(:power_query, **options).get
+ !!self[:power]?.try(&.as_bool)
+ end
+
+ INPUTS = {
+ Inputs::HDMI => "HM1",
+ Inputs::HDMI2 => "HM2",
+ Inputs::VGA => "PC1",
+ Inputs::DVI => "DVI",
+ }
+ INPUT_LOOKUP = INPUTS.invert
+
+ def switch_to(input : Inputs)
+ logger.debug { "requested to switch to: #{input}" }
+ do_send(:input, INPUTS[input], delay: 2.seconds)
+ self[:input] = input # for a responsive UI
+ end
+
+ # There is no input query command
+ def input?
+ self[:input]?
+ end
+
+ # There is no video mute command so this only mutes audio
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ if layer == MuteLayer::Video
+ logger.warn { "requested to mute video which is unsupported" }
+ else
+ logger.debug { "requested audio mute state: #{state}" }
+ do_send(:audio_mute, state ? 1 : 0)
+ end
+ end
+
+ def mute? : Bool
+ do_send(:audio_mute).get
+ !!self[:audio_mute]?.try(&.as_bool)
+ end
+
+ def volume(val : Int32 | Float64)
+ val = val.to_f.clamp(0.0, 100.0).round_away.to_i
+
+ # Unable to query current volume
+ do_send(:volume, val.to_s.rjust(3, '0')).get
+ self[:volume] = val
+ end
+
+ def volume? : Int32?
+ do_send(:volume_query).get
+ self[:volume]?.try(&.as_i)
+ end
+
+ def volume_up
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume + 5.0)
+ end
+
+ def volume_down
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume - 5.0)
+ end
+
+ def do_poll
+ if power?(priority: 0)
+ mute?
+ volume?
+ end
+ end
+
+ ERRORS = {
+ "ERR1" => "1: Undefined control command",
+ "ERR2" => "2: Out of parameter range",
+ "ERR3" => "3: Busy state or no-acceptable period",
+ "ERR4" => "4: Timeout or no-acceptable period",
+ "ERR5" => "5: Wrong data length",
+ "ERRA" => "A: Password mismatch",
+ "ER401" => "401: Command cannot be executed",
+ "ER402" => "402: Invalid parameter is sent",
+ }
+
+ def received(data, task)
+ data = String.new(data).strip
+ logger.debug { "Panasonic display sent: #{data} for #{task.try(&.name) || "unknown"}" }
+
+ # This is sent by the display on initial connection
+ # the channel is used to send the hash salt to the task sending a command
+ if data.starts_with?("NTCONTROL")
+ # check for protected mode
+ if @channel && !@channel.closed?
+ # 1 == protected mode
+ @channel.send(data[10] == '1' ? data[12..-1] : "")
+ else
+ transport.disconnect
+ end
+ return
+ end
+
+ # we no longer need the connection to be open , the display expects
+ # us to close it and a new connection is required per-command
+ transport.disconnect
+
+ # remove the leading 00
+ data = data[2..-1]
+
+ # Check for error response
+ if data[0] == 'E'
+ self[:last_error] = error_msg = ERRORS[data]
+
+ if {"ERR3", "ERR4"}.includes?(data)
+ logger.info { "display busy: #{error_msg} (#{data})" }
+ task.try(&.retry)
+ else
+ logger.error { "display error: #{error_msg} (#{data})" }
+ task.try(&.abort(error_msg))
+ end
+ return
+ end
+
+ # We can't interpret this message without a task reference
+ # This also makes sure it is no longer nil
+ return unless task
+
+ # Process the response
+ resp = data.split(':')
+ cmd = RESPONSES[resp[0]]?
+ val = resp[1]?
+
+ case cmd
+ when :power_on, :power_off, :power_query
+ self[:power] = cmd == :power_on if cmd == :power_on || cmd == :power_off
+ self[:power] = val.not_nil!.to_i == 1 if cmd == :power_query
+
+ # Ensure selected power state is achieved
+ if power_target = @power_target
+ if self[:power] == power_target
+ @power_target = nil
+ else
+ power(power_target)
+ end
+ end
+ when :input
+ self[:input] = INPUT_LOOKUP[val]
+ when :volume, :volume_query
+ self[:volume] = val.not_nil!.to_f
+ when :audio_mute
+ self[:audio_mute] = val.not_nil!.to_i == 1
+ end
+
+ task.success
+ end
+
+ protected def do_send(command, param = nil, **options)
+ # prepare the command
+ cmd = if param.nil?
+ "00#{COMMANDS[command]}\r"
+ else
+ "00#{COMMANDS[command]}:#{param}\r"
+ end
+
+ logger.debug { "queuing #{command}: #{cmd}" }
+
+ # queue the request
+ queue(**({
+ name: command,
+ }.merge(options))) do
+ # prepare channel and connect to the display (which will then send the random key)
+ @channel = Channel(String).new
+ transport.connect
+ # wait for the random key to arrive
+ random_key = @channel.receive
+ # build the password hash
+ password_hash = if random_key.empty?
+ # An empty key indicates unauthenticated mode
+ ""
+ else
+ Digest::MD5.hexdigest("#{@username}:#{@password}:#{random_key}")
+ end
+
+ message = "#{password_hash}#{cmd}"
+ logger.debug { "Sending: #{message}" }
+
+ # send the request
+ # NOTE:: the built in `send` function has implicit queuing, but we are
+ # in a task callback here so should be calling transport send directly
+ transport.send(message)
+ end
+ end
+end
diff --git a/drivers/panasonic/display/protocol2_spec.cr b/drivers/panasonic/display/protocol2_spec.cr
new file mode 100644
index 00000000000..2bda8681e56
--- /dev/null
+++ b/drivers/panasonic/display/protocol2_spec.cr
@@ -0,0 +1,75 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Panasonic::Display::Protocol2" do
+ password = "d4a58eaea919558fb54a33a2effa8b94"
+
+ # Execute a command (triggers the connection)
+ exec(:power?)
+ # Once connected the projector will send a password salt
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ # Check the request was sent with the correct password
+ should_send("#{password}00QPW\r")
+ # Respond with the status then check the state updated
+ responds("00QPW:0\r")
+ status[:power].should eq(false)
+
+ exec(:power, true)
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00PON\r")
+ responds("00PON\r")
+ sleep 8.seconds
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00QPW\r")
+ responds("00QPW:1\r")
+ status[:power].should eq(true)
+
+ exec(:switch_to, "hdmi")
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00IMS:HM1\r")
+ responds("00IMS:HM1\r")
+ status[:input].should eq("HDMI")
+
+ exec(:mute?)
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00AMT\r")
+ responds("00AMT:0\r")
+ status[:audio_mute].should eq(false)
+
+ exec(:mute)
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00AMT:1\r")
+ responds("00AMT:1\r")
+ status[:audio_mute].should eq(true)
+
+ exec(:volume?)
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00QAV\r")
+ responds("00QAV:100\r")
+ status[:volume].should eq(100)
+
+ exec(:volume, 20)
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00AVL:020\r")
+ responds("00AVL:020\r")
+ status[:volume].should eq(20)
+
+ exec(:power, false)
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00POF\r")
+ responds("00POF\r")
+ sleep 8.seconds
+ expect_reconnect
+ responds("NTCONTROL 1 09b075be\r")
+ should_send("#{password}00QPW\r")
+ responds("00QPW:0\r")
+ status[:power].should eq(false)
+end
diff --git a/drivers/panasonic/projector/nt_control.cr b/drivers/panasonic/projector/nt_control.cr
new file mode 100644
index 00000000000..99202460ecc
--- /dev/null
+++ b/drivers/panasonic/projector/nt_control.cr
@@ -0,0 +1,280 @@
+require "digest/md5"
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+# Documentation: https://aca.im/driver_docs/Panasonic/panasonic_pt-vw535n_manual.pdf
+# also https://aca.im/driver_docs/Panasonic/pt-ez580_en.pdf
+
+# How the projector expects you interact with it:
+# ===============================================
+# 1. New connection required for each command sent (hence makebreak!)
+# 2. On connect, the projector sends you a string of characters to use as a password salt
+# 3. Encode your message using the salt and send it to the projector
+# 4. Projector responds with a value
+# 5. You have to disconnect explicitly, projector won't close the connection
+
+class Panasonic::Projector::NTControl < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Inputs
+ HDMI
+ HDMI2
+ VGA
+ VGA2
+ Miracast
+ DVI
+ DisplayPort
+ HDBaseT
+ Composite
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(Inputs)
+
+ # Discovery Information
+ tcp_port 1024
+ descriptive_name "Panasonic Projector"
+ generic_name :Display
+ default_settings({username: "admin1", password: "panasonic"})
+ makebreak!
+
+ def on_load
+ # Communication settings
+ transport.tokenizer = Tokenizer.new("\r")
+
+ schedule.every(40.seconds) do
+ power?(priority: 0)
+ lamp_hours?(priority: 0)
+ end
+
+ on_update
+ end
+
+ def disconnected
+ @channel.close unless @channel.closed?
+ end
+
+ @username : String = "admin1"
+ @password : String = "panasonic"
+
+ # used to coordinate the projector password hash
+ @channel : Channel(String) = Channel(String).new
+ @stable_power : Bool = true
+
+ def on_update
+ @username = setting?(String, :username) || "admin1"
+ @password = setting?(String, :password) || "panasonic"
+ end
+
+ COMMANDS = {
+ power_on: "PON",
+ power_off: "POF",
+ power_query: "QPW",
+ freeze: "OFZ",
+ input: "IIS",
+ mute: "OSH",
+ lamp: "Q$S",
+ lamp_hours: "Q$L",
+ }
+ RESPONSES = COMMANDS.to_h.invert
+
+ def power(state : Bool)
+ self[:stable_power] = @stable_power = false
+ self[:power_target] = state
+
+ if state
+ logger.debug { "requested to power on" }
+ do_send(:power_on, retries: 10, name: :power, delay: 8.seconds)
+ do_send(:lamp)
+ else
+ logger.debug { "requested to power off" }
+ do_send(:power_off, retries: 10, name: :power, delay: 8.seconds).get
+
+ # Schedule this after we have a result for the power function
+ # As the projector does not even update to cooling for awhile
+ schedule.in(10.seconds) { do_send(:lamp) }
+ end
+ end
+
+ def power?(**options)
+ do_send(:lamp, **options)
+ end
+
+ def lamp_hours?(**options)
+ do_send(:lamp_hours, 1, **options)
+ end
+
+ INPUTS = {
+ Inputs::HDMI => "HD1",
+ Inputs::HDMI2 => "HD2",
+ Inputs::VGA => "RG1",
+ Inputs::VGA2 => "RG2",
+ Inputs::Miracast => "MC1",
+ Inputs::DVI => "DVI",
+ Inputs::DisplayPort => "DP1",
+ Inputs::HDBaseT => "DL1",
+ Inputs::Composite => "VID",
+ }
+ INPUT_LOOKUP = INPUTS.invert
+
+ def switch_to(input : Inputs)
+ # Projector doesn't automatically unmute
+ unmute if self[:mute]
+
+ do_send(:input, INPUTS[input], delay: 2.seconds)
+ logger.debug { "requested to switch to: #{input}" }
+
+ self[:input] = input # for a responsive UI
+ end
+
+ # Mutes audio + video
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ logger.debug { "requested mute state: #{state}" }
+
+ # TODO:: remote seems to support audio mute and AV mute
+ # but I can't find an audio mute command
+
+ actual = state ? 1 : 0
+ do_send(:mute, actual)
+ end
+
+ ERRORS = {
+ "ERR1" => "1: Undefined control command",
+ "ERR2" => "2: Out of parameter range",
+ "ERR3" => "3: Busy state or no-acceptable period",
+ "ERR4" => "4: Timeout or no-acceptable period",
+ "ERR5" => "5: Wrong data length",
+ "ERRA" => "A: Password mismatch",
+ "ER401" => "401: Command cannot be executed",
+ "ER402" => "402: Invalid parameter is sent",
+ }
+
+ def received(data, task)
+ data = String.new(data).strip
+ logger.debug { "Panasonic sent: #{data}" }
+
+ # This is sent by the projector on initial connection
+ # the channel is used to send the hash salt to the task sending a command
+ if data.starts_with? "NTCONTROL"
+ # check for protected mode
+ if @channel && !@channel.closed?
+ # 1 == protected mode
+ @channel.send(data[10] == '1' ? data[12..-1] : "")
+ else
+ transport.disconnect
+ end
+ return
+ end
+
+ # we no longer need the connection to be open , the projector expects
+ # us to close it and a new connection is required per-command
+ transport.disconnect
+
+ # Check for error response
+ if data[0] == 'E'
+ self[:last_error] = error_msg = ERRORS[data]
+
+ if {"ERR3", "ERR4"}.includes? data
+ logger.info { "projector busy: #{error_msg} (#{data})" }
+ task.try &.retry
+ else
+ logger.error { "projector error: #{error_msg} (#{data})" }
+ task.try &.abort(error_msg)
+ end
+ return
+ end
+
+ # We can't interpret this message without a task reference
+ # This also makes sure it is no longer nil
+ return unless task
+
+ # Process the response
+ data = data[2..-1]
+ resp = data.split(':')
+ cmd = RESPONSES[resp[0]]?
+ val = resp[1]?
+
+ case cmd
+ when :power_on
+ self[:power] = true
+ when :power_off
+ self[:power] = false
+ when :power_query
+ self[:power] = val.not_nil!.to_i == 1
+ when :freeze
+ self[:frozen] = val.not_nil!.to_i == 1
+ when :input
+ self[:input] = INPUT_LOOKUP[val]
+ when :mute
+ state = self[:mute] = val.not_nil!.to_i == 1
+ self[:mute0] = state
+ self[:mute0_video] = state
+ self[:mute0_audio] = state
+ else
+ case task.name
+ when "lamp"
+ ival = resp[0].to_i
+ self[:power] = {1, 2}.includes?(ival)
+ self[:warming] = ival == 1
+ self[:cooling] = ival == 3
+
+ # check target states here
+ if !@stable_power
+ if self[:power] == self[:power_target]
+ self[:stable_power] = @stable_power = true
+ else
+ power self[:power_target].as_bool
+ end
+ end
+ when "lamp_hours"
+ # Resp looks like: "001682"
+ self[:lamp_usage] = data.to_i
+ end
+ end
+
+ task.success
+ end
+
+ protected def do_send(command, param = nil, **options)
+ # prepare the command
+ cmd = if param.nil?
+ "00#{COMMANDS[command]}\r"
+ else
+ "00#{COMMANDS[command]}:#{param}\r"
+ end
+
+ logger.debug { "queuing #{command}: #{cmd}" }
+
+ # queue the request
+ queue(**({
+ name: command,
+ }.merge(options))) do
+ # prepare channel and connect to the projector (which will then send the random key)
+ @channel = Channel(String).new
+ transport.connect
+ # wait for the random key to arrive
+ random_key = @channel.receive
+ # build the password hash
+ password_hash = if random_key.empty?
+ # An empty key indicates unauthenticated mode
+ ""
+ else
+ Digest::MD5.hexdigest("#{@username}:#{@password}:#{random_key}")
+ end
+
+ message = "#{password_hash}#{cmd}"
+ logger.debug { "Sending: #{message}" }
+
+ # send the request
+ # NOTE:: the built in `send` function has implicit queuing, but we are
+ # in a task callback here so should be calling transport send directly
+ transport.send(message)
+ end
+ end
+end
diff --git a/drivers/panasonic/projector/nt_control_spec.cr b/drivers/panasonic/projector/nt_control_spec.cr
new file mode 100644
index 00000000000..33e4d098039
--- /dev/null
+++ b/drivers/panasonic/projector/nt_control_spec.cr
@@ -0,0 +1,25 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Panasonic::Projector::NTControl" do
+ # Execute a command (triggers the connection)
+ exec(:power?)
+
+ # Once connected the projector will send a password salt
+ expect_reconnect
+ transmit "NTCONTROL 1 09b075be\r"
+
+ # Check the request was sent with the correct password
+ password = "d4a58eaea919558fb54a33a2effa8b94"
+ should_send("#{password}00Q$S\r")
+
+ # Respond with the status then check the state updated
+ transmit("00PON\r")
+ status[:power].should be_true
+
+ exec(:lamp_hours?)
+ expect_reconnect
+ transmit "NTCONTROL 1 09b075be\r"
+ should_send("#{password}00Q$L:1\r")
+ transmit("001682\r")
+ status[:lamp_usage].should eq(1682)
+end
diff --git a/drivers/pattr/chat_bot.cr b/drivers/pattr/chat_bot.cr
new file mode 100644
index 00000000000..0eaedc8f3a2
--- /dev/null
+++ b/drivers/pattr/chat_bot.cr
@@ -0,0 +1,102 @@
+require "placeos-driver"
+require "./chat_bot_models"
+
+class Pattr::ChatBot < PlaceOS::Driver
+ descriptive_name "Pattr Chat Bot"
+ generic_name :ChatBot
+ description %(provides data based on context provided by the chat bot)
+
+ default_settings({
+ debug_webhook: false,
+ # building location services, defualts to the current system
+ buildings: ["system_id1"],
+ })
+
+ @debug_webhook : Bool = false
+ @buildings : Array(PlaceOS::Driver::Proxy::System) = [] of PlaceOS::Driver::Proxy::System
+
+ accessor staff_api : StaffAPI_1
+
+ protected getter zones : Hash(String, String) = {} of String => String
+ protected getter systems : Hash(String, String) = {} of String => String
+
+ def on_load
+ @zones = Hash(String, String).new do |hash, key|
+ zone = staff_api.zone(key).get.as_h
+ hash[key] = zone["display_name"]?.try(&.as_s?.try(&.presence)) || zone["name"].as_s
+ end
+
+ @systems = Hash(String, String).new do |hash, key|
+ zone = staff_api.get_system(key).get.as_h
+ hash[key] = zone["display_name"]?.try(&.as_s?.try(&.presence)) || zone["name"].as_s
+ end
+
+ on_update
+ end
+
+ def on_update
+ @debug_webhook = setting?(Bool, :debug_webhook) || false
+
+ # Convert the building system IDs to system proxies
+ buildings = setting?(Array(String), :buildings) || [config.control_system.not_nil!.id]
+ @buildings = buildings.map { |id| system(id) }
+ end
+
+ def chat_data_request(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "webhook received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+ logger.debug { body } if @debug_webhook
+
+ request = Request.from_json(body)
+ response = case request
+ in Location
+ locate(request.referencing)
+ end
+
+ payload = response.to_json
+ logger.debug { payload } if @debug_webhook
+ {HTTP::Status::OK.to_i, {"Content-Type" => "application/json"}, payload}
+ end
+
+ # map reduce search for the users across all buildings
+ def locate(staff : Array(String))
+ # kick off the searches
+ searches = staff.map do |username|
+ email = username.includes?('@') ? username : nil
+ queries = @buildings.map { |building| building[:LocationServices].locate_user(email, username) }
+ {username, queries}
+ end
+
+ # wait for the responses to flow in
+ response = {} of String => PlaceLocationResult
+ searches.each do |(username, queries)|
+ locations = {} of String => PlaceLocationResult
+ queries.each do |results|
+ Array(PlaceLocationResult).from_json(results.get.to_json).map do |location|
+ locations[location.location] = location
+ end
+ end
+
+ # Grab the location they are most likely to be
+ if location = locations["meeting"]? || locations["wireless"]? || locations["desk"]?
+ response[username] = location
+ end
+ end
+
+ # build the response
+ response.transform_values do |location|
+ case location.location
+ when "meeting"
+ {
+ building: zones[location.building],
+ level: zones[location.level],
+ room: systems[location.sys_id.not_nil!],
+ }
+ else
+ {
+ building: zones[location.building],
+ level: zones[location.level],
+ }
+ end
+ end
+ end
+end
diff --git a/drivers/pattr/chat_bot_models.cr b/drivers/pattr/chat_bot_models.cr
new file mode 100644
index 00000000000..2fd7ce30df5
--- /dev/null
+++ b/drivers/pattr/chat_bot_models.cr
@@ -0,0 +1,35 @@
+require "json"
+
+module Pattr
+ abstract class Request
+ include JSON::Serializable
+
+ # request type hint
+ use_json_discriminator "request", {
+ "location" => Location,
+ }
+
+ getter user : String
+ end
+
+ class Location < Request
+ getter request : String = "location"
+
+ # user emails / usernames of users we want to locate
+ getter referencing : Array(String)
+ end
+
+ class PlaceLocationResult
+ include JSON::Serializable
+
+ # wireless, desk, meeting, booking
+ getter location : String
+
+ # zone ids
+ getter building : String
+ getter level : String
+
+ # system id (if it's a meeting room)
+ getter sys_id : String?
+ end
+end
diff --git a/drivers/pattr/chat_bot_spec.cr b/drivers/pattr/chat_bot_spec.cr
new file mode 100644
index 00000000000..900b83d4d24
--- /dev/null
+++ b/drivers/pattr/chat_bot_spec.cr
@@ -0,0 +1,45 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Pattr::ChatBot" do
+ system({
+ LocationServices: {LocationServicesMock},
+ StaffAPI: {StaffAPIMock},
+ })
+ settings({
+ buildings: [DriverSpecs::SYSTEM_ID],
+ })
+
+ exec(:locate, ["user@org.com"]).get.should eq({"user@org.com" => {
+ "building" => "The Zone",
+ "level" => "The Zone",
+ "room" => "Room 1234",
+ }})
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def get_system(id : String)
+ {
+ name: "Some System",
+ display_name: "Room 1234",
+ }
+ end
+
+ def zone(zone_id : String)
+ {
+ name: "The Zone",
+ }
+ end
+end
+
+# :nodoc:
+class LocationServicesMock < DriverSpecs::MockDriver
+ def locate_user(email : String? = nil, username : String? = nil)
+ [{
+ location: "meeting",
+ building: "zone-id",
+ level: "zone-id",
+ sys_id: "sys-123",
+ }]
+ end
+end
diff --git a/drivers/philips/dynalite.cr b/drivers/philips/dynalite.cr
new file mode 100644
index 00000000000..7152da4db5e
--- /dev/null
+++ b/drivers/philips/dynalite.cr
@@ -0,0 +1,221 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+
+# Documentation: https://aca.im/driver_docs/Philips/Dynet%20Integrators%20hand%20book%20for%20the%20DNG232%20V2.pdf
+# also https://aca.im/driver_docs/Philips/DyNet%201%20Opcode%20Master%20List%20-%202012-08-29.xls
+
+class Philips::Dynalite < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ # Discovery Information
+ descriptive_name "Philips Dynalite Lighting"
+ generic_name :Lighting
+ tcp_port 50000
+
+ def on_load
+ queue.wait = false
+ queue.delay = 35.milliseconds
+ queue.retries = 0
+ # 8 bytes starting with 1C
+ transport.tokenizer = Tokenizer.new(8, Bytes[0x1C])
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def connected
+ # maintain the connection
+ schedule.every(1.minute) do
+ logger.debug { "maintaining connection" }
+ get_current_preset(1)
+ end
+ end
+
+ # fade_time in millisecond
+ def trigger(area : Int32, scene : Int32, fade : Int32 = 1000)
+ # convert to centiseconds
+ fade_centi = fade // 10
+
+ # No response so we should update status here
+ self[Area.new(area.to_u32)] = scene
+
+ # Crazy scene encoding
+ # Supports presets: 1 - 24 (0 indexed)
+ # Presets are in 1 of 3 banks (0 indexed)
+ # Presets in a bank are encoded: 0 = P1, 1 = P2, 2 = P3, 3 = P4, A = P5, B = P6, C = P7, D = P8
+ scene = scene - 1 # zero index
+ bank = scene // 8 # calculate bank this preset resides in
+ scene = scene - (bank * 8) # select the scene in the current bank
+ scene += 6 if scene >= 4 # encode the upper bank presets (P5 -> P8)
+
+ command = Bytes[0x1c, area & 0xFF, fade_centi & 0xFF, scene & 0xFF, (fade_centi >> 8) & 0xFF, bank, 0xFF]
+ schedule.in((fade + 200).milliseconds) { get_light_level(area) }
+
+ do_send(command, name: "preset_#{area}_#{scene}")
+ end
+
+ def get_current_preset(area : UInt8)
+ command = Bytes[0x1c, area, 0, 0x63, 0, 0, 0xFF]
+ do_send(command, wait: true)
+ end
+
+ @[Security(Level::Administrator)]
+ def save_preset(area : UInt8, scene : UInt8)
+ num = (scene - 1) & 0xFF
+ command = Bytes[0x1c, area, num, 0x09, 0, 0, 0xFF]
+ do_send(command)
+ end
+
+ def lighting(area : Int32, state : Bool, fade : Int32 = 1000)
+ level = state ? 100.0 : 0.0
+ light_level(area, level, fade)
+ end
+
+ LEVEL_PERCENTAGE = 0xFE / 100
+
+ def light_level(area : Int32, level : Float64, fade : Int32 = 1000, channel : Int32 = 0xFF)
+ cmd = 0x71
+
+ # Command changes based on the length of the fade time
+ fade = if fade <= 25500
+ fade // 100
+ elsif fade < 255000
+ cmd = 0x72
+ fade // 1000
+ else
+ cmd = 0x73
+ (fade // 60000).clamp(1, 22)
+ end
+
+ # Ensure status values are valid
+ area_key = Area.new(area.to_u32, channel: channel == 0xFF ? nil : channel.to_u32).append("level").to_s
+ self[area_key] = level
+
+ # Levels are percentage based (on the PlaceOS side)
+ # 0x01 == 100%
+ # 0xFF == 0%
+ level = (level.clamp(0.0, 100.0) * LEVEL_PERCENTAGE).to_u8
+ level = 0xFF_u8 - level # Invert
+
+ command = Bytes[0x1c, area & 0xFF, channel & 0xFF, cmd, level, fade & 0xFF, 0xFF]
+ do_send(command, name: "level_#{area}_#{channel}")
+ end
+
+ def stop_fading(area : UInt8, channel : UInt8 = 0xFF_u8)
+ command = Bytes[0x1c, area, channel, 0x76, 0, 0, 0xFF]
+ do_send(command, name: "level_#{area}_#{channel}")
+ end
+
+ def stop_all_fading(area : UInt8)
+ command = Bytes[0x1c, area, 0, 0x7A, 0, 0, 0xFF]
+ do_send(command)
+ end
+
+ def get_light_level(area : Int32, channel : Int32 = 0xFF)
+ do_send(Bytes[0x1c, area & 0xFF, channel & 0xFF, 0x61, 0, 0, 0xFF], wait: true)
+ end
+
+ def increment_area_level(area : UInt8)
+ do_send(Bytes[0x1c, area, 0x64, 6, 0, 0, 0xFF])
+ end
+
+ def decrement_area_level(area : UInt8)
+ do_send(Bytes[0x1c, area, 0x64, 5, 0, 0, 0xFF])
+ end
+
+ def unlink_area(area : UInt8)
+ # 0x1c, area, unlink_bitmap, 0x21, unlink_bitmap, unlink_bitmap, join (0xFF)
+ # do_send(Bytes[0x1c, area & 0xFF, 0xFF, 0x21, 0xFF, 0xFF, 0xFF])
+ link_area area, 0_u8
+ end
+
+ def link_area(area : UInt8, join : UInt8)
+ do_send(Bytes[0x1c, area, join, 0x14, 0x00, 0x00, 0xFF])
+ end
+
+ def received(data, task)
+ logger.debug { "received 0x#{data.hexstring}" }
+
+ case data[3]
+ # current preset selected response
+ when 0, 1, 2, 3, 10, 11, 12, 13
+ # 0-3, A-D == preset 1..8
+ number = data[3]
+ number -= 0x0A + 4 if number > 3
+
+ # Data 4 represets the preset offset or bank
+ number += data[5] * 8 + 1
+ self[Area.new(data[1].to_u32)] = number
+ task.try &.success(number)
+
+ # alternative preset response
+ when 0x62
+ number = data[2] + 1
+ self[Area.new(data[1].to_u32)] = number
+ task.try &.success(number)
+ # level response (area or channel)
+ when 0x60
+ level = data[4]
+
+ # 0x01 == 100%
+ # 0xFF == 0%
+ level = 0xFF - level
+ level = level / LEVEL_PERCENTAGE
+ channel = data[2].to_u32
+ area_key = Area.new(data[1].to_u32, channel: channel == 0xFF_u32 ? nil : channel).append("level").to_s
+ self[area_key] = level
+
+ task.try &.success(level)
+ else
+ task.try &.success
+ end
+ end
+
+ protected def do_send(command : Bytes, **options)
+ # 2's compliment checksum (i.e. negative of the sum and the least significant byte of that result)
+ check = (-command.reduce(0) { |acc, i| acc + i }) & 0xFF
+ data = IO::Memory.new(command.size + 1)
+ data.write command
+ data.write_byte check.to_u8
+
+ command = data.to_slice
+ logger.debug { "sending: 0x#{command.hexstring}" }
+
+ send(command, **options)
+ end
+
+ # ==================
+ # Lighting Interface
+ # ==================
+ protected def check_arguments(area : Area?)
+ area_id = area.try(&.id)
+ # area_join = area.try(&.join) || 0xFF_u32
+ raise ArgumentError.new("area.id required") unless area_id
+ area_id.to_i
+ end
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area_id = check_arguments area
+ trigger(area_id, scene.to_i, fade_time.to_i)
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ area_id = check_arguments area
+ get_current_preset(area_id.to_u8)
+ end
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area_id = check_arguments area
+ area_channel = area.try(&.channel) || 0xFF_u32
+ light_level(area_id, level, fade_time.to_i, area_channel.to_i)
+ end
+
+ def lighting_level?(area : Area? = nil)
+ area_id = check_arguments area
+ area_channel = area.try(&.channel) || 0xFF_u32
+ get_light_level(area_id, area_channel.to_i)
+ end
+end
diff --git a/drivers/philips/dynalite_spec.cr b/drivers/philips/dynalite_spec.cr
new file mode 100644
index 00000000000..e324a5ea8a6
--- /dev/null
+++ b/drivers/philips/dynalite_spec.cr
@@ -0,0 +1,40 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Philips::Dynalite" do
+ exec :trigger, 1, 4, 320
+ should_send Bytes[0x1c, 0x01, 0x20, 0x03, 0x00, 0x00, 0xff, 0xc1]
+ status["area1"].should eq(4)
+
+ # 5 second fade
+ exec :light_level, area: 2, level: 49.5, fade: 5000, channel: 3
+ should_send Bytes[0x1c, 0x02, 0x03, 0x71, 0x82, 0x32, 0xff, 0xbb]
+ status["area2_3_level"].should eq(49.5)
+
+ # 50 second fade
+ exec :light_level, area: 2, level: 49.5, fade: 50000, channel: 3
+ should_send Bytes[0x1c, 0x02, 0x03, 0x72, 0x82, 0x32, 0xff, 0xba]
+ status["area2_3_level"].should eq(49.5)
+
+ # 15 minute fade
+ exec :light_level, area: 2, level: 49.5, fade: 900000, channel: 3
+ should_send Bytes[0x1c, 0x02, 0x03, 0x73, 0x82, 0x0f, 0xff, 0xdc]
+ status["area2_3_level"].should eq(49.5)
+
+ exec :stop_fading, area: 4, channel: 6
+ should_send Bytes[0x1c, 0x04, 0x06, 0x76, 0x00, 0x00, 0xff, 0x65]
+
+ exec :stop_all_fading, area: 4
+ should_send Bytes[0x1c, 0x04, 0x00, 0x7A, 0x00, 0x00, 0xff, 0x67]
+
+ response = exec :get_current_preset, area: 4
+ should_send Bytes[0x1c, 0x04, 0x00, 0x63, 0x00, 0x00, 0xff, 0x7e]
+ responds Bytes[0x1c, 0x04, 0x05, 0x62, 0x00, 0x00, 0xff, 0x7A]
+ response.get.should eq(6)
+ status["area4"].should eq(6)
+
+ response = exec :get_light_level, area: 2, channel: 5
+ should_send Bytes[0x1c, 0x02, 0x05, 0x61, 0x00, 0x00, 0xff, 0x7d]
+ responds Bytes[0x1c, 0x02, 0x05, 0x60, 0x70, 0x70, 0xff, 0x9f]
+ response.get.not_nil!.as_f.to_i.should eq(56)
+ status["area2_5_level"].not_nil!.as_f.to_i.should eq(56)
+end
diff --git a/drivers/philips/dynet_text.cr b/drivers/philips/dynet_text.cr
new file mode 100644
index 00000000000..6b2049ed070
--- /dev/null
+++ b/drivers/philips/dynet_text.cr
@@ -0,0 +1,223 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+require "telnet"
+
+# Documentation: https://aca.im/driver_docs/Philips/DYN_CG_INT_EnvisionGateway_R05.pdf
+# See page 58
+
+class Philips::DyNetText < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ # Discovery Information
+ descriptive_name "Philips DyNet Text Protocol"
+ generic_name :Lighting
+ tcp_port 23
+
+ @ready : Bool = false
+
+ protected getter! telnet : Telnet
+
+ def on_load
+ new_telnet_client
+ transport.pre_processor { |bytes| telnet.buffer(bytes) }
+ transport.tokenizer = Tokenizer.new("\r\n")
+ end
+
+ def connected
+ @ready = false
+ self[:ready] = false
+
+ schedule.every(60.seconds) do
+ logger.debug { "-- polling gateway" }
+ get_date
+ end
+ end
+
+ def disconnected
+ # Ensures the buffer is cleared
+ new_telnet_client
+ schedule.clear
+ end
+
+ protected def new_telnet_client
+ @telnet = Telnet.new { |data| puts "neg: #{data.hexstring}"; transport.send(data) }
+ end
+
+ def received(data, task)
+ data = String.new(data).strip("\x00\r\n\t ")
+ return if data.empty?
+
+ logger.debug { "Dynalite sent: #{data}" }
+
+ if @ready
+ # Extract response
+ components = data.split(", ").map { |component|
+ parts = component.downcase.split
+ value = parts.pop
+ key = parts.join(' ')
+ {key, value}
+ }.to_h
+ process_response data, components, task
+ elsif data =~ /Connection Established/i
+ @ready = true
+ self[:ready] = true
+
+ # Turn off echo
+ do_send "Echo 0", name: "echo"
+ # ensure verbose messages
+ do_send "Verbose", name: "verbose"
+ # Reply with OK
+ do_send "ReplyOK 1", name: "replies"
+ # default join is FF
+ do_send "Join 255", name: "join"
+ end
+ end
+
+ protected def process_response(message : String, parts : Hash(String, String), task)
+ task_name = task.try(&.name)
+ success = task_name.nil?
+
+ # For execute commands we consider complete once we get the OK message
+ if message == "OK"
+ if task && task_name
+ # We want to process replies completely (return the value)
+ # however we don't want to retry in case the target doesn't exist
+ if task_name.starts_with?("get_")
+ task.retries = 0
+ else
+ logger.debug { "execute #{task_name} success!" }
+ task.success
+ end
+ end
+ return
+ end
+
+ check_key = parts.first_key
+ case check_key
+ when "preset"
+ area = parts["area"]?
+ # return here if we are just getting the echo of our request
+ return unless area
+ join = get_join parts["join"]
+ area_key = Area.new(area.to_u32, join: join == 255_u32 ? nil : join)
+ self[area_key] = parts.first_value.to_i
+ when "channel level channel"
+ area = parts["area"].to_u32
+ self[Area.new(area).append("level")] = parts["level"].to_i(strict: false)
+ when .starts_with?("date")
+ success = true if task_name == "date"
+ when .starts_with?("time")
+ success = true if task_name == "time"
+ when .starts_with?("reply")
+ case check_key
+ when .ends_with?("current preset")
+ preset = parts.first_value.to_i
+ area = parts["area"].to_u32
+ join = get_join parts["join"]
+ area_key = Area.new(area, join: join == 255_u32 ? nil : join).to_s
+
+ self[area_key] = preset
+ task.not_nil!.success(preset) if task_name == "get_#{area_key}"
+ when .ends_with?("level ch")
+ area = parts["area"].to_u32
+ join = get_join parts["join"]
+ area_key = Area.new(area, join: join == 255_u32 ? nil : join).append("level").to_s
+ level = parts["targlev"].to_i(strict: false)
+
+ self[area_key] = level
+ task.not_nil!.success(level) if task_name == "get_#{area_key}"
+ end
+ when "channellevel", "stopfade", .starts_with?("requestcurrentpreset"), .starts_with?("requestchannellevel")
+ # we ignore this echo
+ else
+ logger.debug { "ignorning message: #{message}, key: #{check_key.inspect}" }
+ end
+
+ # ignore unless sucess
+ task.try(&.success) if success
+ end
+
+ protected def do_send(command, **options)
+ send telnet.prepare(command), **options
+ end
+
+ protected def get_join(value : String)
+ value = value.rchop("hex")
+ value = value.lchop("0x")
+ value.to_u32(16)
+ end
+
+ def get_date
+ do_send "RequestDate", name: :date
+ end
+
+ def get_time
+ do_send "RequestTime", name: :time
+ end
+
+ def trigger(area : UInt16, scene : UInt16, join : UInt8 = 0xFF_u8, fade : UInt32 = 1000_u32)
+ do_send "Preset #{scene} #{area} #{fade} #{join}", name: "preset#{area}_#{join}"
+ end
+
+ @[Security(Level::Support)]
+ def send_custom(data : String)
+ do_send data
+ end
+
+ def get_current_preset(area : UInt16, join : UInt8 = 0xFF_u8)
+ do_send "RequestCurrentPreset #{area} #{join}", name: (join == 255_u8 ? "get_area#{area}" : "get_area#{area}_#{join}")
+ end
+
+ def lighting(area : UInt16, state : Bool, join : UInt8 = 0xFF_u8, fade : UInt32 = 1000_u32)
+ light_level(area, state ? 100.0 : 0.0, join, fade)
+ end
+
+ def light_level(area : UInt16, level : Float64, join : UInt8 = 0xFF_u8, fade : UInt32 = 1000_u32, channel : UInt16 = 0_u16)
+ # channel 0 is all channels
+ level = level.round_away.to_i
+ do_send "ChannelLevel #{channel} #{level.clamp(0, 100)} #{area} #{fade} #{join}", name: "level#{area}_#{channel}_#{join}"
+ end
+
+ def get_light_level(area : UInt16, join : UInt8 = 0xFF_u8, channel : UInt16 = 1_u16)
+ # can't request level of channel 0 (all channels) so we default to channel 1 which should always exist
+ do_send "RequestChannelLevel #{channel} #{area} #{join}", name: (join == 255_u8 ? "get_area#{area}_level" : "get_area#{area}_#{join}_level")
+ end
+
+ def stop_fading(area : UInt16, join : UInt8 = 0xFF_u8, channel : UInt16 = 0_u16)
+ do_send "StopFade #{channel} #{area} #{join}", name: "stopfade#{area}_#{join}_#{channel}"
+ end
+
+ # ==================
+ # Lighting Interface
+ # ==================
+ protected def check_arguments(area : Area?)
+ area_id = area.try(&.id)
+ area_join = area.try(&.join) || 0xFF_u32
+ raise ArgumentError.new("area.id required, area.join defaults to 0xFF") unless area_id
+ {area_id.to_u16, area_join.to_u8}
+ end
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area_id, area_join = check_arguments area
+ trigger(area_id, scene.to_u16, area_join, fade_time)
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ area_id, area_join = check_arguments area
+ get_current_preset(area_id, area_join)
+ end
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area_id, area_join = check_arguments area
+ area_channel = area.try(&.channel) || 0_u32
+ light_level(area_id, level, area_join, fade_time, area_channel.to_u16)
+ end
+
+ def lighting_level?(area : Area? = nil)
+ area_id, area_join = check_arguments area
+ area_channel = area.try(&.channel) || 1_u32
+ get_light_level(area_id, area_join, area_channel.to_u16)
+ end
+end
diff --git a/drivers/philips/dynet_text_spec.cr b/drivers/philips/dynet_text_spec.cr
new file mode 100644
index 00000000000..71d95e385ae
--- /dev/null
+++ b/drivers/philips/dynet_text_spec.cr
@@ -0,0 +1,101 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Philips::DyNetText" do
+ # Telnet establishment
+ transmit "fffb01".hexbytes
+ should_send "fffd01".hexbytes
+
+ transmit "fffd01".hexbytes
+ should_send "fffc01".hexbytes
+
+ transmit "fffb03".hexbytes
+ should_send "fffd03".hexbytes
+
+ transmit "fffd03".hexbytes
+ should_send "fffc03".hexbytes
+
+ transmit "fffb05".hexbytes
+ should_send "fffe05".hexbytes
+
+ transmit "fffd05".hexbytes
+ should_send "fffc05".hexbytes
+
+ transmit "Telnet Connection Established ...\r\n\r\n"
+ sleep 100.milliseconds
+
+ # Configure protocol
+ status[:ready].should eq true
+
+ should_send "Echo 0\r\x00"
+ responds "OK\r\n"
+ should_send "Verbose\r\x00"
+ responds "OK\r\n"
+ should_send "ReplyOK 1\r\x00"
+ responds "OK\r\n"
+ should_send "Join 255\r\x00"
+ responds "OK\r\n"
+
+ # Process some data
+ transmit "Preset 2, Area 103, Fade 0, Join 0xff\r\n"
+ sleep 100.milliseconds
+ status["area103"].should eq(2)
+
+ # WTF philips, why are there multiple hex representations
+ transmit "Channel Level Channel 43, Level 100%, Area 137, Fade 0, Join ffhex\r\n"
+ sleep 100.milliseconds
+ status["area137_level"].should eq(100)
+
+ # This is a good ping request
+ transmit "Date Wed 8 Jun 2022\r\n"
+ transmit "Time 13:35:51 Standard Time\r\n"
+
+ # NOTE:: Tests here respond with echos even though we turn this off
+ # just for in case the request is ignored
+
+ # Execute some query requests
+ resp = exec :get_current_preset, 58
+ should_send "RequestCurrentPreset 58 255\r\x00"
+ responds "RequestCurrentPreset 58 255\r\n"
+ responds "OK\r\n"
+ # yep, it sometimes replies with a leading null byte, just to screw up anyone dealing with this protocol in C
+ responds "\x00Reply with Current Preset 2, Area 58, Join ffhex\r\n"
+
+ resp.get.should eq 2
+ status["area58"].should eq(2)
+
+ resp = exec :get_light_level, 58
+ should_send "RequestChannelLevel 1 58 255\r\x00"
+ responds "RequestChannelLevel 1 58 255\r\n"
+ responds "OK\r\n"
+ responds "Reply with Current Level Ch 1, Area 58, TargLev 100%, CurrLev 100%, Join ffhex\r\n"
+
+ resp.get.should eq 100
+ status["area58_level"].should eq(100)
+
+ # Execute some update requests
+ resp = exec :trigger, 70, 2
+ should_send "Preset 2 70 1000 255\r\x00"
+ responds "Preset 2 70 1000 255\r\n"
+ responds "OK\r\n"
+ responds "Preset 2, Area 70, Fade 1000, Join 0xff\r\n"
+ resp.get
+ sleep 100.milliseconds
+ status["area70"].should eq(2)
+
+ resp = exec :light_level, 70, 90.1
+ should_send "ChannelLevel 0 90 70 1000 255\r\x00"
+ responds "ChannelLevel 0 100 70 1000 255\r\n"
+ responds "OK\r\n"
+ responds "Channel Level Channel 0, Level 90%, Area 70, Fade 1000, Join ffhex\r\n"
+ resp.get
+ sleep 100.milliseconds
+ status["area70_level"].should eq(90)
+
+ resp = exec :stop_fading, 70
+ should_send "StopFade 0 70 255\r\x00"
+ responds "StopFade 0 70 255\r\n"
+ responds "OK\r\n"
+ resp.get
+
+ puts "Test passed!"
+end
diff --git a/drivers/philips/hue.cr b/drivers/philips/hue.cr
new file mode 100644
index 00000000000..087407c1785
--- /dev/null
+++ b/drivers/philips/hue.cr
@@ -0,0 +1,193 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+
+# documentation: https://developers.meethue.com/develop/hue-api-v2/api-reference/
+
+class Philips::Hue < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+
+ # component == resource
+ # id == id
+ alias Area = Interface::Lighting::Area
+
+ # Discovery Information
+ generic_name :Hue
+ descriptive_name "Philips Hue Lighting"
+ uri_base "https://192.168.4.31"
+
+ default_settings({
+ app_key: "",
+ client_key: "",
+
+ scenes: [""],
+ })
+
+ def on_update
+ @app_key = setting(String, :app_key)
+ @client_key = setting(String, :client_key)
+ @scenes = setting?(Array(String), :scenes) || [] of String
+ end
+
+ @[Security(Level::Administrator)]
+ getter app_key : String = ""
+
+ @[Security(Level::Administrator)]
+ getter client_key : String = ""
+
+ getter scenes : Array(String) = [] of String
+
+ record HueError, type : Int32, address : String, description : String do
+ include JSON::Serializable
+ end
+
+ record RegSuccess, username : String, clientkey : String do
+ include JSON::Serializable
+ end
+
+ record RegResponse, success : RegSuccess?, error : HueError? do
+ include JSON::Serializable
+ end
+
+ def register
+ response = post("/api", body: {
+ devicetype: "placeos##{module_id}",
+ generateclientkey: true,
+ }.to_json)
+
+ raise "unknown error: #{response.body}" unless response.success?
+
+ resp = Array(RegResponse).from_json(response.body)[0]
+ if success = resp.success
+ @app_key = success.username
+ @client_key = success.clientkey
+ define_setting(:app_key, @app_key)
+ define_setting(:client_key, @client_key)
+ @app_key
+ else
+ error = resp.error.as(HueError)
+ logger.error { "type #{error.type}: #{error.description}" }
+ error.description
+ end
+ end
+
+ enum Resource
+ Light
+ Scene
+ Room
+ Zone
+ GroupedLight
+ Device
+ Motion
+ GroupedMotion
+ GroupedLightLevel
+ CameraMotion
+ Temperature
+ end
+
+ def resource_details(resource : Resource, id : String? = nil)
+ # NOTE:: HUE does not like trailing slashes, hence the weird check below
+ response = get("/clip/v2/resource/#{resource.to_s.underscore}#{id.presence ? "/#{id}" : ""}", headers: HTTP::Headers{
+ "hue-application-key" => app_key,
+ })
+ JSON.parse response.body
+ end
+
+ def device_list
+ resource_details(Resource::Device)
+ end
+
+ def scene_list
+ resource_details(Resource::Scene)
+ end
+
+ # convert RGB to CIE which is used by Hue
+ def rgb_to_cie(r : UInt8, g : UInt8, b : UInt8) : Tuple(Float64, Float64)
+ # Normalize RGB values
+ r_norm = r / 255.0
+ g_norm = g / 255.0
+ b_norm = b / 255.0
+
+ # Apply gamma correction
+ r_lin = (r_norm > 0.04045) ? ((r_norm + 0.055) / 1.055) ** 2.4 : r_norm / 12.92
+ g_lin = (g_norm > 0.04045) ? ((g_norm + 0.055) / 1.055) ** 2.4 : g_norm / 12.92
+ b_lin = (b_norm > 0.04045) ? ((b_norm + 0.055) / 1.055) ** 2.4 : b_norm / 12.92
+
+ # Convert to XYZ
+ x = r_lin * 0.4124 + g_lin * 0.3576 + b_lin * 0.1805
+ y = r_lin * 0.2126 + g_lin * 0.7152 + b_lin * 0.0722
+ z = r_lin * 0.0193 + g_lin * 0.1192 + b_lin * 0.9505
+
+ # Convert to xy
+ xy_x = x / (x + y + z)
+ xy_y = y / (x + y + z)
+
+ {xy_x, xy_y}
+ end
+
+ def set_light_colour(light_id : Int32, r : UInt8 = 0_u8, g : UInt8 = 0_u8, b : UInt8 = 0_u8)
+ x, y = rgb_to_cie(r, g, b)
+ response = put("/clip/v2/resource/light/#{light_id}", headers: HTTP::Headers{
+ "hue-application-key" => app_key,
+ }, body: {color: {xy: {x: x, y: y}}}.to_json)
+ raise "error controlling light (#{response.status})\n#{response.body}" unless response.success?
+ JSON.parse response.body
+ end
+
+ def set_light_level(light_id : String, level : UInt32, resource : Resource = Resource::Light)
+ level = level.clamp(0, 100)
+
+ if level == 0
+ response = put("/clip/v2/resource/#{resource.to_s.underscore}/#{light_id}", headers: HTTP::Headers{
+ "hue-application-key" => app_key,
+ }, body: {on: {on: false}}.to_json)
+ else
+ response = put("/clip/v2/resource/#{resource.to_s.underscore}/#{light_id}", headers: HTTP::Headers{
+ "hue-application-key" => app_key,
+ }, body: {on: {on: true}, dimming: {brightness: level}}.to_json)
+ end
+
+ raise "error controlling light (#{response.status})\n#{response.body}" unless response.success?
+ level
+ end
+
+ def set_scene(scene_id : String)
+ response = put("/clip/v2/resource/scene/#{scene_id}", headers: HTTP::Headers{
+ "hue-application-key" => app_key,
+ }, body: {recall: {action: :active}}.to_json)
+ raise "error activating scene (#{response.status})\n#{response.body}" unless response.success?
+ response.body
+ end
+
+ # ==================
+ # Lighting Interface
+ # ==================
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ scene_id = @scenes[scene]
+ set_scene scene_id
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ raise "not really a thing"
+ end
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ level_int = level.round_away.to_u32
+ area = area.as(Area)
+ area_id = area.id.as(String)
+ resource = Resource.parse(area.component || "light")
+
+ # TODO:: fade_time is possible using signaling duration
+ set_light_level(area_id, level_int, resource)
+ end
+
+ def lighting_level?(area : Area? = nil)
+ raise "no area provided" unless area
+ area_id = area.id.as(String)
+ resource = Resource.parse(area.component || "light")
+
+ json = resource_details(resource, area_id)
+ state = json["on"]["on"].as_bool
+ state ? json["dimming"]["brightness"].as_i : 0
+ end
+end
diff --git a/drivers/philips/hue_spec.cr b/drivers/philips/hue_spec.cr
new file mode 100644
index 00000000000..fbcdcc6cbfd
--- /dev/null
+++ b/drivers/philips/hue_spec.cr
@@ -0,0 +1,23 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "MessageMedia::SMS" do
+ # Connect response (both return a 200 success response)
+ success_json = "[{\"success\":{\"username\":\"KrSPjVbcROQj4MIhQ1U2XZ2hgi-jznfhBL8eBZIt\",\"clientkey\":\"26CE9D1876E8570DA1C6A56F2A08F4AA\"}}]"
+ error_json = "[{\"error\":{\"type\":101,\"address\":\"\",\"description\":\"link button not pressed\"}}]"
+
+ # Fail a registration request
+ retval = exec(:register)
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << error_json
+ end
+ retval.get.should eq("link button not pressed")
+
+ # succeed at registration
+ retval = exec(:register)
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response << success_json
+ end
+ retval.get.should eq("KrSPjVbcROQj4MIhQ1U2XZ2hgi-jznfhBL8eBZIt")
+end
diff --git a/drivers/place/area_config.cr b/drivers/place/area_config.cr
new file mode 100644
index 00000000000..7c4df5385ed
--- /dev/null
+++ b/drivers/place/area_config.cr
@@ -0,0 +1,70 @@
+require "json"
+require "./area_polygon"
+
+module Place
+ class Geometry
+ include JSON::Serializable
+
+ def initialize(@coordinates, @geo_type = "Polygon")
+ end
+
+ @[JSON::Field(key: "type")]
+ property geo_type : String
+ property coordinates : Array(Tuple(Float64, Float64))
+ end
+
+ class AreaConfig
+ include JSON::Serializable
+
+ def initialize(@id, name, coordinates, building_id = nil, @area_type = "Feature", @feature_type = "section", capacity = nil)
+ @geometry = Geometry.new(coordinates)
+ @properties = Hash(String, JSON::Any::Type | Hash(String, JSON::Any)).new
+ @properties["name"] = name
+ @properties["building_id"] = building_id if building_id
+ @properties["capacity"] = capacity if capacity
+ end
+
+ @[JSON::Field(ignore: true)]
+ @polygon : Polygon? = nil
+
+ property id : String
+
+ @[JSON::Field(key: "type")]
+ property area_type : String
+ property feature_type : String
+
+ property geometry : Geometry
+ property properties : Hash(String, JSON::Any::Type)
+
+ @[JSON::Field(ignore: true)]
+ @adjusted_coords : Array(Tuple(Float64, Float64))? = nil
+
+ def name : String
+ self.properties["name"].as(String)
+ end
+
+ def building : String?
+ self.properties["building_id"]?.as?(String)
+ end
+
+ def capacity : Int32?
+ self.properties["capacity"]?.as?(Int64 | Float64).try &.to_i
+ end
+
+ def coordinates
+ if coords = @adjusted_coords
+ coords
+ else
+ self.geometry.coordinates
+ end
+ end
+
+ def coordinates(map_width : Float64, map_height : Float64)
+ @adjusted_coords = self.geometry.coordinates.map { |(x, y)| {x * map_width, y * map_height} }
+ end
+
+ def polygon : Polygon
+ @polygon ||= Polygon.new(coordinates.map { |coords| Point.new(*coords) })
+ end
+ end
+end
diff --git a/drivers/place/area_management.cr b/drivers/place/area_management.cr
new file mode 100644
index 00000000000..a634086c728
--- /dev/null
+++ b/drivers/place/area_management.cr
@@ -0,0 +1,686 @@
+require "set"
+require "placeos"
+require "placeos-driver"
+require "./area_config"
+require "./area_polygon"
+require "placeos-driver/interface/sensor"
+
+::PlaceOS::Driver::Interface::Sensor.include_unit_conversions
+
+class Place::AreaManagement < PlaceOS::Driver
+ descriptive_name "PlaceOS Area Management"
+ generic_name :AreaManagement
+ description %(counts trackable objects, such as laptops, in building areas)
+
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ # time in seconds
+ poll_rate: 20,
+
+ # How many decimal places area summaries should be rounded to
+ rounding_precision: 2,
+
+ # How many wireless devices should we ignore
+ duplication_factor: 0.8,
+
+ # Driver to query
+ location_service: "LocationServices",
+ include_sensors: true,
+
+ _areas: {
+ "zone-1234" => [
+ {
+ id: "lobby1",
+ name: "George St Lobby",
+ building: "building-zone-id",
+ coordinates: [{3, 5}, {5, 6}, {6, 1}],
+ },
+ ],
+ },
+
+ # If another systems has different desk IDs configured you can add them to
+ # desk metadata and then specify the alternative field names here
+ # desk_id_mappings: ["floorsensedeskid", "vergesensedeskid"]
+
+ units: {
+ "Temperature" => "Cel",
+ },
+
+ is_campus: false,
+ })
+
+ alias AreaSetting = NamedTuple(
+ id: String,
+ name: String,
+ building: String?,
+ coordinates: Array(Tuple(Float64, Float64)))
+
+ alias LevelCapacity = NamedTuple(
+ total_desks: Int32,
+ total_capacity: Int32,
+ desk_ids: Array(String),
+ desk_mappings: Hash(String, String))
+
+ alias RawLevelDetails = NamedTuple(
+ wireless_devices: Int32,
+ desk_bookings: Int32,
+ desk_usage: Int32,
+ capacity: LevelCapacity,
+ sensors: Hash(String, Float64),
+ )
+
+ getter? campus : Bool = false
+
+ # level_zone_id => building_zone_id
+ getter level_buildings : Hash(String, String) = {} of String => String
+ # zone_id => sensors
+ getter level_sensors : Hash(String, Hash(String, SensorMeta)) = {} of String => Hash(String, SensorMeta)
+ # zone_id => areas
+ getter level_areas : Hash(String, Array(AreaConfig)) = {} of String => Array(AreaConfig)
+ # area_id => area
+ getter areas : Hash(String, AreaConfig) = {} of String => AreaConfig
+
+ # zone_id => desk_ids
+ @duplication_factor : Float64 = 0.8
+ getter level_details : Hash(String, LevelCapacity) = {} of String => LevelCapacity
+
+ # PlaceOS client config
+ getter building_id : String { get_building_id.as(String) }
+
+ @poll_rate : Time::Span = 60.seconds
+ @location_service : String = "LocationServices"
+
+ @rate_limit : Channel(Nil) = Channel(Nil).new
+ @update_lock : Mutex = Mutex.new
+ @include_sensors : Bool = false
+
+ # Building => sensor_id => sensor meta
+ getter sensor_discovery = Hash(String, Hash(String, SensorMeta)).new
+
+ @desk_id_mappings = [] of String
+
+ @rounding_precision : UInt32 = 2
+
+ @units = {} of SensorType => String
+
+ def on_load
+ spawn { rate_limiter }
+ spawn { update_scheduler }
+
+ on_update
+ end
+
+ def on_unload
+ @rate_limit.close
+ end
+
+ def on_update
+ @include_sensors = setting?(Bool, :include_sensors) || false
+ @campus = setting?(Bool, :is_campus) || false
+ @desk_id_mappings = setting?(Array(String), :desk_id_mappings) || [] of String
+
+ @poll_rate = (setting?(Int32, :poll_rate) || 60).seconds
+ @location_service = setting?(String, :location_service).presence || "LocationServices"
+ @duplication_factor = setting?(Float64, :duplication_factor) || 0.8
+ @sensor_discovery = Hash(String, Hash(String, SensorMeta)).new { |hash, key| hash[key] = {} of String => SensorMeta }
+
+ @rounding_precision = setting?(UInt32, :rounding_precision) || 2_u32
+
+ # Areas are defined in metadata, this is mainly here so we can write specs
+ if building_areas = setting?(Hash(String, Array(AreaSetting)), :areas)
+ @level_areas.clear
+ building_areas.each do |zone_id, areas|
+ @level_areas[zone_id] = areas.map do |area|
+ config = AreaConfig.new(area[:id], area[:name], area[:coordinates], area[:building])
+ @areas[config.id] = config
+ config
+ end
+ end
+ end
+
+ schedule.clear
+ schedule.every(@poll_rate) { synchronize_all_levels }
+
+ if @include_sensors
+ schedule.in(@poll_rate * 3) do
+ # sync the sensor discovery data for map placement
+ schedule.every(2.hours + rand(300).seconds, immediate: true) { write_sensor_discovery }
+ end
+ end
+
+ units = setting?(Hash(String, String), :units) || {} of String => String
+ @units = units.transform_keys { |key| SensorType.parse(key) }
+ end
+
+ # The location services provider
+ protected def location_service
+ system[@location_service]
+ end
+
+ # Finds the building ID for the current location services object
+ def get_building_id
+ building_setting = setting?(String, :building_zone_override)
+ return building_setting if building_setting.presence
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ nil
+ end
+
+ # ===============================
+ # SENSOR DETAILS
+ # ===============================
+
+ alias SensorDetail = Interface::Sensor::Detail
+ alias SensorType = Interface::Sensor::SensorType
+
+ struct SensorMeta
+ include JSON::Serializable
+
+ def initialize(@name, @type, @level, @x, @y)
+ end
+
+ property type : SensorType?
+ property name : String?
+ property level : String?
+ property x : Float64?
+ property y : Float64?
+ end
+
+ def write_sensor_discovery
+ sensor_discovery.each do |b_id, sensors|
+ staff_api.write_metadata(b_id, "sensor-discovered", sensors)
+ end
+ end
+
+ # returns the sensor location data that has been configured
+ def sensor_locations(level_id : String? = nil)
+ if level_id
+ @level_sensors[level_id]? || {} of String => SensorMeta
+ else
+ @level_sensors.values.reduce({} of String => SensorMeta) { |acc, i| acc.merge!(i) }
+ end
+ end
+
+ # Queries all the sensors in a building and exposes the data
+ def request_sensor_data(level_id : String) : Array(SensorDetail)
+ level_sensors = @level_sensors[level_id]?
+ sensors = location_service.sensors(zone_id: level_id).get.as_a
+
+ return [] of SensorDetail if sensors.empty?
+ details = Array(SensorDetail).from_json(sensors.to_json)
+
+ building_id_local = level_buildings[level_id]? || building_id
+ locs = sensor_locations(level_id)
+
+ details = details.select! do |sensor|
+ id = sensor.id ? "#{sensor.mac}-#{sensor.id}" : sensor.mac
+ @sensor_discovery[building_id_local][id] = SensorMeta.new(
+ sensor.name,
+ sensor.type,
+ sensor.level,
+
+ # TODO:: calculate x, y if a loc is given
+ sensor.x,
+ sensor.y
+ )
+
+ sensor.module_id = sensor.binding = sensor.loc = nil
+
+ # check if this sensor has a user defined location
+ if location = locs[id]?
+ sensor.x = location.x
+ sensor.y = location.y
+ sensor.level = location.level
+ sensor.building = building_id_local
+ end
+
+ # If a sensor has been added to the map, add the level details
+ if sensor.level.nil? && level_sensors
+ if level_sensors[sensor.id ? "#{sensor.mac}-#{sensor.id}" : sensor.mac]?
+ sensor.level = level_id
+ end
+ end
+
+ if sensor.x && sensor.level
+ # TODO:: calulate the lat, lon and s2 cell id
+
+ # transform different sensor units to a common unit
+ if (curr_unit = sensor.unit) && (desired_unit = @units[sensor.type]?) && curr_unit != desired_unit
+ begin
+ sensor.value = Units::Measurement.new(sensor.value, curr_unit).convert_to(desired_unit).to_f
+ sensor.unit = desired_unit
+ rescue error
+ logger.warn(exception: error) { "failed to convert #{sensor.value} #{curr_unit} => #{desired_unit}" }
+ end
+ end
+
+ # only select sensors that have a location
+ sensor
+ end
+ end
+
+ self["#{level_id}:sensors"] = {
+ value: details,
+ ts_hint: "complex",
+ ts_map: {
+ x: "xloc",
+ y: "yloc",
+ },
+ ts_tag_keys: {"s2_cell_id"},
+ ts_tags: {
+ pos_building: building_id_local,
+ pos_level: level_id,
+ },
+ }
+
+ details
+ end
+
+ # ===============================
+ # LOCATION DETAILS
+ # ===============================
+
+ # Updates a single zone, syncing the metadata
+ protected def update_level_details(level_details, zone, metadata)
+ return unless zone.tags.includes?("level")
+
+ if desks = metadata["desks"]?
+ desk_map = {} of String => String
+
+ if @desk_id_mappings.empty?
+ ids = desks.details.as_a.map { |desk| desk["id"].as_s }
+ else
+ desk_details = desks.details.as_a
+ ids = Array(String).new(desk_details.size)
+
+ desk_details.each do |desk|
+ desk_id = desk["id"].as_s
+ ids << desk_id
+ @desk_id_mappings.each do |mapping|
+ if alt_id = desk[mapping]?
+ desk_map[alt_id.as_s] = desk_id
+ end
+ end
+ end
+ end
+
+ ids = desks.details.as_a.map { |desk| desk["id"].as_s }
+ level_details[zone.id] = {
+ total_desks: ids.size,
+ total_capacity: zone.capacity,
+ desk_ids: ids,
+ desk_mappings: desk_map,
+ }
+ else
+ level_details[zone.id] = {
+ total_desks: zone.count,
+ total_capacity: zone.capacity,
+ desk_ids: [] of String,
+ desk_mappings: {} of String => String,
+ }
+ end
+
+ if regions = metadata["map_regions"]?
+ area_data = Array(AreaConfig).from_json(regions.details["areas"].to_json)
+ @level_areas[zone.id] = area_data
+ area_data.each { |area| @areas[area.id] = area }
+ else
+ @level_areas.delete(zone.id)
+ end
+
+ if sensors = metadata["sensor-locations"]?
+ sensor_data = Hash(String, SensorMeta).from_json(sensors.details.to_json)
+ zone_id = zone.id
+ sensor_data.transform_values! { |sensor| sensor.level = zone_id; sensor }
+ @level_sensors[zone_id] = sensor_data
+ else
+ @level_sensors.delete(zone.id)
+ end
+ end
+
+ alias Zone = PlaceOS::Client::API::Models::Zone
+ alias Metadata = Hash(String, PlaceOS::Client::API::Models::Metadata)
+ alias ChildMetadata = Array(NamedTuple(zone: Zone, metadata: Metadata))
+
+ # Grabs all the level zones in the building and syncs the metadata
+ protected def sync_level_details
+ buildings = if campus?
+ # building_id here is actually the campus id
+ Array(Zone).from_json(staff_api.zones(parent: building_id).get.to_json).map(&.id)
+ else
+ [building_id]
+ end
+
+ level_details = {} of String => LevelCapacity
+ level_buildings = {} of String => String
+
+ buildings.each do |b_id|
+ # Attempt to obtain the latest version of the metadata
+ response = ChildMetadata.from_json(staff_api.metadata_children(b_id).get.to_json)
+ response.each do |meta|
+ level_buildings[meta[:zone].id] = b_id
+ update_level_details(level_details, meta[:zone], meta[:metadata])
+ end
+ end
+
+ @level_details = level_details
+ @level_buildings = level_buildings
+ rescue error
+ logger.error(exception: error) { "obtaining level metadata" }
+ end
+
+ protected def update_level_locations(level_counts, level_id, details, sensor_data)
+ areas = @level_areas[level_id]? || [] of AreaConfig
+ unsorted_sensors = sensor_data || [] of SensorDetail
+ sensors = Hash(String, Array(SensorDetail)).new { |h, k| h[k] = [] of SensorDetail }
+ unsorted_sensors.each { |sensor| sensors[sensor.modified_type.underscore] << sensor }
+
+ # Provide the frontend with the list of all known desk ids on a level
+ self["#{level_id}:desk_ids"] = details[:desk_ids]
+
+ # Get location data for the level
+ locations = location_service.device_locations(level_id).get.as_a
+
+ # Apply any map id transformations
+ desk_mappings = details[:desk_mappings]
+ locations = locations.map do |loc|
+ loc = loc.as_h
+ if location_type = loc["location"]?
+ # measurement name for simplified querying in influxdb
+ loc["measurement"] = location_type
+ case location_type
+ when "desk"
+ if maps_to = desk_mappings[loc["map_id"].as_s]?
+ loc["map_id"] = JSON::Any.new(maps_to)
+ end
+ when "booking"
+ if (has_map_id = loc["map_id"]?.try(&.as_s)) && loc["type"].as_s == "desk" && (maps_to = desk_mappings[has_map_id]?)
+ loc["map_id"] = JSON::Any.new(maps_to)
+ end
+ end
+ end
+ loc
+ end
+
+ # Provide to the frontend
+ self[level_id] = {
+ value: locations,
+ ts_hint: "complex",
+ ts_map: {
+ x: "xloc",
+ y: "yloc",
+ },
+ ts_tag_keys: {"s2_cell_id"},
+ ts_tags: {
+ pos_building: level_buildings[level_id]? || building_id,
+ pos_level: level_id,
+ },
+ }
+
+ # Grab the x,y locations
+ wireless_count = 0
+ desk_count = 0
+ desk_bookings = 0
+ xy_locs = locations.select do |loc|
+ case loc["location"].as_s
+ when "wireless"
+ wireless_count += 1
+
+ # Keep if x, y coords are present
+ !loc["x"].raw.nil?
+ when "desk"
+ desk_count += 1 if (loc["at_location"]?.try(&.as_i?) || 0) > 0
+ false
+ when "booking"
+ desk_bookings += 1 if loc["type"].as_s == "desk"
+ false
+ else
+ false
+ end
+ end
+
+ people_counts = sensors["people_count"]?
+ sensor_summary = sensors.transform_values do |values|
+ if values.size > 0
+ (values.sum(&.value) / values.size).round(@rounding_precision)
+ else
+ 0.0
+ end
+ end
+ if people_counts
+ sensor_summary["people_count_sum"] = people_counts.sum(&.value)
+ end
+
+ # build the level overview
+ level_counts[level_id] = {
+ wireless_devices: wireless_count,
+ desk_bookings: desk_bookings,
+ desk_usage: desk_count,
+ capacity: details,
+ sensors: sensor_summary,
+ }
+
+ # we need to know the map dimensions to be able to count people in areas
+ map_width = 100.0
+ map_height = 100.0
+
+ if tmp_loc = xy_locs[0]?
+ # ensure map width and height are known
+ map_width_raw = tmp_loc["map_width"]?.try(&.raw)
+ case map_width_raw
+ when Int64, Float64
+ map_width = map_width_raw.to_f
+ end
+
+ map_height_raw = tmp_loc["map_height"]?.try(&.raw)
+ case map_height_raw
+ when Int64, Float64
+ map_height = map_height_raw.to_f
+ end
+ end
+
+ # Calculate the device counts for each area
+ area_counts = [] of Hash(String, String | Int32 | Float64)
+ if map_width != -1.0
+ # adjust sensor x,y so we check if they are in areas
+ sensors.each do |_type, array|
+ array.map! do |sensor|
+ sensor.x = sensor.x.as(Float64) * map_width
+ sensor.y = sensor.y.as(Float64) * map_height
+ sensor
+ end
+ end
+
+ areas.each do |area|
+ count = 0
+
+ # Ensure the area is configured
+ area.coordinates(map_width, map_height)
+ polygon = area.polygon
+
+ # Calculate counts, our config uses browser coordinate systems,
+ # so need to adjust any x,y values being received for this
+ xy_locs.each do |loc|
+ case loc["coordinates_from"]?.try(&.raw)
+ when "bottom-left"
+ count += 1 if polygon.contains(loc["x"].as_f, map_height - loc["y"].as_f)
+ else
+ count += 1 if polygon.contains(loc["x"].as_f, loc["y"].as_f)
+ end
+ end
+
+ # build sensor summary for the area
+ area_sensors = Hash(String, Array(SensorDetail)).new { |h, k| h[k] = [] of SensorDetail }
+ sensors.each do |type, array|
+ array.each do |sensor|
+ area_sensors[type] << sensor if polygon.contains(sensor.x.as(Float64), sensor.y.as(Float64))
+ end
+ end
+
+ people_counts = area_sensors["people_count"]?
+ sensor_summary = area_sensors.transform_values do |values|
+ if values.size > 0
+ (values.sum(&.value) / values.size).round(@rounding_precision)
+ else
+ 0.0
+ end
+ end
+ if people_counts
+ sensor_summary["people_count_sum"] = people_counts.sum(&.value)
+ end
+
+ if capacity = area.capacity
+ sensor_summary["capacity"] = capacity
+ end
+
+ area_counts << {
+ "area_id" => area.id,
+ "name" => area.name,
+ "count" => (count * @duplication_factor).to_i,
+ }.merge(sensor_summary)
+ end
+ end
+
+ # Provide the frontend the area details
+ self["#{level_id}:areas"] = {
+ value: area_counts,
+ measurement: "area_summary",
+ ts_hint: "complex",
+ ts_tags: {
+ pos_building: level_buildings[level_id]? || building_id,
+ pos_level: level_id,
+ },
+ }
+ rescue error
+ logger.debug(exception: error) { "while parsing #{level_id}" }
+ sleep 200.milliseconds
+ end
+
+ @level_counts : Hash(String, RawLevelDetails) = {} of String => RawLevelDetails
+
+ def request_level_locations(level_id : String, sensor_data : Array(SensorDetail)? = nil, overview : Bool = true) : Nil
+ @update_lock.synchronize do
+ zone = Zone.from_json(staff_api.zone(level_id).get.to_json)
+ if !zone.tags.includes?("level")
+ logger.warn { "attempted to update location for #{zone.name} (#{level_id}) which is not tagged as a level" }
+ return
+ end
+ metadata = Metadata.from_json(staff_api.metadata(level_id).get.to_json)
+
+ update_level_details @level_details, zone, metadata
+ update_level_locations @level_counts, level_id, @level_details[level_id], sensor_data
+ update_overview if overview
+ end
+ end
+
+ protected def update_overview
+ self[:overview] = @level_counts.transform_values { |details| build_level_stats(**details) }
+ end
+
+ def is_inside?(x : Float64, y : Float64, area_id : String) : Bool
+ area = @areas[area_id]
+ area.polygon.contains(x, y)
+ end
+
+ protected def build_level_stats(wireless_devices, desk_bookings, desk_usage, capacity, sensors)
+ # raw data
+ total_desks = capacity[:total_desks]
+ total_capacity = capacity[:total_capacity]
+
+ # normalised data
+ adjusted_devices = wireless_devices * @duplication_factor
+
+ if total_capacity <= 0
+ percentage_use = 100.0
+ individual_impact = 100.0
+ else
+ percentage_use = (adjusted_devices / total_capacity) * 100.0
+ individual_impact = 100.0 / total_capacity
+ end
+ remaining_capacity = total_capacity - adjusted_devices
+ recommendation = remaining_capacity + remaining_capacity * individual_impact
+
+ {
+ "measurement" => "level_summary",
+ "desk_count" => total_desks,
+ "desk_bookings" => desk_bookings, # booked desks
+ "desk_usage" => desk_usage, # sensor detected someone at a desk
+ "device_capacity" => total_capacity,
+ "device_count" => wireless_devices,
+ "estimated_people" => adjusted_devices.to_i,
+ "percentage_use" => percentage_use,
+
+ # higher the number, better the recommendation
+ "recommendation" => recommendation,
+ }.merge(sensors)
+ end
+
+ # ===============================
+ # RATE LIMITER
+ # ===============================
+
+ # This is to limit the number of "real-time" updates
+ # batching operations to provide fast updates that don't waste CPU cycles
+ protected def rate_limiter
+ sleep 3
+
+ loop do
+ begin
+ break if @rate_limit.closed?
+ @rate_limit.send(nil)
+ rescue error
+ logger.error(exception: error) { "issue with rate limiter" }
+ ensure
+ sleep 3
+ end
+ end
+ rescue
+ # Possible error with logging exception, restart rate limiter silently
+ spawn { rate_limiter } unless terminated?
+ end
+
+ @update_levels : Set(String) = Set.new([] of String)
+ @update_all : Bool = true
+ @schedule_lock : Mutex = Mutex.new
+
+ def update_available(level_ids : Array(String))
+ @schedule_lock.synchronize { @update_levels.concat level_ids }
+ end
+
+ def synchronize_all_levels
+ @schedule_lock.synchronize { @update_all = true }
+ end
+
+ protected def update_scheduler
+ loop do
+ @rate_limit.receive
+ @schedule_lock.synchronize do
+ begin
+ sensor_data = [] of SensorDetail
+ if @update_all
+ @update_lock.synchronize { sync_level_details }
+ @level_buildings.each_key do |level_id|
+ sensor_data = request_sensor_data(level_id) if @include_sensors
+ request_level_locations level_id, sensor_data, false
+ end
+ @update_lock.synchronize { update_overview }
+ else
+ @update_levels.each do |level_id|
+ sensor_data = request_sensor_data(level_id) if @include_sensors
+ request_level_locations level_id, sensor_data, false
+ end
+ @update_lock.synchronize { update_overview }
+ end
+ rescue error
+ logger.error(exception: error) { "error updating floors" }
+ ensure
+ @update_levels.clear
+ @update_all = false
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/place/area_management_spec.cr b/drivers/place/area_management_spec.cr
new file mode 100644
index 00000000000..1bddc0a43d2
--- /dev/null
+++ b/drivers/place/area_management_spec.cr
@@ -0,0 +1,31 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::AreaCount" do
+ settings({
+ areas: {
+ "zone-1234" => [
+ {
+ id: "lobby1",
+ name: "George St Lobby",
+ building: "building-zone-id",
+ coordinates: [{3, 5}, {5, 6}, {6, 1}],
+ },
+ ],
+ },
+ })
+
+ # Used this tool to work out coordinates: https://www.mathsisfun.com/geometry/polygons-interactive.html
+ exec(:is_inside?, 4, 5, "lobby1").get.should eq(true)
+ exec(:is_inside?, 4, 4, "lobby1").get.should eq(true)
+ exec(:is_inside?, 5, 5, "lobby1").get.should eq(true)
+ exec(:is_inside?, 5, 4, "lobby1").get.should eq(true)
+ exec(:is_inside?, 5, 3, "lobby1").get.should eq(true)
+ exec(:is_inside?, 3.1, 5, "lobby1").get.should eq(true)
+
+ exec(:is_inside?, 3, 6, "lobby1").get.should eq(nil)
+ exec(:is_inside?, 4, 6, "lobby1").get.should eq(nil)
+ exec(:is_inside?, 4.6, 5.9, "lobby1").get.should eq(nil)
+ exec(:is_inside?, 5.2, 5.4, "lobby1").get.should eq(nil)
+ exec(:is_inside?, 5.5, 1.5, "lobby1").get.should eq(nil)
+ exec(:is_inside?, 5.9, 2, "lobby1").get.should eq(nil)
+end
diff --git a/drivers/place/area_polygon.cr b/drivers/place/area_polygon.cr
new file mode 100644
index 00000000000..bdc55b15611
--- /dev/null
+++ b/drivers/place/area_polygon.cr
@@ -0,0 +1,71 @@
+require "math"
+
+# Crystal lang point in a polygon, based on
+# https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html
+
+# 1. The polygon may contain multiple separate components, and/or holes, which may be concave, provided that you separate the components and holes with a (0,0) vertex, as follows.
+# First, include a (0,0) vertex.
+# Then include the first component' vertices, repeating its first vertex after the last vertex.
+# Include another (0,0) vertex.
+# Include another component or hole, repeating its first vertex after the last vertex.
+# Repeat the above two steps for each component and hole.
+# Include a final (0,0) vertex.
+# 2. For example, let three components' vertices be A1, A2, A3, B1, B2, B3, and C1, C2, C3. Let two holes be H1, H2, H3, and I1, I2, I3. Let O be the point (0,0). List the vertices thus:
+# O, A1, A2, A3, A1, O, B1, B2, B3, B1, O, C1, C2, C3, C1, O, H1, H2, H3, H1, O, I1, I2, I3, I1, O.
+# 3. Each component or hole's vertices may be listed either clockwise or counter-clockwise.
+# 4. If there is only one connected component, then it is optional to repeat the first vertex at the end. It's also optional to surround the component with zero vertices.
+
+struct Point
+ def initialize(@x : Float64, @y : Float64)
+ end
+
+ property x : Float64
+ property y : Float64
+
+ # pythagoras
+ def distance_to(point : Point)
+ # lengths of triangles edge
+ a = point.x - @x
+ b = point.y - @y
+
+ Math.sqrt((a * a) + (b * b))
+ end
+end
+
+class Polygon
+ def initialize(@points : Array(Point))
+ @xmax = @xmin = @points[0].x
+ @ymax = @ymin = @points[0].y
+
+ @points[1..-1].each do |point|
+ @xmax = point.x if point.x > @xmax
+ @ymax = point.y if point.y > @ymax
+ @xmin = point.x if point.x < @xmin
+ @ymin = point.y if point.y < @ymin
+ end
+ end
+
+ getter points : Array(Point)
+ getter xmin : Float64
+ getter ymin : Float64
+ getter xmax : Float64
+ getter ymax : Float64
+
+ def contains(testx : Float64, testy : Float64)
+ # definitely not within the polygon, quick check
+ return false if testx < @xmin || testx > @xmax || testy < @ymin || testy > @ymax
+
+ inside = false
+ previous_index = @points.size - 1
+
+ @points.each_with_index do |point, index|
+ previous = @points[previous_index]
+ if ((point.y > testy) != (previous.y > testy)) && (testx < (previous.x - point.x) * (testy - point.y) / (previous.y - point.y) + point.x)
+ inside = !inside
+ end
+ previous_index = index
+ end
+
+ inside
+ end
+end
diff --git a/drivers/place/at_capacity_mailer.cr b/drivers/place/at_capacity_mailer.cr
new file mode 100644
index 00000000000..4c9c9cea31b
--- /dev/null
+++ b/drivers/place/at_capacity_mailer.cr
@@ -0,0 +1,225 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+require "place_calendar"
+require "./bookings/asset_name_resolver"
+
+class Place::AtCapacityMailer < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+ include Place::AssetNameResolver
+
+ descriptive_name "PlaceOS At Capacity Mailer"
+ generic_name :AtCapacityMailer
+ description %(sends a notification when the specified type is at capacity)
+
+ default_settings({
+ timezone: "Australia/Sydney",
+ booking_type: "desk", # desk, locker, parking, etc
+ zones: [] of String, # The zones to check for bookings
+ notify_email: ["concierge@place.com"],
+ email_schedule: "*/5 * * * *", # the frequency to check for bookings
+ time_window_hours: 1, # the number of hours to check for bookings
+ debounce_time_minutes: 60, # the time to wait before sending another email
+ email_template: "at_capacity",
+ unique_templates: false, # this appends the booking type to the template name
+ asset_cache_timeout: 3600_i64, # 1 hour
+ })
+
+ accessor staff_api : StaffAPI_1
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ # used by AssetNameResolver and LockerMetadataParser
+ accessor locations : LocationServices_1
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+ getter levels : Array(String) do
+ staff_api.systems_in_building(building_id).get.as_h.keys
+ end
+
+ def on_load
+ on_update
+ end
+
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+
+ @booking_type : String = "desk"
+ @zones : Array(String) = [] of String
+
+ @notify_email : Array(String) = [] of String
+ @email_schedule : String = "*/5 * * * *"
+ @time_window_hours : Int32 = 1
+ @debounce_time_minutes : Int32 = 60
+ @last_email_sent : Hash(String, Time) = {} of String => Time
+
+ @email_template : String = "at_capacity"
+ @unique_templates : Bool = false
+ @template_suffix : String = ""
+ @template_fields_suffix : String = ""
+
+ @zone_cache : Hash(String, Zone) = {} of String => Zone
+
+ def on_update
+ @building_id = nil
+ @levels = nil
+
+ time_zone = setting?(String, :calendar_time_zone).presence || "Australia/Sydney"
+ @time_zone = Time::Location.load(time_zone)
+
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+ @zones = setting?(Array(String), :zones) || [] of String
+
+ @notify_email = setting?(Array(String), :notify_email) || [] of String
+ @email_schedule = setting?(String, :email_schedule).presence || "*/5 * * * *"
+ @time_window_hours = setting?(Int32, :time_window_hours) || 1
+ @debounce_time_minutes = setting?(Int32, :debounce_time_minutes) || 60
+
+ @email_template = setting?(String, :email_template) || "at_capacity"
+ @unique_templates = setting?(Bool, :unique_templates) || false
+ @template_suffix = @unique_templates ? "_#{@booking_type}" : ""
+ @template_fields_suffix = @unique_templates ? " (#{@booking_type})" : ""
+
+ @asset_cache_timeout = setting?(Int64, :asset_cache_timeout) || 3600_i64
+ clear_asset_cache
+
+ schedule.clear
+
+ # find assets
+ schedule.every(60.minutes) { get_asset_ids }
+
+ if emails = @email_schedule
+ schedule.cron(emails, @time_zone) { check_capacity }
+ end
+ end
+
+ @[Security(Level::Support)]
+ def check_capacity
+ asset_ids = self[:assets_ids]? ? Hash(String, Array(String)).from_json(self[:assets_ids].to_json) : get_asset_ids
+
+ booked_asset_ids = get_booked_asset_ids
+
+ @zones.each do |zone_id|
+ next unless (zone_asset_ids = asset_ids[zone_id]?) && !zone_asset_ids.empty?
+
+ if (zone_asset_ids - booked_asset_ids).empty?
+ logger.debug { "zone #{zone_id} is at capacity" }
+ send_email(zone_id)
+ end
+ end
+ end
+
+ def get_asset_ids : Hash(String, Array(String))
+ assets_ids = {} of String => Array(String)
+
+ @zones.each do |zone_id|
+ assets_ids[zone_id] = lookup_assets(zone_id, @booking_type).map { |asset| asset.id }.uniq!
+ end
+
+ self[:assets_ids] = assets_ids
+ end
+
+ def get_booked_asset_ids : Array(String)
+ asset_ids = Array(String).from_json staff_api.booked(
+ type: @booking_type,
+ period_start: Time.utc.to_unix,
+ period_end: (Time.utc + @time_window_hours.hours).to_unix,
+ zones: @zones,
+ ).get.to_json
+
+ logger.debug { "found #{asset_ids.size} booked assets" }
+
+ self[:booked_assets] = asset_ids
+ rescue error
+ logger.warn(exception: error) { "unable to obtain list of booked assets" }
+ self[:booked_assets] = [] of String
+ end
+
+ @[Security(Level::Support)]
+ def send_email(zone_id : String)
+ if (last = @last_email_sent[zone_id]?) && Time.utc - last < @debounce_time_minutes.minutes
+ logger.debug { "skipping email for #{zone_id} due to debounce timer" }
+ return
+ end
+
+ zone = fetch_zone(zone_id)
+ args = {
+ booking_type: @booking_type,
+ zone_id: zone_id,
+ zone_name: zone.name,
+ zone_description: zone.description,
+ zone_location: zone.location,
+ zone_display_name: zone.display_name,
+ zone_timezone: zone.timezone,
+ }
+
+ begin
+ mailer.send_template(
+ to: @notify_email,
+ template: {"at_capacity", "#{@email_template}#{@template_suffix}"},
+ args: args)
+ @last_email_sent[zone_id] = Time.utc
+ rescue error
+ logger.warn(exception: error) { "failed to send at capacity email for zone #{zone_id}" }
+ end
+ end
+
+ def template_fields : Array(TemplateFields)
+ [
+ TemplateFields.new(
+ trigger: {@email_template, "at_capacity#{@template_suffix}"},
+ name: "At capacity#{@template_fields_suffix}",
+ description: "Notification when the assets of a zone is at capacity",
+ fields: [
+ {name: "booking_type", description: "Type of booking that is at capacity"},
+ {name: "zone_id", description: "Identifier of the zone that is at capacity"},
+ {name: "zone_name", description: "Name of the zone that is at capacity"},
+ {name: "zone_description", description: "Description of the zone that is at capacity"},
+ {name: "zone_location", description: "Location of the zone that is at capacity"},
+ {name: "zone_display_name", description: "Display name of the zone that is at capacity"},
+ {name: "zone_timezone", description: "Timezone of the zone that is at capacity"},
+ ]
+ ),
+ ]
+ end
+
+ def fetch_zone(zone_id : String) : Zone
+ @zone_cache[zone_id] ||= Zone.from_json staff_api.zone(zone_id).get.to_json
+ rescue error
+ logger.warn(exception: error) { "unable to find zone #{zone_id}" }
+ Zone.new(id: zone_id)
+ end
+
+ struct Zone
+ include JSON::Serializable
+
+ property id : String
+
+ property name : String = ""
+ property description : String = ""
+ property tags : Set(String) = Set(String).new
+ property location : String?
+ property display_name : String?
+ property timezone : String?
+
+ property parent_id : String?
+
+ def initialize(@id : String)
+ end
+
+ @[JSON::Field(ignore: true)]
+ @time_location : Time::Location?
+
+ def time_location? : Time::Location?
+ if tz = timezone.presence
+ @time_location ||= Time::Location.load(tz)
+ end
+ end
+
+ def time_location! : Time::Location
+ time_location?.not_nil!
+ end
+ end
+end
diff --git a/drivers/place/at_capacity_mailer_spec.cr b/drivers/place/at_capacity_mailer_spec.cr
new file mode 100644
index 00000000000..83949b20f58
--- /dev/null
+++ b/drivers/place/at_capacity_mailer_spec.cr
@@ -0,0 +1,247 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/mailer"
+
+class StaffAPI < DriverSpecs::MockDriver
+ ZONES = [
+ {
+ created_at: 1660537814,
+ updated_at: 1681800971,
+ id: "level-1",
+ name: "Level 1",
+ display_name: "Level 1",
+ location: "",
+ description: "",
+ code: "",
+ type: "",
+ count: 0,
+ capacity: 0,
+ map_id: "",
+ tags: [
+ "level",
+ ],
+ triggers: [] of String,
+ parent_id: "zone-0000",
+ timezone: "Australia/Sydney",
+ },
+ {
+ created_at: 1660537814,
+ updated_at: 1681800971,
+ id: "level-2",
+ name: "Level 2",
+ display_name: "Level 2",
+ location: "",
+ description: "",
+ code: "",
+ type: "",
+ count: 0,
+ capacity: 0,
+ map_id: "",
+ tags: [
+ "level",
+ ],
+ triggers: [] of String,
+ parent_id: "zone-0000",
+ timezone: "Australia/Sydney",
+ },
+ ]
+
+ def zone(zone_id : String)
+ zones = ZONES.select { |z| z["id"] == zone_id }
+ JSON.parse(zones.to_json)
+ end
+
+ def metadata(id : String, key : String? = nil)
+ zone = ZONES.find! { |z| z["id"] == id }
+ key = key.not_nil!
+
+ details = case key
+ when "desks"
+ [
+ {
+ "id": "desk-1",
+ "name": "Desk 1",
+ "images": [] of String,
+ "bookable": true,
+ "features": [] of String,
+ },
+ {
+ "id": "desk-2",
+ "name": "Desk 2",
+ "images": [] of String,
+ "bookable": true,
+ "features": [] of String,
+ },
+ ]
+ when "parking-spaces"
+ [
+ {
+ "id": "park-1",
+ "name": "Bay 1",
+ "zone": zone[:id],
+ "notes": "",
+ "map_id": "",
+ "zone_id": zone[:id],
+ "assigned_to": nil,
+ "map_rotation": 0,
+ "assigned_name": nil,
+ "assigned_user": nil,
+ },
+ {
+ "id": "park-2",
+ "name": "Bay 2",
+ "zone": zone[:id],
+ "notes": "",
+ "map_id": "",
+ "zone_id": zone[:id],
+ "assigned_to": nil,
+ "map_rotation": 0,
+ "assigned_name": nil,
+ "assigned_user": nil,
+ },
+ ]
+ end
+
+ JSON.parse(
+ {key => {
+ name: key,
+ description: "#{key} for zone #{id}",
+ details: details,
+ parent_id: zone[:parent_id],
+ editors: [] of String,
+ modified_by_id: "user-1234",
+ }}.to_json)
+ end
+
+ def booked(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ checked_in : Bool? = nil,
+ include_checked_out : Bool? = nil,
+ include_booked_by : Bool? = nil,
+ department : String? = nil,
+ limit : Int32? = nil,
+ offset : Int32? = nil,
+ permission : String? = nil,
+ extension_data : JSON::Any? = nil,
+ )
+ assets = case type
+ when "desk"
+ ["desk-1", "desk-2"]
+ when "parking"
+ ["park-1"]
+ end
+ JSON.parse(assets.to_json)
+ end
+end
+
+class Mailer < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Mailer
+
+ def on_load
+ self[:sent] = 0
+ end
+
+ def send_template(
+ to : String | Array(String),
+ template : Tuple(String, String),
+ args : TemplateItems,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ self[:sent] = self[:sent].as_i + 1
+ end
+
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ ) : Bool
+ true
+ end
+end
+
+DriverSpecs.mock_driver "Place::AtCapacityMailer" do
+ system({
+ StaffAPI: {StaffAPI},
+ Mailer: {Mailer},
+ })
+
+ # Start of tests for: #get_booked_asset_ids
+ ###########################################
+
+ settings({
+ booking_type: "parking",
+ zones: ["level-1"],
+ })
+
+ resp = exec(:get_booked_asset_ids).get
+ resp.not_nil!.as_a.should eq ["park-1"]
+
+ ###########################################
+ # End of tests for: #get_booked_asset_ids
+
+ # Start of tests for: #get_asset_ids
+ ####################################
+
+ settings({
+ booking_type: "desk",
+ zones: ["level-1"],
+ })
+
+ resp = exec(:get_asset_ids).get
+ resp.not_nil!.as_h.should eq Hash{"level-1" => ["desk-1", "desk-2"]}
+
+ ####################################
+ # End of tests for: #get_asset_ids
+
+ # Start of tests for: #check_capacity
+ #####################################
+
+ # Not fully booked
+ settings({
+ booking_type: "parking",
+ zones: ["level-1"],
+ })
+ _resp = exec(:get_asset_ids).get # asset_ids are cached
+
+ resp = exec(:check_capacity).get
+ system(:Mailer_1)[:sent].should eq 0
+
+ # Fully booked
+ settings({
+ booking_type: "desk",
+ zones: ["level-1"],
+ })
+ _resp = exec(:get_asset_ids).get # asset_ids are cached
+
+ resp = exec(:check_capacity).get
+ system(:Mailer_1)[:sent].should eq 1
+
+ # spam protection
+ resp = exec(:check_capacity).get
+ system(:Mailer_1)[:sent].should eq 1
+
+ #####################################
+ # End of tests for: #check_capacity
+end
diff --git a/drivers/place/attendee_scanner.cr b/drivers/place/attendee_scanner.cr
new file mode 100644
index 00000000000..48c999461e4
--- /dev/null
+++ b/drivers/place/attendee_scanner.cr
@@ -0,0 +1,193 @@
+require "placeos-driver"
+require "place_calendar"
+
+class Place::AttendeeScanner < PlaceOS::Driver
+ descriptive_name "PlaceOS Attendee scanner"
+ generic_name :AttendeeScanner
+ description %(Scans for attendees that don't have a visitor invite and creates one)
+
+ accessor staff_api : StaffAPI_1
+ accessor locations : LocationServices_1
+
+ default_settings({
+ attendee_scan_every_minutes: 5,
+ _internal_domains: ["comment.out", "use authority / domain email_domains by preference"],
+ })
+
+ def on_update
+ @internal_domains = nil
+ @building_id = nil
+ @timezone = nil
+ @systems = nil
+ @org_id = nil
+
+ period = setting?(Int32, :attendee_scan_every_minutes) || 5
+ schedule.clear
+ schedule.every(period.minutes) { invite_external_guests }
+ end
+
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+
+ # Grabs the list of systems in the building
+ getter systems : Hash(String, Array(String)) do
+ staff_api.systems_in_building(building_id).get.as_h.transform_values(&.as_a.map(&.as_s))
+ end
+
+ getter org_id : String do
+ building_details = staff_api.zone(building_id).get
+
+ if tz = building_details["timezone"].as_s?
+ @timezone = Time::Location.load(tz)
+ end
+
+ building_details["parent_id"].as_s
+ end
+
+ protected getter timezone : Time::Location do
+ building_details = staff_api.zone(building_id).get
+ @org_id = building_details["parent_id"].as_s?
+
+ tz = building_details["timezone"]?.try(&.as_s?).presence || config.control_system.try(&.timezone)
+ Time::Location.load(tz.as(String))
+ end
+
+ getter internal_domains : Array(String) do
+ # use authority email_domains so this setting isn't required
+ domains = (setting?(Array(String), :internal_domains) || [] of String).map!(&.strip.downcase)
+ if domains.empty?
+ staff_api.auth_authority.get["email_domains"].as_a?.try(&.map(&.as_s.strip.downcase)) || domains
+ else
+ domains
+ end
+ end
+
+ alias Event = PlaceCalendar::Event
+ alias Attendee = PlaceCalendar::Event::Attendee
+
+ record Guest, zones : Tuple(String, String, String), system_id : String, details : Attendee, event : Event do
+ include JSON::Serializable
+ end
+
+ # extract the list of externals invited to meetings in the building today
+ def externals_in_events
+ building = building_id
+ externals = [] of Guest
+
+ # get the current time
+ now = Time.local(timezone)
+ end_of_day = now.at_end_of_day
+
+ # Find all the guests
+ systems.each do |level_id, system_ids|
+ zones = {org_id, building, level_id}
+
+ system_ids.each do |system_id|
+ sys = system(system_id)
+ if sys.exists?("Bookings", 1)
+ events = sys.get("Bookings", 1).status(Array(Event), :bookings) rescue [] of Event
+ events.each do |event|
+ # all bookings are sorted in this array
+ event_end = event.event_end || end_of_day
+ next if event_end <= now
+ break if event.event_start >= end_of_day
+
+ externals.concat(event.attendees.reject { |attendee|
+ internal_domains.find { |domain| attendee.email.downcase.ends_with? domain }
+ }.map { |attendee|
+ Guest.new(zones, system_id, attendee, event)
+ })
+ end
+ end
+ end
+ end
+
+ externals
+ end
+
+ record Booking, visitor_email : String, booking_start : Time, booking_end : Time do
+ include JSON::Serializable
+ end
+
+ # Find the list of external guests expected in the building today
+ def externals_booked_to_visit
+ building = building_id
+ now = Time.local(timezone)
+ end_of_day = now.at_end_of_day
+
+ staff_api.query_bookings(now.to_unix, end_of_day.to_unix, zones: {building}, type: "visitor").get.as_a.map do |booking|
+ Booking.new(booking["asset_id"].as_s.downcase, Time.unix(booking["booking_start"].as_i64), Time.unix(booking["booking_end"].as_i64))
+ end
+ end
+
+ # invite missing guests
+ def invite_external_guests
+ bookings = externals_booked_to_visit
+ externals = externals_in_events
+ checked = externals.size
+ failed = 0
+
+ logger.debug { "found bookings #{bookings.size} and #{externals.size} externals" }
+
+ externals.reject! do |guest|
+ guest_email = guest.details.email.downcase
+ bookings.find { |booking| booking.visitor_email == guest_email }
+ end
+
+ logger.debug { "found #{externals.size} guests without bookings" }
+
+ now = Time.local(timezone)
+ end_of_day = now.at_end_of_day
+
+ externals.each do |guest|
+ begin
+ event = guest.event
+ host_email = event.host.as(String).downcase
+ host = guest.event.attendees.find! { |attend| attend.email.downcase == host_email }
+ guest_email = guest.details.email.downcase
+ guest_name = guest.details.name
+
+ sys_info = staff_api.get_system(guest.system_id).get
+
+ staff_api.create_booking(
+ booking_type: "visitor",
+ asset_id: guest_email,
+ user_id: host_email,
+ user_email: host_email,
+ user_name: host.name,
+ zones: guest.zones,
+ booking_start: event.event_start.to_unix,
+ booking_end: event.event_end.try(&.to_unix) || end_of_day.to_unix,
+ checked_in: false,
+ approved: true,
+ title: guest_name,
+ description: event.title,
+ time_zone: timezone.name,
+ extension_data: {
+ name: guest_name,
+ parent_id: event.id,
+ location_id: sys_info["name"].as_s,
+ },
+ utm_source: "attendee_scanner",
+ limit_override: 999,
+ event_id: event.id,
+ ical_uid: event.ical_uid,
+ attendees: [{
+ name: guest_name,
+ email: guest_email,
+ }]
+ ).get
+ rescue error
+ failed += 1
+ logger.warn(exception: error) { "failed to invite guest: #{guest.details.email}" }
+ end
+ end
+
+ {
+ invited: externals.size - failed,
+ checked: checked,
+ failure: failed,
+ }
+ end
+end
diff --git a/drivers/place/attendee_scanner_spec.cr b/drivers/place/attendee_scanner_spec.cr
new file mode 100644
index 00000000000..c1a7ea3600d
--- /dev/null
+++ b/drivers/place/attendee_scanner_spec.cr
@@ -0,0 +1,157 @@
+require "placeos-driver/spec"
+
+# :nodoc:
+class LocationServices < DriverSpecs::MockDriver
+ def on_load
+ self[:building_id_requested] = false
+ end
+
+ def building_id
+ self[:building_id_requested] = true
+ "zone-building"
+ end
+end
+
+# :nodoc:
+class StaffAPI < DriverSpecs::MockDriver
+ def zone(zone_id : String)
+ raise "unexpected zone requested #{zone_id}" unless zone_id == "zone-building"
+
+ {
+ id: "zone-building",
+ timezone: "Australia/Sydney",
+ parent_id: "zone-org",
+ }
+ end
+
+ def systems_in_building(zone_id : String, ids_only : Bool = true)
+ self[:systems_requested] = zone_id
+ {
+ "zone-level1": [
+ "spec_runner_system",
+ ],
+ }
+ end
+
+ def get_system(id : String, complete : Bool = false)
+ raise "unexpected system requested #{id}" unless id == "spec_runner_system"
+
+ {
+ name: "Test Room 1",
+ }
+ end
+
+ def query_bookings(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil,
+ include_checked_out : Bool? = nil,
+ extension_data : JSON::Any? = nil
+ )
+ [] of Nil
+ end
+
+ def create_booking(
+ booking_type : String,
+ asset_id : String,
+ user_id : String,
+ user_email : String,
+ user_name : String,
+ zones : Array(String),
+ booking_start : Int64? = nil,
+ booking_end : Int64? = nil,
+ checked_in : Bool = false,
+ approved : Bool? = nil,
+ title : String? = nil,
+ description : String? = nil,
+ time_zone : String? = nil,
+ extension_data : JSON::Any? = nil,
+ utm_source : String? = nil,
+ limit_override : Int64? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ attendees : Array(JSON::Any)? = nil
+ )
+ true
+ end
+end
+
+# :nodoc:
+class Bookings < DriverSpecs::MockDriver
+ def on_load
+ self[:bookings] = [{
+ "event_start": 1.hour.ago.to_unix,
+ "event_end": 1.hour.from_now.to_unix,
+ "id": "AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AVrOjSWJ0R0_lv6HqEl72fQABnPXAjwAA",
+ "host": "IsaiahL@email.com",
+ "title": "Test Meeting",
+ "body": " \r\n \r\n\r\n\r\n\r\n
\r\n\r\n\r\n",
+ "attendees": [
+ {
+ "name": "Isaiah Langer",
+ "email": "isaiahl@email.com",
+ "response_status": "needsAction",
+ "resource": false,
+ },
+ {
+ "name": "steve@vontaka.ch",
+ "email": "steve@vontaka.ch",
+ "response_status": "needsAction",
+ "resource": false,
+ },
+ {
+ "name": "Test Room 1",
+ "email": "testroom1@email.com",
+ "response_status": "accepted",
+ "resource": true,
+ },
+ ],
+ "hide_attendees": false,
+ "location": "Test Room 1",
+ "private": false,
+ "all_day": false,
+ "timezone": "Australia/Sydney",
+ "recurring": false,
+ "created": "2024-12-03T08:59:00Z",
+ "updated": "2024-12-03T08:59:56Z",
+ "attachments": [] of Nil,
+ "status": "confirmed",
+ "creator": "IsaiahL@email.com",
+ "ical_uid": "040000008200E00074C5B7101A82E00800000000B5C273946145DB01000000000000000010000000651007D546B31E4EB651ED0F73A0CDB6",
+ "online_meeting_provider": "teamsForBusiness",
+ "online_meeting_phones": [] of Nil,
+ "online_meeting_url": "https://teams.microsoft.com/l/meetup-join/19%3ameeting_ZjRkMTM2ZTYtZGIxNi00NDFkLWI5NGYtNDA3Mjg1NDg0YzA2%40thread.v2/0?context=%7b%22Tid%22%3a%22bc9d5ad8-7518-422b-ac8d-b69429ca4cb9%22%2c%22Oid%22%3a%22905b5cbc-ac57-4159-98a7-9b9d8e%22%7d",
+ "mailbox": "testroom1@email.com",
+ }]
+ end
+end
+
+DriverSpecs.mock_driver "Place::AttendeeScanner" do
+ settings({
+ internal_domains: ["email.com"],
+ })
+
+ system({
+ StaffAPI: {StaffAPI},
+ LocationServices: {LocationServices},
+ Bookings: {Bookings},
+ })
+
+ resp = exec(:invite_external_guests).get
+ resp.should eq({
+ "invited" => 1,
+ "checked" => 1,
+ "failure" => 0,
+ })
+end
diff --git a/drivers/place/auto_release.cr b/drivers/place/auto_release.cr
new file mode 100644
index 00000000000..ac93b45d4e9
--- /dev/null
+++ b/drivers/place/auto_release.cr
@@ -0,0 +1,493 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+require "place_calendar"
+require "./booking_model"
+require "./bookings/asset_name_resolver"
+
+class Place::AutoRelease < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+ include Place::AssetNameResolver
+
+ descriptive_name "PlaceOS Auto Release"
+ generic_name :AutoRelease
+ description %(emails visitors to confirm automatic release of their booking when they have indicated they are not on-site and releases the booking if they do not confirm)
+
+ default_settings({
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+ email_schedule: "*/5 * * * *",
+ email_template: "auto_release",
+ unique_templates: false,
+ time_window_hours: 4, # The number of hours to check for bookings pending release
+ release_locations: ["wfh", "aol"], # Locations to release bookings for
+ # available locations:
+ # - wfh: Work From Home
+ # - aol: Away on Leave
+ # - wfo: Work From Office
+ skip_created_after_start: true, # Skip bookings created after the start time
+ skip_same_day: false, # Skip bookings created on the same day as the booking
+ skip_all_day: false, # Skip all day bookings
+ asset_cache_timeout: 3600_i64, # 1 hour
+ })
+
+ accessor staff_api : StaffAPI_1
+
+ getter building_zone : Zone { get_building_zone?.not_nil! }
+
+ # used by AssetNameResolver and LockerMetadataParser
+ getter building_id : String { building_zone.id }
+ getter levels : Array(String) do
+ staff_api.systems_in_building(building_id).get.as_h.keys
+ end
+
+ protected getter timezone : Time::Location do
+ tz = config.control_system.try(&.timezone) || building_zone.timezone.presence || "UTC"
+ Time::Location.load(tz)
+ end
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+
+ @auto_release_emails_sent : UInt64 = 0_u64
+ @auto_release_email_errors : UInt64 = 0_u64
+
+ @email_template : String = "auto_release"
+ @unique_templates : Bool = false
+ @email_schedule : String? = nil
+
+ @time_window_hours : Int32 = 1
+ @release_locations : Array(String) = ["wfh", "aol"]
+ @auto_release : AutoReleaseConfig = AutoReleaseConfig.new
+ @skip_created_after_start : Bool = true
+ @skip_same_day : Bool = true
+ @skip_all_day : Bool = false
+
+ def on_update
+ @building_zone = nil
+ @building_id = nil
+ @levels = nil
+ @timezone = nil
+
+ @email_schedule = setting?(String, :email_schedule).presence
+ @email_template = setting?(String, :email_template) || "auto_release"
+ @unique_templates = setting?(Bool, :unique_templates) || false
+
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+
+ @time_window_hours = setting?(Int32, :time_window_hours) || 1
+ @release_locations = setting?(Array(String), :release_locations) || ["wfh", "aol"]
+ @auto_release = setting?(AutoReleaseConfig, :auto_release) || AutoReleaseConfig.new
+ @skip_created_after_start = setting?(Bool, :skip_created_after_start) || true
+ @skip_same_day = setting?(Bool, :skip_same_day) || false
+ @skip_all_day = setting?(Bool, :skip_all_day) || false
+
+ @asset_cache_timeout = setting?(Int64, :asset_cache_timeout) || 3600_i64
+ clear_asset_cache
+
+ schedule.clear
+
+ # find bookins pending release
+ schedule.every(5.minutes) { pending_release }
+
+ # release bookings
+ schedule.every(1.minute) { release_bookings }
+
+ if emails = @email_schedule
+ schedule.cron(emails, timezone) { send_release_emails }
+ end
+ end
+
+ # Finds the building zone for the current location services object
+ def get_building_zone? : Zone?
+ zones = Array(Zone).from_json staff_api.zones(tags: "building").get.to_json
+ zone_ids = zones.map(&.id)
+ zone_id = (zone_ids & system.zones).first
+ zones.find { |zone| zone.id == zone_id }
+ rescue error
+ logger.error(exception: error) { "unable to determine building zone" }
+ nil
+ end
+
+ @[Security(Level::Support)]
+ def enabled? : Bool
+ if !@auto_release.resources.empty? &&
+ !building_zone.time_location?.nil?
+ true
+ else
+ logger.notice { "auto release is not enabled on zone #{building_zone.id}" }
+ logger.debug { "auto release is not enabled on zone #{building_zone.id} due to auto_release.resources being empty" } if @auto_release.resources.empty?
+ logger.debug { "auto release is not enabled on zone #{building_zone.id} due to building_zone.time_location being nil" } if building_zone.time_location?.nil?
+ false
+ end
+ end
+
+ @[Security(Level::Support)]
+ def get_pending_bookings : Array(Booking)
+ results = [] of Booking
+
+ @auto_release.resources.each do |type|
+ bookings = Array(Booking).from_json staff_api.query_bookings(
+ type: type,
+ period_start: Time.utc.to_unix,
+ period_end: (Time.utc + @time_window_hours.hours).to_unix,
+ zones: [building_zone.id],
+ ).get.to_json
+ results += bookings.select { |booking| !booking.checked_in }
+ end
+
+ logger.debug { "found #{results.size} pending bookings" }
+
+ self[:pending_bookings] = results
+ rescue error
+ logger.error(exception: error) { "unable to obtain list of bookings" }
+ self[:pending_bookings] = [] of Booking
+ end
+
+ @[Security(Level::Support)]
+ def get_user_preferences?(user_id : String)
+ user = User.from_json staff_api.user(user_id).get.to_json
+
+ work_preferences = user.work_preferences
+ work_preferences = @auto_release.default_work_preferences if work_preferences.empty?
+
+ {work_preferences: work_preferences, work_overrides: user.work_overrides}
+ rescue
+ logger.debug { "unable to obtain work location for user #{user_id}" }
+ nil
+ end
+
+ def in_preference_hours?(start_time : Float64, end_time : Float64, event_time : Float64) : Bool
+ if start_time < end_time
+ start_time < event_time && end_time > event_time
+ else
+ start_time < event_time || end_time > event_time
+ end
+ end
+
+ def in_preference?(preference : WorktimePreference, event_time : Float64, locations : Array(String), match_locations : Bool = true) : Bool
+ if match_locations
+ preference.blocks.any? do |block|
+ in_preference_hours?(block.start_time, block.end_time, event_time) &&
+ locations.includes? block.location
+ end
+ else
+ preference.blocks.any? do |block|
+ in_preference_hours?(block.start_time, block.end_time, event_time) &&
+ !locations.includes?(block.location)
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def pending_release
+ results = [] of Booking
+ return results unless enabled?
+
+ bookings = get_pending_bookings
+
+ bookings.each do |booking|
+ next if @skip_created_after_start && (created_at = booking.created) && created_at >= booking.booking_start
+ next if @skip_same_day && (created_at = booking.created) &&
+ Time.unix(created_at).in(building_zone.time_location!).day == Time.unix(booking.booking_start).in(building_zone.time_location!).day
+ next if @skip_all_day && booking.all_day
+
+ if preferences = get_user_preferences?(booking.user_id)
+ # get the booking start time in the building timezone
+ booking_start = Time.unix(booking.booking_start).in building_zone.time_location!
+
+ day_of_week = booking_start.day_of_week.value
+ day_of_week = 0 if day_of_week == 7 # Crystal uses 7 for Sunday, but we use 0 (all other days match up)
+
+ # convert unix timestamp to float hours/minutes
+ # e.g. 7:30AM = 7.5
+ event_time = booking_start.hour + (booking_start.minute / 60.0)
+
+ # use all_day_start for all day bookings
+ event_time = @auto_release.all_day_start if booking.all_day
+
+ # exclude overrides with empty time blocks
+ overrides = preferences[:work_overrides].select { |_, pref| pref.blocks.size > 0 }
+
+ if (override = overrides[booking_start.to_s(format: "%F")]?) &&
+ in_preference?(override, event_time, @release_locations)
+ results << booking
+ elsif (override = overrides[booking_start.to_s(format: "%F")]?) &&
+ in_preference?(override, event_time, @release_locations, false)
+ elsif (preference = preferences[:work_preferences].find { |pref| pref.day_of_week == day_of_week }) &&
+ in_preference?(preference, event_time, @release_locations)
+ results << booking
+ elsif @auto_release.release_outside_hours
+ results << booking
+ end
+ end
+ end
+
+ logger.debug { "found #{results.size} bookings pending release" }
+
+ self[:pending_release] = results
+ end
+
+ def skip_release?(cached_booking : Booking) : Bool
+ if (booking_json_any = staff_api.get_booking(cached_booking.id).get) &&
+ (booking = Booking.from_json(booking_json_any.to_json))
+ booking.checked_in || booking.booking_start != cached_booking.booking_start
+ else
+ true
+ end
+ end
+
+ def release_bookings
+ released_booking_ids = [] of Int64
+ return released_booking_ids unless enabled?
+
+ bookings = self[:pending_release]? ? Array(Booking).from_json(self[:pending_release].to_json) : [] of Booking
+
+ previously_released = self[:released_booking_ids]? ? Array(Int64).from_json(self[:released_booking_ids].to_json) : [] of Int64
+ # remove previously released bookings that are no longer pending release
+ previously_released -= previously_released - bookings.map(&.id)
+ # add previously released bookings that are still pending release
+ released_booking_ids += previously_released
+
+ bookings.each do |booking|
+ next if previously_released.includes? booking.id
+
+ # get the booking start time in the building timezone
+ booking_start = Time.unix(booking.booking_start).in building_zone.time_location!
+
+ # convert hours (all_day_start) to seconds
+ booking_start = booking.all_day ? all_day_start_time(booking_start).to_unix : booking.booking_start
+
+ # convert minutes (time_after) to seconds for comparison with unix timestamps (booking_start)
+ if Time.utc.to_unix - booking_start > @auto_release.time_after(booking.booking_type) * 60
+ # skip if there's been changes to the cached bookings checked_in status or booking_start time
+ next if skip_release?(booking)
+
+ logger.debug { "rejecting booking #{booking.id} as it is within the time_after window" }
+ staff_api.reject(booking.id, "auto_release", booking.instance).get
+ released_booking_ids << booking.id
+ end
+ end
+
+ logger.debug { "released #{released_booking_ids.size} bookings" }
+
+ self[:released_booking_ids] = released_booking_ids
+ rescue error
+ logger.error(exception: error) { "unable to release bookings" }
+ self[:released_booking_ids] = [] of Int64
+ end
+
+ private def all_day_start_time(booking_start : Time) : Time
+ # Convert float hours/minutes to time
+ # e.g. 7.5 = 7:30AM in the specified timezone
+ hours = @auto_release.all_day_start.to_i
+ minutes = ((@auto_release.all_day_start - hours) * 60).to_i
+ time_in_zone = Time.local(booking_start.year, booking_start.month, booking_start.day, hours, minutes, location: building_zone.time_location!)
+ end
+
+ @[Security(Level::Support)]
+ def send_release_emails
+ emailed_booking_ids = [] of Int64
+ bookings = self[:pending_release]? ? Array(Booking).from_json(self[:pending_release].to_json) : [] of Booking
+ previously_released = self[:released_booking_ids]? ? Array(Int64).from_json(self[:released_booking_ids].to_json) : [] of Int64
+
+ previously_emailed = self[:emailed_booking_ids]? ? Array(Int64).from_json(self[:emailed_booking_ids].to_json) : [] of Int64
+ # remove previously emailed bookings that are no longer pending release
+ previously_emailed -= previously_emailed - bookings.map(&.id)
+ # add previously emailed bookings that are still pending release
+ emailed_booking_ids += previously_emailed
+
+ bookings.each do |booking|
+ next if previously_released.includes? booking.id
+ next if previously_emailed.includes? booking.id
+
+ # convert minutes (time_after) to seconds for comparison with unix timestamps (booking_start)
+ if enabled? &&
+ (booking.booking_start - Time.utc.to_unix < @auto_release.time_before(booking.booking_type) * 60) &&
+ (Time.utc.to_unix - booking.booking_start < @auto_release.time_after(booking.booking_type) * 60)
+ logger.debug { "sending release email to #{booking.user_email} for booking #{booking.id} as it is withing the time_before window" }
+
+ location = Time::Location.load(booking.timezone.presence || timezone.name)
+ starting = Time.unix(booking.booking_start).in(location)
+ ending = Time.unix(booking.booking_end).in(location)
+
+ args = {
+ booking_id: booking.id,
+ booking_start: booking.booking_start,
+ booking_end: booking.booking_end,
+
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+
+ asset_id: booking.asset_id,
+ asset_name: lookup_asset(asset_id: booking.asset_id, type: booking.booking_type, zones: booking.zones),
+ user_id: booking.user_id,
+ user_email: booking.user_email,
+ user_name: booking.user_name,
+ reason: booking.title,
+
+ approver_name: booking.approver_name,
+ approver_email: booking.approver_email,
+
+ booked_by_name: booking.booked_by_name,
+ booked_by_email: booking.booked_by_email,
+ }
+
+ begin
+ mailer.send_template(
+ to: booking.user_email,
+ template: {@email_template, "auto_release#{template_suffix(booking.booking_type)}"},
+ args: args)
+ emailed_booking_ids << booking.id
+ rescue error
+ logger.warn(exception: error) { "failed to send release email to #{booking.user_email}" }
+ end
+ end
+ end
+ self[:emailed_booking_ids] = emailed_booking_ids
+ end
+
+ def template_fields : Array(TemplateFields)
+ if @unique_templates && !@auto_release.resources.empty?
+ @auto_release.resources.map { |type| unique_template_fields(type) }
+ else
+ [unique_template_fields]
+ end
+ end
+
+ private def unique_template_fields(booking_type : String = "") : TemplateFields
+ time_now = Time.utc.in(timezone)
+
+ TemplateFields.new(
+ trigger: {@email_template, "auto_release#{template_suffix(booking_type)}"},
+ name: "Auto release booking#{template_fields_suffix(booking_type)}",
+ description: "Notification when a booking is pending automatic release due to user's work location preferences",
+ fields: [
+ {name: "booking_id", description: "Unique identifier for the booking that may be released"},
+ {name: "booking_start", description: "Unix timestamp of when the booking begins"},
+ {name: "booking_end", description: "Unix timestamp of when the booking ends"},
+ {name: "start_time", description: "Formatted start time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "start_date", description: "Formatted start date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "start_datetime", description: "Formatted start date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "end_time", description: "Formatted end time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "end_date", description: "Formatted end date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "end_datetime", description: "Formatted end date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "asset_id", description: "Identifier of the booked resource"},
+ {name: "asset_name", description: "Name of the booked resource"},
+ {name: "user_id", description: "Identifier of the person who has the booking"},
+ {name: "user_email", description: "Email address of the person who has the booking"},
+ {name: "user_name", description: "Full name of the person who has the booking"},
+ {name: "reason", description: "Title or purpose of the booking"},
+ {name: "approver_name", description: "Name of the person who approved the booking"},
+ {name: "approver_email", description: "Email of the person who approved the booking"},
+ {name: "booked_by_name", description: "Name of the person who made the booking"},
+ {name: "booked_by_email", description: "Email of the person who made the booking"},
+ ]
+ )
+ end
+
+ private def template_suffix(booking_type : String) : String
+ @unique_templates && !@auto_release.resources.empty? ? "_#{booking_type}" : ""
+ end
+
+ private def template_fields_suffix(booking_type : String) : String
+ @unique_templates && !@auto_release.resources.empty? ? " (#{booking_type})" : ""
+ end
+
+ struct AutoReleaseConfig
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter time_before : Int64 = 0 # Notification time before booking start in minutes
+ getter time_after : Int64 = 0 # Release time after booking start in minutes
+ getter resources : Array(String) = [] of String # Resources to release bookings for
+ getter default_work_preferences : Array(WorktimePreference) = [] of WorktimePreference # Default work preferences for users
+ getter release_outside_hours : Bool = false # Release bookings outside of work hours
+ getter all_day_start : Float64 = 8.0 # Start time used for all day bookings
+
+ def initialize
+ end
+
+ def time_before(resource : String) : Int64
+ if resource_time_before = json_unmapped["#{resource}_time_before"]?
+ resource_time_before.as_i64
+ else
+ time_before
+ end
+ end
+
+ def time_after(resource : String) : Int64
+ if resource_time_after = json_unmapped["#{resource}_time_after"]?
+ resource_time_after.as_i64
+ else
+ time_after
+ end
+ end
+ end
+
+ record User,
+ id : String,
+ work_preferences : Array(WorktimePreference) = [] of WorktimePreference,
+ work_overrides : Hash(String, WorktimePreference) = Hash(String, WorktimePreference).new do
+ include JSON::Serializable
+ end
+
+ # start_time: Start time of work hours. e.g. `7.5` being 7:30AM
+ # end_time: End time of work hours. e.g. `18.5` being 6:30PM
+ # location: Name of the location the work is being performed at
+ struct WorktimeBlock
+ include JSON::Serializable
+
+ property start_time : Float64
+ property end_time : Float64
+ property location : String = ""
+ end
+
+ # day_of_week: Index of the day of the week. `0` being Sunday
+ struct WorktimePreference
+ include JSON::Serializable
+
+ property day_of_week : Int32
+ property blocks : Array(WorktimeBlock) = [] of WorktimeBlock
+ end
+
+ struct Zone
+ include JSON::Serializable
+
+ property id : String
+
+ property name : String
+ property description : String
+ property tags : Set(String)
+ property location : String?
+ property display_name : String?
+ property timezone : String?
+
+ property parent_id : String?
+
+ @[JSON::Field(ignore: true)]
+ @time_location : Time::Location?
+
+ def time_location? : Time::Location?
+ if tz = timezone.presence
+ @time_location ||= Time::Location.load(tz)
+ end
+ end
+
+ def time_location! : Time::Location
+ time_location?.not_nil!
+ end
+ end
+end
diff --git a/drivers/place/auto_release_locker.cr b/drivers/place/auto_release_locker.cr
new file mode 100644
index 00000000000..066541dd95e
--- /dev/null
+++ b/drivers/place/auto_release_locker.cr
@@ -0,0 +1,86 @@
+require "placeos-driver"
+require "./booking_model"
+
+class Place::AutoReleaseLocker < PlaceOS::Driver
+ descriptive_name "PlaceOS Auto Release Locker"
+ generic_name :AutoReleaseLocker
+ description %(automatic release locker on specified interval)
+
+ default_settings({
+ booking_type: "locker",
+ release_schedule: "0 23 * * 5",
+ time_window_hours: 1,
+ })
+
+ accessor staff_api : StaffAPI_1
+
+ @booking_type : String = "locker"
+ @release_schedule : String = "0 23 * * 5"
+ @time_window_hours : Int32 = 1
+
+ def on_update
+ @timezone = nil
+ @building_id = nil
+ @booking_type = setting?(String, :booking_type).presence || "locker"
+ @time_window_hours = setting?(Int32, :time_window_hours) || 1
+ @release_schedule = setting(String, :release_schedule)
+
+ schedule.clear
+ schedule_cron
+ end
+
+ protected def schedule_cron : Nil
+ schedule.cron(@release_schedule, timezone) { release_lockers }
+ rescue error
+ logger.warn(exception: error) { "failed to schedule cron job" }
+ schedule.in(1.minute) { schedule_cron }
+ end
+
+ # Finds the building ID for the current location services object
+ getter building_id : String do
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ raise error
+ end
+
+ protected getter timezone : Time::Location do
+ tz = config.control_system.try(&.timezone) || staff_api.zone(building_id).get["timezone"].as_s
+ Time::Location.load(tz)
+ end
+
+ def get_bookings : Array(Booking)
+ bookings = Array(Booking).from_json staff_api.query_bookings(
+ type: @booking_type,
+ period_start: Time.utc.to_unix,
+ period_end: (Time.utc + @time_window_hours.hours).to_unix,
+ zones: [building_id],
+ ).get.to_json
+ logger.debug { "found #{bookings.size} #{@booking_type} bookings" }
+
+ bookings
+ rescue error
+ logger.warn(exception: error) { "unable to obtain list of #{@booking_type} bookings" }
+ [] of Booking
+ end
+
+ @[Security(Level::Support)]
+ def release_lockers
+ bookings = get_bookings
+ released = 0
+ bookings.each do |booking|
+ logger.debug { "releasing booking #{booking.id} as it is within the time_after window" }
+ begin
+ staff_api.update_booking(booking.id, recurrence_end: booking.booking_end).get if booking.instance
+ staff_api.booking_check_in(booking.id, false, "auto-release", instance: booking.instance).get
+ released += 1
+ rescue error
+ logger.warn(exception: error) { "unable to release #{@booking_type} with booking id #{booking.id} (inst: #{booking.instance})" }
+ end
+ end
+ results = {total: bookings.size, released: released}
+ logger.debug { results.inspect }
+ results
+ end
+end
diff --git a/drivers/place/auto_release_locker_spec.cr b/drivers/place/auto_release_locker_spec.cr
new file mode 100644
index 00000000000..51463fdbfe0
--- /dev/null
+++ b/drivers/place/auto_release_locker_spec.cr
@@ -0,0 +1,181 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::BookingCheckInHelper" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ })
+ resp = exec(:get_bookings).get
+ resp.should_not be_nil
+ resp.not_nil!.as_a.size.should eq 4
+ resp = exec(:release_lockers).get
+ resp.not_nil!.as_h["total"].should eq 4
+ resp.not_nil!.as_h["released"].should eq 4
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ BOOKINGS = [
+ {
+ id: 1,
+ user_id: "user-one",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "locker_001",
+ zones: ["zone-1234"],
+ booking_type: "locker",
+ booking_start: (Time.utc - 10.hour).to_unix,
+ booking_end: (Time.utc + 5.hours).to_unix,
+ timezone: "Australia/Darwin",
+ title: "ignore",
+ description: "",
+ checked_in: true,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-one",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: Time.utc.to_unix,
+ created: Time.utc.to_unix,
+ },
+ {
+ id: 2,
+ user_id: "user-one",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "locker_002",
+ zones: ["zone-1234"],
+ booking_type: "locker",
+ booking_start: (Time.utc - 5.minutes).to_unix,
+ booking_end: (Time.utc + 1.hour).to_unix,
+ timezone: "Australia/Darwin",
+ title: "notify",
+ description: "",
+ checked_in: true,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-one",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: Time.utc.to_unix,
+ created: Time.utc.to_unix,
+ },
+ {
+ id: 3,
+ user_id: "user-one",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "locker_003",
+ zones: ["zone-1234"],
+ booking_type: "locker",
+ booking_start: (Time.utc - 11.minutes).to_unix,
+ booking_end: (Time.utc + 1.hour).to_unix,
+ timezone: "Australia/Darwin",
+ title: "reject",
+ description: "",
+ checked_in: true,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-one",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: Time.utc.to_unix,
+ created: Time.utc.to_unix,
+ },
+ {
+ id: 4,
+ user_id: "user-one",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "locker_004",
+ zones: ["zone-1234"],
+ booking_type: "locker",
+ booking_start: (Time.utc - 5.hours).to_unix,
+ booking_end: (Time.utc + 1.hours).to_unix,
+ timezone: "Australia/Darwin",
+ title: "ignore_after_hours",
+ description: "",
+ checked_in: true,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-one",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: Time.utc.to_unix,
+ created: Time.utc.to_unix,
+ },
+ ]
+
+ def query_bookings(
+ type : String,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil
+ )
+ BOOKINGS
+ end
+
+ def zones(q : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0,
+ parent : String? = nil,
+ tags : Array(String) | String? = nil)
+ zones = [
+ {
+ created_at: 1660537814,
+ updated_at: 1681800971,
+ id: "zone-1234",
+ name: "Test Zone",
+ display_name: "Test Zone",
+ location: "",
+ description: "",
+ code: "",
+ type: "",
+ count: 0,
+ capacity: 0,
+ map_id: "",
+ tags: [
+ "building",
+ ],
+ triggers: [] of String,
+ parent_id: "zone-0000",
+ timezone: "Australia/Sydney",
+ },
+ ]
+
+ zones
+ end
+
+ def update_booking(
+ booking_id : String | Int64,
+ booking_start : Int64? = nil,
+ booking_end : Int64? = nil,
+ asset_id : String? = nil,
+ title : String? = nil,
+ description : String? = nil,
+ timezone : String? = nil,
+ extension_data : JSON::Any? = nil,
+ approved : Bool? = nil,
+ checked_in : Bool? = nil,
+ limit_override : Int64? = nil,
+ instance : Int64? = nil,
+ recurrence_end : Int64? = nil,
+ )
+ true
+ end
+
+ def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil, instance : Int64? = nil)
+ true
+ end
+end
diff --git a/drivers/place/auto_release_spec.cr b/drivers/place/auto_release_spec.cr
new file mode 100644
index 00000000000..666b4353b6f
--- /dev/null
+++ b/drivers/place/auto_release_spec.cr
@@ -0,0 +1,1293 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/mailer"
+
+class StaffAPI < DriverSpecs::MockDriver
+ def on_load
+ self[:rejected] = 0
+ end
+
+ def reject(booking_id : String | Int64, utm_source : String? = nil, instance : Int64? = nil)
+ self[:rejected] = self[:rejected].as_i + 1
+ end
+
+ TIMEZONE = "Australia/Sydney"
+ TIME_LOCAL = Time.local(location: Time::Location.load(TIMEZONE))
+ TIME_YESTERDAY = TIME_LOCAL - 1.day
+ TIME_START_OF_DAY = TIME_LOCAL - TIME_LOCAL.hour.hours - TIME_LOCAL.minute.minutes - TIME_LOCAL.second.seconds
+ TIME_END_OF_DAY = TIME_START_OF_DAY + 1.day - 1.seconds
+ DATE = TIME_LOCAL.to_s(format: "%F")
+ DAY_OF_WEEK = TIME_LOCAL.day_of_week.value == 0 ? 7 : TIME_LOCAL.day_of_week.value
+
+ # Using a constant for bookings to ensure the times don't change during tests
+ BOOKINGS = [
+ {
+ id: 1,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_001",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL + 1.hour).to_unix,
+ booking_end: (TIME_LOCAL + 2.hours).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: TIME_LOCAL.to_unix,
+ created: TIME_LOCAL.to_unix,
+ },
+ {
+ id: 2,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_002",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL + 5.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "notify",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: TIME_LOCAL.to_unix,
+ created: TIME_LOCAL.to_unix,
+ },
+ {
+ id: 3,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_003",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "reject",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 4,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_004",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL + 5.hours).to_unix,
+ booking_end: (TIME_LOCAL + 6.hours).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_after_hours",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: TIME_LOCAL.to_unix,
+ created: TIME_LOCAL.to_unix,
+ },
+ {
+ id: 5,
+ user_id: "user-wfo",
+ user_email: "user_two@example.com",
+ user_name: "User Two",
+ asset_id: "desk_005",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_wfo",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfo",
+ booked_by_email: "user_two@example.com",
+ booked_by_name: "User Two",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 6,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_006",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_last_minute_checkin",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 7,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_007",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_last_minute_schedule_change",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 8,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_008",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 2.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "reject_on_start",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 9,
+ user_id: "user-aol",
+ user_email: "user_three@example.com",
+ user_name: "User Three",
+ asset_id: "desk_009",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "release_override_aol",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-aol",
+ booked_by_email: "user_three@example.com",
+ booked_by_name: "User Three",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 10,
+ user_id: "user-wfh-override",
+ user_email: "user_four@example.com",
+ user_name: "User Four",
+ asset_id: "desk_010",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_override",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfo-override",
+ booked_by_email: "user_four@example.com",
+ booked_by_name: "User Four",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 11,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_011",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_checked_in",
+ description: "",
+ checked_in: true,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 12,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_012",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 1.seconds).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_created_after_start",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: TIME_LOCAL.to_unix,
+ created: TIME_LOCAL.to_unix,
+ },
+ {
+ id: 13,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_013",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: TIME_LOCAL.to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "ignore_same_created_and_start",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: TIME_LOCAL.to_unix,
+ created: TIME_LOCAL.to_unix,
+ },
+ {
+ id: 14,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_014",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL + 5.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "notify_created_yesterday",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: TIME_YESTERDAY.to_unix,
+ created: TIME_YESTERDAY.to_unix,
+ },
+ {
+ id: 15,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_015",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 11.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "reject_created_yesterday",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: TIME_YESTERDAY.to_unix,
+ created: TIME_YESTERDAY.to_unix,
+ },
+ {
+ id: 16,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_016",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 6.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 1.hour).to_unix,
+ timezone: TIMEZONE,
+ title: "notify_after_start",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 20.minutes).to_unix,
+ created: (TIME_LOCAL - 20.minutes).to_unix,
+ },
+ {
+ id: 17,
+ user_id: "user-wfh",
+ user_email: "user_one@example.com",
+ user_name: "User One",
+ asset_id: "desk_017",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: TIME_START_OF_DAY.to_unix,
+ booking_end: TIME_END_OF_DAY.to_unix,
+ timezone: TIMEZONE,
+ all_day: true,
+ title: "all_day",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-wfh",
+ booked_by_email: "user_one@example.com",
+ booked_by_name: "User One",
+ process_state: "approved",
+ last_changed: (TIME_LOCAL - 2.days).to_unix,
+ created: (TIME_LOCAL - 2.days).to_unix,
+ },
+ {
+ id: 18,
+ user_id: "user-after-hours",
+ user_email: "user_five@example.com",
+ user_name: "User Five",
+ asset_id: "desk_018",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL - 20.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 40.minutes).to_unix,
+ timezone: TIMEZONE,
+ all_day: false,
+ title: "outside_hours",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-after-hours",
+ booked_by_email: "user_five@example.com",
+ booked_by_name: "User Five",
+ process_state: "approved",
+ last_changed: TIME_YESTERDAY.to_unix,
+ created: TIME_YESTERDAY.to_unix,
+ },
+ {
+ id: 19,
+ user_id: "user-no-preferences",
+ user_email: "user_six@example.com",
+ user_name: "User Six",
+ asset_id: "desk_019",
+ zones: ["zone-1234"],
+ booking_type: "desk",
+ booking_start: (TIME_LOCAL + 5.minutes).to_unix,
+ booking_end: (TIME_LOCAL + 25.minutes).to_unix,
+ timezone: TIMEZONE,
+ all_day: false,
+ title: "no_preferences",
+ description: "",
+ checked_in: false,
+ rejected: false,
+ approved: true,
+ booked_by_id: "user-no-preferences",
+ booked_by_email: "user_six@example.com",
+ booked_by_name: "User Six",
+ process_state: "approved",
+ last_changed: TIME_YESTERDAY.to_unix,
+ created: TIME_YESTERDAY.to_unix,
+ },
+ ]
+
+ def query_bookings(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil
+ )
+ JSON.parse(BOOKINGS.to_json)
+ end
+
+ def get_booking(booking_id : String | Int64)
+ booking = query_bookings.as_a.find { |b| b.as_h["id"] == booking_id }
+ return unless booking
+
+ case booking_id
+ when 6
+ booking.as_h["checked_in"] = JSON.parse(true.to_json)
+ when 7
+ booking.as_h["booking_start"] = JSON.parse((Time.utc + 1.hour).to_unix.to_json)
+ booking.as_h["booking_end"] = JSON.parse((Time.utc + 2.hours).to_unix.to_json)
+ end
+ booking
+ end
+
+ def user(id : String)
+ user_wfh = {
+ created_at: Time.utc.to_unix,
+ id: id,
+ email_digest: "not_real_digest",
+ name: "User One",
+ first_name: "User",
+ last_name: "One",
+ groups: [] of String,
+ country: "Australia",
+ building: "",
+ image: "",
+ authority_id: "authority-wfh",
+ deleted: false,
+ department: "",
+ work_preferences: 7.times.map do |i|
+ {
+ day_of_week: i,
+ blocks: [
+ {
+ start_time: (TIME_LOCAL - 4.hours).hour,
+ end_time: (TIME_LOCAL + 4.hours).hour,
+ location: "wfh",
+ },
+ ],
+ }
+ end,
+ sys_admin: false,
+ support: false,
+ email: "user_one@example.com",
+ phone: "",
+ ui_theme: "light",
+ login_name: "",
+ staff_id: "",
+ card_number: "",
+ }
+
+ user_wfo = {
+ created_at: Time.utc.to_unix,
+ id: id,
+ email_digest: "not_real_digest",
+ name: "User Two",
+ first_name: "User",
+ last_name: "Two",
+ groups: [] of String,
+ country: "Australia",
+ building: "",
+ image: "",
+ authority_id: "authority-wfo",
+ deleted: false,
+ department: "",
+ work_preferences: 7.times.map do |i|
+ {
+ day_of_week: i,
+ blocks: [
+ {
+ start_time: (TIME_LOCAL - 4.hours).hour,
+ end_time: (TIME_LOCAL + 4.hours).hour,
+ location: "wfo",
+ },
+ ],
+ }
+ end,
+ sys_admin: false,
+ support: false,
+ email: "user_two@example.com",
+ phone: "",
+ ui_theme: "light",
+ login_name: "",
+ staff_id: "",
+ card_number: "",
+ }
+
+ user_aol = {
+ created_at: Time.utc.to_unix,
+ id: id,
+ email_digest: "not_real_digest",
+ name: "User Three",
+ first_name: "User",
+ last_name: "Three",
+ groups: [] of String,
+ country: "Australia",
+ building: "",
+ image: "",
+ authority_id: "authority-aol",
+ deleted: false,
+ department: "",
+ work_preferences: 7.times.map do |i|
+ {
+ day_of_week: i,
+ blocks: [
+ {
+ start_time: (TIME_LOCAL - 4.hours).hour,
+ end_time: (TIME_LOCAL + 4.hours).hour,
+ location: "wfo",
+ },
+ ],
+ }
+ end,
+ work_overrides: {
+ DATE => {
+ day_of_week: DAY_OF_WEEK,
+ blocks: [
+ {
+ start_time: (TIME_LOCAL - 4.hours).hour,
+ end_time: (TIME_LOCAL + 4.hours).hour,
+ location: "aol",
+ },
+ ],
+ },
+ },
+ sys_admin: false,
+ support: false,
+ email: "user_three@example.com",
+ phone: "",
+ ui_theme: "light",
+ login_name: "",
+ staff_id: "",
+ card_number: "",
+ }
+
+ user_wfh_override = {
+ created_at: Time.utc.to_unix,
+ id: id,
+ email_digest: "not_real_digest",
+ name: "User Four",
+ first_name: "User",
+ last_name: "Four",
+ groups: [] of String,
+ country: "Australia",
+ building: "",
+ image: "",
+ authority_id: "authority-wfo-override",
+ deleted: false,
+ department: "",
+ work_preferences: 7.times.map do |i|
+ {
+ day_of_week: i,
+ blocks: [
+ {
+ start_time: (TIME_LOCAL - 4.hours).hour,
+ end_time: (TIME_LOCAL + 4.hours).hour,
+ location: "wfh",
+ },
+ ],
+ }
+ end,
+ work_overrides: {
+ DATE => {
+ day_of_week: DAY_OF_WEEK,
+ blocks: [
+ {
+ start_time: (TIME_LOCAL - 4.hours).hour,
+ end_time: (TIME_LOCAL + 4.hours).hour,
+ location: "wfo",
+ },
+ ],
+ },
+ },
+ sys_admin: false,
+ support: false,
+ email: "user_four@example.com",
+ phone: "",
+ ui_theme: "light",
+ login_name: "",
+ staff_id: "",
+ card_number: "",
+ }
+
+ user_after_hours = {
+ created_at: Time.utc.to_unix,
+ id: id,
+ email_digest: "not_real_digest",
+ name: "User Five",
+ first_name: "User",
+ last_name: "Five",
+ groups: [] of String,
+ country: "Australia",
+ building: "",
+ image: "",
+ authority_id: "authority-after-hours",
+ deleted: false,
+ department: "",
+ work_preferences: 7.times.map do |i|
+ {
+ day_of_week: i,
+ blocks: [
+ {
+ start_time: (TIME_LOCAL - 6.hours).hour,
+ end_time: (TIME_LOCAL - 2.hours).hour,
+ location: "wfo",
+ },
+ ],
+ }
+ end,
+ sys_admin: false,
+ support: false,
+ email: "user_five@example.com",
+ phone: "",
+ ui_theme: "light",
+ login_name: "",
+ staff_id: "",
+ card_number: "",
+ }
+
+ user_no_preferences = {
+ created_at: Time.utc.to_unix,
+ id: id,
+ email_digest: "not_real_digest",
+ name: "User Six",
+ first_name: "User",
+ last_name: "Six",
+ groups: [] of String,
+ country: "Australia",
+ building: "",
+ image: "",
+ authority_id: "authority-no-preferences",
+ deleted: false,
+ department: "",
+ sys_admin: false,
+ support: false,
+ email: "user_six@example.com",
+ phone: "",
+ ui_theme: "light",
+ login_name: "",
+ staff_id: "",
+ card_number: "",
+ }
+
+ case id
+ when "user-wfh"
+ JSON.parse(user_wfh.to_json)
+ when "user-wfo"
+ JSON.parse(user_wfo.to_json)
+ when "user-aol"
+ JSON.parse(user_aol.to_json)
+ when "user-wfh-override"
+ JSON.parse(user_wfh_override.to_json)
+ when "user-after-hours"
+ JSON.parse(user_after_hours.to_json)
+ when "user-no-preferences"
+ JSON.parse(user_no_preferences.to_json)
+ else
+ JSON.parse(user_wfh.to_json)
+ end
+ end
+
+ def zones(q : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0,
+ parent : String? = nil,
+ tags : Array(String) | String? = nil)
+ zones = [
+ {
+ created_at: 1660537814,
+ updated_at: 1681800971,
+ id: "zone-1234",
+ name: "Test Zone",
+ display_name: "Test Zone",
+ location: "",
+ description: "",
+ code: "",
+ type: "",
+ count: 0,
+ capacity: 0,
+ map_id: "",
+ tags: [
+ "building",
+ ],
+ triggers: [] of String,
+ parent_id: "zone-0000",
+ timezone: TIMEZONE,
+ },
+ ]
+
+ JSON.parse(zones.to_json)
+ end
+end
+
+class Mailer < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Mailer
+
+ def on_load
+ self[:sent] = 0
+ end
+
+ def send_template(
+ to : String | Array(String),
+ template : Tuple(String, String),
+ args : TemplateItems,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ self[:sent] = self[:sent].as_i + 1
+ end
+
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ ) : Bool
+ true
+ end
+end
+
+DriverSpecs.mock_driver "Place::AutoRelease" do
+ system({
+ StaffAPI: {StaffAPI},
+ Mailer: {Mailer},
+ })
+
+ timezone = "Australia/Sydney"
+ time_local = Time.local(location: Time::Location.load(timezone))
+
+ settings({
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ },
+ release_locations: ["wfh", "aol"],
+ })
+
+ resp = exec(:get_building_zone?).get
+ resp.not_nil!.as_h["id"].should eq "zone-1234"
+
+ resp = exec(:get_pending_bookings).get
+ resp.not_nil!.as_a.size.should eq 18
+
+ resp = exec(:get_user_preferences?, "user-wfh").get
+ resp.not_nil!.as_h.keys.should eq ["work_preferences", "work_overrides"]
+
+ # Start of tests for: #pending_release
+ #######################################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: false,
+ skip_all_day: true,
+ })
+
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "ignore",
+ "notify",
+ "reject",
+ "ignore_last_minute_checkin",
+ "ignore_last_minute_schedule_change",
+ "reject_on_start",
+ "release_override_aol",
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "notify_after_start",
+ ]
+
+ # skip_same_day: true
+ #####################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: true,
+ })
+
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ ]
+
+ # all_day_start:
+ ################
+
+ # all_day_start: 20 minutes in the future
+ all_day_start_in_20_minutes = (time_local + 20.minutes).hour + ((time_local + 20.minutes).minute / 60.0)
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ all_day_start: all_day_start_in_20_minutes,
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: false,
+ })
+
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "all_day",
+ ]
+
+ # release_outside_hours: true
+ #############################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ release_outside_hours: true,
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: true,
+ })
+
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "outside_hours",
+ "no_preferences",
+ ]
+
+ # when release_outside_hours is false (the default)
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ release_outside_hours: false,
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: true,
+ })
+
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ ]
+
+ # default_work_preferences
+ ##########################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ release_outside_hours: false,
+ default_work_preferences: 7.times.map do |i|
+ {
+ day_of_week: i,
+ blocks: [
+ {
+ start_time: (time_local - 4.hours).hour,
+ end_time: (time_local + 4.hours).hour,
+ location: "wfh",
+ },
+ ],
+ }
+ end,
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: true,
+ })
+
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "no_preferences",
+ ]
+
+ #####################################
+ # End of tests for: #pending_release
+
+ # Start of tests for: #release_bookings
+ #######################################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: false,
+ })
+
+ # Ensure self[:pending_release] holds the correct bookings for the settings before testing #release_bookings
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "ignore",
+ "notify",
+ "reject",
+ "ignore_last_minute_checkin",
+ "ignore_last_minute_schedule_change",
+ "reject_on_start",
+ "release_override_aol",
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "notify_after_start",
+ ]
+
+ # Should reject 3 bookings
+ # booking_id: 3, title: reject
+ # booking_id: 9, title: release_override_aol
+ # booking_id: 15, title: reject_created_yesterday
+ resp = exec(:release_bookings).get
+ resp.should eq [3, 9, 15]
+ system(:StaffAPI_1)[:rejected].should eq 3
+
+ # Don't try to reject bookings that have already been rejected
+ resp = exec(:release_bookings).get
+ resp.should eq [3, 9, 15]
+ system(:StaffAPI_1)[:rejected].should eq 3
+
+ # Reject bookings immidiatly on start
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 0,
+ resources: ["desk"],
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: false,
+ })
+
+ # Should reject 5 bookings
+ # booking_id: [3, 9, 15, 8, 16], title: ["reject", "release_override_aol", "reject_created_yesterday", "reject_on_start", "notify_after_start"]
+ resp = exec(:release_bookings).get
+ resp.should eq [3, 9, 15, 8, 16]
+ system(:StaffAPI_1)[:rejected].should eq 5
+
+ # all_day_start:
+ ################
+
+ # all_day_start: 20 minutes in the future
+ all_day_start_in_20_minutes = (time_local + 20.minutes).hour + ((time_local + 20.minutes).minute / 60.0)
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ all_day_start: all_day_start_in_20_minutes,
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: false,
+ })
+
+ # Ensure self[:pending_release] holds the correct bookings for the settings before testing #release_bookings
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "all_day",
+ ]
+
+ resp = exec(:release_bookings).get
+ resp.should eq [15]
+ system(:StaffAPI_1)[:rejected].should eq 5
+
+ # all_day_start: 20 minutes in the past
+ all_day_start_20_minutes_ago = (time_local - 20.minutes).hour + ((time_local - 20.minutes).minute / 60.0)
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ all_day_start: all_day_start_20_minutes_ago,
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: false,
+ })
+
+ resp = exec(:release_bookings).get
+ resp.should eq [15, 17]
+ system(:StaffAPI_1)[:rejected].should eq 6
+
+ # release_outside_hours: true
+ #############################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ release_outside_hours: true,
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: true,
+ skip_all_day: true,
+ })
+
+ # Ensure self[:pending_release] holds the correct bookings for the settings before testing #release_bookings
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "outside_hours",
+ "no_preferences",
+ ]
+
+ resp = exec(:release_bookings).get
+ resp.should eq [15, 18]
+ system(:StaffAPI_1)[:rejected].should eq 7
+
+ #####################################
+ # End of tests for: #release_bookings
+
+ # Start of tests for: #send_release_emails
+ ##########################################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: 10,
+ time_after: 0,
+ resources: ["desk"],
+ },
+ release_locations: ["wfh", "aol"],
+ skip_created_after_start: true,
+ skip_same_day: false,
+ skip_all_day: true,
+ })
+
+ # Ensure self[:pending_release] holds the correct bookings for the settings before testing #release_bookings
+ resp = exec(:pending_release).get
+ pending_release = resp.not_nil!.as_a.map(&.as_h["title"])
+ pending_release.should eq [
+ "ignore",
+ "notify",
+ "reject",
+ "ignore_last_minute_checkin",
+ "ignore_last_minute_schedule_change",
+ "reject_on_start",
+ "release_override_aol",
+ "notify_created_yesterday",
+ "reject_created_yesterday",
+ "notify_after_start",
+ ]
+
+ # Ensure self[:released_booking_ids] holds the correct bookings for the settings before testing #send_release_emails
+ resp = exec(:release_bookings).get
+ resp.should eq [15, 3, 8, 9, 16]
+
+ # Send email once booking is past the time_before window,
+ # but before the time_after window
+ # (booking_id: 2, title: notify)
+ # (booking_id: 14, title: notify_created_yesterday)
+ resp = exec(:send_release_emails).get
+ resp.should eq [2, 14]
+ system(:Mailer_1)[:sent].should eq 2
+
+ # Spam protection, should not send email again
+ resp = exec(:send_release_emails).get
+ resp.should eq [2, 14]
+ system(:Mailer_1)[:sent].should eq 2
+
+ # Notify after start
+ ####################
+
+ settings({
+ time_window_hours: 8,
+ auto_release: {
+ time_before: -5,
+ time_after: 40,
+ resources: ["desk"],
+ },
+ })
+ status[:released_booking_ids] = [2, 14, 3, 6, 7, 9, 15]
+ status[:emailed_booking_ids] = nil
+
+ # (booking_id: 16, title: notify_after_start)
+ resp = exec(:send_release_emails).get
+ resp.should eq [16]
+ system(:Mailer_1)[:sent].should eq 3
+
+ ########################################
+ # End of tests for: #send_release_emails
+
+ # Start of tests for: #in_preference_hours?
+ ###########################################
+
+ # normal work hours, event in range
+ # start at 8am, end at 4pm, event at 3pm
+ resp = exec(:in_preference_hours?, 8.0, 16.0, 15.0).get
+ resp.should eq true
+
+ # normal work hours, event out of range (after)
+ # start at 8am, end at 4pm, event at 5pm
+ resp = exec(:in_preference_hours?, 8.0, 16.0, 17.0).get
+ resp.should eq nil
+
+ # normal work hours, event out of range (before)
+ # start at 8am, end at 4pm, event at 6am
+ resp = exec(:in_preference_hours?, 8.0, 16.0, 6.0).get
+ resp.should eq nil
+
+ # work hours crosses midnight, event in range
+ # start at 10pm, end at 6am, event at 3am
+ resp = exec(:in_preference_hours?, 22.0, 6.0, 3.0).get
+ resp.should eq true
+
+ # work hours crosses midnight, event out of range (after)
+ # start at 10pm, end at 6am, event at 7am
+ resp = exec(:in_preference_hours?, 22.0, 6.0, 7.0).get
+ resp.should eq nil
+
+ # work hours crosses midnight, event out of range (before)
+ # start at 10pm, end at 6am, event at 8pm
+ resp = exec(:in_preference_hours?, 22.0, 6.0, 20.0).get
+ resp.should eq nil
+
+ #########################################
+ # End of tests for: #in_preference_hours?
+
+ # Start of tests for: #enabled?
+ ###############################
+
+ # enabled when there are resources
+ settings({
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: ["desk"],
+ },
+ })
+ resp = exec(:enabled?).get
+ resp.should eq true
+
+ # disabled when resources is empty
+ resp = exec(:enabled?).get
+ resp.should eq true
+ settings({
+ auto_release: {
+ time_before: 10,
+ time_after: 10,
+ resources: [] of String,
+ },
+ })
+ resp = exec(:enabled?).get
+ resp.should eq nil
+
+ #############################
+ # End of tests for: #enabled?
+end
diff --git a/drivers/place/booking_approval_workflows.cr b/drivers/place/booking_approval_workflows.cr
new file mode 100644
index 00000000000..30e7ae19467
--- /dev/null
+++ b/drivers/place/booking_approval_workflows.cr
@@ -0,0 +1,807 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+require "digest/md5"
+require "placeos"
+require "file"
+
+require "./booking_model"
+
+class Place::BookingApprovalWorkflows < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "Desk Booking Approval Workflows"
+ generic_name :BookingApproval
+ description %(picks an approval strategy based on configuration)
+
+ default_settings({
+ timezone: "Australia/Sydney",
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+
+ booking_type: "desk",
+ unique_templates: false, # This appends the booking type to the template name
+ remind_after: 24,
+ escalate_after: 48,
+ disable_attachments: true,
+
+ approval_type: {
+ zone_id1: {
+ name: "Sydney Building 1",
+ approval: :notify,
+ support_email: "support@place.com",
+ },
+ zone_id2: {
+ name: "Melb Building",
+ approval: :manager_approval,
+ attachments: {"file-name.pdf" => "https://s3/your_file.pdf"},
+ support_email: "msupport@place.com",
+ },
+ },
+
+ reminders: {
+ crons: ["0 10 * * *", "0 14 * * *"],
+ zones: {
+ "Australia/Sydney" => ["zone_id1", "zone_id2"],
+ "Australia/Perth" => ["zone_id3"],
+ },
+ },
+ })
+
+ # bookings states:
+ # * manager_contacted = manager has been emailed to approve
+ # * manager_reminded = manager has been emailed a second time to approve
+ # * managers_manager = managers manager has been emailed to approve
+
+ accessor staff_api : StaffAPI_1
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ def on_load
+ # Some form of asset booking has occured
+ monitor("staff/booking/changed") { |_subscription, payload| parse_booking(payload) }
+
+ on_update
+ end
+
+ # See: https://crystal-lang.org/api/0.35.1/Time/Format.html
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+
+ @booking_type : String = "desk"
+ @unique_templates : Bool = false
+ @template_suffix : String = ""
+ @template_fields_suffix : String = ""
+ @bookings_checked : UInt64 = 0_u64
+ @error_count : UInt64 = 0_u64
+
+ @remind_after : Time::Span = 24.hours
+ @escalate_after : Time::Span = 48.hours
+ @disable_attachments : Bool = true
+ @notify_managers : Bool = false
+
+ alias SiteDetails = NamedTuple(approval: String, name: String, support_email: String, attachments: Hash(String, String)?)
+ alias Reminders = NamedTuple(crons: Array(String), zones: Hash(String, Array(String)))
+
+ # Zone_id => approval type
+ @approval_lookup : Hash(String, SiteDetails) = {} of String => SiteDetails
+
+ def on_update
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+ @unique_templates = setting?(Bool, :unique_templates) || false
+ @template_suffix = @unique_templates ? "_#{@booking_type}" : ""
+ @template_fields_suffix = @unique_templates ? " (#{@booking_type})" : ""
+
+ time_zone = setting?(String, :calendar_time_zone).presence || "Australia/Sydney"
+ @time_zone = Time::Location.load(time_zone)
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+
+ @remind_after = (setting?(Int32, :remind_after) || 24).hours
+ @escalate_after = (setting?(Int32, :escalate_after) || 48).hours
+ @notify_managers = setting?(Bool, :notify_managers) || false
+
+ @approval_lookup = setting(Hash(String, SiteDetails), :approval_type)
+ attach = setting?(Bool, :disable_attachments)
+ @disable_attachments = attach.nil? ? true : !!attach
+
+ schedule.clear
+ schedule.every(5.minutes) { check_bookings }
+
+ reminders = setting?(Reminders, :reminders) || {crons: [] of String, zones: {} of String => Array(String)}
+ reminders[:crons].each do |cron|
+ reminders[:zones].each do |timezone, zones|
+ begin
+ schedule.cron(cron, Time::Location.load(timezone)) { send_checkin_reminder(zones) }
+ rescue error
+ logger.warn(exception: error) { "failed to schedule reminder: #{zones} => #{timezone} : #{cron}" }
+ end
+ end
+ end
+ end
+
+ def template_fields : Array(TemplateFields)
+ time_now = Time.utc.in(@time_zone)
+
+ common_fields = [
+ {name: "booking_id", description: "Unique identifier for the booking"},
+ {name: "start_time", description: "Booking start time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "start_date", description: "Booking start date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "start_datetime", description: "Booking start date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "end_time", description: "Booking end time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "end_date", description: "Booking end date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "end_datetime", description: "Booking end date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "starting_unix", description: "Booking start time as Unix timestamp"},
+ {name: "desk_id", description: "Identifier of the booked desk"},
+ {name: "user_id", description: "Identifier of the person the booking is for"},
+ {name: "user_email", description: "Email of the person the booking is for"},
+ {name: "user_name", description: "Name of the person the booking is for"},
+ {name: "reason", description: "Purpose or title of the booking"},
+ {name: "level_zone", description: "Zone identifier for the specific floor level"},
+ {name: "building_zone", description: "Zone identifier for the building"},
+ {name: "building_name", description: "Name of the building"},
+ {name: "support_email", description: "Contact email for booking support"},
+ {name: "approver_name", description: "Name of the person who approved/rejected the booking"},
+ {name: "approver_email", description: "Email of the person who approved/rejected the booking"},
+ {name: "booked_by_name", description: "Name of the person who made the booking"},
+ {name: "booked_by_email", description: "Email of the person who made the booking"},
+ {name: "attachment_name", description: "Name of any attached files"},
+ {name: "attachment_url", description: "URL to download any attachments"},
+ ]
+
+ [
+ TemplateFields.new(
+ trigger: {"bookings", "group_booking_sent#{@template_suffix}"},
+ name: "Group booking sent#{@template_fields_suffix}",
+ description: "Notification when a group booking has been created",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "approved_by#{@template_suffix}"},
+ name: "Booking approved by#{@template_fields_suffix}",
+ description: "Notification when booking is approved by someone other than the requester",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "approved#{@template_suffix}"},
+ name: "Booking approved#{@template_fields_suffix}",
+ description: "Notification when booking is approved",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "rejected#{@template_suffix}"},
+ name: "Booking rejected#{@template_fields_suffix}",
+ description: "Notification when booking is rejected",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "checked_in#{@template_suffix}"},
+ name: "Booking checked in#{@template_fields_suffix}",
+ description: "Notification when user checks in to their booking",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "cancelled_by#{@template_suffix}"},
+ name: "Booking cancelled by#{@template_fields_suffix}",
+ description: "Notification when booking is cancelled by someone other than the booker",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "cancelled#{@template_suffix}"},
+ name: "Booking cancelled#{@template_fields_suffix}",
+ description: "Notification when booking is cancelled by the booker",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "manager_notify_cancelled#{@template_suffix}"},
+ name: "Booking cancelled manager notification#{@template_fields_suffix}",
+ description: "Notification to manager when their team member's booking is cancelled",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "manager_approval#{@template_suffix}"},
+ name: "Booking manager approval#{@template_fields_suffix}",
+ description: "Request for manager to approve a booking#{@template_suffix}",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "manager_contacted#{@template_suffix}"},
+ name: "Booking manager contacted#{@template_fields_suffix}",
+ description: "Notification to user that their manager has been contacted for approval",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "notify_manager#{@template_suffix}"},
+ name: "Booking manager notification#{@template_fields_suffix}",
+ description: "Notification to manager about their team member's booking",
+ fields: common_fields
+ ),
+ ]
+ end
+
+ # Booking id => event, timestamp
+ @debounce = {} of Int64 => {String?, Int64}
+
+ # Booker has been informed of the group booking email
+ @group_email_notifications = {} of String => Int64
+
+ protected def parse_booking(payload)
+ logger.debug { "received booking event payload: #{payload}" }
+ booking_details = Booking.from_json payload
+
+ # Only process booking types of interest
+ return unless booking_details.booking_type == @booking_type
+
+ # Ignore when a bookings state is updated
+ return if {"process_state", "metadata_changed"}.includes?(booking_details.action)
+
+ # Ignore the same event in a short period of time
+ previous = @debounce[booking_details.id]?
+ return if previous && previous[0] == booking_details.action
+ @debounce[booking_details.id] = {booking_details.action, Time.utc.to_unix}
+
+ approval_details = get_building_name(booking_details.zones)
+ return unless approval_details
+ building_zone, building_name, approval_type, support_email, attachments = approval_details
+ building_key = building_name.downcase.gsub(' ', '_')
+
+ timezone = booking_details.timezone.presence || @time_zone.name
+ location = Time::Location.load(timezone)
+
+ # https://crystal-lang.org/api/0.35.1/Time/Format.html
+ # date and time (Tue Apr 5 10:26:19 2016)
+ starting = Time.unix(booking_details.booking_start).in(location)
+ ending = Time.unix(booking_details.booking_end).in(location)
+
+ # Ignore changes to meetings that have already ended
+ return if Time.utc > ending
+
+ attach = attachments.first?
+
+ args = {
+ booking_id: booking_details.id,
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+ starting_unix: booking_details.booking_start,
+
+ desk_id: booking_details.asset_id,
+ user_id: booking_details.user_id,
+ user_email: booking_details.user_email,
+ user_name: booking_details.user_name,
+ reason: booking_details.title,
+
+ level_zone: booking_details.zones.reject { |z| z == building_zone }.first?,
+ building_zone: building_zone,
+ building_name: building_name,
+ support_email: support_email,
+
+ approver_name: booking_details.approver_name,
+ approver_email: booking_details.approver_email,
+
+ booked_by_name: booking_details.booked_by_name,
+ booked_by_email: booking_details.booked_by_email,
+
+ attachment_name: attach.try &.[](:file_name),
+ attachment_url: attach.try &.[](:uri),
+ }
+
+ attachments.clear if @disable_attachments
+
+ case booking_details.action
+ when "create", "changed"
+ group_id = booking_details.extension_data["group_id"]?.try &.to_s
+ if group_id && !@group_email_notifications.has_key?(group_id)
+ @group_email_notifications[group_id] = Time.utc.to_unix
+
+ mailer.send_template(
+ to: booking_details.booked_by_email,
+ template: {"bookings", "group_booking_sent#{@template_suffix}"},
+ args: args
+ )
+ end
+
+ check_approval(
+ booking_details,
+ approval_type,
+ building_key,
+ attachments,
+ args
+ )
+ when "approved"
+ return if booking_details.process_state == "approval_sent"
+
+ third_party = approval_type == "manager_approval" && booking_details.user_email != booking_details.booked_by_email
+ mailer.send_template(
+ to: booking_details.user_email,
+ template: {"bookings", third_party ? "approved_by#{@template_suffix}" : "approved#{@template_suffix}"},
+ args: args,
+ attachments: attachments
+ ).get
+
+ staff_api.booking_state(booking_details.id, "approval_sent", booking_details.instance).get
+ when "rejected", "checked_in"
+ # no attachment for rejection email
+ user_email = booking_details.user_email
+ mailer.send_template(
+ to: user_email,
+ template: {"bookings", "#{booking_details.action}#{@template_suffix}"},
+ args: args
+ )
+ when "cancelled"
+ third_party = booking_details.approver_email && booking_details.approver_email != booking_details.user_email.downcase
+
+ # no attachment for rejection email
+ user_email = booking_details.user_email
+ mailer.send_template(
+ to: user_email,
+ template: {"bookings", third_party ? "cancelled_by#{@template_suffix}" : "cancelled#{@template_suffix}"},
+ args: args
+ )
+
+ if @notify_managers && (manager_email = get_manager(user_email).try(&.at(0)))
+ mailer.send_template(
+ to: manager_email,
+ template: {"bookings", "manager_notify_cancelled#{@template_suffix}"},
+ args: args
+ )
+ end
+ end
+
+ @bookings_checked += 1
+ self[:bookings_checked] = @bookings_checked
+ rescue error
+ logger.error { error.inspect_with_backtrace }
+ self[:last_error] = {
+ error: error.message,
+ time: Time.local.to_s,
+ user: payload,
+ }
+ @error_count += 1
+ self[:error_count] = @error_count
+ end
+
+ def get_building_name(zones : Array(String))
+ zones.each do |zone_id|
+ details = @approval_lookup[zone_id]?
+ if details
+ attachments = (details[:attachments] || {} of String => String).compact_map { |n, l| get_attachment(n, l) }
+ logger.debug { "attaching #{attachments.size} files" }
+ return {zone_id, details[:name], details[:approval], details[:support_email], attachments}
+ end
+ end
+ nil
+ end
+
+ protected def check_approval(
+ booking_details,
+ approval_type,
+ building_key,
+ attachments,
+ args
+ )
+ user_email = booking_details.user_email
+ state = booking_details.process_state
+
+ logger.debug { "checking status of #{approval_type} booking #{booking_details.id} for #{booking_details.user_name}\ncurrent state: #{state}" }
+
+ case approval_type
+ when "manager_approval"
+ logger.debug { "checking manager approval state: #{state}" }
+
+ case state
+ # manager needs to approve this bookings
+ when nil || state.try(&.empty?)
+ manager_email, manager_name = get_manager(user_email)
+ if manager_email && manager_name
+ logger.debug { "requesting manager approval..." }
+
+ mailer.send_template(
+ to: manager_email,
+ template: {"bookings", "manager_approval#{@template_suffix}"},
+ args: args
+ ).get
+
+ # set the booking state
+ staff_api.booking_state(booking_details.id, "manager_contacted", booking_details.instance).get
+
+ mailer.send_template(
+ to: user_email,
+ template: {"bookings", "manager_contacted#{@template_suffix}"},
+ args: args.merge({
+ manager_email: manager_email,
+ manager_name: manager_name,
+ })
+ )
+ else
+ logger.debug { "manager not found, approving booking!" }
+ # approve automatically if no manager to approve
+ staff_api.approve(booking_details.id, booking_details.instance).get
+ end
+ # we might need to remind this manager to approve or reject a booking
+ when "manager_contacted"
+ if booking_details.changed < @remind_after.ago
+ logger.debug { "sending manager reminder email" }
+
+ if manager_email = get_manager(user_email).try(&.at(0))
+ mailer.send_template(
+ to: manager_email,
+ template: {"bookings", "manager_approval#{@template_suffix}"},
+ args: args
+ ).get
+
+ # set the booking state
+ staff_api.booking_state(booking_details.id, "manager_reminded", booking_details.instance).get
+ else
+ logger.debug { "manager not found, approving booking!" }
+ # approve automatically if no manager to approve
+ staff_api.approve(booking_details.id, booking_details.instance).get
+ end
+ end
+ # do we need to escalate the approval?
+ when "manager_reminded"
+ if booking_details.changed < @escalate_after.ago
+ if manager_email = get_manager(user_email).try(&.at(0))
+ if manager_email = get_manager(manager_email).try(&.at(0))
+ logger.debug { "sending managers manager an email" }
+
+ mailer.send_template(
+ to: manager_email,
+ template: {"bookings", "manager_approval#{@template_suffix}"},
+ args: args
+ ).get
+
+ # set the booking state
+ staff_api.booking_state(booking_details.id, "managers_manager", booking_details.instance).get
+ else
+ # approve automatically if no manager to approve
+ logger.debug { "managers manager not found, approving booking!" }
+ staff_api.approve(booking_details.id, booking_details.instance).get
+ end
+ else
+ # approve automatically if no manager to approve
+ logger.debug { "manager not found, approving booking!" }
+ staff_api.approve(booking_details.id, booking_details.instance).get
+ end
+ end
+ when "managers_manager"
+ if booking_details.changed > 5.days.ago
+ # approve automatically if no manager approves in over 4 days
+ logger.debug { "approving booking as managers have failed to approve" }
+ staff_api.approve(booking_details.id, booking_details.instance).get
+ end
+ end
+ when "notify"
+ logger.debug { "approving booking and notifing manager = #{@notify_managers}" }
+
+ # manager needs to be notified of approval
+ staff_api.approve(booking_details.id, booking_details.instance).get
+ # NOTE:: user will be sent email via the approval event
+
+ if @notify_managers && (manager_email = get_manager(user_email).try(&.at(0)))
+ mailer.send_template(
+ to: manager_email,
+ template: {"bookings", "notify_manager#{@template_suffix}"},
+ args: args
+ )
+ end
+ else
+ # Auto approval
+ logger.debug { "approving booking as unknown approval type: #{approval_type.inspect}" }
+ staff_api.approve(booking_details.id, booking_details.instance).get
+ # NOTE:: user will be sent email via the approval event
+ end
+ end
+
+ protected def get_attachment(filename : String, uri : String)
+ return {file_name: filename, content: "", uri: uri} if @disable_attachments
+
+ ext = filename.split('.')[-1]
+ file = Digest::MD5.base64digest(uri).gsub(/[^0-9a-zA-Z\.]/, "") + ext
+
+ # Local cache is pre-encoded
+ if File.exists?(file)
+ content = File.read(file)
+ logger.debug { "attachment saved locally #{filename} - #{content.bytesize}" }
+ return {file_name: filename, content: content, uri: uri}
+ end
+
+ # Download the file from the internet
+ buffer = IO::Memory.new
+ begin
+ buf = Bytes.new(64)
+ HTTP::Client.get(uri) do |response|
+ raise "HTTP request failed with #{response.status_code}" unless response.success?
+ body_io = response.body_io
+ while ((bytes = body_io.read(buf)) > 0)
+ buffer.write(buf[0, bytes])
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "unable to download attachment: #{uri}" }
+ return nil
+ end
+
+ encoded = Base64.strict_encode(buffer)
+ File.write file, encoded
+
+ logger.debug { "attachment downloaded #{filename} - #{encoded.bytesize}" }
+
+ {file_name: filename, content: encoded, uri: uri}
+ end
+
+ @check_bookings_mutex = Mutex.new
+
+ @[Security(Level::Support)]
+ def check_bookings(months_from_now : Int32 = 2)
+ # Clean up old debounce data
+ expired = 5.minutes.ago.to_unix
+ @debounce.reject! { |_, (_event, entered)| expired > entered }
+
+ expired = 1.hour.ago.to_unix
+ @group_email_notifications.reject! { |_, entered| expired > entered }
+
+ @check_bookings_mutex.synchronize do
+ @approval_lookup.each do |building_zone, details|
+ building_name = details[:name]
+ approval_type = details[:approval]
+ support_email = details[:support_email]
+ attachments = (details[:attachments] || {} of String => String).compact_map { |n, l| get_attachment(n, l) }
+ building_key = building_name.downcase.gsub(' ', '_')
+
+ perform_booking_check(building_zone, approval_type, building_name, building_key, support_email, attachments, months_from_now)
+ end
+ end
+ end
+
+ protected def perform_booking_check(building_zone, approval_type, building_name, building_key, support_email, attachments, months_from_now = 2)
+ now = Time.utc.to_unix
+ later = months_from_now.months.from_now.to_unix
+
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: now,
+ period_end: later,
+ zones: [building_zone],
+ approved: false,
+ rejected: false,
+ created_before: 2.minutes.ago.to_unix
+ ).get.as_a
+
+ bookings = bookings + staff_api.query_bookings(
+ type: @booking_type,
+ period_start: now,
+ period_end: later,
+ zones: [building_zone],
+ approved: true,
+ rejected: false,
+ created_before: 2.minutes.ago.to_unix
+ ).get.as_a
+
+ bookings = Array(Booking).from_json(bookings.to_json)
+ logger.debug { "checking #{bookings.size} requested bookings in #{building_name}" }
+ bookings.each do |booking_details|
+ timezone = booking_details.timezone.presence || @time_zone.name
+ location = Time::Location.load(timezone)
+
+ starting = Time.unix(booking_details.booking_start).in(location)
+ ending = Time.unix(booking_details.booking_end).in(location)
+
+ attach = attachments.first?
+
+ args = {
+ booking_id: booking_details.id,
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+ starting_unix: booking_details.booking_start,
+
+ desk_id: booking_details.asset_id,
+ user_id: booking_details.user_id,
+ user_email: booking_details.user_email,
+ user_name: booking_details.user_name,
+ reason: booking_details.title,
+
+ level_zone: booking_details.zones.reject { |z| z == building_zone }.first?,
+ building_zone: building_zone,
+ building_name: building_name,
+ support_email: support_email,
+
+ booked_by_name: booking_details.booked_by_name,
+ booked_by_email: booking_details.booked_by_email,
+
+ attachment_name: attach.try &.[](:file_name),
+ attachment_url: attach.try &.[](:uri),
+ }
+
+ attachments.clear if @disable_attachments
+
+ begin
+ if booking_details.approved
+ if booking_details.process_state != "approval_sent"
+ third_party = booking_details.user_email != booking_details.booked_by_email
+
+ mailer.send_template(
+ to: booking_details.user_email,
+ template: {"bookings", third_party ? "approved_by#{@template_suffix}" : "approved#{@template_suffix}"},
+ args: args,
+ attachments: attachments
+ )
+ staff_api.booking_state(booking_details.id, "approval_sent", booking_details.instance).get
+ end
+ else
+ check_approval(
+ booking_details,
+ approval_type,
+ building_key,
+ attachments,
+ args
+ )
+ end
+ rescue error
+ logger.error(exception: error) { "while processing booking id #{booking_details.id}" }
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def get_manager(staff_email : String)
+ manager = mailer.get_user_manager(staff_email).get
+ {(manager["email"]? || manager["username"]).as_s, manager["name"].as_s}
+ rescue error
+ logger.warn { "failed to email manager of #{staff_email}\n#{error.inspect_with_backtrace}" }
+ {nil, nil}
+ end
+
+ @[Security(Level::Support)]
+ def users_with_invalid_desk_bookings(building_zone : String, ending : Int64)
+ # [] of {zone: {id:}, metadata: {desks: {details: [{id:, groups: [] of String}]}}}
+ meta_raw = staff_api.metadata_children(building_zone, "desks").get.as_a
+
+ # Zone => Desk_id => Groups
+ metadata = {} of String => Hash(String, Array(String))
+ meta_raw.each do |zone|
+ desks = {} of String => Array(String)
+ zone_id = zone["zone"]["id"].as_s
+ zone["metadata"]["desks"]["details"].as_a.each do |desk|
+ desks[desk["id"].as_s] = desk["groups"].as_a.map(&.as_s.downcase)
+ end
+ metadata[zone_id] = desks
+ end
+
+ # User email, Desk ID, zone, booking id, starting, starting friendly
+ users = [] of Tuple(String, String, String, Int64, Int64, String)
+
+ # [] of {user_email:, zones:, asset_id:}
+ bookings = staff_api.query_bookings(type: "desk", period_end: ending, zones: [building_zone], rejected: false).get.as_a
+ bookings.each do |booking|
+ user_email = booking["user_email"].as_s
+ level_id = booking["zones"].as_a.map(&.as_s).reject(building_zone).first
+ desk_id = booking["asset_id"].as_s
+ booking_id = booking["id"].as_i64
+ starting = booking["booking_start"].as_i64
+
+ if desks = metadata[level_id]?
+ if groups = desks[desk_id]?
+ next if groups.empty?
+
+ users_groups = mailer.get_groups(user_email).get.as_a.map { |g| g["name"].as_s.downcase }
+ overlap = users_groups & groups
+ if overlap.empty?
+ date_friendly = Time.unix(starting).to_s(@date_format)
+ users << {user_email, desk_id, level_id, booking_id, starting, date_friendly}
+ end
+ end
+ end
+ end
+
+ logger.debug { "Email,Desk ID,Zone,Booking id,Starting,Start date\n#{users.map { |u| "#{u[0]},#{u[1]},#{u[2]},#{u[3]},#{u[4]},#{u[5]}" }.join("\n")}" }
+
+ nil
+ end
+
+ @[Security(Level::Support)]
+ def send_checkin_reminder(zones : Array(String)? = nil, timezone : String? = nil)
+ time_now = Time.utc.in(timezone ? Time::Location.load(timezone) : @time_zone)
+ time_now = time_now.at_beginning_of_day + 12.hours
+ time_now = time_now.to_local_in(Time::Location::UTC)
+
+ query_start = time_now.to_unix
+ query_end = (time_now + 30.minutes).to_unix
+
+ @check_bookings_mutex.synchronize do
+ @approval_lookup.each do |building_zone, details|
+ next if zones && !zones.includes?(building_zone)
+
+ building_name = details[:name]
+ support_email = details[:support_email]
+ attachments = (details[:attachments] || {} of String => String).compact_map { |n, l| get_attachment(n, l) }
+ building_key = building_name.downcase.gsub(' ', '_')
+
+ perform_checkin_reminder(building_zone, building_name, building_key, support_email, attachments, query_start, query_end)
+ end
+ end
+ end
+
+ protected def perform_checkin_reminder(
+ building_zone,
+ building_name,
+ building_key,
+ support_email,
+ attachments,
+ start_of_day,
+ time_now
+ )
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: start_of_day,
+ period_end: time_now,
+ zones: [building_zone],
+ approved: true,
+ rejected: false,
+ checked_in: false
+ ).get.as_a
+
+ logger.debug { "querying for bookings requiring a reminder, start time: #{start_of_day}, end time #{time_now}" }
+ bookings = Array(Booking).from_json(bookings.to_json)
+ logger.debug { "found #{bookings.size} bookings in #{building_name} requiring a reminder" }
+
+ bookings.each do |booking_details|
+ timezone = booking_details.timezone.presence || @time_zone.name
+ location = Time::Location.load(timezone)
+
+ starting = Time.unix(booking_details.booking_start).in(location)
+ ending = Time.unix(booking_details.booking_end).in(location)
+
+ attach = attachments.first?
+
+ args = {
+ booking_id: booking_details.id,
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+ starting_unix: booking_details.booking_start,
+
+ desk_id: booking_details.asset_id,
+ user_id: booking_details.user_id,
+ user_email: booking_details.user_email,
+ user_name: booking_details.user_name,
+ reason: booking_details.title,
+
+ level_zone: booking_details.zones.reject { |z| z == building_zone }.first?,
+ building_zone: building_zone,
+ building_name: building_name,
+ support_email: support_email,
+
+ booked_by_name: booking_details.booked_by_name,
+ booked_by_email: booking_details.booked_by_email,
+
+ attachment_name: attach.try &.[](:file_name),
+ attachment_url: attach.try &.[](:uri),
+ }
+
+ attachments.clear if @disable_attachments
+ mailer.send_template(
+ to: booking_details.user_email,
+ template: {"bookings", "checkin_reminder#{@template_suffix}"},
+ args: args,
+ attachments: attachments
+ )
+ end
+ end
+end
diff --git a/drivers/place/booking_approver.cr b/drivers/place/booking_approver.cr
new file mode 100644
index 00000000000..7430cc0aa22
--- /dev/null
+++ b/drivers/place/booking_approver.cr
@@ -0,0 +1,100 @@
+require "placeos-driver"
+require "./booking_model"
+
+class Place::BookingApprover < PlaceOS::Driver
+ descriptive_name "Booking Auto Approver"
+ generic_name :BookingApprover
+ description %(Automatically approves all PlaceOS bookings)
+
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ approve_booking_types: ["desk"],
+ approve_zones: ["zone-12345"],
+ })
+
+ def on_load
+ monitor("staff/booking/changed") do |_subscription, payload|
+ logger.debug { "received booking changed event #{payload}" }
+ approve_booking(Booking.from_json payload)
+ end
+ on_update
+ end
+
+ @bookings_approved : Int32 = 0u32
+ @approve_zones : Array(String) = [] of String
+ @approve_booking_types : Array(String) = [] of String
+
+ def on_update
+ @approve_zones = setting?(Array(String), :approve_zones) || [] of String
+ @approve_booking_types = setting?(Array(String), :approve_booking_types) || [] of String
+
+ schedule.clear
+ schedule.every(10.minutes) { approve_missed }
+ end
+
+ # Finds the building ID for the current location services object
+ def get_building_id
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ nil
+ end
+
+ private def approve_booking(booking : Booking)
+ return if booking.action == "cancelled" || booking.rejected
+
+ if booking.action != "create"
+ booking = Booking.from_json(staff_api.get_booking(booking.id).get.to_json)
+ end
+
+ if !@approve_zones.empty?
+ if (booking.zones & @approve_zones).empty?
+ logger.debug { "Ignoring booking as no booking zone matches #{booking.id}" }
+ return false
+ end
+ end
+
+ if !@approve_booking_types.empty?
+ if !@approve_booking_types.includes?(booking.booking_type)
+ logger.debug { "Ignoring booking as booking_type #{booking.booking_type} doesn't match #{booking.id}" }
+ return false
+ end
+ end
+
+ if !booking.approved
+ # approving booking instances isn't a thing
+ staff_api.approve(booking.id).get
+ logger.debug { "Approved Booking #{booking.id}" }
+ @bookings_approved += 1
+ true
+ else
+ false
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to approve booking #{booking.id}" }
+ end
+
+ def approve_missed
+ booking_type = @approve_booking_types[0]? || "desk"
+
+ bookings = Array(Booking).from_json staff_api.query_bookings(
+ type: booking_type,
+ zones: [get_building_id],
+ approved: false,
+ period_end: 8.weeks.from_now.to_unix
+ ).get.to_json
+
+ bookings.each do |booking|
+ booking.action = "create"
+ approve_booking booking
+ end
+
+ "found #{bookings.size} missed"
+ end
+
+ def status
+ {bookings_approved: @bookings_approved}
+ end
+end
diff --git a/drivers/place/booking_check_in_helper.cr b/drivers/place/booking_check_in_helper.cr
new file mode 100644
index 00000000000..320b8f2c4c4
--- /dev/null
+++ b/drivers/place/booking_check_in_helper.cr
@@ -0,0 +1,384 @@
+require "placeos-driver"
+require "place_calendar"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+
+class Place::BookingCheckInHelper < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "PlaceOS Check-in helper"
+ generic_name :CheckInHelper
+ description "works in conjunction with the Bookings driver to help automate check-in"
+
+ accessor bookings : Bookings_1
+ accessor calendar : Calendar_1
+
+ def mailer
+ sys_id = @mailer_system.presence
+ sys = sys_id ? system(sys_id) : system
+ sys.implementing(Interface::Mailer)[0]
+ end
+
+ default_settings({
+ # how many minutes until we want to prompt the user
+ prompt_after: 10,
+ auto_cancel: false,
+ decline_message: "optionally use this instead of a custom email template",
+
+ # how many minutes to wait before we enable auto-check-in
+ present_from: 5,
+ ignore_longer_than: 120,
+
+ time_zone: "Australia/Sydney",
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+
+ # URIs for confirming or denying a meeting
+ check_in_url: "https://domain.com/meeting/check-in",
+ no_show_url: "https://domain.com/meeting/no-show",
+
+ _mailer_system: "sys-12345",
+
+ jwt_private_key: <<-STRING
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAt01C9NBQrA6Y7wyIZtsyur191SwSL3MjR58RIjZ5SEbSyzMG
+3r9v12qka4UtpB2FmON2vwn0fl/7i3Jgh1Xth/s+TqgYXMebdd123wodrbex5pi3
+Q7PbQFT6hhNpnsjBh9SubTf+IeTIFeXUyqtqcDBmEoT5GxU6O+Wuch2GtbfEAmaD
+roy+uyB7P5DxpKLEx8nlVYgpx5g2mx2LufHvykVnx4bFzLezU93SIEW6yjPwUmv9
+R+wDM/AOg60dIf3hCh1DO+h22aKT8D8ysuFodpLTKCToI/AbK4IYOOgyGHZ7xizX
+HYXZdsqX5/zBFXu/NOVrSd/QBYYuCxbqe6tz4wIDAQABAoIBAQCEIRxXrmXIcMlK
+36TfR7h8paUz6Y2+SGew8/d8yvmH4Q2HzeNw41vyUvvsSVbKC0HHIIfzU3C7O+Lt
+9OeiBo2vTKrwNflBv9zPDHHoerlEBLsnNwQ7uEUeTWM9DHdBLwNaLzQApLD6q5iT
+OFW4NfIGpsydIt8R565PiNPDjIcTKwhbVdlsSbI87cLkQ9UuYIMRkvXSD1Q2cg3I
+VsC0SpE4zmfTe7YTZQ5yTxtsoLKPBXrSxhhGuhdayeN7A4YHFYVD39RuQ6/T2w2a
+W/0UaGOk8XWgydDpD5w9wiBdH2I4i6D35IynCcodc5JvmTajzJT+xj6aGjjvMSyq
+q5ZdwJ4JAoGBAOPdZgjbOCf3ONUoiZ5Qw/a4b4xJgMokgqZ5QGBF5GqV1Xsphmk1
+apYmgC7fmab/EOdycrQMS0am2FmtwX1f7gYgJoyWtK4TVkUc5rf+aoWi0ieIsegv
+rjhuiIAc12+vVIbegRgnq8mOI5icrwm6OkwdqHkwTt6VRYdJGEmu67n/AoGBAM3v
+RAd5uIjVwVDLXqaOpvF3pxWfl+cf6PJtAE5y+nbabeTmrw//fJMank3o7qCXkFZR
+F0OJ2tmENwV+LPM8Gy3So8YP2nkOz4bryaGrxQ4eMA+K9+RiACVaKv+tNx/NbyMS
+e9gg504u0cwa60XjM5KUKrmT3RXpY4YIfUPZ1J4dAoGAB6jalDOiSJ2j2G57acn3
+PGTowwN5g9IEXko3IsVWr0qIGZLExOaZxaBXsLutc5KhY9ZSCsFbCm3zWdhgZ7GA
+083i3dj3C970iHA3RToVJJbbj56ltFNd/OGiTwQpLcTsB3iVSFWVDbpsceXacG5F
+JWfd0O0RyaOk6a5IVbm+jMsCgYBglxAOfY4LSE8y6SCM+K3e5iNNZhymgHYPdwbE
+xPMrWgpfab/Evi2dBcgofM+oLU663bAOspMeoP/5qJPGxnNtC7ZbSMZNL6AxBVj+
+ZoW3uHsMXz8kNL8ixecTIxiO5xlwltPVrKExL46hsCKYFhfzcWGUx4DULTLMBCFU
++M/cFQKBgQC+Ite962yJOnE+bjtSReOrvR9+I+YNGqt7vyRa2nGFxL7ZNIqHss5T
+VjaMgjzVJqqYozNT/74pE/b9UjYyMzO/EhrjUmcwriMMan/vTbYoBMYWvGoy536r
+4n455vizig2c4/sxU5yu9AF9Dv+qNsGCx2e9uUOTDUlHM9NXwxU9rQ==
+-----END RSA PRIVATE KEY-----
+STRING
+ })
+
+ @sensor_stale : Bool = false
+ @mailer_system : String? = nil
+
+ # See: https://crystal-lang.org/api/latest/Time/Format.html
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+ @timezone : Time::Location = Time::Location::UTC
+
+ @ignore_longer_than : Time::Span? = nil
+ protected getter! prompt_after : Time::Span
+ protected getter! present_from : Time::Span
+ @auto_cancel : Bool = false
+
+ getter? meeting_pending : Bool = false
+ getter? people_present : Bool = false
+ getter current_meeting : PlaceCalendar::Event? = nil
+
+ # This last meeting id we prompted
+ @prompted : String = ""
+
+ # The URLS we want to send to the user
+ @check_in_url : String = ""
+ @no_show_url : String = ""
+ @domain : String = ""
+
+ @jwt_private_key : String = ""
+ @decline_message : String? = nil
+
+ def on_update
+ @jwt_private_key = setting?(String, :jwt_private_key) || ""
+ @decline_message = setting?(String, :decline_message)
+ @mailer_system = setting?(String, :mailer_system)
+
+ @ignore_longer_than = setting?(Int32, :ignore_longer_than).try &.minutes
+ @prompt_after = (setting?(Int32, :prompt_after) || 10).minutes
+ @present_from = (setting?(Int32, :present_from) || 5).minutes
+ @auto_cancel = setting?(Bool, :auto_cancel) || false
+
+ @check_in_url = setting?(String, :check_in_url) || ""
+ @no_show_url = setting?(String, :no_show_url) || ""
+ if @check_in_url.presence
+ @domain = URI.parse(@check_in_url).host.not_nil!
+ end
+
+ subscriptions.clear
+ bookings.subscribe(:current_booking) do |_sub, pending|
+ event = PlaceCalendar::Event?.from_json(pending)
+ update_current event
+ end
+ bookings.subscribe(:current_pending) { |_sub, pending| update_pending(pending == "true") }
+ bookings.subscribe(:sensor_stale) { |_sub, sensor_stale| update_stale_state(sensor_stale == "true") }
+ bookings.subscribe(:presence) { |_sub, presence| update_presence(presence == "true") }
+
+ monitor("#{config.control_system.not_nil!.id}/guest/bookings/prompted") do |_sub, response|
+ checkin_or_end_meeting(**NamedTuple(id: String, check_in: Bool).from_json(response))
+ end
+
+ timezone = setting?(String, :time_zone) || config.control_system.not_nil!.timezone.presence
+ @timezone = Time::Location.load(timezone) if timezone
+
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+ end
+
+ def ignore_long_meeting? : Bool
+ meeting = current_meeting
+ return false unless meeting
+
+ # we always want to ignore all day events
+ event_end = meeting.event_end
+ return true unless event_end
+
+ # don't ignore meetings if ignore longer than isn't set
+ ignore_length = @ignore_longer_than
+ return false unless ignore_length
+
+ # check if we're over the limit
+ meeting_length = event_end - meeting.event_start
+ meeting_length >= ignore_length
+ end
+
+ protected def update_current(meeting : PlaceCalendar::Event?)
+ logger.debug { "> checking current meeting: #{!!meeting}" }
+
+ @current_meeting = meeting
+ self[:current_meeting] = !!meeting
+ meeting ? apply_state_changes : cleanup_state
+ end
+
+ protected def update_pending(state : Bool)
+ logger.debug { "> meeting pending: #{state}" }
+ self[:meeting_pending] = @meeting_pending = state
+ state ? apply_state_changes : cleanup_state
+ end
+
+ protected def update_presence(state : Bool)
+ logger.debug { "> people present: #{state}" }
+ self[:people_present] = @people_present = state
+ apply_state_changes
+ end
+
+ protected def update_stale_state(stale : Bool)
+ @sensor_stale = stale
+ apply_state_changes
+ end
+
+ protected def cleanup_state
+ logger.debug { "cleaning up state, pending: #{@meeting_pending}, meeting: #{!!@current_meeting}" }
+ schedule.clear
+
+ meeting = current_meeting
+ if meeting.try(&.id) != @prompted
+ self[:prompted] = false
+ self[:no_show] = false
+ self[:checked_in] = false
+ self[:responded] = false
+ end
+ end
+
+ protected def apply_state_changes
+ meeting = self.current_meeting
+ return unless meeting && meeting_pending?
+
+ logger.debug { "applying state changes" }
+ schedule.clear
+
+ time_now = Time.utc
+ start_time = meeting.event_start
+ prompt_at = start_time + prompt_after
+ check_presence_from = start_time + present_from
+
+ # Schedule an auto check-in check if the sensor is stale
+ if @sensor_stale
+ logger.debug { "stale sensor detected... Scheduling meeting start" }
+ schedule.at(check_presence_from) do
+ logger.debug { "starting meeting with stale sensor" }
+ bookings.start_meeting(start_time.to_unix)
+ end
+ return
+ end
+
+ # Can we auto check-in?
+ logger.debug { "people_present? #{people_present?}" }
+ if people_present?
+ if time_now >= check_presence_from
+ logger.debug { "starting meeting!" }
+ bookings.start_meeting(start_time.to_unix)
+ else
+ # Schedule an auto check-in check as people_present? might remain high
+ logger.debug { "scheduling meeting start" }
+ schedule.at(check_presence_from) do
+ logger.debug { "starting meeting!" }
+ bookings.start_meeting(start_time.to_unix)
+ end
+ end
+ return
+ end
+
+ # don't prompt if a long meeting
+ if ignore_long_meeting?
+ logger.debug { "> ignoring meeting due to length" }
+ else
+ # should we be scheduling a prompt email?
+ if time_now >= prompt_at
+ logger.debug { "no show, prompting user" }
+ send_prompt_or_auto_cancel meeting
+ else
+ logger.debug { "scheduling no show" }
+ schedule.at(prompt_at) do
+ logger.debug { "scheduled no show, prompting user" }
+ send_prompt_or_auto_cancel meeting
+ end
+ end
+ end
+ end
+
+ def template_fields : Array(TemplateFields)
+ time_now = Time.utc.in(@timezone)
+ [
+ TemplateFields.new(
+ trigger: {"bookings", "check_in_prompt"},
+ name: "Check in prompt",
+ description: "Email template for prompting meeting hosts to check in or cancel their booking",
+ fields: [
+ {name: "jwt", description: "Authentication token for secure responses"},
+ {name: "host_email", description: "Email address of the meeting organizer"},
+ {name: "host_name", description: "Full name of the meeting organizer"},
+ {name: "event_id", description: "Unique identifier for the calendar event"},
+ {name: "system_id", description: "Unique identifier for the room/space"},
+ {name: "meeting_room_name", description: "Display name of the meeting room"},
+ {name: "meeting_summary", description: "Title or subject of the meeting"},
+ {name: "meeting_datetime", description: "Formatted date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "meeting_time", description: "Formatted time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "meeting_date", description: "Formatted date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "check_in_url", description: "URL for confirming attendance"},
+ {name: "no_show_url", description: "URL for cancelling the booking"},
+ ]
+ ),
+ ]
+ end
+
+ # decides whether to decline the event now or sends the templated email to the host asking them to end or keep it
+ protected def send_prompt_or_auto_cancel(meeting : PlaceCalendar::Event)
+ if @prompted == meeting.id
+ logger.debug { "user has already been prompted" }
+ return
+ end
+
+ present = (Float64 | Nil).from_json(bookings.people_present?.get.to_json)
+ if present.nil? || present > 0.0
+ logger.debug { "not prompting as people present or presence is unknown: #{present.inspect}" }
+ return
+ end
+
+ unless @decline_message && @auto_cancel
+ logger.debug { "prompting user about meeting room booking #{meeting.id}" }
+ begin
+ params = generate_guest_jwt
+ mailer.send_template(params[:host_email], {"bookings", "check_in_prompt"}, params)
+ rescue error
+ logger.warn(exception: error) { "failed to notify user" }
+ end
+ end
+
+ @prompted = meeting.id.not_nil!
+ self[:no_show] = false
+ self[:checked_in] = false
+ self[:responded] = false
+ self[:prompted] = true
+
+ checkin_or_end_meeting(meeting.id.not_nil!, false) if @auto_cancel
+ end
+
+ # actually decline the meeting now or processes the response if the user clicks one of the links in the email
+ protected def checkin_or_end_meeting(id : String, check_in : Bool)
+ meeting = current_meeting
+ unless meeting
+ logger.warn { "received response but no current meeting" }
+ return
+ end
+
+ if @prompted == meeting.id && @prompted == id
+ self[:responded] = true
+ logger.info { "host has responded with #{check_in}" }
+
+ if check_in
+ bookings.start_meeting(meeting.event_start.to_unix)
+ self[:checked_in] = true
+ elsif @decline_message
+ bookings.end_meeting(meeting.event_start.to_unix, notify: true, comment: @decline_message)
+ self[:no_show] = true
+ else
+ bookings.end_meeting(meeting.event_start.to_unix)
+ self[:no_show] = true
+ end
+ else
+ logger.warn { "received response for another meeting #{id} != #{meeting.id} or #{@prompted}" }
+ end
+ end
+
+ # generates the parameters that can be mixed into the template email
+ protected def generate_guest_jwt
+ meeting = current_meeting
+ raise "expected current meeting" unless meeting
+
+ ctrl_system = config.control_system.not_nil!
+ system_id = ctrl_system.id
+ event_id = meeting.id
+ host_email = meeting.host.not_nil!
+ user = PlaceCalendar::User.from_json calendar.get_user(host_email).get.to_json
+
+ now = Time.utc
+ starting = meeting.event_start.in(@timezone)
+ end_of_meeting = (meeting.event_end || starting.at_end_of_day).in(@timezone)
+
+ payload = {
+ iss: "POS",
+ iat: now.to_unix,
+ exp: end_of_meeting.to_unix,
+ jti: UUID.random.to_s,
+ aud: @domain,
+ scope: ["guest"],
+ sub: host_email,
+ u: {
+ n: user.name,
+ e: host_email,
+ p: 0,
+ r: [event_id, system_id],
+ },
+ }
+
+ jwt = JWT.encode(payload, @jwt_private_key, JWT::Algorithm::RS256)
+
+ {
+ jwt: jwt,
+ host_email: host_email,
+ host_name: user.name,
+ event_id: event_id,
+ system_id: system_id,
+ meeting_room_name: ctrl_system.display_name.presence || ctrl_system.name,
+ meeting_summary: meeting.title,
+ meeting_datetime: starting.to_s(@date_time_format),
+ meeting_time: starting.to_s(@time_format),
+ meeting_date: starting.to_s(@date_format),
+ check_in_url: @check_in_url,
+ no_show_url: @no_show_url,
+ }
+ end
+end
diff --git a/drivers/place/booking_check_in_helper_readme.md b/drivers/place/booking_check_in_helper_readme.md
new file mode 100644
index 00000000000..04ac537113b
--- /dev/null
+++ b/drivers/place/booking_check_in_helper_readme.md
@@ -0,0 +1,66 @@
+# Booking Checkin Helper Readme
+
+Docs on how to configure the booking check-in helper.
+This helper provides a simple method for asking users if they intend to attend their meeting.
+
+* Bookings driver tracks room schedule and looks for sensors indicating if there is presence in the room
+* Sensor driver like Vergesense Room Sensor exposes room presence
+* Booking checkin helper monitors `Booking.current_pending` and `Booking.presence`
+ * auto checks in if presence found
+ * emails the host about the booking if they have not checked in
+* configuring `auto_cancel: true` will have the system decline the meeting with the `decline_message` instead of asking the host if they still intend to attend.
+
+
+## Requirements
+
+Requires the following drivers in the system
+
+* Booking - for room state
+* StaffAPI - for peoples names
+* Mailer - for querying the host if they will be using the room
+* (some sensor driver for Booking driver to obtain presence state from)
+
+Note:: to avoid having to add Mailer and StaffAPI to each system you can define a `mailer_system` setting.
+This will delegate to that system for StaffAPI and Mailers.
+
+
+## Usage
+
+The driver generates a guest JWT and listens for a signal on path `"#{control_system.id}/guest/bookings/prompted"`
+You signal via `POST /api/engine/v2/signal?channel=control_system_id/guest/bookings/prompted`
+That signal is expected to have a payload of
+```json
+{"id": "event_id", "check_in": true / false}
+```
+
+To build the email with the links to your frontend interfaces you need to create an email template:
+(email templates live on the mailer driver, this can be a dedicated SMTP driver or the Calendar driver, depending on Suncorp preferences)
+
+```yaml
+email_templates:
+ bookings:
+ check_in_prompt:
+ subject: Reminder about your meeting: %{meeting_summary}
+ html: >
+
+ click here to release your booking
+
+ click here to check-in your booking
+
+```
+
+The variables available to mix into the email template are:
+ jwt
+ host_email
+ host_name
+ event_id
+ system_id
+ meeting_room_name
+ meeting_summary
+ meeting_datetime
+ meeting_time
+ meeting_date
+ check_in_url
+ no_show_url
+
+The urls above are optional, i.e. https://corp.com/booking-confirmation from the template, if you want to configure this as a driver setting vs in the template.
diff --git a/drivers/place/booking_check_in_helper_spec.cr b/drivers/place/booking_check_in_helper_spec.cr
new file mode 100644
index 00000000000..523ccd9a89e
--- /dev/null
+++ b/drivers/place/booking_check_in_helper_spec.cr
@@ -0,0 +1,30 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::BookingCheckInHelper" do
+ system({
+ Bookings: {BookingsMock},
+ })
+
+ sleep 1
+
+ system(:Bookings_1)[:current_pending].should eq(false)
+end
+
+# :nodoc:
+class BookingsMock < DriverSpecs::MockDriver
+ def on_load
+ self[:current_booking] = {
+ event_start: 6.minutes.ago.to_unix,
+ attendees: [] of String,
+ private: false,
+ all_day: false,
+ attachments: [] of String,
+ }
+ self[:current_pending] = true
+ self[:presence] = true
+ end
+
+ def start_meeting(time : Int64)
+ self[:current_pending] = false
+ end
+end
diff --git a/drivers/place/booking_model.cr b/drivers/place/booking_model.cr
new file mode 100644
index 00000000000..14f6f0bb122
--- /dev/null
+++ b/drivers/place/booking_model.cr
@@ -0,0 +1,149 @@
+require "json"
+
+class Place::Booking
+ include JSON::Serializable
+
+ # This is to support events
+ property action : String? = nil
+
+ property id : Int64
+ property instance : Int64? = nil
+ property booking_type : String
+ property booking_start : Int64
+ property booking_end : Int64
+ property timezone : String?
+
+ # events use resource_id instead of asset_id
+ property asset_id : String?
+ property asset_ids : Array(String) = [] of String
+ property resource_id : String?
+
+ def asset_id : String
+ (@asset_id || @resource_id).as(String)
+ end
+
+ property user_id : String
+ property user_email : String
+ property user_name : String
+
+ property zones : Array(String)
+
+ property rejected : Bool?
+ property rejected_at : Int64? = nil
+ property approved : Bool?
+ property process_state : String?
+ property last_changed : Int64?
+ property created : Int64?
+
+ property approver_id : String?
+ property approver_name : String?
+ property approver_email : String?
+
+ property booked_by_id : String?
+ property booked_by_name : String
+ property booked_by_email : String
+
+ property checked_out_at : Int64? = nil
+ property deleted : Bool? = nil
+ property checked_in : Bool { false }
+ property title : String?
+ property description : String?
+
+ property extension_data : Hash(String, JSON::Any) { {} of String => JSON::Any }
+ getter recurrence_type : String? = nil
+
+ property all_day : Bool = false
+
+ def recurring?
+ @recurrence_type != "none"
+ end
+
+ def recurring_master?
+ recurring? && instance.nil?
+ end
+
+ def in_progress?
+ now = Time.utc.to_unix
+ now >= @booking_start && now < @booking_end
+ end
+
+ def changed
+ Time.unix(last_changed.not_nil!)
+ end
+
+ def expand
+ return {self}.each if asset_ids.size < 2
+
+ asset_ids.map do |aid|
+ Place::Booking.new(
+ id: id,
+ booking_type: booking_type,
+ booking_start: booking_start,
+ booking_end: booking_end,
+ user_id: user_id,
+ user_email: user_email,
+ user_name: user_name,
+ zones: zones,
+ booked_by_name: booked_by_name,
+ booked_by_email: booked_by_email,
+ action: action,
+ timezone: timezone,
+ asset_id: aid,
+ resource_id: resource_id,
+ checked_in: checked_in,
+ rejected: rejected,
+ approved: approved,
+ process_state: process_state,
+ last_changed: last_changed,
+ approver_name: approver_name,
+ approver_email: approver_email,
+ title: title,
+ description: description,
+ asset_ids: [aid],
+ created: created,
+ approver_id: approver_id,
+ booked_by_id: booked_by_id,
+ extension_data: extension_data,
+ )
+ end
+ end
+
+ def initialize(
+ @id,
+ @booking_type,
+ @booking_start,
+ @booking_end,
+ @user_id,
+ @user_email,
+ @user_name,
+ @zones,
+ @booked_by_name,
+ @booked_by_email,
+ @action = nil,
+ @timezone = nil,
+ @asset_id = nil,
+ @resource_id = nil,
+ @checked_in = nil,
+ @rejected = nil,
+ @approved = nil,
+ @process_state = nil,
+ @last_changed = nil,
+ @approver_name = nil,
+ @approver_email = nil,
+ @title = nil,
+ @description = nil,
+ @asset_ids = [] of String,
+ @created = nil,
+ @approver_id = nil,
+ @booked_by_id = nil,
+ @instance = nil,
+ @extension_data = nil,
+ )
+ asset = asset_id.presence
+ if @asset_ids.empty?
+ @asset_ids << asset if asset
+ elsif asset.nil?
+ @asset_id = @asset_ids.first
+ end
+ end
+end
diff --git a/drivers/place/booking_notifier.cr b/drivers/place/booking_notifier.cr
new file mode 100644
index 00000000000..ba301dad770
--- /dev/null
+++ b/drivers/place/booking_notifier.cr
@@ -0,0 +1,513 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+require "digest/md5"
+require "placeos"
+require "file"
+require "uuid"
+
+require "./booking_model"
+require "./password_generator_helper"
+
+class Place::BookingNotifier < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "Booking Notifier"
+ generic_name :BookingNotifier
+ description %(notifies users when a booking takes place)
+
+ default_settings({
+ timezone: "Australia/Sydney",
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+ debug: false,
+
+ booking_type: "desk",
+ unique_templates: false, # This appends the booking type to the template name
+ disable_attachments: true,
+ poll_bookings: false,
+ poll_every_minutes: 5,
+
+ notify: {
+ zone_id1: {
+ name: "Sydney Building 1",
+ email: ["concierge@place.com"],
+ notify_manager: true,
+ notify_booking_owner: true,
+ include_network_credentials: false,
+ network_password_length: DEFAULT_PASSWORD_LENGTH,
+ network_password_exclude: DEFAULT_PASSWORD_EXCLUDE,
+ network_password_minimum_lowercase: DEFAULT_PASSWORD_MINIMUM_LOWERCASE,
+ network_password_minimum_uppercase: DEFAULT_PASSWORD_MINIMUM_UPPERCASE,
+ network_password_minimum_numbers: DEFAULT_PASSWORD_MINIMUM_NUMBERS,
+ network_password_minimum_symbols: DEFAULT_PASSWORD_MINIMUM_SYMBOLS,
+ network_group_ids: [] of String,
+ },
+ zone_id2: {
+ name: "Melb Building",
+ attachments: {"file-name.pdf" => "https://s3/your_file.pdf"},
+ notify_booking_owner: true,
+ },
+ },
+ })
+
+ accessor staff_api : StaffAPI_1
+ accessor network_provider : NetworkAccess_1 # Written for Cisco ISE Driver, but ideally compatible with others
+
+ # We want to use the first driver in the system that is a mailer
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ def calendar
+ system[:Calendar]
+ end
+
+ def on_load
+ # Some form of asset booking has occured
+ monitor("staff/booking/changed") { |_subscription, payload| parse_booking(payload) }
+ on_update
+ end
+
+ # See: https://crystal-lang.org/api/latest/Time/Format.html
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+ @debug : Bool = false
+
+ @booking_type : String = "desk"
+ @unique_templates : Bool = false
+ @template_suffix : String = ""
+ @template_fields_suffix : String = ""
+ @bookings_checked : UInt64 = 0_u64
+ @error_count : UInt64 = 0_u64
+
+ @disable_attachments : Bool = true
+ @poll_bookings : Bool = false
+ @poll_every_minutes : Int32 = 5
+
+ # Zone_id => notify settings
+ @notify_lookup : Hash(String, SiteDetails) = {} of String => SiteDetails
+
+ class SiteDetails
+ include JSON::Serializable
+
+ getter name : String
+ getter email : Array(String) { [] of String }
+ getter attachments : Hash(String, String) { {} of String => String }
+ getter notify_manager : Bool?
+ getter notify_booking_owner : Bool?
+ getter include_network_credentials : Bool?
+ getter network_password_length : Int32?
+ getter network_password_exclude : String?
+ getter network_password_minimum_lowercase : Int32?
+ getter network_password_minimum_uppercase : Int32?
+ getter network_password_minimum_numbers : Int32?
+ getter network_password_minimum_symbols : Int32?
+ getter network_group_ids : Array(String) { [] of String }
+ end
+
+ def on_update
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+ @unique_templates = setting?(Bool, :unique_templates) || false
+ @template_suffix = @unique_templates ? "_#{@booking_type}" : ""
+ @template_fields_suffix = @unique_templates ? " (#{@booking_type})" : ""
+
+ time_zone = setting?(String, :calendar_time_zone).presence || "Australia/Sydney"
+ @time_zone = Time::Location.load(time_zone)
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+ @debug = setting?(Bool, :debug) || false
+
+ @notify_lookup = setting(Hash(String, SiteDetails), :notify)
+ attach = setting?(Bool, :disable_attachments)
+ @disable_attachments = attach.nil? ? true : !!attach
+ @poll_bookings = setting(Bool, :poll_bookings)
+ @poll_every_minutes = setting(Int32, :poll_every_minutes)
+
+ schedule.clear
+ schedule.every(@poll_every_minutes.minutes) { check_bookings } if @poll_bookings
+ end
+
+ def template_fields : Array(TemplateFields)
+ time_now = Time.utc.in(@time_zone)
+ common_fields = [
+ {name: "booking_id", description: "Unique identifier for the booking"},
+ {name: "start_time", description: "Booking start time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "start_date", description: "Booking start date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "start_datetime", description: "Booking start date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "end_time", description: "Booking end time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "end_date", description: "Booking end date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "end_datetime", description: "Booking end date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "starting_unix", description: "Booking start time as Unix timestamp"},
+ {name: "asset_id", description: "Identifier of the booked asset (e.g., desk)"},
+ {name: "user_id", description: "Identifier of the person the booking is for"},
+ {name: "user_email", description: "Email of the person the booking is for"},
+ {name: "user_name", description: "Name of the person the booking is for"},
+ {name: "reason", description: "Purpose or title of the booking"},
+ {name: "level_zone", description: "Zone identifier for the specific floor level"},
+ {name: "building_zone", description: "Zone identifier for the building"},
+ {name: "building_name", description: "Name of the building"},
+ {name: "approver_name", description: "Name of the person who approved/rejected the booking"},
+ {name: "approver_email", description: "Email of the person who approved/rejected the booking"},
+ {name: "booked_by_name", description: "Name of the person who made the booking"},
+ {name: "booked_by_email", description: "Email of the person who made the booking"},
+ {name: "attachment_name", description: "Name of any attached files"},
+ {name: "attachment_url", description: "URL to download any attachments"},
+ {name: "network_username", description: "Network access username (if configured)"},
+ {name: "network_password", description: "Generated network access password (if configured)"},
+ ]
+
+ [
+ TemplateFields.new(
+ trigger: {"bookings", "booked_by_notify#{@template_suffix}"},
+ name: "Booking booked by notification#{@template_fields_suffix}",
+ description: "Notification when someone books on behalf of another person",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "booking_notify#{@template_suffix}"},
+ name: "Booking booked notification#{@template_fields_suffix}",
+ description: "Notification when a booking is created for yourself",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"bookings", "cancelled#{@template_suffix}"},
+ name: "Booking cancelled#{@template_fields_suffix}",
+ description: "Notification when a booking is cancelled",
+ fields: common_fields
+ ),
+ ]
+ end
+
+ # Booking id => event, timestamp
+ @debounce = {} of Int64 => {String?, Int64}
+
+ protected def parse_booking(payload)
+ logger.debug { "received booking event payload: #{payload}" }
+ booking_details = Booking.from_json payload
+
+ # Only process booking types of interest
+ return unless booking_details.booking_type == @booking_type
+
+ # Ignore when a bookings state is updated
+ return unless {"approved", "cancelled"}.includes?(booking_details.action)
+
+ # Ignore the same event in a short period of time
+ previous = @debounce[booking_details.id]?
+ return if previous && previous[0] == booking_details.action
+ @debounce[booking_details.id] = {booking_details.action, Time.utc.to_unix}
+
+ building_zone, notify_details, attachments = get_building_name(booking_details.zones)
+ return unless notify_details && building_zone && attachments
+
+ building_key = notify_details.name.downcase.gsub(' ', '_')
+
+ timezone = booking_details.timezone.presence || @time_zone.name
+ location = Time::Location.load(timezone)
+
+ # https://crystal-lang.org/api/0.35.1/Time/Format.html
+ # date and time (Tue Apr 5 10:26:19 2016)
+ starting = Time.unix(booking_details.booking_start).in(location)
+ ending = Time.unix(booking_details.booking_end).in(location)
+
+ # Ignore changes to meetings that have already ended
+ return if Time.utc > ending
+
+ attach = attachments.first?
+
+ network_username = network_password = nil
+ if notify_details.include_network_credentials
+ network_username, network_password = update_network_user_password(
+ booking_details.user_email,
+ generate_password(
+ length: notify_details.network_password_length,
+ exclude: notify_details.network_password_exclude,
+ minimum_lowercase: notify_details.network_password_minimum_lowercase,
+ minimum_uppercase: notify_details.network_password_minimum_uppercase,
+ minimum_numbers: notify_details.network_password_minimum_numbers,
+ minimum_symbols: notify_details.network_password_minimum_symbols
+ ),
+ notify_details.network_group_ids
+ )
+ end
+
+ args = {
+ booking_id: booking_details.id,
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+ starting_unix: booking_details.booking_start,
+
+ asset_id: booking_details.asset_id,
+ user_id: booking_details.user_id,
+ user_email: booking_details.user_email,
+ user_name: booking_details.user_name,
+ reason: booking_details.title,
+
+ level_zone: booking_details.zones.reject { |z| z == building_zone }.first?,
+ building_zone: building_zone,
+ building_name: notify_details.name,
+
+ approver_name: booking_details.approver_name,
+ approver_email: booking_details.approver_email,
+
+ booked_by_name: booking_details.booked_by_name,
+ booked_by_email: booking_details.booked_by_email,
+
+ attachment_name: attach.try &.[](:file_name),
+ attachment_url: attach.try &.[](:uri),
+
+ network_username: network_username,
+ network_password: network_password,
+ }
+
+ attachments.clear if @disable_attachments
+ third_party = booking_details.user_email != booking_details.booked_by_email
+
+ send_to = notify_details.email.dup
+ send_to << booking_details.user_email if notify_details.notify_booking_owner
+
+ if notify_details.notify_manager
+ email = get_manager(booking_details.user_email)
+ send_to << email if email
+ end
+
+ if booking_details.action == "approved"
+ mailer.send_template(
+ to: send_to,
+ template: {"bookings", third_party ? "booked_by_notify#{@template_suffix}" : "booking_notify#{@template_suffix}"},
+ args: args,
+ attachments: attachments
+ )
+ else
+ mailer.send_template(
+ to: send_to,
+ template: {"bookings", "cancelled#{@template_suffix}"},
+ args: args,
+ attachments: attachments
+ )
+ end
+ staff_api.booking_state(booking_details.id, "notified", booking_details.instance).get
+
+ @bookings_checked += 1
+ self[:bookings_checked] = @bookings_checked
+ rescue error
+ logger.error { error.inspect_with_backtrace }
+ self[:last_error] = {
+ error: error.message,
+ time: Time.local.to_s,
+ user: payload,
+ }
+ @error_count += 1
+ self[:error_count] = @error_count
+ end
+
+ def get_building_name(zones : Array(String))
+ zones.each do |zone_id|
+ details = @notify_lookup[zone_id]?
+ if details
+ attachments = details.attachments.compact_map { |n, l| get_attachment(n, l) }
+ logger.debug { "attaching #{attachments.size} files" }
+ return {zone_id, details, attachments}
+ end
+ end
+ {nil, nil, nil}
+ end
+
+ protected def get_attachment(filename : String, uri : String)
+ return {file_name: filename, content: "", uri: uri} if @disable_attachments
+
+ ext = filename.split('.')[-1]
+ file = Digest::MD5.base64digest(uri).gsub(/[^0-9a-zA-Z\.]/, "") + ext
+
+ # Local cache is pre-encoded
+ if File.exists?(file)
+ content = File.read(file)
+ logger.debug { "attachment saved locally #{filename} - #{content.bytesize}" }
+ return {file_name: filename, content: content, uri: uri}
+ end
+
+ # Download the file from the internet
+ buffer = IO::Memory.new
+ begin
+ buf = Bytes.new(64)
+ HTTP::Client.get(uri) do |response|
+ raise "HTTP request failed with #{response.status_code}" unless response.success?
+ body_io = response.body_io
+ while ((bytes = body_io.read(buf)) > 0)
+ buffer.write(buf[0, bytes])
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "unable to download attachment: #{uri}" }
+ return nil
+ end
+
+ encoded = Base64.strict_encode(buffer)
+ File.write file, encoded
+
+ logger.debug { "attachment downloaded #{filename} - #{encoded.bytesize}" }
+
+ {file_name: filename, content: encoded, uri: uri}
+ end
+
+ @check_bookings_mutex = Mutex.new
+
+ @[Security(Level::Support)]
+ def check_bookings(months_from_now : Int32 = 2)
+ # Clean up old debounce data
+ expired = 5.minutes.ago.to_unix
+ @debounce.reject! { |_, (_event, entered)| expired > entered }
+
+ @check_bookings_mutex.synchronize do
+ @notify_lookup.each do |building_zone, details|
+ building_name = details.name
+ email = details.email
+ attachments = details.attachments.compact_map { |n, l| get_attachment(n, l) }
+ building_key = building_name.downcase.gsub(' ', '_')
+
+ perform_booking_check(building_zone, building_name, building_key, email, details.notify_booking_owner, details.notify_manager, attachments, months_from_now)
+ end
+ end
+ end
+
+ protected def perform_booking_check(building_zone, building_name, building_key, emails, notify_owner, notify_manager, attachments, months_from_now = 2)
+ now = Time.utc.to_unix
+ later = months_from_now.months.from_now.to_unix
+
+ bookings = staff_api.query_bookings(
+ type: @booking_type,
+ period_start: now,
+ period_end: later,
+ zones: [building_zone],
+ approved: false,
+ rejected: false,
+ created_before: 2.minutes.ago.to_unix
+ ).get.as_a
+
+ bookings = bookings + staff_api.query_bookings(
+ type: @booking_type,
+ period_start: now,
+ period_end: later,
+ zones: [building_zone],
+ approved: true,
+ rejected: false,
+ created_before: 2.minutes.ago.to_unix
+ ).get.as_a
+
+ bookings = Array(Booking).from_json(bookings.to_json)
+ logger.debug { "checking #{bookings.size} requested bookings in #{building_name}" }
+ bookings.each do |booking_details|
+ next unless booking_details.process_state.nil?
+ timezone = booking_details.timezone.presence || @time_zone.name
+ location = Time::Location.load(timezone)
+ starting = Time.unix(booking_details.booking_start).in(location)
+ ending = Time.unix(booking_details.booking_end).in(location)
+
+ attach = attachments.first?
+ attachments.clear if @disable_attachments
+
+ notify_details = @notify_lookup[building_zone]
+ network_username = network_password = nil
+ if notify_details.include_network_credentials
+ network_username, network_password = update_network_user_password(
+ booking_details.user_email,
+ generate_password(
+ length: notify_details.network_password_length,
+ exclude: notify_details.network_password_exclude,
+ minimum_lowercase: notify_details.network_password_minimum_lowercase,
+ minimum_uppercase: notify_details.network_password_minimum_uppercase,
+ minimum_numbers: notify_details.network_password_minimum_numbers,
+ minimum_symbols: notify_details.network_password_minimum_symbols
+ )
+ )
+ end
+
+ args = {
+ booking_id: booking_details.id,
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+ starting_unix: booking_details.booking_start,
+
+ asset_id: booking_details.asset_id,
+ user_id: booking_details.user_id,
+ user_email: booking_details.user_email,
+ user_name: booking_details.user_name,
+ reason: booking_details.title,
+
+ level_zone: booking_details.zones.reject { |z| z == building_zone }.first?,
+ building_zone: building_zone,
+ building_name: building_name,
+
+ booked_by_name: booking_details.booked_by_name,
+ booked_by_email: booking_details.booked_by_email,
+
+ attachment_name: attach.try &.[](:file_name),
+ attachment_url: attach.try &.[](:uri),
+
+ network_username: network_username,
+ network_password: network_password,
+ }
+
+ send_to = emails.dup
+ send_to << booking_details.user_email if notify_owner
+
+ begin
+ if notify_manager
+ email = get_manager(booking_details.user_email)
+ send_to << email if email
+ end
+
+ third_party = booking_details.user_email != booking_details.booked_by_email
+
+ mailer.send_template(
+ to: send_to,
+ template: {"bookings", third_party ? "booked_by_notify#{@template_suffix}" : "booking_notify#{@template_suffix}"},
+ args: args,
+ attachments: attachments
+ )
+ staff_api.booking_state(booking_details.id, "notified", booking_details.instance).get
+ rescue error
+ logger.error(exception: error) { "while processing booking id #{booking_details.id}" }
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def get_manager(staff_email : String)
+ manager = calendar.get_user_manager(staff_email).get
+ (manager["email"]? || manager["username"]).as_s
+ rescue error
+ logger.warn { "failed to email manager of #{staff_email}\n#{error.inspect_with_backtrace}" }
+ nil
+ end
+
+ def update_network_user_password(user_email : String, password : String, network_group_ids : Array(String) = [] of String)
+ # Check if they already exist
+ response = network_provider.update_internal_user_password_by_name(user_email, password).get
+ logger.debug { "Response from Network Identity provider for lookup of #{user_email} was:\n#{response}" } if @debug
+ rescue # todo: catch the specific error where the user already exists, instead of any error. Catch other errors in seperate rescue
+ # Create them if they don't already exist
+ create_network_user(user_email, password, network_group_ids)
+ else
+ {user_email, password}
+ end
+
+ def create_network_user(user_email : String, password : String, group_ids : Array(String) = [] of String)
+ response = network_provider.create_internal_user(email: user_email, name: user_email, password: password, identity_groups: group_ids).get
+ logger.debug { "Response from Network Identity provider for creating user #{user_email} was:\n #{response}\n\nDetails:\n#{response.inspect}" } if @debug
+ {response["name"], password}
+ end
+end
diff --git a/drivers/place/booking_notifier_readme.md b/drivers/place/booking_notifier_readme.md
new file mode 100644
index 00000000000..be0c6104ac5
--- /dev/null
+++ b/drivers/place/booking_notifier_readme.md
@@ -0,0 +1,97 @@
+# Booking Notifier Readme
+
+Docs on how to configure the booking notifier helper.
+This helper provides a simple way to notify users of bookings.
+
+* The notifier monitors for new asset bookings (defaults to desks)
+* periodically checks for new bookings
+* for buildings or floors, it notifies a selection of: pre-defined email addresses, the owner of the booking and / or the manager of the booking owner
+
+
+## Requirements
+
+Requires the following drivers in the system
+
+* StaffAPI - for querying bookings
+* Mailer - for sending emails, this also will be where the templates are configured
+* Calendar - for querying a users manager (only if manager notification is desired)
+
+
+## Booking Notifier Configuration
+
+```yaml
+ # How do we want dates to be formatted in the email template
+ timezone: "Australia/Sydney"
+ date_time_format: "%c"
+ time_format: "%l:%M%p"
+ date_format: "%A, %-d %B"
+
+ # What type of asset are we notifying people about
+ booking_type: "desk"
+
+ # Do we want to be emailing out attachments?
+ disable_attachments: true
+
+ # what zones are we notifying about?
+ notify: {
+ # You can configure notification settings for building and floor zones
+ zone_id1: {
+ # name of the building or floor that will be in the email template
+ name: "Sydney Building 1",
+ # optional list of emails you always want to be notified of bookings in this zone
+ email: ["concierge@place.com"],
+ # do we want to notify the booking owners manager?
+ notify_manager: true,
+ # do we want to notify the booking owner?
+ notify_booking_owner: true,
+ },
+ zone_id2: {
+ name: "Melb Building",
+ attachments: {"file-name.pdf" => "https://s3/your_file.pdf"},
+ notify_booking_owner: true,
+ },
+ }
+```
+
+
+## Template configuration on Mailer
+
+There are two templates that are expected:
+
+* `booking_notify` (the booking owner booked the asset)
+* `booked_by_notify` (someone booked on the owners behalf)
+
+```yaml
+email_templates:
+ bookings:
+ booking_notify:
+ subject: Thank you for booking a desk
+ html: >
+
+ your desk %{asset_id} has been booked for %{start_date}
+
+```
+
+The variables available to mix into the email template are:
+ booking_id
+ start_time (formatted as per Booking Notifier Configuration)
+ start_date
+ start_datetime
+ end_time
+ end_date
+ end_datetime
+ starting_unix
+ asset_id
+ user_id (where user is the booking owner)
+ user_email
+ user_name
+ reason (or booking title)
+ level_zone
+ building_zone
+ building_name
+ approver_name
+ approver_email
+ booked_by_name
+ booked_by_email
+ attachment_name
+ attachment_url
diff --git a/drivers/place/booking_notifier_spec.cr b/drivers/place/booking_notifier_spec.cr
new file mode 100644
index 00000000000..90d5759ae4f
--- /dev/null
+++ b/drivers/place/booking_notifier_spec.cr
@@ -0,0 +1,113 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/mailer"
+
+DriverSpecs.mock_driver "Place::BookingCheckInHelper" do
+ system({
+ Mailer: {MailerMock},
+ Calendar: {CalendarMock},
+ StaffAPI: {StaffAPIMock},
+ })
+
+ exec(:check_bookings).get
+
+ system(:StaffAPI)[:queries].should eq 4
+ system(:StaffAPI)[:booking_state].should eq "1--notified"
+ system(:Mailer)[:template].should eq ["bookings", "booking_notify"]
+ system(:Mailer)[:to].should eq ["concierge@place.com", "user1234@org.com", "manager@site.com"]
+end
+
+# :nodoc:
+class MailerMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Mailer
+
+ # need this for the interface
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ true
+ end
+
+ # we don't have templates defined so we'll override this for testing
+ def send_template(
+ to : String | Array(String),
+ template : Tuple(String, String),
+ args : TemplateItems,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ self[:template] = template
+ self[:to] = to
+ end
+end
+
+# :nodoc:
+class CalendarMock < DriverSpecs::MockDriver
+ def get_user_manager(staff_email : String)
+ {
+ email: "manager@site.com",
+ }
+ end
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ @called : Int32 = 0
+
+ def query_bookings(
+ type : String,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil
+ )
+ logger.debug { "Querying desk bookings!" }
+
+ @called += 1
+ self[:queries] = @called
+ return [] of String if @called >= 2
+
+ now = Time.local
+ start = now.at_beginning_of_day.to_unix
+ ending = now.at_end_of_day.to_unix
+ [{
+ id: 1,
+ booking_type: type,
+ booking_start: start,
+ booking_end: ending,
+ asset_id: "desk-123",
+ user_id: "user-1234",
+ user_email: "user1234@org.com",
+ user_name: "Bob Jane",
+ zones: zones + ["zone-building"],
+ checked_in: true,
+ rejected: false,
+ booked_by_name: "Bob Jane",
+ booked_by_email: "user1234@org.com",
+ }]
+ end
+
+ def booking_state(booking_id : String | Int64, state : String, instance : Int64? = nil)
+ self[:booking_state] = "#{booking_id}--#{state}"
+ true
+ end
+end
diff --git a/drivers/place/bookings.cr b/drivers/place/bookings.cr
new file mode 100644
index 00000000000..df9148075a8
--- /dev/null
+++ b/drivers/place/bookings.cr
@@ -0,0 +1,934 @@
+require "placeos-driver"
+require "place_calendar"
+require "placeos-driver/interface/locatable"
+require "placeos-driver/interface/sensor"
+
+class Place::Bookings < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "PlaceOS Room Events"
+ generic_name :Bookings
+
+ default_settings({
+ calendar_id: nil,
+ calendar_ids: [] of String,
+ calendar_time_zone: "Australia/Sydney",
+ book_now_default_title: "Ad Hoc booking",
+ disable_book_now_host: false,
+ disable_book_now: false,
+ disable_end_meeting: true,
+ pending_period: 5,
+ pending_before: 5,
+ cache_polling_period: 5,
+ cache_days: 30,
+
+ # consider sensor data older than this unreliable.
+ sensor_stale_minutes: 8,
+
+ # as graph API is eventually consistent we want to delay syncing for a moment
+ change_event_sync_delay: 5,
+
+ control_ui: "https://if.panel/to_be_used_for_control",
+ catering_ui: "https://if.panel/to_be_used_for_catering",
+
+ application_permissions: true,
+ include_cancelled_bookings: false,
+ show_qr_code: false,
+ custom_qr_url: "https://domain.com/path",
+ custom_qr_color: "black",
+
+ # This image is displayed along with the capacity when the room is not bookable
+ room_image: "https://domain.com/room_image.svg",
+ _sensor_mac: "device-mac",
+
+ hide_meeting_details: false,
+ hide_meeting_title: false,
+ enable_end_meeting_button: false,
+ max_user_search_results: 20,
+
+ # use this to expose arbitrary fields to influx
+ # expose_for_analytics: {"binding" => "key->subkey"},
+
+ # use these for enabling push notifications
+ _push_authority: "authority-GAdySsf05",
+ # push_notification_url: "https://your.domain/api/engine/v2/notifications/google"
+ _push_notification_url: "https://your.domain/api/engine/v2/notifications/office365",
+ })
+
+ accessor calendar : Calendar_1
+
+ getter calendar_id : String = ""
+ getter calendar_ids : Array(String) = [] of String
+ @time_zone : Time::Location = Time::Location.load("Australia/Sydney")
+ @default_title : String = "Ad Hoc booking"
+ @disable_book_now : Bool = false
+ @disable_end_meeting : Bool = false
+ @pending_period : Time::Span = 5.minutes
+ @pending_before : Time::Span = 5.minutes
+ @change_event_sync_delay : UInt32 = 5_u32
+ @cache_days : Time::Span = 30.days
+ @include_cancelled_bookings : Bool = false
+ @application_permissions : Bool = false
+ @disable_book_now_host : Bool = false
+ @max_user_search_results : UInt32 = 20
+
+ @current_meeting_id : String = ""
+ @current_pending : Bool = false
+ @next_pending : Bool = false
+ @expose_for_analytics : Hash(String, String) = {} of String => String
+
+ @sensor_stale_minutes : Time::Span = 8.minutes
+ @perform_sensor_search : Bool = true
+ @sensor_mac : String? = nil
+
+ def on_update
+ schedule.clear
+ @calendar_id = (setting?(String, :calendar_id).presence || system.email.not_nil!).downcase
+ ids = (setting?(Array(String), :calendar_ids) || [] of String).map!(&.downcase)
+ ids.unshift @calendar_id
+ @calendar_ids = ids.uniq!
+
+ @perform_sensor_search = true
+ schedule.in(Random.rand(30).seconds + Random.rand(30_000).milliseconds) { poll_events }
+
+ cache_polling_period = (setting?(UInt32, :cache_polling_period) || 2_u32).minutes.total_milliseconds.to_i
+ cache_polling_period += Random.rand(5_000)
+ cache_random_period = cache_polling_period // 3
+ schedule.every(cache_polling_period.milliseconds) do
+ schedule.in(Random.rand(cache_random_period).milliseconds) { poll_events }
+ end
+
+ time_zone = setting?(String, :calendar_time_zone).presence || config.control_system.not_nil!.timezone.presence
+ @time_zone = Time::Location.load(time_zone) if time_zone
+
+ @default_title = setting?(String, :book_now_default_title).presence || "Ad Hoc booking"
+
+ book_now = setting?(Bool, :disable_book_now)
+ not_bookable = setting?(Bool, :not_bookable) || false
+ self[:bookable] = bookable = not_bookable ? false : system.bookable
+ @disable_book_now = book_now.nil? ? !bookable : !!book_now
+ @disable_end_meeting = !!setting?(Bool, :disable_end_meeting)
+ @disable_book_now_host = setting?(Bool, :disable_book_now_host) || false
+ @max_user_search_results = setting?(UInt32, :max_user_search_results) || 20_u32
+
+ pending_period = setting?(UInt32, :pending_period) || 5_u32
+ @pending_period = pending_period.minutes
+
+ pending_before = setting?(UInt32, :pending_before) || 5_u32
+ @pending_before = pending_before.minutes
+
+ cache_days = setting?(UInt32, :cache_days) || 30_u32
+ @cache_days = cache_days.days
+
+ @change_event_sync_delay = setting?(UInt32, :change_event_sync_delay) || 5_u32
+
+ # ensure we don't load any millisecond timestamps
+ last_started = setting?(Int64, :last_booking_started) || 0_i64
+ @last_booking_started = last_started > 30.minutes.from_now.to_unix ? 0_i64 : last_started
+
+ @include_cancelled_bookings = setting?(Bool, :include_cancelled_bookings) || false
+ @application_permissions = setting?(Bool, :application_permissions) || false
+
+ @sensor_stale_minutes = (setting?(Int32, :sensor_stale_minutes) || 8).minutes
+ @expose_for_analytics = setting?(Hash(String, String), :expose_for_analytics) || {} of String => String
+
+ # ensure current booking is updated at the start of every minute
+ # rand spreads the load placed on redis
+ schedule.cron("* * * * *") do
+ schedule.in(rand(1000).milliseconds) do
+ if list = self[:bookings]?
+ check_current_booking(list.as_a)
+ end
+ end
+ end
+
+ # configure push notifications
+ push_notificaitons_configure
+
+ # Write to redis last on the off chance there is a connection issue
+ control_sys = config.control_system.not_nil!
+ self[:room_name] = setting?(String, :room_name).presence || control_sys.display_name.presence || control_sys.name
+ self[:room_capacity] = setting?(Int32, :room_capacity) || control_sys.capacity
+ self[:default_title] = @default_title
+ self[:disable_book_now_host] = @disable_book_now_host
+ self[:disable_book_now] = @disable_book_now
+ self[:disable_end_meeting] = @disable_end_meeting
+ self[:pending_period] = pending_period
+ self[:pending_before] = pending_before
+ self[:control_ui] = setting?(String, :control_ui)
+ self[:catering_ui] = setting?(String, :catering_ui)
+ self[:room_image] = setting?(String, :room_image) || control_sys.images.try(&.first?)
+ self[:hide_meeting_details] = setting?(Bool, :hide_meeting_details) || false
+ self[:hide_meeting_title] = setting?(Bool, :hide_meeting_title) || false
+
+ self[:offline_color] = setting?(String, :offline_color)
+ self[:offline_image] = setting?(String, :offline_image)
+
+ self[:custom_qr_color] = setting?(String, :custom_qr_color)
+ self[:custom_qr_url] = setting?(String, :custom_qr_url).try do |custom_url|
+ url = custom_url.gsub("{system_id}", control_sys.id)
+ url = url.gsub("{system_email}", @calendar_id)
+ url = url.gsub("{system_email_prefix}", @calendar_id.split('@', 2)[0])
+ url = url.gsub("{system_map_id}", control_sys.map_id.as(String)) if control_sys.map_id
+ url = url.gsub("{system_code}", control_sys.code.as(String)) if control_sys.code
+ url = url.gsub("{system_type}", control_sys.type.as(String)) if control_sys.type
+ url
+ end
+
+ hide_qr_code = setting?(Bool, :hide_qr_code) || false
+ show_qr_code = setting?(Bool, :show_qr_code)
+
+ self[:show_qr_code] = show_qr_code.nil? ? !hide_qr_code : show_qr_code
+
+ self[:sensor_mac] = @sensor_mac = setting?(String, :sensor_mac)
+
+ # min and max meeting duration
+ self[:min_duration] = setting?(Int32, :min_duration) || 15
+ self[:max_duration] = setting?(Int32, :max_duration) || 480
+
+ self[:enable_end_meeting_button] = setting?(Bool, :enable_end_meeting_button) || false
+ end
+
+ # This is how we check the rooms status
+ @last_booking_started : Int64 = 0_i64
+
+ # we no longer accept user specified values
+ def start_meeting(meeting_start_time : Int64) : Nil
+ logger.warn { "deprecated function call to start_meeting, please use checkin" }
+ checkin
+ end
+
+ def checkin : Nil
+ if booking = pending || current
+ check_in_actual booking.event_start.to_unix
+ end
+ end
+
+ private def check_in_actual(meeting_start_time : Int64, check_bookings : Bool = true)
+ logger.debug { "starting meeting @ #{meeting_start_time}" }
+ @last_booking_started = meeting_start_time
+ define_setting(:last_booking_started, meeting_start_time)
+ self[:last_booking_started] = meeting_start_time
+ check_current_booking(self[:bookings].as_a) if check_bookings
+ end
+
+ # End either the current meeting early, or the pending meeting
+ def end_meeting(meeting_start_time : Int64, notify : Bool = true, comment : String = "cancelled at booking panel") : Nil
+ cmeeting = current
+ result = if cmeeting && cmeeting.event_start.to_unix == meeting_start_time
+ logger.debug { "deleting event #{cmeeting.title}, from #{@calendar_id}" }
+ calendar.decline_event(@calendar_id, cmeeting.id, notify: notify, comment: comment)
+ else
+ nmeeting = upcoming
+ if nmeeting && nmeeting.event_start.to_unix == meeting_start_time
+ logger.debug { "declining event #{nmeeting.title}, from #{@calendar_id}" }
+ calendar.decline_event(@calendar_id, nmeeting.id, notify: notify, comment: comment)
+ else
+ raise "only the current or pending meeting can be cancelled"
+ end
+ end
+ result.get
+
+ # Update booking info after creating event
+ schedule.in(1.seconds) { poll_events } unless (subscription = @subscription) && !subscription.expired?
+ end
+
+ # Allow apps to search for attendees (to add to new bookings) via driver instead of via staff-api (as some role based accounts may not have MS Graph access)
+ def list_users(query : String? = nil, limit : UInt32? = 20_u32)
+ calendar.list_users(query, limit)
+ end
+
+ def book_now(period_in_seconds : Int64, title : String? = nil, owner : String? = nil)
+ title ||= @default_title
+ starting = Time.utc.to_unix
+ ending = starting + period_in_seconds
+
+ # is the room about to be used?
+ raise "the room is currently in use" if @next_pending || status?(Bool, "in_use")
+
+ # will the next booking overlap with the room?
+ if next_booking = upcoming
+ raise "unable to book due to clash" if next_booking.event_start.to_unix < ending
+ end
+
+ logger.debug { "booking event #{title}, from #{starting}, to #{ending}, in #{@time_zone.name}, on #{@calendar_id}" }
+
+ room_email = system.email.not_nil!
+
+ if @application_permissions
+ host_calendar = @calendar_id
+ attendees = [::PlaceCalendar::Event::Attendee.new(room_email, room_email, "accepted", true, true)]
+ attendees << ::PlaceCalendar::Event::Attendee.new(owner, owner) if owner && !owner.empty?
+ else
+ host_calendar = owner.presence || @calendar_id
+ room_is_organizer = host_calendar == room_email
+ attendees = [
+ ::PlaceCalendar::Event::Attendee.new(room_email, room_email, "accepted", true, room_is_organizer),
+ ]
+ end
+
+ event = calendar.create_event(
+ title: title,
+ event_start: starting,
+ event_end: ending,
+ description: "",
+ attendees: attendees,
+ location: status?(String, "room_name"),
+ timezone: @time_zone.name,
+ calendar_id: host_calendar
+ )
+ # Update booking info after creating event
+ schedule.in(2.seconds) { poll_events } unless (subscription = @subscription) && !subscription.expired?
+
+ check_in_actual starting, check_bookings: false
+ event
+ end
+
+ @polling : Bool = false
+
+ def poll_events : Nil
+ return if @polling
+ @polling = true
+ check_for_sensors if @perform_sensor_search
+
+ now = Time.local @time_zone
+ start_of_day = now.at_beginning_of_day.to_unix
+ cache_period = start_of_day + @cache_days.to_i
+
+ events = @calendar_ids.flat_map { |cal_id|
+ logger.debug { "polling events #{cal_id}, from #{start_of_day}, to #{cache_period}, in #{@time_zone.name}" }
+
+ calendar.list_events(
+ cal_id,
+ start_of_day,
+ cache_period,
+ @time_zone.name,
+ include_cancelled: @include_cancelled_bookings
+ ).get.as_a.map do |evt|
+ visibility = if evt["private"].as_bool?
+ "private"
+ else
+ evt["visibility"]?.try &.as_s?.try &.downcase || "normal"
+ end
+ if visibility == "private"
+ evt.as_h["title"] = JSON::Any.new("Private")
+ evt.as_h["host"] = JSON::Any.new("Private")
+ elsif visibility == "personal"
+ evt.as_h["title"] = evt["host"]
+ elsif visibility == "confidential"
+ evt.as_h["title"] = JSON::Any.new("Confidential")
+ evt.as_h["host"] = JSON::Any.new("Confidential")
+ end
+ evt
+ end
+ }.sort { |a, b| a["event_start"].as_i64 <=> b["event_start"].as_i64 }
+
+ self[:bookings] = events
+ check_current_booking(events)
+ events
+ ensure
+ @polling = false
+ end
+
+ protected def check_current_booking(bookings) : Nil
+ now = Time.utc.to_unix
+ previous_booking = nil
+ current_booking = nil
+ next_booking = Int32::MAX
+
+ bookings.each_with_index do |event, index|
+ starting = event["event_start"].as_i64
+
+ # All meetings are in the future
+ if starting > now
+ next_booking = index
+ previous_booking = index - 1 if index > 0
+ break
+ end
+
+ # Calculate event end time
+ ending_unix = if ending = event["event_end"]?
+ ending.as_i64
+ else
+ starting + 24.hours.to_i
+ end
+
+ # Event ended in the past
+ next if ending_unix < now
+
+ # We've found the current event
+ if starting <= now && ending_unix > now
+ current_booking = index
+ previous_booking = index - 1 if index > 0
+ next_booking = index + 1
+ break
+ end
+ end
+
+ self[:previous_booking] = previous_booking ? bookings[previous_booking] : nil
+
+ # Configure room status (free, pending, in-use)
+ current_pending = false
+ next_pending = false
+ booked = false
+
+ if current_booking
+ booking = bookings[current_booking]
+ start_time = booking["event_start"].as_i64
+ ending_at = booking["event_end"]?
+ booked = true
+
+ # Up to the frontend to delete pending bookings that have past their start time
+ if !@disable_end_meeting
+ if start_time > @last_booking_started
+ if sensor_stale? || status?(Bool, "presence")
+ check_in_actual(start_time, check_bookings: false)
+ else
+ current_pending = true
+ end
+ end
+ elsif @pending_period.to_i > 0_i64
+ pending_limit = (Time.unix(start_time) + @pending_period).to_unix
+ current_pending = true if start_time < pending_limit && start_time > @last_booking_started
+ end
+
+ self[:current_booking] = booking
+ self[:host_email] = booking["extension_data"]?.try(&.[]?("host_override")) || booking["host"]?
+ self[:started_at] = start_time
+ self[:ending_at] = ending_at ? ending_at.as_i64 : (start_time + 24.hours.to_i)
+ self[:all_day_event] = !ending_at
+ self[:event_id] = booking["id"]?
+
+ @expose_for_analytics.each do |binding, path|
+ begin
+ binding_keys = path.split("->")
+ data = booking
+ binding_keys.each do |key|
+ data = data.dig? key
+ break unless data
+ end
+ self[binding] = data
+ rescue error
+ logger.warn(exception: error) { "failed to expose #{binding}: #{path} for analytics" }
+ self[binding] = nil
+ end
+ end
+
+ previous_booking_id = @current_meeting_id
+ new_booking_id = booking["id"].as_s
+ schedule.in(1.second) { check_for_sensors } unless new_booking_id == previous_booking_id
+ @current_meeting_id = new_booking_id
+ else
+ self[:current_booking] = nil
+ self[:host_email] = nil
+ self[:started_at] = nil
+ self[:ending_at] = nil
+ self[:all_day_event] = nil
+ self[:event_id] = nil
+
+ @expose_for_analytics.each_key do |binding|
+ self[binding] = nil
+ end
+ end
+
+ # We haven't checked the index of `next_booking` exists, hence the `[]?`
+ if booking = bookings[next_booking]?
+ start_time = booking["event_start"].as_i64
+
+ # is the next meeting pending?
+ if start_time <= @pending_before.from_now.to_unix
+ # if start time is greater than last started, then no one has checked in yet
+ if start_time > @last_booking_started
+ next_pending = true
+ else
+ booked = true
+ end
+ end
+
+ # don't display upcoming bookings if they are a long way off
+ if start_time < 10.hours.from_now.to_unix
+ self[:next_booking] = booking
+ self[:next_host] = booking["extension_data"]?.try(&.[]?("host_override")) || booking["host"]?
+ else
+ self[:next_booking] = nil
+ self[:next_host] = nil
+ end
+ else
+ self[:next_booking] = nil
+ self[:next_host] = nil
+ end
+
+ self[:booked] = booked
+
+ # Check if pending is enabled
+ if @pending_period.to_i > 0_i64 || @pending_before.to_i > 0_i64
+ self[:current_pending] = @current_pending = current_pending
+ self[:next_pending] = @next_pending = next_pending
+ self[:pending] = current_pending || next_pending
+
+ self[:in_use] = booked && !current_pending
+ else
+ self[:current_pending] = @current_pending = current_pending = false
+ self[:next_pending] = @next_pending = next_pending = false
+ self[:pending] = false
+
+ self[:in_use] = booked
+ end
+
+ # TODO:: set video_conference_url if found in the event details
+
+ self[:status] = (current_pending || next_pending) ? "pending" : (booked ? "busy" : "free")
+ end
+
+ protected def current : ::PlaceCalendar::Event?
+ status?(::PlaceCalendar::Event, :current_booking)
+ end
+
+ protected def upcoming : ::PlaceCalendar::Event?
+ status?(::PlaceCalendar::Event, :next_booking)
+ end
+
+ protected def pending : ::PlaceCalendar::Event?
+ if @current_pending
+ current
+ elsif @next_pending
+ upcoming
+ end
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ protected def to_location_format(events : Enumerable(::PlaceCalendar::Event))
+ sys = system.config
+ events.map do |event|
+ event_ends = event.all_day? ? event.event_start.in(@time_zone).at_end_of_day : event.event_end.not_nil!
+ {
+ location: :meeting,
+ mac: @calendar_id,
+ event_id: event.id,
+ map_id: sys.map_id,
+ sys_id: sys.id,
+ ends_at: event_ends.to_unix,
+ started_at: event.event_start.to_unix,
+ private: !!event.private?,
+ }
+ end
+ end
+
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "searching for #{email}, #{username}" }
+
+ email = email.to_s.downcase
+ username = username.to_s.downcase
+ matching_events = [] of ::PlaceCalendar::Event
+
+ if event = current
+ emails = event.attendees.map(&.email.downcase)
+ if host = event.host
+ emails << host.downcase
+ end
+
+ if emails.includes?(email) || emails.includes?(username)
+ logger.debug { "found user {#{email}, #{username}} in list of attendees" }
+ matching_events << event
+ elsif !username.empty? && emails.find(&.starts_with?(username))
+ logger.debug { "found email starting with username '#{username}' in list of attendees" }
+ matching_events << event
+ end
+ end
+
+ to_location_format matching_events
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ locate_user(email, username).map(&.[](:mac))
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "searching for owner of #{mac_address}" }
+ sys_email = @calendar_id.downcase
+ if sys_email == mac_address.downcase && (host = current.try &.host)
+ {
+ location: "meeting",
+ assigned_to: host,
+ mac_address: sys_email,
+ }
+ end
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching devices in zone #{zone_id}" }
+ [] of Nil
+ end
+
+ def people_count? : Float64?
+ drivers = system.implementing(Interface::Sensor)
+ count_data = drivers.sensors("people_count", @sensor_mac).get.flat_map(&.as_a).first?
+
+ return nil unless count_data
+ return nil if is_stale?(count_data["last_seen"]?.try &.as_i64)
+
+ data = count_data["value"]
+ (data.as_f? || data.as_i).to_f
+ rescue error
+ logger.warn(exception: error) { "error checking people count" }
+ nil
+ end
+
+ def people_present? : Float64?
+ count = people_count?
+ return count > 0.0 ? 1.0 : 0.0 if count
+
+ drivers = system.implementing(Interface::Sensor)
+ presence_data = drivers.sensors("presence", @sensor_mac).get.flat_map(&.as_a).first?
+
+ return nil unless presence_data
+ return nil if is_stale?(presence_data["last_seen"]?.try &.as_i64)
+
+ data = presence_data["value"]
+ (data.as_f? || data.as_i).to_f > 0.0 ? 1.0 : 0.0
+ rescue error
+ logger.warn(exception: error) { "error checking people presence" }
+ nil
+ end
+
+ @sensor_subscription : PlaceOS::Driver::Subscriptions::Subscription? = nil
+
+ protected def check_for_sensors
+ drivers = system.implementing(Interface::Sensor)
+
+ if sub = @sensor_subscription
+ subscriptions.unsubscribe(sub)
+ @sensor_subscription = nil
+ end
+
+ # Prefer people count data in a space
+ count_data = drivers.sensors("people_count", @sensor_mac).get.flat_map(&.as_a).first?
+
+ if count_data && count_data["module_id"]?.try(&.raw.is_a?(String))
+ if !is_stale?(count_data["last_seen"]?.try &.as_i64)
+ self[:sensor_name] = count_data["name"].as_s
+
+ # the binding might be multiple layers deep
+ binding_keys = count_data["binding"].as_s.split("->")
+ binding = binding_keys.shift
+ @sensor_subscription = subscriptions.subscribe(count_data["module_id"].as_s, binding) do |_sub, payload|
+ data = JSON.parse payload
+ binding_keys.each do |key|
+ data = data.dig? key
+ break unless data
+ end
+ value = data ? (data.as_f? || data.as_i).to_f : nil
+ if value
+ self[:people_count] = value
+ self[:presence] = value > 0.0
+ else
+ self[:people_count] = self[:presence] = nil
+ end
+ end
+ @perform_sensor_search = false
+ end
+ end
+
+ # a people count sensor was stale or not found
+ if @perform_sensor_search
+ self[:people_count] = nil
+
+ # Fallback to checking for presence
+ presence = drivers.sensors("presence", @sensor_mac).get.flat_map(&.as_a).first?
+ if presence && presence["module_id"]?.try(&.raw.is_a?(String))
+ if !is_stale?(presence["last_seen"]?.try &.as_i64)
+ self[:sensor_name] = presence["name"].as_s
+
+ # the binding might be multiple layers deep
+ binding_keys = presence["binding"].as_s.split("->")
+ binding = binding_keys.shift
+ @sensor_subscription = subscriptions.subscribe(presence["module_id"].as_s, binding) do |_sub, payload|
+ data = JSON.parse payload
+ binding_keys.each do |key|
+ data = data.dig? key
+ break unless data
+ end
+ value = data ? (data.as_f? || data.as_i).to_f : nil
+ self[:presence] = value ? value > 0.0 : nil
+ end
+ @perform_sensor_search = false
+ else
+ self[:sensor_name] = self[:presence] = nil
+ @perform_sensor_search = true
+ end
+ end
+ end
+ rescue error
+ @perform_sensor_search = true
+ logger.error(exception: error) { "checking for sensors" }
+ self[:people_count] = nil
+ self[:presence] = nil
+ self[:sensor_name] = nil
+ self[:sensor_stale] = true
+ end
+
+ protected def is_stale?(timestamp : Int64?) : Bool
+ if timestamp.nil?
+ return self[:sensor_stale] = false
+ end
+
+ sensor_time = Time.unix(timestamp)
+ stale_time = @sensor_stale_minutes.ago
+
+ if sensor_time > stale_time
+ self[:sensor_stale] = false
+ else
+ @perform_sensor_search = true
+ self[:sensor_stale] = true
+ end
+ end
+
+ def sensor_stale? : Bool
+ status?(Bool, "sensor_stale") || false
+ end
+
+ enum ServiceName
+ Google
+ Office365
+ end
+
+ enum NotifyType
+ # resource event changes
+ Created # a resource was created (MS only)
+ Updated # a resource was updated (in Google this could also mean created)
+ Deleted # a resource was deleted
+
+ # subscription lifecycle event (MS only)
+ Renew # subscription was deleted
+ Missed # MS sends this to mean resource event changes were not sent
+ Reauthorize # subscription needs reauthorization
+ end
+
+ struct NotifyEvent
+ include JSON::Serializable
+
+ getter event_type : NotifyType
+ getter resource_id : String?
+ getter resource_uri : String
+ getter subscription_id : String
+ getter client_secret : String
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ getter expiration_time : Time
+ end
+
+ # TODO:: remove in the future
+ struct ::PlaceCalendar::Subscription
+ @client_secret : String | Int64?
+
+ def client_secret
+ @client_secret.to_s
+ end
+
+ def expired?
+ if time = expires_at
+ 1.hour.from_now >= time
+ else
+ false
+ end
+ end
+ end
+
+ @subscription : ::PlaceCalendar::Subscription? = nil
+ @push_notification_url : String? = nil
+ @push_authority : String? = nil
+ @push_service_name : ServiceName? = nil
+ @push_monitoring : PlaceOS::Driver::Subscriptions::ChannelSubscription? = nil
+ @push_mutex : Mutex = Mutex.new(:reentrant)
+
+ # the API reports that 6 days is the max:
+ # Subscription expiration can only be 10070 minutes in the future.
+ SUBSCRIPTION_LENGTH = 3.hours
+
+ protected def push_notificaitons_configure
+ @push_notification_url = setting?(String, :push_notification_url).presence
+ @push_authority = setting?(String, :push_authority).presence
+
+ # load any existing subscriptions
+ subscription = setting?(::PlaceCalendar::Subscription, :push_subscription)
+
+ if @push_notification_url
+ # clear the monitoring if authority changed
+ if subscription && subscription.try(&.id) != @subscription.try(&.id) && (monitor = @push_monitoring)
+ subscriptions.unsubscribe(monitor)
+ @push_monitoring = nil
+ end
+ @subscription = subscription
+ schedule.every(5.minutes + rand(120).seconds) { push_notificaitons_maintain }
+ schedule.in(rand(30).seconds) { push_notificaitons_maintain(true) }
+ elsif subscription
+ push_notificaitons_cleanup(subscription)
+ end
+ end
+
+ # delete a subscription
+ protected def push_notificaitons_cleanup(sub)
+ @push_mutex.synchronize do
+ logger.debug { "removing subscription" }
+
+ calendar.delete_notifier(sub) if sub
+ @subscription = nil
+ define_setting(:push_subscription, nil)
+ end
+ end
+
+ getter sub_renewed_at : Time = 21.minutes.ago
+
+ # creates and maintains a subscription
+ protected def push_notificaitons_maintain(force_renew = false) : Nil
+ should_force = force_renew && @sub_renewed_at < 20.minutes.ago
+
+ @push_mutex.synchronize do
+ subscription = @subscription
+
+ logger.debug { "maintaining push subscription, monitoring: #{!!@push_monitoring}, subscription: #{subscription ? !subscription.expired? : "none"}" }
+
+ return create_subscription unless subscription
+
+ if should_force || subscription.expired?
+ # renew subscription
+ begin
+ logger.debug { "renewing subscription" }
+ expires = SUBSCRIPTION_LENGTH.from_now
+ sub = calendar.renew_notifier(subscription, expires.to_unix).get
+ @subscription = ::PlaceCalendar::Subscription.from_json(sub.to_json)
+
+ # save the subscription details for processing
+ define_setting(:push_subscription, @subscription)
+ @sub_renewed_at = Time.local
+ rescue error
+ logger.error(exception: error) { "failed to renew expired subscription, creating new subscription" }
+ @subscription = nil
+ schedule.in(1.second) { push_notificaitons_maintain; nil }
+ end
+
+ configure_push_monitoring
+ return
+ end
+
+ configure_push_monitoring if @push_monitoring.nil?
+ end
+ end
+
+ protected def configure_push_monitoring
+ subscription = @subscription.as(::PlaceCalendar::Subscription)
+ channel_path = "#{subscription.id}/event"
+
+ if old = @push_monitoring
+ subscriptions.unsubscribe old
+ end
+
+ @push_monitoring = monitor(channel_path) { |_subscription, payload| push_event_occured(payload) }
+ logger.debug { "monitoring channel: #{channel_path}" }
+ end
+
+ protected def push_event_occured(payload : String)
+ logger.debug { "push notification received! #{payload}" }
+
+ notification = NotifyEvent.from_json payload
+
+ secret = @subscription.try &.client_secret
+ unless secret && secret == notification.client_secret
+ logger.warn { "ignoring notify event with mismatched secret: #{notification.inspect}" }
+ return
+ end
+
+ case notification.event_type
+ in .created?, .updated?, .deleted?
+ logger.debug { "polling events as received #{notification.event_type} notification" }
+ if resource_id = notification.resource_id
+ self[:last_event_notification] = {notification.event_type, resource_id, Time.utc.to_unix}
+ end
+
+ # fetch the event from the calendar and signal to staff API
+ # staff-api will:
+ # * notify change
+ # * which will link_master_metadata
+ begin
+ event = calendar.get_event(
+ @calendar_id,
+ notification.resource_id
+ ).get unless notification.event_type.deleted?
+
+ publish("#{@push_authority}/bookings/event", {
+ event_id: notification.resource_id,
+ change: notification.event_type,
+ system_id: system.id,
+ event: event,
+ }.to_json)
+ rescue error
+ logger.warn(exception: error) { "fetching booking event on change notification" }
+ nil
+ end
+
+ poll_events
+ in .missed?
+ # we don't know the exact event id that changed
+ logger.debug { "polling events as a notification was previously missed" }
+ poll_events
+ in .renew?
+ # we need to create a new subscription as the old one has expired
+ logger.debug { "a subscription renewal is required" }
+ create_subscription
+ in .reauthorize?
+ logger.debug { "a subscription reauthorization is required" }
+ expires = SUBSCRIPTION_LENGTH.from_now
+ calendar.reauthorize_notifier(@subscription, expires.to_unix)
+ end
+ rescue error
+ logger.error(exception: error) { "error processing push notification" }
+ end
+
+ protected def create_subscription
+ @push_mutex.synchronize do
+ @push_service_name = service_name = @push_service_name || ServiceName.parse(calendar.calendar_service_name.get.as_s)
+
+ # different resource routes for the different services
+ case service_name
+ in .google?
+ resource = "/calendars/#{calendar_id}/events"
+ in .office365?
+ resource = "/users/#{calendar_id}/events"
+ in Nil
+ raise "service name not known, waiting for "
+ end
+
+ logger.debug { "registering for push notifications! #{resource}" }
+
+ # create a new secret and subscription
+ expires = SUBSCRIPTION_LENGTH.from_now
+ push_secret = "a#{Random.new.hex(4)}"
+ sub = calendar.create_notifier(resource, @push_notification_url, expires.to_unix, push_secret, @push_notification_url).get
+ @subscription = ::PlaceCalendar::Subscription.from_json(sub.to_json)
+
+ # save the subscription details for processing
+ define_setting(:push_subscription, @subscription)
+ @sub_renewed_at = Time.local
+
+ configure_push_monitoring
+ end
+ end
+
+ # =======================================
+ # Helper for detecting invalid config
+ # =======================================
+
+ def syllabus_plus_resource_check
+ sleep rand(10_000).milliseconds
+ @calendar_ids.each do |cal_id|
+ resource_id = cal_id.split('@')[0]
+ begin
+ calendar.resource_lookup(resource_id).get
+ rescue error
+ logger.error(exception: error) { "invalid syllabus plus resource id #{resource_id} in #{config.control_system.not_nil!.name}" }
+ end
+ end
+ end
+end
diff --git a/drivers/place/bookings/asset_name_resolver.cr b/drivers/place/bookings/asset_name_resolver.cr
new file mode 100644
index 00000000000..717e85463cc
--- /dev/null
+++ b/drivers/place/bookings/asset_name_resolver.cr
@@ -0,0 +1,85 @@
+require "json"
+require "./locker_models"
+
+module Place::AssetNameResolver
+ include Place::LockerMetadataParser
+
+ @asset_cache : AssetCache = AssetCache.new
+ @asset_cache_timeout : Int64 = 3600_i64 # 1 hour
+
+ private getter asset_cache : AssetCache
+
+ private def clear_asset_cache
+ @asset_cache = AssetCache.new
+ end
+
+ private def lookup_asset(asset_id : String, type : String, zones : Array(String) = [building_id]) : String
+ if type == "locker"
+ locker = locker_details[asset_id]?
+ return locker.name if locker
+ else
+ zones.each do |zone_id|
+ asset = if (cache = asset_cache[{zone_id, type}]?) && cache[0] > Time.utc.to_unix
+ cache[1].find { |asset| asset.id == asset_id }
+ else
+ assets = lookup_assets(zone_id, type)
+ @asset_cache[{zone_id, type}] = {Time.utc.to_unix + @asset_cache_timeout, assets}
+ assets.find { |asset| asset.id == asset_id }
+ end
+
+ return asset.name if asset
+ end
+ end
+
+ logger.debug { "unable to resolve asset name for #{asset_id}" }
+ asset_id
+ end
+
+ private def lookup_assets(zone_id : String, type : String) : Array(Asset)
+ assets = [] of Asset
+
+ metadata_field = case type
+ when "desk"
+ "desks"
+ when "parking"
+ "parking-spaces"
+ end
+
+ if metadata_field
+ metadata = Metadata.from_json staff_api.metadata(zone_id, metadata_field).get[metadata_field].to_json
+ assets = metadata.details.as_a.map { |asset| Asset.from_json asset.to_json }
+ elsif type == "locker"
+ assets = locker_details.map { |id, locker| Asset.new(id, locker.name) }
+ end
+
+ assets
+ rescue error
+ logger.debug { "unable to get #{metadata_field} from zone #{zone_id} metadata" }
+ [] of Asset
+ end
+
+ # zone_id, type timeout, assets
+ alias AssetCache = Hash(Tuple(String, String), Tuple(Int64, Array(Asset)))
+
+ struct Asset
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+
+ def initialize(@id : String, @name : String)
+ end
+ end
+
+ struct Metadata
+ include JSON::Serializable
+
+ property name : String
+ property description : String = ""
+ property details : JSON::Any
+ property parent_id : String
+ property schema_id : String? = nil
+ property editors : Set(String) = Set(String).new
+ property modified_by_id : String? = nil
+ end
+end
diff --git a/drivers/place/bookings/auto_desk_checkin.cr b/drivers/place/bookings/auto_desk_checkin.cr
new file mode 100644
index 00000000000..37455f289f2
--- /dev/null
+++ b/drivers/place/bookings/auto_desk_checkin.cr
@@ -0,0 +1,49 @@
+require "placeos-driver"
+
+class Place::AutoDeskCheckin < PlaceOS::Driver
+ descriptive_name "Auto Desk Checkin"
+ generic_name :AutoDeskCheckin
+ description %(automatically checks in desks that will be in use in the near future)
+
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ check_in_zones: ["zone-id1", "zone-id2"],
+ hours_before_booking_start: 1,
+ booking_category: "desk",
+ })
+
+ @time_period : Time::Span = 1.hour
+ @booking_category : String = "desk"
+ @zones : Array(String) = [] of String
+
+ def on_update
+ @zones = setting(Array(String), :check_in_zones)
+ @time_period = setting(Int32, :hours_before_booking_start).hours
+ @booking_category = setting(String, :booking_category)
+
+ schedule.clear
+ schedule.every(5.minutes) { fetch_and_check_in }
+ end
+
+ def fetch_and_check_in
+ period_start = Time.utc.to_unix
+ period_end = @time_period.from_now.to_unix
+ booking_ids = staff_api.query_bookings(@booking_category, period_start, period_end, @zones, checked_in: false).get.as_a.map { |booking| booking["id"].as_i64 }
+
+ success = 0
+ failed = [] of Int64
+
+ booking_ids.each do |id|
+ begin
+ staff_api.booking_check_in(id, true, "auto-checkin").get
+ success += 1
+ rescue error
+ failed << id
+ logger.debug(exception: error) { "failed to check-in booking #{id}" }
+ end
+ end
+
+ "checked-in #{success} bookings, failed #{failed.size}: #{failed}"
+ end
+end
diff --git a/drivers/place/bookings/auto_desk_checkin_spec.cr b/drivers/place/bookings/auto_desk_checkin_spec.cr
new file mode 100644
index 00000000000..4657f7624a0
--- /dev/null
+++ b/drivers/place/bookings/auto_desk_checkin_spec.cr
@@ -0,0 +1,41 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::EventAttendanceRecorder" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ })
+
+ # Start a new meeting
+ exec(:fetch_and_check_in).get.should eq "checked-in 2 bookings, failed 1: [3]"
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def query_bookings(
+ type : String,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil
+ )
+ [{id: 1}, {id: 2}, {id: 3}]
+ end
+
+ def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil)
+ logger.debug { "checking in booking #{booking_id} to: #{state} from #{utm_source}" }
+
+ case booking_id
+ when 3
+ raise "issue updating booking state #{booking_id}: 404"
+ else
+ true
+ end
+ end
+end
diff --git a/drivers/place/bookings/booking_checkin_notifier.cr b/drivers/place/bookings/booking_checkin_notifier.cr
new file mode 100644
index 00000000000..3c9b8949cd3
--- /dev/null
+++ b/drivers/place/bookings/booking_checkin_notifier.cr
@@ -0,0 +1,171 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+require "../booking_model"
+
+class Place::BookingCheckinNotifier < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "Booking checkin notifier"
+ generic_name :BookingCheckinNotifier
+ description %(notifies information by email when bookings are checked in)
+
+ default_settings({
+ notify_address: "mailing_list@org.com",
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+ determine_host_name_using: "calendar-driver",
+ booking_type: "parking",
+ _zone_override: "booking zone id",
+ })
+
+ accessor calendar : Calendar_1
+ accessor staff_api : StaffAPI_1
+ accessor locations : LocationServices_1
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ def on_load
+ monitor("staff/booking/changed") { |_subscription, payload| parse_booking(payload) }
+ on_update
+ end
+
+ # See: https://crystal-lang.org/api/latest/Time/Format.html
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+ @zone_override : String? = nil
+ @booking_type : String = "parking"
+ @bookings_checked : UInt64 = 0_u64
+ @notify_address : String = ""
+
+ def on_update
+ @building_id = nil
+ @building = nil
+ @building_name = nil
+ @zone_override = setting?(String, :zone_override)
+
+ @notify_address = setting(String, :notify_address)
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+ @booking_type = setting?(String, :booking_type).presence || "parking"
+ end
+
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+
+ getter building : JSON::Any do
+ staff_api.zone(building_id).get
+ end
+
+ getter building_name : String do
+ building["name"].as_s
+ end
+
+ # system or building timezone
+ protected getter timezone : Time::Location do
+ tz = config.control_system.try(&.timezone) || building["timezone"].as_s
+ Time::Location.load(tz)
+ end
+
+ protected def parse_booking(payload)
+ booking_details = Booking.from_json payload
+
+ # Only process booking types of interest
+ return unless booking_details.booking_type == @booking_type
+
+ # We only care for checked in
+ return unless booking_details.action == "checked_in"
+
+ # in a particular zone
+ return unless booking_details.zones.includes?(@zone_override || building_id)
+
+ logger.debug { "received checked_in event payload:\n#{payload}" }
+ notify_check_in booking_details
+ end
+
+ protected def notify_check_in(booking_details)
+ # https://crystal-lang.org/api/0.35.1/Time/Format.html
+ # date and time (Tue Apr 5 10:26:19 2016)
+ location = timezone
+ starting = Time.unix(booking_details.booking_start).in(location)
+ ending = Time.unix(booking_details.booking_end).in(location)
+
+ # Ignore changes to meetings that have already ended
+ return if Time.utc > ending
+
+ args = {
+ booking_id: booking_details.id,
+ start_time: starting.to_s(@time_format),
+ start_date: starting.to_s(@date_format),
+ start_datetime: starting.to_s(@date_time_format),
+ end_time: ending.to_s(@time_format),
+ end_date: ending.to_s(@date_format),
+ end_datetime: ending.to_s(@date_time_format),
+ starting_unix: booking_details.booking_start,
+
+ asset_id: booking_details.asset_id,
+ user_id: booking_details.user_id,
+ user_email: booking_details.user_email,
+ user_name: booking_details.user_name,
+ reason: booking_details.title,
+
+ building_zone: building_id,
+ building_name: building_name,
+
+ approver_name: booking_details.approver_name,
+ approver_email: booking_details.approver_email,
+
+ booked_by_name: booking_details.booked_by_name,
+ booked_by_email: booking_details.booked_by_email,
+ }
+
+ mailer.send_template(
+ to: @notify_address,
+ template: {"bookings", "check_in_notifier"},
+ args: args
+ )
+
+ @bookings_checked += 1
+ self[:bookings_checked] = @bookings_checked
+ end
+
+ def template_fields : Array(TemplateFields)
+ time_now = Time.utc.in(timezone)
+ common_fields = [
+ {name: "booking_id", description: "Unique identifier for the booking"},
+ {name: "start_time", description: "Booking start time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "start_date", description: "Booking start date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "start_datetime", description: "Booking start date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "end_time", description: "Booking end time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "end_date", description: "Booking end date (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "end_datetime", description: "Booking end date and time (e.g., #{time_now.to_s(@date_time_format)})"},
+ {name: "starting_unix", description: "Booking start time as Unix timestamp"},
+ {name: "asset_id", description: "Identifier of the booked asset (e.g., desk)"},
+ {name: "user_id", description: "Identifier of the person the booking is for"},
+ {name: "user_email", description: "Email of the person the booking is for"},
+ {name: "user_name", description: "Name of the person the booking is for"},
+ {name: "reason", description: "Purpose or title of the booking"},
+ {name: "building_zone", description: "Zone identifier for the building"},
+ {name: "building_name", description: "Name of the building"},
+ {name: "approver_name", description: "Name of the person who approved/rejected the booking"},
+ {name: "approver_email", description: "Email of the person who approved/rejected the booking"},
+ {name: "booked_by_name", description: "Name of the person who made the booking"},
+ {name: "booked_by_email", description: "Email of the person who made the booking"},
+ ]
+
+ [
+ TemplateFields.new(
+ trigger: {"bookings", "check_in_notifier"},
+ name: "Booking check in notifier",
+ description: "Notification to a mailing group when a booking is checked in",
+ fields: common_fields
+ ),
+ ]
+ end
+end
diff --git a/drivers/place/bookings/booking_checkin_notifier_spec.cr b/drivers/place/bookings/booking_checkin_notifier_spec.cr
new file mode 100644
index 00000000000..beeb94a835d
--- /dev/null
+++ b/drivers/place/bookings/booking_checkin_notifier_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::BookingCheckinNotifier" do
+end
diff --git a/drivers/place/bookings/event_attendance_recorder.cr b/drivers/place/bookings/event_attendance_recorder.cr
new file mode 100644
index 00000000000..a9c930ddefc
--- /dev/null
+++ b/drivers/place/bookings/event_attendance_recorder.cr
@@ -0,0 +1,157 @@
+require "placeos-driver"
+require "simple_retry"
+
+class Place::EventAttendanceRecorder < PlaceOS::Driver
+ descriptive_name "PlaceOS Event Attendance Recorder"
+ generic_name :EventAttendanceRecorder
+
+ default_settings({
+ metadata_key: "people_count",
+ debounce_seconds: 0,
+ })
+
+ accessor staff_api : StaffAPI_1
+
+ # Staff API metadata key
+ @metadata_key : String = "people_count"
+ @system_id : String = ""
+ getter count : UInt64 = 0_u64
+
+ # Tracking meeting details
+ getter status : String = "free"
+ getter booking_id : String? = nil
+
+ getter should_save : Bool = false
+ getter people_counts : Array(Int32) = [] of Int32
+ getter last_saved_count : Int32 = 0
+ getter last_known_count : Int32 = 0
+
+ @update_mutex = Mutex.new
+ @debounce_seconds : Int32 = 0
+
+ def on_load
+ @system_id = config.control_system.not_nil!.id
+ on_update
+ end
+
+ def on_update
+ @metadata_key = setting?(String, :metadata_key).presence || "people_count"
+ @debounce_seconds = setting?(Int32, :debounce_seconds) || 0
+ end
+
+ bind Bookings_1, :current_booking, :current_booking_changed
+ bind Bookings_1, :people_count, :people_count_changed
+ bind Bookings_1, :status, :status_changed
+
+ class StaffEventChange
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property event_id : String
+ end
+
+ private def current_booking_changed(_subscription, new_value)
+ logger.debug { "booking changed: #{new_value}" }
+ event = (StaffEventChange?).from_json(new_value)
+
+ apply_new_state(event.try(&.event_id), @status)
+ rescue e
+ logger.warn(exception: e) { "failed to parse event" }
+ end
+
+ private def people_count_changed(_subscription, new_value) : Nil
+ logger.debug { "new people count received #{new_value}" }
+ return if new_value == "null"
+ value = (Int32 | Float64).from_json(new_value).to_i
+ value = value < 0 ? 0 : value
+
+ @last_known_count = value
+
+ if @debounce_seconds > 0
+ schedule.clear
+ schedule.in(@debounce_seconds.seconds) { record_new_people value }
+ else
+ record_new_people value
+ end
+ end
+
+ private def record_new_people(count : Int32)
+ @last_saved_count = count
+ if people_counts.last? != count
+ logger.debug { "recording new people count: #{count}" }
+ people_counts << count
+ end
+ end
+
+ private def status_changed(_subscription, new_value)
+ logger.debug { "new room status: #{new_value}" }
+ new_status = (String?).from_json(new_value) rescue new_value.to_s
+
+ apply_new_state(booking_id, new_status)
+ end
+
+ private def apply_new_state(new_booking_id : String?, new_status : String?)
+ @update_mutex.synchronize do
+ logger.debug { "#apply_new_state called with new_booking_id: #{new_booking_id}, new_status: #{new_status}" }
+
+ old_booking_id = @booking_id
+ @booking_id = new_booking_id
+ old_status = @status
+ @status = new_status || "free"
+
+ if old_booking_id && (new_booking_id != old_booking_id || new_status != old_status)
+ save_booking_stats(old_booking_id, people_counts) if @should_save
+ logger.debug { "#apply_new_state event_id: #{new_booking_id}, resetting people counts" }
+ @people_counts = [] of Int32
+ if @debounce_seconds > 0
+ schedule.clear
+ schedule.in(@debounce_seconds.seconds) { record_new_people last_known_count }
+ else
+ record_new_people last_known_count
+ end
+ end
+
+ @should_save = true if @booking_id && @status == "busy"
+ end
+ end
+
+ private def save_booking_stats(event_id : String, counts : Array(Int32))
+ logger.debug { "#save_booking_stats event_id: #{event_id}, counts: #{counts}" }
+
+ if counts.empty?
+ logger.warn { "no counts found for event #{event_id}" }
+ min = 0
+ max = 0
+ median = 0
+ average = 0
+ else
+ min = counts.min
+ max = counts.max
+ total = counts.reduce(0) { |acc, i| acc + i }
+ average = total / counts.size
+ counts.sort!
+ index = (counts.size / 2).round_away.to_i - 1
+ median = counts[index]
+ end
+
+ @count += 1_u64
+ @should_save = false
+
+ SimpleRetry.try_to(
+ max_attempts: 5,
+ base_interval: 10.milliseconds,
+ max_interval: 10.seconds,
+ ) do
+ staff_api.patch_event_metadata(@system_id, event_id, {
+ @metadata_key => {
+ min: min,
+ max: max,
+ median: median,
+ average: average,
+ },
+ }).get
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to save event metadata" }
+ end
+end
diff --git a/drivers/place/bookings/event_attendance_recorder_spec.cr b/drivers/place/bookings/event_attendance_recorder_spec.cr
new file mode 100644
index 00000000000..df713e47eb7
--- /dev/null
+++ b/drivers/place/bookings/event_attendance_recorder_spec.cr
@@ -0,0 +1,69 @@
+require "placeos-driver/spec"
+require "uuid"
+
+DriverSpecs.mock_driver "Place::EventAttendanceRecorder" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ Bookings: {BookingsMock},
+ })
+
+ # Start a new meeting
+ exec(:count).get.should eq 0
+ bookings = system(:Bookings).as(BookingsMock)
+ bookings.new_meeting
+ exec(:count).get.should eq 0
+ bookings.next_people_count
+
+ # Update the people counts
+ bookings.next_people_count
+ bookings.next_people_count
+ bookings.next_people_count
+ bookings.next_people_count
+ exec(:count).get.should eq 0
+
+ # End the meeting
+ bookings.new_meeting
+ sleep 0.1
+
+ exec(:count).get.should eq 1
+
+ # check the update that was applied
+ system(:StaffAPI)[:patched_with][2].should eq({
+ "people_count" => {
+ "min" => 1,
+ "max" => 10,
+ "median" => 2,
+ "average" => 3.4,
+ },
+ })
+end
+
+# :nodoc:
+class BookingsMock < DriverSpecs::MockDriver
+ @people_count_index : Int32 = 0
+ @people_counts : Array(Int32) = [10, 1, 2, 3, 1]
+
+ def next_people_count : Nil
+ self[:status] = "busy"
+ self[:people_count] = @people_counts[@people_count_index]
+ @people_count_index += 1
+ end
+
+ def new_meeting : Nil
+ self[:current_booking] = {
+ id: UUID.random.to_s,
+ }
+ self[:status] = "pending"
+ end
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ @people_count_index : Int32 = 0
+ @people_counts : Array(Int32) = [10, 1, 2, 3, 1]
+
+ def patch_event_metadata(system_id : String, event_id : String, metadata : JSON::Any)
+ self[:patched_with] = {system_id, event_id, metadata}
+ true
+ end
+end
diff --git a/drivers/place/bookings/grant_area_access.cr b/drivers/place/bookings/grant_area_access.cr
new file mode 100644
index 00000000000..0e222296533
--- /dev/null
+++ b/drivers/place/bookings/grant_area_access.cr
@@ -0,0 +1,429 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+require "placeos-driver/interface/zone_access_security"
+require "../booking_model"
+
+class Place::Bookings::GrantAreaAccess < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "PlaceOS Booking Area Access"
+ generic_name :BookingAreaAccess
+ description "ensures users can access areas they have booked. i.e. a private office allocated to a user"
+
+ default_settings({
+ # the channel id we're looking for events on
+ lookup_using_username: true,
+ _security_zone_whitelist: ["zone_name_or_id"],
+
+ # At 10:00 on every day-of-week from Monday through Friday
+ _email_cron: "0 10 * * 1-5",
+ _email_errors_to: "admin@org.com",
+ })
+
+ accessor calendar : Calendar_1
+ accessor staff_api : StaffAPI_1
+ accessor locations : LocationServices_1
+
+ def security_system
+ system.implementing(Interface::ZoneAccessSecurity).first
+ end
+
+ def on_load
+ monitor("staff/booking/changed") do |_subscription, payload|
+ logger.debug { "received booking changed event #{payload}" }
+ check_access(Booking.from_json payload)
+ end
+
+ on_update
+ end
+
+ @mutex = Mutex.new
+
+ # user_id => Array(special access)
+ getter allocations : Hash(String, Array(String)) = {} of String => Array(String)
+ getter cached_username : Hash(String, String) = {} of String => String
+ getter cached_user_lookups : Hash(String, String | Int64) = {} of String => String | Int64
+ getter cached_zone_lookups : Hash(String, String | Int64) = {} of String => String | Int64
+ getter security_zone_whitelist : Array(String | Int64) = [] of String | Int64
+
+ @lookup_using_username : Bool = false
+ @email_errors_to : String? = nil
+
+ def on_update
+ @building_id = nil
+ @timezone = nil
+ @systems = nil
+
+ @lookup_using_username = setting?(Bool, :lookup_using_username) || false
+ @security_zone_whitelist = setting?(Array(String | Int64), :security_zone_whitelist) || [] of String | Int64
+
+ # we ensure that allocations are recorded so we can unallocate as required
+ @mutex.synchronize do
+ @allocations = setting?(Hash(String, Array(String)), :permissions_allocated) || Hash(String, Array(String)).new
+ end
+
+ schedule.clear
+ schedule.every(30.minutes) { ensure_booking_access }
+
+ @email_errors_to = setting?(String, :email_errors_to)
+ if @email_errors_to && (cron = setting?(String, :email_cron))
+ schedule.cron(cron, timezone) { notify_issues }
+ end
+ end
+
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+
+ # Grabs the list of systems in the building
+ getter systems : Hash(String, Array(String)) do
+ staff_api.systems_in_building(building_id).get.as_h.transform_values(&.as_a.map(&.as_s))
+ end
+
+ def levels : Array(String)
+ systems.keys
+ end
+
+ # system or building timezone
+ protected getter timezone : Time::Location do
+ tz = config.control_system.try(&.timezone) || staff_api.zone(building_id).get["timezone"].as_s
+ Time::Location.load(tz)
+ end
+
+ protected def check_access(booking : Booking)
+ now = Time.local(timezone)
+ end_of_day = now.at_end_of_day
+
+ return unless booking.booking_start <= end_of_day.to_unix && booking.booking_end >= now.to_unix
+ ensure_booking_access
+ end
+
+ struct Desk
+ include JSON::Serializable
+
+ getter id : String
+ getter security : String? = nil
+ end
+
+ def username_lookup(email : String) : String
+ email = email.strip.downcase
+ if username = cached_username[email]?
+ username
+ else
+ username = calendar.get_user(email).get["username"]?.try(&.as_s.downcase) || email
+ if username == email
+ cached_username[email] = email
+ else
+ cached_username[email] = username
+ end
+ username
+ end
+ end
+
+ def user_id?(email : String) : String | Int64 | Nil
+ security = security_system
+ lookup_user_id security, email.downcase
+ end
+
+ protected def lookup_user_id(security, email : String) : String | Int64 | Nil
+ id = cached_user_lookups[email]?
+ return id if id
+
+ email = username_lookup(email) if @lookup_using_username
+
+ if json = (security.card_holder_id_lookup(email).get rescue nil)
+ cached_user_lookups[email] = (String | Int64).from_json(json.to_json)
+ end
+ end
+
+ def zone_id?(name_or_id : String) : String | Int64 | Nil
+ security = security_system
+ lookup_zone_id security, name_or_id
+ end
+
+ protected def lookup_zone_id(security, name_or_id : String) : String | Int64 | Nil
+ id = cached_zone_lookups[name_or_id]?
+ return id if id
+
+ # check if this was a name and lookup the id
+ if id = security.zone_access_id_lookup(name_or_id).get
+ id = cached_zone_lookups[name_or_id] = (String | Int64).from_json(id.to_json)
+ return id
+ end
+
+ # check if the ID was passed directly
+ if (security.zone_access_lookup(name_or_id).get rescue nil)
+ cached_zone_lookups[name_or_id] = name_or_id
+ return name_or_id
+ end
+
+ # otherwise the id or name is probably incorrect
+ logger.warn { "Zone lookup failed for: #{name_or_id}" }
+ nil
+ end
+
+ # returns desk_id => security zone name / id
+ def desks(level_id : String, blocked : Hash(String, String | Int64) = {} of String => String | Int64) : Hash(String, String)
+ desks = staff_api.metadata(level_id, "desks").get.dig?("desks", "details")
+ security = {} of String => String
+ return security unless desks
+
+ Array(Desk).from_json(desks.to_json).each do |desk|
+ sec = desk.security.presence
+ next unless sec
+
+ if security_zone_whitelist.empty?
+ security[desk.id] = sec
+ elsif security_zone_whitelist.includes?(sec)
+ security[desk.id] = sec
+ else
+ blocked[desk.id] = sec
+ end
+ end
+ security
+ end
+
+ protected def has_access?(security, zone_id, user_id) : Bool
+ has_access = (String | Int64 | Nil).from_json(security.zone_access_member?(zone_id, user_id).get.to_json)
+ !!has_access
+ end
+
+ @check_mutex : Mutex = Mutex.new
+ @performing_check : Bool = false
+ @check_queued : Bool = false
+
+ def ensure_booking_access
+ errors = [] of String
+ # desk id => security zone
+ # where mapping blocked due to not being whitelisted
+ blocked = {} of String => String | Int64
+
+ @check_mutex.synchronize do
+ if @performing_check
+ @check_queued = true
+ return
+ end
+
+ @performing_check = true
+ @check_queued = false
+ end
+
+ @mutex.synchronize do
+ now = Time.local(timezone).at_beginning_of_day
+ end_of_day = 3.days.from_now.in(timezone).at_end_of_day
+
+ access_required = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String }
+
+ # calculate who needs access
+ levels.each do |level_id|
+ desks = desks(level_id, blocked)
+ next if desks.empty?
+
+ desk_bookings = staff_api.query_bookings(now.to_unix, end_of_day.to_unix, zones: {level_id}, type: "desk").get.as_a
+ next if desk_bookings.empty?
+
+ desk_bookings.each do |booking|
+ desk = booking["asset_id"].as_s
+ if security = desks[desk]?
+ user_access = access_required[booking["user_email"].as_s.downcase]
+ user_access << security
+ user_access.uniq!
+ end
+ end
+ end
+
+ # apply access this access to the system, need to find the differences
+ allocations = @allocations
+ logger.debug { "found #{access_required.size} users that need access" }
+
+ if allocations == access_required
+ logger.debug { "no access changes are required" }
+ return
+ end
+
+ remove = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String }
+ add = Hash(String, Array(String)).new { |hash, key| hash[key] = [] of String }
+
+ # Collect all keys from both hashes
+ all_keys = allocations.keys.concat(access_required.keys)
+ all_keys.each do |key|
+ current = allocations[key]? || [] of String
+ desired = access_required[key]? || [] of String
+
+ # Calculate elements to remove and add
+ to_remove = current - desired
+ to_add = desired - current
+
+ # Add to `remove` hash if there are elements to remove
+ remove[key] = to_remove unless to_remove.empty?
+
+ # Add to `add` hash if there are elements to add
+ add[key] = to_add unless to_add.empty?
+ end
+
+ logger.debug { "deleting permissions: #{remove.size}" }
+ logger.debug { "granting permissions: #{add.size}" }
+
+ # apply the differences
+ security = security_system
+ remove.each do |user_email, zones|
+ begin
+ user_id = lookup_user_id(security, user_email)
+ raise "unable to find user_id for: #{user_email}" unless user_id
+
+ zones.uniq!.each do |zone|
+ begin
+ zone_id = lookup_zone_id(security, zone)
+ raise "unable to find zone_id for: #{zone}" unless zone_id
+ security.zone_access_remove_member(zone_id, user_id).get if has_access?(security, zone_id, user_id)
+ rescue error
+ # add the user back to the zone so it can be removed in a later sync
+ access_required[user_email] << zone
+ msg = "failed to remove #{user_email} from security zone: #{zone}"
+ errors << msg
+ logger.warn(exception: error) { msg }
+ end
+ end
+ rescue error
+ access_required[user_email] = allocations[user_email]
+ add.delete(user_email)
+ msg = "failed to remove #{user_email} from security zones"
+ errors << msg
+ logger.warn(exception: error) { msg }
+ end
+ end
+
+ add.each do |user_email, zones|
+ begin
+ user_id = lookup_user_id(security, user_email)
+ raise "unable to find user_id for: #{user_email}" unless user_id
+
+ zones.uniq!.each do |zone|
+ begin
+ zone_id = lookup_zone_id(security, zone)
+ raise "unable to find zone_id for: #{zone}" unless zone_id
+ security.zone_access_add_member(zone_id, user_id).get unless has_access?(security, zone_id, user_id)
+ rescue error
+ # remove the user from the recorded zone so it can be added in a later sync
+ access_required[user_email].delete zone
+ msg = "failed to add #{user_email} to security zone: #{zone}"
+ errors << msg
+ logger.warn(exception: error) { msg }
+ end
+ end
+ rescue error
+ access_required.delete(user_email)
+ msg = "failed to add #{user_email} to security zones"
+ errors << msg
+ logger.warn(exception: error) { msg }
+ end
+ end
+
+ # save the newly applied access permissions
+ define_setting(:permissions_allocated, access_required)
+ ensure
+ @check_mutex.synchronize do
+ @performing_check = false
+ spawn { ensure_booking_access } if @check_queued
+ end
+ end
+
+ # expose errors and anything blocked as not on the whitelist
+ self[:sync_errors] = errors
+ self[:sync_blocked] = blocked
+ end
+
+ @[Security(Level::Support)]
+ def security_zone_report
+ # desk id => security id
+ blocked = {} of String => String | Int64
+ found = {} of String => String | Int64
+ levels.each do |level_id|
+ found.merge! desks(level_id, blocked)
+ end
+
+ found_values = found.values.map(&.to_s).uniq!
+ security_groups = found.values + blocked.values
+ in_whitelist_only = security_zone_whitelist - security_groups
+
+ {
+ blocked: blocked,
+ allocated: found_values,
+ in_whitelist_only: in_whitelist_only,
+ }
+ end
+
+ @[Security(Level::Support)]
+ def approve_security_zone_list
+ # save all the security zones to the whitelist
+ details = security_zone_report
+ new_whitelist = details[:in_whitelist_only] + details[:allocated] + details[:blocked].values.uniq!
+ whitelist_strings = new_whitelist.compact_map { |item| item.as(String) if item.is_a?(String) }.sort!
+ whitelist_ints = new_whitelist.compact_map { |item| item.as(Int64) if item.is_a?(Int64) }.sort!
+
+ new_whitelist = [] of String | Int64
+ new_whitelist.concat whitelist_strings
+ new_whitelist.concat whitelist_ints
+
+ define_setting(:security_zone_whitelist, new_whitelist)
+ new_whitelist
+ end
+
+ # =========================
+ # MailerTemplates interface
+ # =========================
+
+ def template_fields : Array(TemplateFields)
+ [
+ TemplateFields.new(
+ trigger: {"security", "area_access_errors"},
+ name: "Booking Area Access Errors",
+ description: "Email sent when there are errors adding users to security groups so they can access the desk they have booked or allocated",
+ fields: [
+ {name: "errors", description: "a formatted list of email addresses to security groups that could not be applied"},
+ {name: "system_id", description: "the system with the BookingAreaAccess driver"},
+ ]
+ ),
+ ]
+ end
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ def notify_issues
+ sync_blocked = status?(Hash(String, String | Int64), :sync_blocked) || Hash(String, String | Int64).new
+ sync_errors = status?(Array(String), :sync_errors) || [] of String
+ return if sync_blocked.empty? && sync_errors.empty?
+
+ issue_list = String.build do |io|
+ if !sync_blocked.empty?
+ io << "security groups not in the whitelist: \n"
+ io << "===================================== \n"
+
+ sync_blocked.each do |desk_id, security_zone|
+ io << "desk: #{desk_id}, security group: #{security_zone} \n"
+ end
+ io << " \n\n"
+ end
+
+ if !sync_errors.empty?
+ io << "unable to allocate desks for: \n"
+ io << "============================= \n"
+
+ sync_errors.each do |error|
+ io << " - "
+ io << error
+ io << " \n"
+ end
+ io << " \n\n"
+ end
+ end
+
+ mailer.send_template(@email_errors_to.as(String), {"security", "area_access_errors"}, {
+ errors: issue_list,
+ system_id: config.control_system.try(&.id),
+ })
+ end
+end
diff --git a/drivers/place/bookings/grant_area_access_spec.cr b/drivers/place/bookings/grant_area_access_spec.cr
new file mode 100644
index 00000000000..1d1eee8f9df
--- /dev/null
+++ b/drivers/place/bookings/grant_area_access_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Bookings::GrantAreaAccess" do
+end
diff --git a/drivers/place/bookings/locker_booking_sync.cr b/drivers/place/bookings/locker_booking_sync.cr
new file mode 100644
index 00000000000..1f105989961
--- /dev/null
+++ b/drivers/place/bookings/locker_booking_sync.cr
@@ -0,0 +1,327 @@
+require "placeos-driver"
+require "placeos-driver/interface/lockers"
+require "../booking_model"
+require "./locker_models"
+
+# makes the assumption that there are no future bookings,
+# you can only book a locker now if it's currently free.
+class Place::Bookings::LockerBookingSync < PlaceOS::Driver
+ include Place::LockerMetadataParser
+
+ descriptive_name "PlaceOS Locker Booking Sync"
+ generic_name :LockerBookingSync
+ description "Syncs placeos bookings with a physical lockers system via the placeos locker interface"
+
+ default_settings({
+ authority_id: "auth-1234",
+ end_of_week_bookings: true,
+ })
+
+ # synced == allocation_id in placeos booking.extension_data
+ alias PlaceLocker = PlaceOS::Driver::Interface::Lockers::PlaceLocker
+ alias LockerMetadata = Place::Locker
+
+ accessor staff_api : StaffAPI_1
+ accessor locations : LocationServices_1
+
+ def locker_locations
+ system.implementing(Interface::Lockers)
+ end
+
+ def on_load
+ schedule.every(10.minutes) { ensure_locker_access }
+ on_update
+ end
+
+ @end_of_week_bookings : Bool = false
+
+ def on_update
+ @building_id = nil
+ @timezone = nil
+ @systems = nil
+ @locker_banks = nil
+ @locker_details = nil
+ @end_of_week_bookings = setting?(Bool, :end_of_week_bookings) || false
+
+ authority_id = setting(String, :authority_id)
+ subscriptions.clear
+ monitor("#{authority_id}/staff/booking/changed") do |_subscription, payload|
+ logger.debug { "received booking changed event #{payload}" }
+ check_allocation(Booking.from_json payload)
+ end
+ end
+
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+
+ # Grabs the list of systems in the building
+ getter systems : Hash(String, Array(String)) do
+ staff_api.systems_in_building(building_id).get.as_h.transform_values(&.as_a.map(&.as_s))
+ end
+
+ def levels : Array(String)
+ systems.keys
+ end
+
+ # system or building timezone
+ protected getter timezone : Time::Location do
+ tz = config.control_system.try(&.timezone) || staff_api.zone(building_id).get["timezone"].as_s
+ Time::Location.load(tz)
+ end
+
+ # locker_banks included from LockerMetadataParser
+
+ # ===========================
+ # Primary Locker booking sync
+ # ===========================
+
+ @mutex = Mutex.new
+ @sync_busy : Hash(String, Bool) = Hash(String, Bool).new { |hash, key| hash[key] = false }
+ @sync_queue : Hash(String, Int32) = Hash(String, Int32).new { |hash, key| hash[key] = 0 }
+
+ def ensure_locker_access
+ levels.each { |zone_id| sync_level zone_id }
+ end
+
+ def sync_level(zone : String) : Nil
+ @mutex.synchronize do
+ @sync_queue[zone] += 1
+ if !@sync_busy[zone]
+ spawn { queue_sync_level(zone) }
+ :syncing
+ else
+ :queued
+ end
+ end
+ end
+
+ protected def queue_sync_level(zone : String) : Nil
+ # ensure we're not already syncing
+ @mutex.synchronize do
+ return if @sync_busy[zone]
+ @sync_busy[zone] = true
+ end
+
+ begin
+ loop do
+ begin
+ @mutex.synchronize { @sync_queue[zone] = 0 }
+ unique_id = Random::Secure.hex(10)
+ do_sync_level(zone, unique_id)
+ rescue error
+ logger.warn(exception: error) { "syncing #{zone}" }
+ end
+
+ break if @mutex.synchronize { @sync_queue[zone].zero? }
+ Fiber.yield
+ end
+ rescue error
+ logger.warn(exception: error) { "error syncing floor: #{zone}" }
+ ensure
+ @mutex.synchronize { @sync_busy[zone] = false }
+ end
+ end
+
+ protected def do_sync_level(level_id : String, unique_id : String) : Nil
+ logger.debug { "syncing #{level_id} -- id:#{unique_id}" }
+
+ # grab placeos bookings (now to 1 hour from now, including deleted / checked out)
+ starting = Time.local(timezone)
+ end_of_day = starting.at_end_of_day
+ place_bookings_raw = staff_api.query_bookings(starting.to_unix, end_of_day.to_unix, zones: {level_id}, type: "locker", include_checked_out: true).get.as_a
+ place_bookings_raw.concat staff_api.query_bookings(starting.to_unix, end_of_day.to_unix, zones: {level_id}, type: "locker", deleted: true).get.as_a
+ place_bookings = Array(Booking).from_json place_bookings_raw.to_json
+ place_bookings_raw.clear
+
+ # remove older instances of the recurring booking
+ place_bookings.sort! { |a, b| a.booking_start <=> b.booking_start }.uniq! { |book| book.id }
+
+ logger.debug { "found #{place_bookings.size} place bookings -- id:#{unique_id}" }
+
+ # grab current locker allocations
+ locker_systems = locker_locations
+ lockers = locker_systems.flat_map do |locker_system|
+ Array(PlaceLocker).from_json locker_system.device_locations(level_id).get.to_json
+ end
+
+ logger.debug { "found #{lockers.size} locker allocations -- id:#{unique_id}" }
+
+ # match bookings with the allocations
+ allocate_lockers = [] of Booking
+ release_lockers = [] of Booking
+ place_bookings.reject! do |booking|
+ if booking.deleted == true || booking.rejected == true || !booking.checked_out_at.nil?
+ logger.debug { " -- releasing locker #{booking.asset_id} as booking deleted #{booking.deleted.inspect}, rejected #{booking.rejected.inspect}, checked out #{booking.checked_out_at.inspect} -- id:#{unique_id}" }
+ release_lockers << booking
+ elsif has_allocation?(booking).nil?
+ allocate_lockers << booking
+ end
+ end
+
+ # there might be new allocations or valid bookings and we don't want to
+ # release the locker if there are existing bookings
+ release_lockers.reject! do |booking|
+ allocate_lockers.find { |book| book.asset_id == booking.asset_id && (book.user_id == booking.user_id || book.user_email.downcase == booking.user_email.downcase) } ||
+ place_bookings.find { |book| book.asset_id == booking.asset_id && (book.user_id == booking.user_id || book.user_email.downcase == booking.user_email.downcase) }
+ end
+
+ logger.debug { "planning to allocate #{allocate_lockers.size}, release #{release_lockers.size} and check #{place_bookings.size} against #{lockers.size} allocations -- id:#{unique_id}" }
+
+ # remove allocations where a place booking has been checked out
+ # ensure the locker is still allocated to that user
+ allocated = 0
+ release_lockers.each do |place_booking|
+ allocation_id = has_allocation?(place_booking)
+ next unless allocation_id
+
+ if locker = lockers.find { |lock| lock.allocation_id == allocation_id }
+ allocated += 1
+ logger.debug { " -- released #{locker.locker_id} (#{locker.allocation_id}) from #{place_booking.user_id} (#{place_booking.user_email}) -- id:#{unique_id}" }
+ locker_systems.locker_release(locker.bank_id, locker.locker_id, place_booking.user_id.presence || place_booking.user_email) rescue nil
+ lockers.delete locker
+ end
+ end
+
+ logger.debug { "released #{allocated} lockers, checked #{release_lockers.size} bookings -- id:#{unique_id}" }
+
+ # allocate lockers where a place booking has been created
+ allocated = 0
+ skipped = 0
+ alloc_failed = [] of Booking
+ allocate_lockers.each do |place_booking|
+ asset_id = place_booking.asset_id
+ place_user_id = place_booking.user_id.presence || place_booking.user_email
+
+ locker_meta = locker_details[asset_id]?
+ if locker_meta.nil?
+ skipped += 1
+ logger.warn { "unable to find locker details for locker id: #{asset_id}" }
+ next
+ end
+
+ locker = locker_systems.compact_map do |locker_system|
+ if json = (locker_system.locker_allocate(place_user_id, locker_meta.bank_id, locker_meta.id).get rescue nil)
+ PlaceLocker.from_json json.to_json
+ end
+ end.first?
+
+ # check if the locker is allocated to the current user (booking_state update may have failed earlier)
+ if locker.nil? && (found = lockers.find { |lock| lock.locker_id == asset_id })
+ locker = locker_systems.flat_map { |locker_system|
+ Array(PlaceLocker).from_json locker_system.lockers_allocated_to(place_user_id).get.to_json
+ }.select! { |lock| lock.locker_id == asset_id }.first?
+
+ if locker
+ logger.debug { " -- allocated #{found.locker_id} to #{place_user_id} (#{place_booking.user_email}) -- id:#{unique_id}" }
+ lockers.delete(found)
+ end
+ end
+
+ # store the allocation id in placeos, if locker allocate failed then we'll hopefully
+ # resolve this below if this step failed in a previous run
+ if locker
+ logger.debug { " -- update #{locker.locker_id} booking state on #{place_booking.id} to #{locker.allocation_id} -- id:#{unique_id}" }
+ staff_api.booking_extension_data(place_booking.id, {locker_allocation_id: locker.allocation_id}) if place_booking.instance
+ staff_api.booking_extension_data(place_booking.id, {locker_allocation_id: locker.allocation_id}, instance: place_booking.instance)
+ allocated += 1
+ else
+ alloc_failed << place_booking
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to allocate #{place_booking.asset_id}" }
+ end
+
+ logger.debug { "allocated #{allocated} lockers, failed #{alloc_failed.size}, skipped #{skipped} -- id:#{unique_id}" }
+
+ # checkout placeos bookings where a locker has been released
+ # or there is a clash (locker allocated to someone else already)
+ checked_out = 0
+ place_bookings.each do |booking|
+ allocation_id = has_allocation?(booking)
+ if locker = lockers.find { |lock| lock.allocation_id == allocation_id }
+ # we found the locker so we don't need to create a placeos booking
+ lockers.delete(locker)
+ else
+ checked_out += 1
+ logger.debug { " -- booking #{booking.id} checked out of #{booking.asset_id} (#{booking.user_id}: #{booking.user_email}) -- id:#{unique_id}" }
+ # locker has been released so we should free the booking
+ staff_api.update_booking(booking.id, recurrence_end: booking.booking_end) if booking.instance
+ staff_api.booking_check_in(booking.id, false, "locker-sync", instance: booking.instance)
+ end
+ end
+
+ logger.debug { "ended #{checked_out} placeos locker bookings -- id:#{unique_id}" }
+
+ # create placeos bookings where a locker has been allocated
+ start_of_day = starting.at_beginning_of_day
+ end_of_week = starting.at_end_of_week
+ allocated = 0
+ skipped = 0
+ lockers.each do |lock|
+ owner = locker_systems.check_ownership_of(lock.mac).get.first?
+ email = owner["assigned_to"]?.try(&.as_s?).presence if owner
+ if email.nil?
+ logger.warn { "unable to find locker mac #{lock.mac} -- id:#{unique_id}" }
+ skipped += 1
+ next
+ end
+
+ email = email.strip.downcase
+ logger.debug { " -- creating booking for #{lock.locker_id} and #{email} -- id:#{unique_id}" }
+ user = begin
+ staff_api.user(email).get
+ rescue error
+ {
+ "id" => email,
+ "name" => email.split('@', 2)[0].gsub('.', ' '),
+ }
+ end
+ recurrence_end = end_of_week.to_unix if @end_of_week_bookings
+ staff_api.create_booking(
+ booking_type: "locker",
+ asset_id: lock.locker_id,
+ user_id: user["id"],
+ user_email: email,
+ user_name: user["name"],
+ zones: [level_id] + config.control_system.not_nil!.zones,
+ booking_start: start_of_day.to_unix,
+ booking_end: end_of_day.to_unix,
+ time_zone: timezone.name,
+ # checked_in: true, # results in a 422 error
+ title: lock.locker_name,
+ extension_data: {locker_allocation_id: lock.allocation_id},
+ recurrence_type: "DAILY",
+ recurrence_days: 127,
+ recurrence_end: recurrence_end,
+ ).get
+ allocated += 1
+ rescue error
+ logger.warn(exception: error) { "error creating booking for locker mac #{lock.mac} -- id:#{unique_id}" }
+ skipped += 1
+ end
+
+ logger.debug { "created #{allocated} placeos locker bookings. Failed to find #{skipped} users -- id:#{unique_id}" }
+ rescue error
+ logger.error(exception: error) { "unexpected error syncing bookings" }
+ end
+
+ protected def has_allocation?(booking) : String?
+ booking.extension_data["locker_allocation_id"]?.try(&.as_s.presence)
+ end
+
+ # ===========================
+ # Booking change notification
+ # ===========================
+
+ protected def check_allocation(booking : Booking)
+ return unless booking.booking_type == "locker"
+ if zone = (booking.zones & levels).first?
+ booking = Booking.from_json staff_api.get_booking(booking.id, booking.instance).get.to_json
+ # only sync level if the update is a create (unsynced) or the ending of a booking
+ if has_allocation?(booking).nil? || booking.deleted == true || booking.rejected == true || !booking.checked_out_at.nil?
+ queue_sync_level(zone)
+ end
+ end
+ end
+end
diff --git a/drivers/place/bookings/locker_booking_sync_spec.cr b/drivers/place/bookings/locker_booking_sync_spec.cr
new file mode 100644
index 00000000000..630c553cbad
--- /dev/null
+++ b/drivers/place/bookings/locker_booking_sync_spec.cr
@@ -0,0 +1,737 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/lockers"
+require "../booking_model"
+require "./locker_models"
+
+DriverSpecs.mock_driver "Place::Bookings::LockerBookingSync" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ LocationServices: {LocationServicesMock},
+ Lockers: {LockersMock},
+ })
+
+ # Ensure sync works and creates no bookings
+ puts "\n\nEMPTY SYNC\n"
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ staff_api = system(:StaffAPI).as(StaffAPIMock)
+ staff_api.created.should eq 0
+ staff_api.query_calls.should eq 2
+ staff_api.reset
+
+ # create a staff api booking as a locker booking has been made
+ puts "\n\nLOCKER ALLOCATION SYNC\n"
+ lockers = system(:Lockers).as(LockersMock)
+ lockers.locker_allocate("user-1", "bank-1", "locker-2")
+ lockers.total_lockers_allocated.should eq 1
+ staff_api.bookings.size.should eq 0
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ staff_api.bookings.size.should eq 1
+ staff_api.created.should eq 1
+ staff_api.query_calls.should eq 2
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 1
+ staff_api.bookings.size.should eq 1
+ staff_api.created.should eq 1
+ staff_api.checked_out.should eq 0
+ staff_api.updated.should eq 0
+ staff_api.query_calls.should eq 4
+
+ # create a locker allocation if a staff API booking has been made
+ puts "\n\nSTAFF API BOOKING SYNC\n"
+ timezone = Time::Location.load("Australia/Sydney")
+ now = Time.local(timezone)
+ staff_api.create_booking(
+ booking_type: "locker",
+ asset_id: "locker-1",
+ user_id: "user-2",
+ user_email: "user-2@email.com",
+ user_name: "User 2",
+ zones: ["zone-level1", "zone-1234"],
+ booking_start: now.to_unix,
+ booking_end: now.at_end_of_day.to_unix,
+ title: "Lock 2",
+ time_zone: "Australia/Sydney"
+ )
+ staff_api.bookings.size.should eq 2
+ staff_api.created.should eq 2
+ lockers.total_lockers_allocated.should eq 1
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 2
+ staff_api.bookings.size.should eq 2
+ staff_api.created.should eq 2
+ staff_api.checked_out.should eq 0
+ staff_api.updated.should eq 1
+ staff_api.query_calls.should eq 6
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 2
+ staff_api.bookings.size.should eq 2
+ staff_api.created.should eq 2
+ staff_api.checked_out.should eq 0
+ staff_api.updated.should eq 1
+ staff_api.query_calls.should eq 8
+
+ # release a locker if a staff api booking is ended
+ puts "\n\nSTAFF BOOKING ENDED SYNC\n"
+ booking = staff_api.bookings.values.find! { |book| book.user_id == "user-2" }
+ booking.checked_in = false
+ booking.checked_out_at = Time.utc.to_unix
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 1
+ staff_api.bookings.size.should eq 2
+ staff_api.created.should eq 2
+ staff_api.checked_out.should eq 0
+ staff_api.updated.should eq 1
+ staff_api.query_calls.should eq 10
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 1
+ staff_api.bookings.size.should eq 2
+ staff_api.created.should eq 2
+ staff_api.checked_out.should eq 0
+ staff_api.updated.should eq 1
+ staff_api.query_calls.should eq 12
+
+ # end a booking if a locker is released
+ puts "\n\nLOCKER RELEASE SYNC\n"
+ lockers.locker_release_mine("bank-1", "locker-2")
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 0
+ staff_api.bookings.size.should eq 2
+ staff_api.created.should eq 2
+ staff_api.checked_out.should eq 1
+ staff_api.updated.should eq 1
+ staff_api.query_calls.should eq 14
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 0
+ staff_api.bookings.size.should eq 2
+ staff_api.created.should eq 2
+ staff_api.checked_out.should eq 1
+ staff_api.updated.should eq 1
+ staff_api.query_calls.should eq 16
+
+ # ===================================================
+ # create a new booking and ensure bookings are stable
+ # repeating all these previous tasks with old entries
+ # ===================================================
+
+ # create a staff api booking as a locker booking has been made
+ puts "\n\nLOCKER ALLOCATION SYNC\n"
+ lockers = system(:Lockers).as(LockersMock)
+ lockers.locker_allocate("user-1", "bank-1", "locker-2")
+ lockers.total_lockers_allocated.should eq 1
+ staff_api.bookings.size.should eq 2
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ staff_api.bookings.size.should eq 3
+ staff_api.created.should eq 3
+ staff_api.query_calls.should eq 18
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 1
+ staff_api.bookings.size.should eq 3
+ staff_api.created.should eq 3
+ staff_api.checked_out.should eq 1
+ staff_api.updated.should eq 1
+ staff_api.query_calls.should eq 20
+
+ # create a locker allocation if a staff API booking has been made
+ puts "\n\nSTAFF API BOOKING SYNC\n"
+ timezone = Time::Location.load("Australia/Sydney")
+ now = Time.local(timezone)
+ staff_api.create_booking(
+ booking_type: "locker",
+ asset_id: "locker-1",
+ user_id: "user-2",
+ user_email: "user-2@email.com",
+ user_name: "User 2",
+ zones: ["zone-level1", "zone-1234"],
+ booking_start: now.to_unix,
+ booking_end: now.at_end_of_day.to_unix,
+ title: "Lock 2",
+ time_zone: "Australia/Sydney"
+ )
+ staff_api.bookings.size.should eq 4
+ staff_api.created.should eq 4
+ lockers.total_lockers_allocated.should eq 1
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 2
+ staff_api.bookings.size.should eq 4
+ staff_api.created.should eq 4
+ staff_api.checked_out.should eq 1
+ staff_api.updated.should eq 2
+ staff_api.query_calls.should eq 22
+ exec :sync_level, "zone-level1"
+ sleep 300.milliseconds
+ lockers.total_lockers_allocated.should eq 2
+ staff_api.bookings.size.should eq 4
+ staff_api.created.should eq 4
+ staff_api.checked_out.should eq 1
+ staff_api.updated.should eq 2
+ staff_api.query_calls.should eq 24
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ # always requesting the building zone for timezone info
+ def zone(id : String)
+ raise "unexpected id #{id.inspect}, expected zone-building-id" unless id == "zone-building-id"
+ {
+ timezone: "Australia/Sydney",
+ }
+ end
+
+ def systems_in_building(id : String, ids_only : Bool = true)
+ raise "unexpected id #{id.inspect}, expected zone-building-id" unless id == "zone-building-id"
+ raise "only ids supported, unexpected call" unless ids_only
+ {
+ "zone-level1" => [] of String,
+ "zone-level2" => [] of String,
+ }
+ end
+
+ def metadata(id : String, key : String? = nil)
+ raise "unexpected building id: #{id}" unless id == "zone-building-id"
+ case key
+ when "locker_banks"
+ {
+ locker_banks: {
+ details: [
+ {
+ id: "bank-1",
+ name: "Bank 1",
+ zones: ["zone-building-id", "zone-level1"],
+ },
+ {
+ id: "bank-2",
+ name: "Bank 2",
+ zones: ["zone-building-id", "zone-level2"],
+ },
+ ],
+ },
+ }
+ when "lockers"
+ {
+ lockers: {
+ details: [
+ {
+ id: "locker-1",
+ name: "Lock 1",
+ bank_id: "bank-1",
+ bookable: true,
+ },
+ {
+ id: "locker-2",
+ name: "Lock 2",
+ bank_id: "bank-1",
+ bookable: true,
+ },
+ {
+ id: "locker-3",
+ name: "Lock 3",
+ bank_id: "bank-2",
+ bookable: true,
+ },
+ {
+ id: "locker-4",
+ name: "Lock 4",
+ bank_id: "bank-2",
+ bookable: true,
+ },
+ ],
+ },
+ }
+ else
+ {} of Nil => Nil
+ end
+ end
+
+ def reset
+ @query_calls = 0
+ @created = 0
+ @checked_out = 0
+ @updated = 0
+ @bookings = {} of Int64 => Place::Booking
+ end
+
+ # emulate a basic database
+ getter bookings : Hash(Int64, Place::Booking) = {} of Int64 => Place::Booking
+ getter query_calls : Int32 = 0
+ getter created : Int32 = 0
+ getter updated : Int32 = 0
+ getter checked_out : Int32 = 0
+
+ def query_bookings(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil,
+ include_checked_out : Bool? = nil,
+ extension_data : JSON::Any? = nil,
+ deleted : Bool? = nil
+ )
+ @query_calls += 1
+ # return the bookings in the database
+ # ignore calls to deleted and return an empty array
+ return [] of Nil if deleted
+ @bookings.values
+ end
+
+ def booking_extension_data(booking_id : String | Int64, extension_data : Hash(String, JSON::Any), instance : Int64? = nil, signal_changes : Bool = false)
+ booking = @bookings[booking_id]?
+ raise "could not find booking #{booking_id}" unless booking
+ @updated += 1
+ booking.extension_data = extension_data
+ booking
+ end
+
+ # we won't test with a booking instance here as it jsut complicates things
+ # def update_booking
+
+ def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil, instance : Int64? = nil)
+ booking = @bookings[booking_id]?
+ raise "could not find booking #{booking_id}" unless booking
+ booking.checked_in = state
+ booking.checked_out_at = Time.utc.to_unix unless state
+ @checked_out += 1 unless state
+ booking
+ end
+
+ def user(id : String)
+ case id
+ when "user-1", "user-1@email.com"
+ {
+ id: "user-1",
+ email: "user-1@email.com",
+ name: "User 1",
+ }
+ when "user-2", "user-2@email.com"
+ {
+ id: "user-2",
+ email: "user-2@email.com",
+ name: "User 2",
+ }
+ else
+ raise "unexpected user id requested #{id}"
+ end
+ end
+
+ def create_booking(
+ booking_type : String,
+ asset_id : String,
+ user_id : String,
+ user_email : String,
+ user_name : String,
+ zones : Array(String),
+ booking_start : Int64? = nil,
+ booking_end : Int64? = nil,
+ checked_in : Bool = false,
+ approved : Bool? = nil,
+ title : String? = nil,
+ description : String? = nil,
+ time_zone : String? = nil,
+ extension_data : JSON::Any? = nil,
+ utm_source : String? = nil,
+ limit_override : Int64? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ attendees : Array(Nil)? = nil,
+ process_state : String? = nil,
+ recurrence_type : String? = nil,
+ recurrence_days : Int32? = nil,
+ recurrence_nth_of_month : Int32? = nil,
+ recurrence_interval : Int32? = nil,
+ recurrence_end : Int64? = nil
+ )
+ @created += 1
+ id = rand(Int64::MAX)
+ @bookings[id] = Place::Booking.new(
+ id: id,
+ booking_type: booking_type,
+ asset_id: asset_id,
+ user_id: user_id,
+ user_email: user_email,
+ user_name: user_name,
+ zones: zones,
+ booked_by_name: user_name,
+ booked_by_email: user_email,
+ booking_start: booking_start.not_nil!,
+ booking_end: booking_end.not_nil!,
+ timezone: time_zone.not_nil!,
+ extension_data: extension_data.try(&.as_h),
+ )
+ end
+
+ def get_booking(booking_id : String | Int64, instance : Int64? = nil)
+ # this function shouldn't really be called
+ logger.warn { "UNEXPECTED CALL TO staff_api.get_booking(#{booking_id.inspect}, #{instance.inspect})" }
+ @bookings[booking_id.to_i]
+ end
+end
+
+# re-open classes to add some helpers
+class ::Place::Locker
+ def initialize(@id, @name, @bank_id, @bookable, @level_id)
+ end
+
+ # for tracking, not part of metadata
+ property allocated_to : String? = nil
+ property allocated_at : Time? = nil
+ property allocated_until : Time? = nil
+ property shared_with : Array(String) = [] of String
+
+ def release
+ @allocated_to = nil
+ @allocated_at = nil
+ @allocated_until = nil
+ @shared_with = [] of String
+ end
+
+ def allocated? : Bool
+ if time = self.allocated_until
+ if time > Time.utc
+ true
+ else
+ false
+ end
+ elsif self.allocated_to.presence
+ true
+ else
+ false
+ end
+ end
+
+ def not_allocated? : Bool
+ !allocated?
+ end
+end
+
+class ::PlaceOS::Driver::Interface::Lockers::PlaceLocker
+ def initialize(@bank_id, locker : ::Place::Locker, @building = nil)
+ @locker_id = locker.id
+ @locker_name = locker.name
+ @mac = "lb=#{@bank_id}&lk=#{locker.id}"
+ if time = locker.allocated_until
+ if time > Time.utc
+ in_use = true
+ @expires_at = time
+ else
+ in_use = false
+ @expires_at = nil
+ end
+ elsif allocated_to = locker.allocated_to
+ in_use = true
+ @expires_at = nil
+ else
+ in_use = false
+ @expires_at = nil
+ end
+ @allocated = in_use
+ @allocation_id = "#{locker.allocated_to}--#{locker.id}--#{locker.allocated_at.try(&.to_unix_ns)}" if in_use
+ @level = locker.level_id
+ end
+end
+
+class Place::LockerBank
+ def initialize(@id, @name, @zones, @level_id, @lockers)
+ end
+end
+
+# :nodoc:
+class LockersMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Lockers
+
+ alias LockerBank = Place::LockerBank
+ alias Locker = Place::Locker
+
+ def reset
+ @locker_banks = nil
+ @locker_details = nil
+ end
+
+ def total_lockers_allocated : Int32
+ allocated = 0
+ locker_details.each_value do |locker|
+ allocated += 1 if locker.allocated?
+ end
+ allocated
+ end
+
+ def invoked_by_user_id
+ "user-1"
+ end
+
+ # implement the locker metadata parser methods
+ getter locker_banks : Hash(String, LockerBank) do
+ {
+ "bank-1" => LockerBank.new("bank-1", "Bank 1", ["zone-building-id", "zone-level1"], "zone-level1", [
+ Locker.new("locker-1", "Lock 1", "bank-1", true, "zone-level1"),
+ Locker.new("locker-2", "Lock 2", "bank-1", true, "zone-level1"),
+ ]),
+ "bank-2" => LockerBank.new("bank-2", "Bank 2", ["zone-building-id", "zone-level2"], "zone-level2", [
+ Locker.new("locker-3", "Lock 3", "bank-2", true, "zone-level2"),
+ Locker.new("locker-4", "Lock 4", "bank-2", true, "zone-level2"),
+ ]),
+ }
+ end
+
+ getter locker_details : Hash(String, Locker) do
+ lockers = {} of String => Locker
+ locker_banks.each_value do |bank|
+ bank.lockers.each do |locker|
+ lockers[locker.id] = locker
+ end
+ end
+ lockers
+ end
+
+ def building_id : String
+ "zone-building-id"
+ end
+
+ def levels : Array(String)
+ ["zone-level1", "zone-level2"]
+ end
+
+ # allocates a locker now, the allocation may expire
+ def locker_allocate(
+ # PlaceOS user id
+ user_id : String,
+
+ # the locker location
+ bank_id : String | Int64,
+
+ # allocates a random locker if this is nil
+ locker_id : String | Int64? = nil,
+
+ # attempts to create a booking that expires at the time specified
+ expires_at : Int64? = nil
+ ) : PlaceLocker
+ bank = locker_banks[bank_id.to_s]
+ locker_id = locker_id ? locker_id : bank.locker_hash.values.select(&.not_allocated?).sample.id
+ locker = bank.locker_hash[locker_id.to_s]
+ locker.allocated_to = user_id
+ locker.allocated_at = Time.utc
+ locker.allocated_until = Time.unix(expires_at) if expires_at
+ PlaceLocker.new(bank_id, locker, building_id)
+ rescue
+ raise "no available lockers"
+ end
+
+ # return the locker to the pool
+ def locker_release(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # release / unshare just this user - otherwise release the whole locker
+ owner_id : String? = nil
+ ) : Nil
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ if locker.allocated_to == owner_id
+ locker.release
+ else
+ locker.shared_with.delete(owner_id)
+ end
+ end
+
+ # a list of lockers that are allocated to the user
+ def lockers_allocated_to(user_id : String) : Array(PlaceLocker)
+ now = Time.utc
+ building = building_id
+
+ locker_banks.values.flat_map do |bank|
+ bank.locker_hash.values.compact_map do |locker|
+ if locker.allocated_to == user_id
+ if time = locker.allocated_until
+ PlaceLocker.new(bank.id, locker, building) if time > now
+ else
+ PlaceLocker.new(bank.id, locker, building)
+ end
+ end
+ end
+ end
+ end
+
+ def locker_share(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ share_with : String
+ ) : Nil
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ perform_share = false
+ if locker.allocated_to == owner_id
+ if time = locker.allocated_until
+ perform_share = time > Time.utc
+ else
+ perform_share = true
+ end
+ end
+
+ if perform_share
+ locker.shared_with << share_with
+ locker.shared_with.uniq!
+ end
+ end
+
+ def locker_unshare(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ # the individual you previously shared with (optional)
+ shared_with_id : String? = nil
+ ) : Nil
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ perform_share = false
+ if locker.allocated_to == owner_id
+ if time = locker.allocated_until
+ perform_share = time > Time.utc
+ else
+ perform_share = true
+ end
+ end
+
+ if perform_share
+ if shared_with_id
+ locker.shared_with.delete shared_with_id
+ else
+ locker.shared_with = [] of String
+ end
+ end
+ end
+
+ # a list of user-ids that the locker is shared with.
+ # this can be placeos user ids or emails
+ def locker_shared_with(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String
+ ) : Array(String)
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ perform_share = false
+ if locker.allocated_to == owner_id
+ if time = locker.allocated_until
+ perform_share = time > Time.utc
+ else
+ perform_share = true
+ end
+ end
+
+ if perform_share
+ locker.shared_with
+ else
+ [] of String
+ end
+ end
+
+ def locker_unlock(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # sometimes required by locker systems
+ owner_id : String? = nil,
+ # time in seconds the locker should be unlocked
+ # (can be ignored if not implemented)
+ open_time : Int32 = 60,
+ # optional pin code - if user entered from a kiosk
+ pin_code : String? = nil
+ ) : Nil
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ # we could find the floorsense user, grab the reservations the user has
+ # and list them here, but probably not amazingly useful
+ [] of String
+ end
+
+ USER_EMAILS = {
+ "user-1" => "user-1@email.com",
+ "user-2" => "user-2@email.com",
+ }
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ # "lb=#{@bank_id}&lk=#{locker.id}"
+ return nil unless mac_address.starts_with?("lb=")
+ floor_mac = URI::Params.parse mac_address
+ locker_bank = floor_mac["lb"]
+ locker_key = floor_mac["lk"]
+ locker = locker_banks[locker_bank].locker_hash[locker_key]
+
+ has_reservation = false
+ if user_id = locker.allocated_to
+ if time = locker.allocated_until
+ has_reservation = time > Time.utc
+ else
+ has_reservation = true
+ end
+ end
+
+ if has_reservation
+ {
+ location: "locker",
+ assigned_to: USER_EMAILS[locker.allocated_to],
+ mac_address: mac_address,
+ }
+ end
+ rescue
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching lockers in zone #{zone_id}" }
+ return [] of Nil if location && location != "locker"
+
+ building = building_id
+ level_zone = zone_id == building ? nil : zone_id
+ return [] of Nil if level_zone && !level_zone.in?(levels)
+
+ now = Time.utc
+ locker_banks.values.flat_map do |bank|
+ next [] of PlaceLocker if level_zone && bank.level_id != level_zone
+
+ bank.locker_hash.values.compact_map do |locker|
+ if locker.allocated_to
+ if time = locker.allocated_until
+ PlaceLocker.new(bank.id, locker, building) if time > now
+ else
+ PlaceLocker.new(bank.id, locker, building)
+ end
+ end
+ end
+ end
+ end
+end
+
+# :nodoc:
+class LocationServicesMock < DriverSpecs::MockDriver
+ def building_id : String
+ "zone-building-id"
+ end
+end
diff --git a/drivers/place/bookings/locker_models.cr b/drivers/place/bookings/locker_models.cr
new file mode 100644
index 00000000000..ff3fc461da9
--- /dev/null
+++ b/drivers/place/bookings/locker_models.cr
@@ -0,0 +1,90 @@
+require "json"
+
+class Place::Locker
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String { id }
+ getter bank_id : String
+ getter bookable : Bool { false }
+
+ # for tracking, not part of metadata
+ property level_id : String? = nil
+end
+
+class Place::LockerBank
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String { id }
+ getter zones : Array(String)
+
+ # for tracking, not part of metadata
+ property level_id : String? = nil
+ getter lockers : Array(Locker) = [] of Locker
+ getter locker_hash : Hash(String, Locker) do
+ lookup = {} of String => Locker
+ level = self.level_id
+ lockers.each do |locker|
+ locker.level_id = level
+ lookup[locker.id] = locker
+ end
+ lookup
+ end
+end
+
+module Place::LockerMetadataParser
+ getter locker_banks : Hash(String, LockerBank) do
+ # Grab bank details
+ banks = staff_api.metadata(building_id, "locker_banks").get.dig?("locker_banks", "details")
+ return Hash(String, LockerBank).new unless banks
+
+ banks = begin
+ Array(LockerBank).from_json(banks.to_json)
+ rescue error
+ message = "error parsing banks json on building #{building_id}:\n#{banks.to_pretty_json}"
+ logger.warn(exception: error) { message }
+ raise message
+ end
+
+ lookup = {} of String => LockerBank
+ banks.each do |bank|
+ bank.level_id = (levels & bank.zones).first?
+ lookup[bank.id] = bank
+ end
+
+ # Grab locker details:
+ lockers = staff_api.metadata(building_id, "lockers").get.dig?("lockers", "details")
+ return lookup unless lockers
+
+ lockers = begin
+ Array(Locker).from_json(lockers.to_json)
+ rescue error
+ message = "error parsing locker json on building #{building_id}:\n#{lockers.to_pretty_json}"
+ logger.warn(exception: error) { message }
+ raise message
+ end
+
+ lockers.each do |locker|
+ begin
+ bank = lookup[locker.bank_id]
+ locker.level_id = bank.level_id
+ bank.lockers << locker
+ rescue error
+ logger.warn(exception: error) { "config issue with locker #{locker.id} on bank #{locker.bank_id}" }
+ end
+ end
+
+ lookup
+ end
+
+ getter locker_details : Hash(String, Locker) do
+ lockers = {} of String => Locker
+ locker_banks.each_value do |bank|
+ bank.lockers.each do |locker|
+ lockers[locker.id] = locker
+ end
+ end
+ lockers
+ end
+end
diff --git a/drivers/place/bookings/security_booking_check_in.cr b/drivers/place/bookings/security_booking_check_in.cr
new file mode 100644
index 00000000000..fd5241fa63c
--- /dev/null
+++ b/drivers/place/bookings/security_booking_check_in.cr
@@ -0,0 +1,81 @@
+require "placeos-driver"
+require "place_calendar"
+require "placeos-driver/interface/door_security"
+
+class Place::SecurityBookingCheckin < PlaceOS::Driver
+ descriptive_name "Security based Booking Checkin"
+ generic_name :SecurityBookingCheckin
+ description %(Checks in users to bookings based on swipe card events in the security system)
+
+ default_settings({
+ # the channel id we're looking for events on
+ organization_id: "event",
+
+ # booking types we want to check in
+ booking_types: ["desk"],
+ })
+
+ def on_update
+ @booking_types = setting(Array(String), :booking_types)
+ time_zone_string = setting?(String, :time_zone).presence || config.control_system.not_nil!.timezone.presence || "GMT"
+ @time_zone = Time::Location.load(time_zone_string)
+ @building_id = nil
+
+ subscriptions.clear
+ org_id = setting?(String, :organization_id) || "event"
+ monitor("security/#{org_id}/door") { |_subscription, payload| door_event(payload) }
+ end
+
+ @booking_types : Array(String) = [] of String
+ @time_zone : Time::Location = Time::Location.load("GMT")
+
+ accessor staff_api : StaffAPI_1
+
+ getter building_id : String { get_building_id.not_nil! }
+
+ def get_building_id : String
+ building_setting = setting?(String, :building_zone_override)
+ return building_setting if building_setting && building_setting.presence
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ end
+
+ getter event_count : UInt64 = 0_u64
+ getter check_ins : UInt64 = 0_u64
+ getter matched_users : UInt64 = 0_u64
+
+ @[Security(Level::Administrator)]
+ def door_event(json : String)
+ logger.debug { "new door event detected: #{json}" }
+ event = Interface::DoorSecurity::DoorEvent.from_json(json)
+ @event_count += 1_u64
+
+ now = Time.local(@time_zone).at_beginning_of_day
+ end_of_day = now.in(@time_zone).at_end_of_day - 2.hours
+ building = building_id
+
+ @booking_types.each do |booking_type|
+ if email = event.user_email.presence
+ staff_user = staff_api.user(email.strip.downcase).get rescue nil
+ if staff_user
+ email = staff_user["email"].as_s
+ @matched_users += 1_u64
+ end
+
+ # find any bookings that user may have
+ bookings = staff_api.query_bookings(now.to_unix, end_of_day.to_unix, zones: {building}, type: booking_type, email: email).get.as_a
+ logger.debug { "found #{bookings.size} of #{booking_type} for #{email}" }
+
+ bookings.each do |booking|
+ if !booking["checked_in"].as_bool?
+ logger.debug { " -- checking in #{booking_type} for #{email}" }
+ @check_ins += 1_u64
+ staff_api.booking_check_in(booking["id"], true, "security-access", instance: booking["instance"]?)
+ else
+ logger.debug { " -- skipping #{booking_type} for #{email} as already checked-in" }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/place/bookings/security_booking_check_in_spec.cr b/drivers/place/bookings/security_booking_check_in_spec.cr
new file mode 100644
index 00000000000..be1d9c7c1f1
--- /dev/null
+++ b/drivers/place/bookings/security_booking_check_in_spec.cr
@@ -0,0 +1,73 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::SecurityBookingCheckin" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ })
+
+ exec(:door_event, {
+ module_id: "mod-123",
+ security_system: "gallagher",
+ door_id: "door 123",
+ timestamp: 0,
+ action: "Granted",
+ user_email: "user@email.com",
+ }.to_json).get
+
+ exec(:event_count).get.should eq 1
+ exec(:check_ins).get.should eq 1
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def user(id : String)
+ raise "unknown user #{id}" unless id == "user@email.com"
+ {
+ email: "user@email.com",
+ login_name: "user@email.com",
+ }
+ end
+
+ def query_bookings(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil,
+ include_checked_out : Bool? = nil,
+ extension_data : JSON::Any? = nil,
+ deleted : Bool? = nil
+ )
+ raise "unexpected bookings query" unless type == "desk" && zones.includes?("zone-building") && email == "user@email.com"
+
+ [{
+ id: 2345,
+ checked_in: false,
+ }]
+ end
+
+ def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil, instance : Int64? = nil)
+ raise "unexpected booking id" unless booking_id == 2345 && state
+ true
+ end
+
+ def zones(q : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0,
+ parent : String? = nil,
+ tags : Array(String) | String? = nil)
+ raise "unexpected tag" unless tags == "building"
+ [{
+ "id" => "zone-building",
+ }]
+ end
+end
diff --git a/drivers/place/bookings_spec.cr b/drivers/place/bookings_spec.cr
new file mode 100644
index 00000000000..30d1b15473f
--- /dev/null
+++ b/drivers/place/bookings_spec.cr
@@ -0,0 +1,292 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/sensor"
+
+DriverSpecs.mock_driver "Place::Bookings" do
+ system({
+ Calendar: {CalendarMock},
+ Sensor: {SensorMock},
+ })
+
+ sleep 200.milliseconds
+
+ # Check it calculates state properly
+ exec(:poll_events).get
+
+ sleep 200.milliseconds
+
+ bookings = status[:bookings].as_a
+ bookings.size.should eq(4)
+
+ sleep 200.milliseconds
+
+ status[:booked].should eq(true)
+ status[:in_use].should eq(false)
+ status[:pending].should eq(true)
+ status[:current_pending].should eq(true)
+ status[:next_pending].should eq(false)
+ status[:status].should eq("pending")
+
+ current_start = bookings[0]["event_start"]
+
+ # Start a meeting
+ exec(:start_meeting, current_start).get
+ sleep 200.milliseconds
+
+ bookings = status[:bookings].as_a
+ bookings.size.should eq(4)
+ status[:booked].should eq(true)
+ status[:in_use].should eq(true)
+ status[:pending].should eq(false)
+ status[:current_pending].should eq(false)
+ status[:next_pending].should eq(false)
+ status[:status].should eq("busy")
+
+ # End a meeting
+ exec(:end_meeting, current_start).get
+ sleep 2200.milliseconds # polls after 2 seconds
+
+ bookings = status[:bookings].as_a
+ bookings.size.should eq(3)
+
+ status[:booked].should eq(false)
+ status[:in_use].should eq(false)
+ status[:pending].should eq(false)
+ status[:current_pending].should eq(false)
+ status[:next_pending].should eq(false)
+ status[:status].should eq("free")
+
+ status[:people_count].should eq(12.0)
+ status[:sensor_name].should eq("Mock People Count")
+ status[:presence].should eq(true)
+end
+
+# :nodoc:
+class SensorMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Sensor
+
+ alias Interface = PlaceOS::Driver::Interface
+
+ def on_load
+ self[:people_count] = 12.0
+ end
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ if type == "people_count"
+ [Interface::Sensor::Detail.new(
+ type: Interface::Sensor::SensorType::PeopleCount,
+ value: 12.0,
+ last_seen: Time.utc.to_unix,
+ mac: "mock-people-count",
+ id: nil,
+ name: "Mock People Count",
+ module_id: "mod-Sensor/1",
+ binding: "people_count"
+ )]
+ else
+ [] of Interface::Sensor::Detail
+ end
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ nil
+ end
+end
+
+# :nodoc:
+class CalendarMock < DriverSpecs::MockDriver
+ def on_load
+ self[:checked_calendar] = nil
+ self[:deleted_event] = nil
+ end
+
+ def decline_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil)
+ self[:deleted_event] = {calendar_id, event_id}
+ @events = @events.reject { |event| event["id"] == event_id }
+ nil
+ end
+
+ def delete_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil)
+ self[:deleted_event] = {calendar_id, event_id}
+ @events = @events.reject { |event| event["id"] == event_id }
+ nil
+ end
+
+ def list_events(
+ calendar_id : String,
+ period_start : Int64,
+ period_end : Int64,
+ time_zone : String? = nil,
+ user_id : String? = nil,
+ include_cancelled : Bool = false
+ )
+ self[:checked_calendar] = calendar_id
+ @events
+ end
+
+ @events : Array(Hash(String, Array(Hash(String, String)) | Bool | Int64 | String | Array(Nil))) = [
+ {
+ "event_start" => 10.minutes.ago.to_unix,
+ "event_end" => 20.minutes.from_now.to_unix,
+ "id" => "2hg6c13j9ko8hiugmuj8n3jtap_20200804T000000Z",
+ "host" => "jeremy@place.nology",
+ "title" => "A Standup",
+ "description" => "",
+ "attendees" => [
+ {
+ "name" => "alexandre@place.nology",
+ "email" => "alexandre@place.nology",
+ },
+ {
+ "name" => "candy@place.nology",
+ "email" => "candy@place.nology",
+ },
+ {
+ "name" => "viv@place.nology",
+ "email" => "viv@place.nology",
+ },
+ {
+ "name" => "steve@place.nology",
+ "email" => "steve@place.nology",
+ },
+ {
+ "name" => "jeremy@place.nology",
+ "email" => "jeremy@place.nology",
+ },
+ ],
+ "private" => false,
+ "recurring" => false,
+ "all_day" => false,
+ "timezone" => "UTC",
+ "attachments" => [] of Nil,
+ },
+ {
+ "event_start" => 40.minutes.from_now.to_unix,
+ "event_end" => 1.hour.from_now.to_unix,
+ "id" => "2hg6c13j9ko8hiugmuj8n3jtap_20200806T000000Z",
+ "host" => "jeremy@place.nology",
+ "title" => "A Standup",
+ "description" => "",
+ "attendees" => [
+ {
+ "name" => "alexandre@place.nology",
+ "email" => "alexandre@place.nology",
+ },
+ {
+ "name" => "candy@place.nology",
+ "email" => "candy@place.nology",
+ },
+ {
+ "name" => "viv@place.nology",
+ "email" => "viv@place.nology",
+ },
+ {
+ "name" => "steve@place.nology",
+ "email" => "steve@place.nology",
+ },
+ {
+ "name" => "jeremy@place.nology",
+ "email" => "jeremy@place.nology",
+ },
+ ],
+ "private" => false,
+ "recurring" => false,
+ "all_day" => false,
+ "timezone" => "UTC",
+ "attachments" => [] of Nil,
+ },
+ {
+ "event_start" => 4.hour.from_now.to_unix,
+ "event_end" => 5.hour.from_now.to_unix,
+ "id" => "0e1f5n6a898n85eo9gsj169kh1_20200806T010000Z",
+ "host" => "shreya@external.com",
+ "title" => "Place weekly catchup",
+ "description" => "",
+ "attendees" => [
+ {
+ "name" => "Michael",
+ "email" => "michael@external.com",
+ },
+ {
+ "name" => "Glenn",
+ "email" => "glenn@external.com",
+ },
+ {
+ "name" => "Shreya",
+ "email" => "shreya@external.com",
+ },
+ {
+ "name" => "jeremy@place.nology",
+ "email" => "jeremy@place.nology",
+ },
+ {
+ "name" => "Lisa",
+ "email" => "lisa@external.com",
+ },
+ {
+ "name" => "Sheshank",
+ "email" => "sheshank@external.com",
+ },
+ {
+ "name" => "steve@place.nology",
+ "email" => "steve@place.nology",
+ },
+ {
+ "name" => "Zinoca",
+ "email" => "zain@external.com",
+ },
+ {
+ "name" => "Aymie",
+ "email" => "aymie@external.com",
+ },
+ ],
+ "private" => false,
+ "recurring" => false,
+ "all_day" => false,
+ "timezone" => "UTC",
+ "attachments" => [] of Nil,
+ },
+ {
+ "event_start" => 10.hours.from_now.to_unix,
+ "event_end" => 11.hours.from_now.to_unix,
+ "id" => "d8n8u5a5u8j45jgm5248ir49qs_20200806T010000Z",
+ "host" => "jeremy@place.nology",
+ "title" => "PlaceOS Standup",
+ "description" => "Regular Standup to discuss Engine2 Development and Product Requirements.",
+ "attendees" => [
+ {
+ "name" => "caspian@place.nology",
+ "email" => "caspian@place.nology",
+ },
+ {
+ "name" => "viv@place.nology",
+ "email" => "viv@place.nology",
+ },
+ {
+ "name" => "Kim",
+ "email" => "kim@place.nology",
+ },
+ {
+ "name" => "William",
+ "email" => "w.le@place.nology",
+ },
+ {
+ "name" => "jeremy@place.nology",
+ "email" => "jeremy@place.nology",
+ },
+ {
+ "name" => "Shane",
+ "email" => "shane@place.nology",
+ },
+ {
+ "name" => "steve@place.nology",
+ "email" => "steve@place.nology",
+ },
+ ],
+ "private" => false,
+ "recurring" => false,
+ "all_day" => false,
+ "timezone" => "UTC",
+ "attachments" => [] of Nil,
+ },
+ ]
+end
diff --git a/drivers/place/calendar.cr b/drivers/place/calendar.cr
new file mode 100644
index 00000000000..145b0698e08
--- /dev/null
+++ b/drivers/place/calendar.cr
@@ -0,0 +1,39 @@
+require "./calendar_common"
+
+class Place::Calendar < PlaceOS::Driver
+ include Place::CalendarCommon
+
+ # update to trigger build.
+ descriptive_name "PlaceOS Calendar"
+ generic_name :Calendar
+
+ uri_base "https://staff.api.com"
+
+ default_settings({
+ calendar_service_account: "service_account@email.address",
+ calendar_config: {
+ scopes: ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/admin.directory.user.readonly"],
+ domain: "primary.domain.com",
+ sub: "default.service.account@google.com",
+ issuer: "placeos@organisation.iam.gserviceaccount.com",
+ signing_key: "PEM encoded private key",
+ },
+ calendar_config_office: {
+ _note_: "rename to 'calendar_config' for use",
+ tenant: "",
+ client_id: "",
+ client_secret: "",
+ conference_type: nil, # This can be set to "teamsForBusiness" to add a Teams link to EVERY created Event
+ },
+
+ # only applies to google
+ rate_limit: 5,
+
+ # defaults to calendar_service_account if not configured
+ mailer_from: "email_or_office_userPrincipalName",
+ email_templates: {visitor: {checkin: {
+ subject: "%{name} has arrived",
+ text: "for your meeting at %{time}",
+ }}},
+ })
+end
diff --git a/drivers/place/calendar_common.cr b/drivers/place/calendar_common.cr
new file mode 100644
index 00000000000..a30f10c8490
--- /dev/null
+++ b/drivers/place/calendar_common.cr
@@ -0,0 +1,448 @@
+require "placeos-driver"
+require "place_calendar"
+require "placeos-driver/interface/mailer"
+require "qr-code"
+require "qr-code/export/png"
+
+module Place::CalendarCommon
+ include PlaceOS::Driver::Interface::Mailer
+
+ alias GoogleParams = NamedTuple(
+ scopes: String | Array(String),
+ domain: String,
+ sub: String,
+ issuer: String,
+ signing_key: String,
+ )
+
+ alias OfficeParams = NamedTuple(
+ tenant: String,
+ client_id: String,
+ client_secret: String,
+ conference_type: String | Nil,
+ )
+
+ macro included
+ @client : ::PlaceCalendar::Client? = nil
+ @service_account : String? = nil
+ @rate_limit : Int32 = 10
+ @channel : Channel(Nil) = Channel(Nil).new(9)
+ @in_flight : Channel(Nil) = Channel(Nil).new(10)
+
+ @queue_lock : Mutex = Mutex.new
+ @queue_size = 0
+ @flight_size = 0
+ @wait_time : Time::Span = 300.milliseconds
+
+ @mailer_from : String? = nil
+ end
+
+ def on_unload
+ @in_flight.close
+ @channel.close
+ end
+
+ def on_update
+ if proxy_config = setting?(NamedTuple(host: String, port: Int32, auth: NamedTuple(username: String, password: String)?), :proxy)
+ ConnectProxy.proxy_uri = "http://#{proxy_config[:host]}:#{proxy_config[:port]}"
+ if proxy_auth = proxy_config[:auth]
+ ConnectProxy.username = proxy_auth[:username]
+ ConnectProxy.password = proxy_auth[:password]
+ end
+ end
+
+ ConnectProxy.verify_tls = !!setting?(Bool, :proxy_verify_tls)
+ ConnectProxy.disable_crl_checks = !!setting?(Bool, :proxy_disable_crl)
+
+ @service_account = setting?(String, :calendar_service_account).presence
+ @rate_limit = setting?(Int32, :rate_limit) || 10
+ @wait_time = 1.second / @rate_limit
+
+ @mailer_from = setting?(String, :mailer_from).presence || @service_account
+ @templates = setting?(Templates, :email_templates) || Templates.new
+
+ @in_flight.close
+ @channel.close
+
+ # Work around crystal limitation of splatting a union
+ @client = begin
+ config = setting(GoogleParams, :calendar_config)
+ cli = ::PlaceCalendar::Client.new(**config)
+
+ # only google uses the rate limiter
+ @channel = Channel(Nil).new(9)
+ @in_flight = Channel(Nil).new(10)
+ spawn { rate_limiter }
+ cli
+ rescue
+ config = setting(OfficeParams, :calendar_config)
+ ::PlaceCalendar::Client.new(**config)
+ end
+ end
+
+ protected def client
+ # office365 execute queries against the users mailbox and hence doesn't require rate limiting
+ if @client.not_nil!.client_id == :office365
+ return yield(@client.not_nil!)
+ end
+
+ if (@wait_time * @queue_size) > 90.seconds
+ raise "wait time would be exceeded for API request, #{@queue_size} requests already queued"
+ end
+
+ @queue_lock.synchronize { @queue_size += 1 }
+ @channel.receive
+ @in_flight.send(nil)
+
+ begin
+ @queue_lock.synchronize { @queue_size -= 1; @flight_size += 1 }
+ result = yield @client.not_nil!
+ result
+ ensure
+ @in_flight.receive
+ @queue_lock.synchronize { @flight_size -= 1 }
+ end
+ end
+
+ def queue_size
+ @queue_size
+ end
+
+ def in_flight_size
+ @flight_size
+ end
+
+ def generate_svg_qrcode(text : String) : String
+ QRCode.new(text).as_svg
+ end
+
+ def generate_png_qrcode(text : String, size : Int32 = 128) : String
+ Base64.strict_encode QRCode.new(text).as_png(size: size)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ sender = case from
+ in String
+ from
+ in Array(String)
+ from.first? || @mailer_from.not_nil!
+ in Nil
+ @mailer_from.not_nil!
+ end
+
+ logger.debug { "an email was sent from: #{sender}, to: #{to}" }
+
+ client &.calendar.send_mail(
+ sender,
+ to,
+ subject,
+ message_plaintext,
+ message_html,
+ resource_attachments,
+ attachments,
+ cc,
+ bcc
+ )
+ end
+
+ @[PlaceOS::Driver::Security(Level::Administrator)]
+ def access_token(user_id : String? = nil)
+ logger.info { "access token requested #{user_id}" }
+ client &.access_token(user_id)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def get_groups(user_id : String)
+ logger.debug { "getting group membership for user: #{user_id}" }
+ client &.get_groups(user_id)
+ end
+
+ class ::PlaceCalendar::Member
+ property next_page : String? = nil
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def get_members(
+ group_id : String,
+ next_page : String? = nil
+ )
+ logger.debug { "listing members of group: #{group_id}" }
+
+ if group_id.includes?('@')
+ client do |_client|
+ if _client.client_id == :office365
+ logger.warn { "inefficient group members request. Recommended obtaining group.id versus using email" }
+ end
+ end
+ end
+ members = client &.get_members(group_id, next_link: next_page)
+
+ if member = members.first?
+ member.next_page = member.next_link
+ end
+ members
+ end
+
+ class ::PlaceCalendar::User
+ property next_page : String? = nil
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def list_users(
+ query : String? = nil,
+ limit : Int32? = nil,
+ filter : String? = nil,
+ next_page : String? = nil
+ )
+ logger.debug { "listing user details, query #{query || filter}, limit #{limit} (next: #{!!next_page})" }
+ users = client &.list_users(query, limit, filter: filter, next_link: next_page)
+ # next link is not returned to reduce payload size and used
+ # in the staff API for setting a header
+ if user = users.first?
+ user.next_page = user.next_link
+ end
+ users
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def get_user(user_id : String)
+ logger.debug { "getting user details for #{user_id}" }
+ client &.get_user_by_email(user_id)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def list_calendars(user_id : String)
+ logger.debug { "listing calendars for #{user_id}" }
+ client &.list_calendars(user_id)
+ end
+
+ # NOTE:: GraphAPI Only!
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def get_user_manager(user_id : String)
+ logger.debug { "getting manager details for #{user_id}, note: graphAPI only" }
+ client do |_client|
+ if _client.client_id == :office365
+ _client.calendar.as(PlaceCalendar::Office365).client.get_user_manager(user_id).to_place_calendar
+ end
+ end
+ end
+
+ # NOTE:: GraphAPI Only! - here for use with configuration
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def list_groups(
+ query : String? = nil,
+ filter : String? = nil
+ )
+ logger.debug { "listing groups, filtering by #{filter || query}, note: graphAPI only" }
+ client do |_client|
+ if _client.client_id == :office365
+ _client.calendar.as(PlaceCalendar::Office365).client.list_groups(query, filter: filter).value.map(&.to_place_group)
+ end
+ end
+ end
+
+ # NOTE:: GraphAPI Only!
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def get_group(group_id : String)
+ logger.debug { "getting group #{group_id}, note: graphAPI only" }
+ client do |_client|
+ if _client.client_id == :office365
+ office_client = _client.calendar.as(PlaceCalendar::Office365).client
+ if group_id.includes?('@')
+ group = office_client.list_groups(filter: "mail eq '#{group_id}'").value.first?
+ return group.to_place_group if group
+ end
+ office_client.get_group(group_id).to_place_group
+ end
+ end
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def list_events(calendar_id : String, period_start : Int64, period_end : Int64, time_zone : String? = nil, user_id : String? = nil, include_cancelled : Bool = false, ical_uid : String? = nil)
+ location = time_zone ? Time::Location.load(time_zone) : Time::Location.local
+ period_start = Time.unix(period_start).in location
+ period_end = Time.unix(period_end).in location
+ user_id = user_id || @service_account.presence || calendar_id
+
+ logger.debug { "listing events for #{calendar_id}" }
+
+ _client = @client.not_nil!
+ events = if _client.client_id == :google
+ _client.calendar.as(PlaceCalendar::Google).list_events(user_id, calendar_id,
+ period_start: period_start,
+ period_end: period_end,
+ showDeleted: include_cancelled,
+ ical_uid: ical_uid,
+ # https://cloud.google.com/apis/docs/system-parameters (avoid hitting request quotas in common driver usage)
+ quotaUser: calendar_id[0..39]
+ )
+ else
+ _client.list_events(user_id, calendar_id,
+ period_start: period_start,
+ period_end: period_end,
+ showDeleted: include_cancelled,
+ ical_uid: ical_uid
+ )
+ end
+ # FFS MS doesn't always filter for icaluid correctly
+ events = events.select { |e| e.ical_uid == ical_uid } if ical_uid
+ events
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def get_event(calendar_id : String, event_id : String, user_id : String? = nil)
+ logger.debug { "fetching event #{event_id} on #{calendar_id}" }
+ user_id = user_id || @service_account.presence || calendar_id
+ client &.get_event(user_id, id: event_id, calendar_id: calendar_id)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def decline_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil)
+ user_id = user_id || @service_account.presence || calendar_id
+
+ logger.debug { "declining event #{event_id} on #{calendar_id}" }
+
+ client &.decline_event(user_id, event_id, calendar_id: calendar_id, notify: notify, comment: comment)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def delete_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil)
+ user_id = user_id || @service_account.presence || calendar_id
+
+ logger.debug { "deleting event #{event_id} on #{calendar_id}" }
+
+ client &.delete_event(user_id, event_id, calendar_id: calendar_id, notify: notify)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def accept_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil)
+ user_id = user_id || @service_account.presence || calendar_id
+
+ logger.debug { "accepting event #{event_id} on #{calendar_id}" }
+
+ client &.accept_event(user_id, event_id, calendar_id: calendar_id, notify: notify, comment: comment)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def create_event(
+ title : String,
+ event_start : Int64,
+ event_end : Int64? = nil,
+ description : String = "",
+ attendees : Array(::PlaceCalendar::Event::Attendee) = [] of ::PlaceCalendar::Event::Attendee,
+ location : String? = nil,
+ timezone : String? = nil,
+ user_id : String? = nil,
+ calendar_id : String? = nil,
+ online_meeting_id : String? = nil,
+ online_meeting_provider : String? = nil,
+ online_meeting_url : String? = nil,
+ online_meeting_sip : String? = nil,
+ online_meeting_phones : Array(String)? = nil,
+ online_meeting_pin : String? = nil
+ )
+ user_id = (user_id || @service_account.presence || calendar_id).not_nil!
+ calendar_id = calendar_id || user_id
+
+ logger.debug { "creating event on #{calendar_id}" }
+
+ event = ::PlaceCalendar::Event.new(
+ host: calendar_id,
+ title: title,
+ body: description,
+ location: location,
+ timezone: timezone,
+ attendees: attendees,
+ online_meeting_id: online_meeting_id,
+ online_meeting_url: online_meeting_url,
+ online_meeting_sip: online_meeting_sip,
+ online_meeting_pin: online_meeting_pin,
+ online_meeting_phones: online_meeting_phones,
+ online_meeting_provider: online_meeting_provider,
+ )
+
+ tz = Time::Location.load(timezone) if timezone
+ event.event_start = timezone ? Time.unix(event_start).in tz.not_nil! : Time.unix(event_start)
+ event.event_end = timezone ? Time.unix(event_end).in tz.not_nil! : Time.unix(event_end) if event_end
+
+ event.all_day = true unless event_end
+
+ client &.create_event(user_id, event, calendar_id)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def update_event(event : ::PlaceCalendar::Event, user_id : String? = nil, calendar_id : String? = nil)
+ user_id = (user_id || @service_account.presence || calendar_id).not_nil!
+ calendar_id = calendar_id || user_id
+
+ logger.debug { "updating event #{event.id} on #{event.host}" }
+
+ client &.update_event(user_id: user_id, event: event, calendar_id: calendar_id)
+ end
+
+ # returns: google or office365
+ def calendar_service_name
+ @client.not_nil!.client_id
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def create_notifier(resource : String, notification_url : String, expiration_time : Int64, client_secret : String? = nil, lifecycle_notification_url : String? = nil) : ::PlaceCalendar::Subscription
+ expires = Time.unix expiration_time
+ client &.create_notifier(resource, notification_url, expires, client_secret, lifecycle_notification_url: lifecycle_notification_url)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def renew_notifier(subscription : ::PlaceCalendar::Subscription, new_expiration_time : Int64) : ::PlaceCalendar::Subscription
+ expires = Time.unix new_expiration_time
+ client &.renew_notifier(subscription, expires)
+ end
+
+ # NOTE:: GraphAPI Only!
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def reauthorize_notifier(subscription : ::PlaceCalendar::Subscription, new_expiration_time : Int64? = nil) : ::PlaceCalendar::Subscription
+ expires = new_expiration_time ? Time.unix(new_expiration_time) : nil
+ client &.reauthorize_notifier(subscription, expires)
+ end
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def delete_notifier(subscription : ::PlaceCalendar::Subscription) : Nil
+ client &.delete_notifier(subscription)
+ end
+
+ protected def rate_limiter
+ in_flight = @in_flight
+ channel = @channel
+ begin
+ loop do
+ break if channel.closed? || in_flight.closed?
+ begin
+ # ensure there is an available slot before allowing more requests
+ in_flight.send(nil)
+ in_flight.receive
+
+ # allow more requests through
+ channel.send(nil)
+ rescue error
+ logger.error(exception: error) { "issue with rate limiter" }
+ ensure
+ sleep @wait_time
+ end
+ end
+ rescue
+ # Possible error with logging exception, restart rate limiter silently
+ spawn { rate_limiter } unless terminated? || channel.closed? || in_flight.closed?
+ end
+ end
+end
diff --git a/drivers/place/calendar_delegated.cr b/drivers/place/calendar_delegated.cr
new file mode 100644
index 00000000000..59566589be5
--- /dev/null
+++ b/drivers/place/calendar_delegated.cr
@@ -0,0 +1,288 @@
+require "placeos-driver"
+require "place_calendar"
+require "rate_limiter"
+
+class Place::CalendarDelegated < PlaceOS::Driver
+ descriptive_name "PlaceOS Calendar (delegating to Staff API)"
+ generic_name :Calendar
+
+ uri_base "https://staff.app.api.com"
+
+ default_settings({
+ # PlaceOS X-API-key
+ api_key: "key-here",
+ rate_limit: 5,
+
+ jwt_private_key: <<-STRING
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEAt01C9NBQrA6Y7wyIZtsyur191SwSL3MjR58RIjZ5SEbSyzMG
+3r9v12qka4UtpB2FmON2vwn0fl/7i3Jgh1Xth/s+TqgYXMebdd123wodrbex5pi3
+Q7PbQFT6hhNpnsjBh9SubTf+IeTIFeXUyqtqcDBmEoT5GxU6O+Wuch2GtbfEAmaD
+roy+uyB7P5DxpKLEx8nlVYgpx5g2mx2LufHvykVnx4bFzLezU93SIEW6yjPwUmv9
+R+wDM/AOg60dIf3hCh1DO+h22aKT8D8ysuFodpLTKCToI/AbK4IYOOgyGHZ7xizX
+HYXZdsqX5/zBFXu/NOVrSd/QBYYuCxbqe6tz4wIDAQABAoIBAQCEIRxXrmXIcMlK
+36TfR7h8paUz6Y2+SGew8/d8yvmH4Q2HzeNw41vyUvvsSVbKC0HHIIfzU3C7O+Lt
+9OeiBo2vTKrwNflBv9zPDHHoerlEBLsnNwQ7uEUeTWM9DHdBLwNaLzQApLD6q5iT
+OFW4NfIGpsydIt8R565PiNPDjIcTKwhbVdlsSbI87cLkQ9UuYIMRkvXSD1Q2cg3I
+VsC0SpE4zmfTe7YTZQ5yTxtsoLKPBXrSxhhGuhdayeN7A4YHFYVD39RuQ6/T2w2a
+W/0UaGOk8XWgydDpD5w9wiBdH2I4i6D35IynCcodc5JvmTajzJT+xj6aGjjvMSyq
+q5ZdwJ4JAoGBAOPdZgjbOCf3ONUoiZ5Qw/a4b4xJgMokgqZ5QGBF5GqV1Xsphmk1
+apYmgC7fmab/EOdycrQMS0am2FmtwX1f7gYgJoyWtK4TVkUc5rf+aoWi0ieIsegv
+rjhuiIAc12+vVIbegRgnq8mOI5icrwm6OkwdqHkwTt6VRYdJGEmu67n/AoGBAM3v
+RAd5uIjVwVDLXqaOpvF3pxWfl+cf6PJtAE5y+nbabeTmrw//fJMank3o7qCXkFZR
+F0OJ2tmENwV+LPM8Gy3So8YP2nkOz4bryaGrxQ4eMA+K9+RiACVaKv+tNx/NbyMS
+e9gg504u0cwa60XjM5KUKrmT3RXpY4YIfUPZ1J4dAoGAB6jalDOiSJ2j2G57acn3
+PGTowwN5g9IEXko3IsVWr0qIGZLExOaZxaBXsLutc5KhY9ZSCsFbCm3zWdhgZ7GA
+083i3dj3C970iHA3RToVJJbbj56ltFNd/OGiTwQpLcTsB3iVSFWVDbpsceXacG5F
+JWfd0O0RyaOk6a5IVbm+jMsCgYBglxAOfY4LSE8y6SCM+K3e5iNNZhymgHYPdwbE
+xPMrWgpfab/Evi2dBcgofM+oLU663bAOspMeoP/5qJPGxnNtC7ZbSMZNL6AxBVj+
+ZoW3uHsMXz8kNL8ixecTIxiO5xlwltPVrKExL46hsCKYFhfzcWGUx4DULTLMBCFU
++M/cFQKBgQC+Ite962yJOnE+bjtSReOrvR9+I+YNGqt7vyRa2nGFxL7ZNIqHss5T
+VjaMgjzVJqqYozNT/74pE/b9UjYyMzO/EhrjUmcwriMMan/vTbYoBMYWvGoy536r
+4n455vizig2c4/sxU5yu9AF9Dv+qNsGCx2e9uUOTDUlHM9NXwxU9rQ==
+-----END RSA PRIVATE KEY-----
+STRING
+ })
+
+ @api_key : String = ""
+ @host : String = ""
+ @jwt_private_key : String = ""
+
+ private getter! limiter : RateLimiter::Limiter
+
+ def on_update
+ rate_limit = setting?(Float64, :rate_limit) || 3.0
+ @limiter = RateLimiter.new(rate: rate_limit, max_burst: rate_limit.to_i)
+
+ @api_key = api_key = setting(String, :api_key)
+ transport.before_request do |request|
+ request.headers["X-API-Key"] = api_key unless request.headers["Authorization"]?
+ end
+
+ @host = URI.parse(config.uri.not_nil!).host.not_nil!
+ @debug_payload = setting?(Bool, :debug_payload) || false
+ @jwt_private_key = setting?(String, :jwt_private_key) || ""
+ end
+
+ protected def client(skip_limiter)
+ limiter.get!(max_wait: 90.seconds) unless skip_limiter
+ self
+ end
+
+ protected def process(response)
+ raise "request failed with #{response.status_code} (#{response.body})" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ @[Security(Level::Support)]
+ def get_groups(user_id : String, act_as_user : String? = nil)
+ logger.debug { "getting group membership for user: #{user_id}" }
+ process client(act_as_user).get("/api/staff/v1/people/#{user_id}/groups", headers: act_as(act_as_user))
+ end
+
+ @[Security(Level::Support)]
+ def get_members(group_id : String, act_as_user : String? = nil)
+ logger.debug { "listing members of group: #{group_id}" }
+ process client(act_as_user).get("/api/staff/v1/groups/#{group_id}/members", headers: act_as(act_as_user))
+ end
+
+ @[Security(Level::Support)]
+ def list_users(query : String? = nil, limit : Int32? = nil, act_as_user : String? = nil)
+ logger.debug { "listing user details, query #{query}" }
+ params = query ? {"q" => query} : {} of String => String?
+ process client(act_as_user).get("/api/staff/v1/people", params: params, headers: act_as(act_as_user))
+ end
+
+ @[Security(Level::Support)]
+ def get_user(user_id : String, act_as_user : String? = nil)
+ logger.debug { "getting user details for #{user_id}" }
+ process client(act_as_user).get("/api/staff/v1/people/#{user_id}", headers: act_as(act_as_user))
+ end
+
+ @[Security(Level::Support)]
+ def list_calendars(user_id : String, act_as_user : String? = nil)
+ logger.debug { "listing calendars for #{user_id}" }
+ process client(act_as_user).get("/api/staff/v1/people/#{user_id}/calendars", headers: act_as(act_as_user))
+ end
+
+ # NOTE:: GraphAPI Only!
+ @[Security(Level::Support)]
+ def get_user_manager(user_id : String, act_as_user : String? = nil)
+ logger.debug { "getting manager details for #{user_id}, note: graphAPI only" }
+ process client(act_as_user).get("/api/staff/v1/people/#{user_id}/manager", headers: act_as(act_as_user))
+ end
+
+ # NOTE:: GraphAPI Only! - here for use with configuration
+ @[Security(Level::Support)]
+ def list_groups(query : String? = nil, act_as_user : String? = nil)
+ logger.debug { "listing groups, filtering by #{query}, note: graphAPI only" }
+ params = query ? {"q" => query} : {} of String => String?
+ process client(act_as_user).get("/api/staff/v1/groups", params: params, headers: act_as(act_as_user))
+ end
+
+ # NOTE:: GraphAPI Only!
+ @[Security(Level::Support)]
+ def get_group(group_id : String, act_as_user : String? = nil)
+ logger.debug { "getting group #{group_id}, note: graphAPI only" }
+ process client(act_as_user).get("/api/staff/v1/groups/#{group_id}", headers: act_as(act_as_user))
+ end
+
+ protected def check_if_resource(email)
+ # attempt get the system the requested email is in
+ # assuming we are using this driver for resource calendars
+ email = email.downcase
+ response = get("/api/engine/v2/systems/", params: {
+ "email" => email,
+ "limit" => "1000",
+ })
+ if response.success?
+ result = Array(NamedTuple(id: String, email: String?)).from_json(response.body)
+ result.find { |sys| sys[:email].try(&.downcase) == email }.try &.[](:id)
+ end
+ end
+
+ @[Security(Level::Support)]
+ def list_events(
+ calendar_id : String,
+ period_start : Int64,
+ period_end : Int64,
+ time_zone : String? = nil,
+ user_id : String? = nil,
+ include_cancelled : Bool = false,
+ act_as_user : String? = nil
+ )
+ logger.debug { "listing events for #{calendar_id}" }
+
+ # Query the calendar
+ if system_id = check_if_resource(calendar_id)
+ params = {
+ "system_ids" => system_id,
+ }
+ else
+ params = {
+ "calendars" => calendar_id,
+ }
+ end
+
+ params["period_start"] = period_start.to_s
+ params["period_end"] = period_end.to_s
+ params["include_cancelled"] = "true" if include_cancelled
+ process client(act_as_user).get("/api/staff/v1/events", params: params, headers: act_as(act_as_user))
+ end
+
+ @[Security(Level::Support)]
+ def delete_event(
+ calendar_id : String,
+ event_id : String,
+ user_id : String? = nil,
+ notify : Bool = false,
+ act_as_user : String? = nil
+ )
+ logger.debug { "deleting event #{event_id} on #{calendar_id}" }
+
+ # Query the calendar
+ if system_id = check_if_resource(calendar_id)
+ params = {
+ "system_ids" => system_id,
+ }
+ else
+ params = {
+ "calendars" => calendar_id,
+ }
+ end
+
+ if notify
+ begin
+ process client(act_as_user).post("/api/staff/v1/events/#{event_id}/decline", params: params, headers: act_as(act_as_user))
+ rescue
+ process client(act_as_user).delete("/api/staff/v1/events/#{event_id}", params: params, headers: act_as(act_as_user))
+ end
+ else
+ params["notify"] = "false"
+ process client(act_as_user).delete("/api/staff/v1/events/#{event_id}", params: params, headers: act_as(act_as_user))
+ end
+ end
+
+ @[Security(Level::Support)]
+ def create_event(
+ title : String,
+ event_start : Int64,
+ event_end : Int64? = nil,
+ description : String = "",
+ attendees : Array(PlaceCalendar::Event::Attendee) = [] of PlaceCalendar::Event::Attendee,
+ location : String? = nil,
+ timezone : String? = nil,
+ user_id : String? = nil,
+ calendar_id : String? = nil,
+ online_meeting_id : String? = nil,
+ online_meeting_provider : String? = nil,
+ online_meeting_url : String? = nil,
+ online_meeting_sip : String? = nil,
+ online_meeting_phones : Array(String)? = nil,
+ online_meeting_pin : String? = nil,
+ act_as_user : String? = nil
+ )
+ calendar_id = calendar_id || user_id
+ logger.debug { "creating event on #{calendar_id}" }
+
+ event = PlaceCalendar::Event.new(
+ host: calendar_id,
+ title: title,
+ body: description,
+ location: location,
+ timezone: timezone,
+ attendees: attendees,
+ online_meeting_id: online_meeting_id,
+ online_meeting_url: online_meeting_url,
+ online_meeting_sip: online_meeting_sip,
+ online_meeting_pin: online_meeting_pin,
+ online_meeting_phones: online_meeting_phones,
+ online_meeting_provider: online_meeting_provider,
+ )
+
+ tz = Time::Location.load(timezone) if timezone
+ event.event_start = timezone ? Time.unix(event_start).in tz.not_nil! : Time.unix(event_start)
+ event.event_end = timezone ? Time.unix(event_end).in tz.not_nil! : Time.unix(event_end) if event_end
+ event.all_day = true unless event_end
+
+ process client(act_as_user).post("/api/staff/v1/events", body: event.to_json, headers: act_as(act_as_user))
+ end
+
+ struct User
+ include JSON::Serializable
+
+ getter name : String
+ getter email : String
+ getter id : String
+ end
+
+ protected def act_as(user_id : String?)
+ return ::HTTP::Headers.new unless user_id
+ return ::HTTP::Headers.new if @jwt_private_key.empty?
+
+ response = get("/api/engine/v2/users/#{user_id}")
+ raise "error fetching user details: #{response.status} (response.status_code)\n#{response.body}" unless response.success?
+ user = User.from_json(response.body)
+
+ logger.debug { "generating JWT for #{user.email}" }
+
+ payload = {
+ iss: "POS",
+ iat: 5.minutes.ago.to_unix,
+ exp: 10.minutes.from_now.to_unix,
+ jti: UUID.random.to_s,
+ aud: @host,
+ scope: ["public"],
+ sub: user.id,
+ u: {
+ n: user.name,
+ e: user.email,
+ p: 0,
+ r: [] of String,
+ },
+ }
+
+ jwt = JWT.encode(payload, @jwt_private_key, JWT::Algorithm::RS256)
+ HTTP::Headers{"Authorization" => "Bearer #{jwt}"}
+ end
+end
diff --git a/drivers/place/calendar_delegated_spec.cr b/drivers/place/calendar_delegated_spec.cr
new file mode 100644
index 00000000000..aa19c28f26e
--- /dev/null
+++ b/drivers/place/calendar_delegated_spec.cr
@@ -0,0 +1,23 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::CalendarDelegated" do
+ resp = exec(:list_groups)
+
+ expect_http_request do |request, response|
+ headers = request.headers
+ if headers["X-API-Key"]? == "key-here"
+ response.status_code = 200
+ response << %([{
+ "id": "1234",
+ "name": "Some Group"
+ }])
+ else
+ response.status_code = 401
+ end
+ end
+
+ resp.get.should eq(JSON.parse(%([{
+ "id": "1234",
+ "name": "Some Group"
+ }])))
+end
diff --git a/drivers/place/calendar_spec.cr b/drivers/place/calendar_spec.cr
new file mode 100644
index 00000000000..1b667f8b050
--- /dev/null
+++ b/drivers/place/calendar_spec.cr
@@ -0,0 +1,11 @@
+require "placeos-driver/spec"
+
+# this spec isn't implemented as this driver wraps an independently tested library
+# however sometimes it is useful to run tests here.
+#
+# To run specs, add proper credentials to the drivers settings
+# then you can check the responses here
+DriverSpecs.mock_driver "Place::Calendar" do
+ # exec(:get_members, "SalesandMarketing@0cbfs.onmicrosoft.com").get
+ # sleep 1
+end
diff --git a/drivers/place/chat/health_notification_models.cr b/drivers/place/chat/health_notification_models.cr
new file mode 100644
index 00000000000..0cf39ce4d1c
--- /dev/null
+++ b/drivers/place/chat/health_notification_models.cr
@@ -0,0 +1,143 @@
+require "json"
+require "./health_rooms_models"
+
+module Place::Chat
+ # room defaults are at: settings -> notifications
+ # Metadata stored on the user model:
+ # notifications = {
+ # general => NotificationSettings, (defaults)
+ # system_id => NotificationSettings (per-room override)
+ # }
+
+ struct NotifyEventSettings
+ include JSON::Serializable
+
+ def initialize
+ end
+
+ getter? enabled : Bool = true
+ getter? browser : Bool = true
+ getter? email : Bool = true
+ getter? sms : Bool = true
+
+ # minutes before notification
+ getter delay : Int32 = 3
+ end
+
+ struct NotificationSettings
+ include JSON::Serializable
+
+ def initialize
+ end
+
+ # only alert if the user selected me
+ getter? chosen_provider : Bool = false
+ getter? enabled : Bool = true
+
+ getter on_enter : NotifyEventSettings = NotifyEventSettings.new
+ # how often should it send notifications
+ getter on_recurr : NotifyEventSettings = NotifyEventSettings.new
+ # do we only notify if the user has been waiting for a certain amount of time
+ getter on_waiting : NotifyEventSettings = NotifyEventSettings.new
+ # settings if the patient has been waiting for a long time
+ getter on_escalate : NotifyEventSettings = NotifyEventSettings.new
+ end
+
+ class RoomMember
+ include JSON::Serializable
+
+ getter? available : Bool
+ property email : String
+ getter id : String
+ property name : String
+ property phone : String?
+ getter roles : Array(String)
+
+ @[JSON::Field(ignore: true)]
+ property! notifications : NotificationSettings
+
+ def coordinator?
+ roles.includes? "coordinator"
+ end
+
+ def clinician?
+ roles.includes? "clinician"
+ end
+
+ def admin?
+ roles.includes? "admin"
+ end
+ end
+
+ struct OpeningHours
+ def initialize(opening_times : Tuple(String, String, Bool))
+ @opens = parse_time opening_times[0]
+ @closes = parse_time opening_times[1]
+ @enabled = opening_times[2]
+ end
+
+ protected def parse_time(time : String)
+ hours, minutes = time.split(':').map(&.strip)
+ hours.to_i.hours + minutes.to_i.minutes
+ end
+
+ getter opens : Time::Span
+ getter closes : Time::Span
+ getter enabled : Bool
+
+ def is_open?(now : Time)
+ return false unless enabled
+ start_of_day = now.at_beginning_of_day
+ opening = start_of_day + opens
+ return false unless now >= opening
+ closing = start_of_day + closes
+ now < closing
+ end
+ end
+
+ # Room metadata => settings key
+ class RoomSettings
+ include JSON::Serializable
+
+ def initialize
+ end
+
+ getter available : Bool = true
+ getter open_24_7 : Bool = true
+ getter notifications : NotificationSettings do
+ NotificationSettings.new
+ end
+
+ property members : Array(RoomMember) = [] of RoomMember
+
+ @[JSON::Field(ignore: true)]
+ property! timezone : Time::Location
+
+ # 0 index == Monday
+ # open time, close time, enabled
+ getter opening_hours : Array(Tuple(String, String, Bool)) do
+ [] of Tuple(String, String, Bool)
+ end
+
+ @[JSON::Field(ignore: true)]
+ getter opening : Hash(Time::DayOfWeek, OpeningHours) do
+ times = {} of Time::DayOfWeek => OpeningHours
+ opening_hours.each_with_index do |times, index|
+ index += 1
+ times[Time::DayOfWeek.from_value(index)] = OpeningHours.new(times)
+ end
+ times
+ end
+
+ def is_open?(timezone : Time::Location)
+ return false unless available
+ return true if open_24_7
+ now = Time.local timezone
+
+ # more efficient version of
+ # opening[now.day_of_week].is_open? now
+ index = now.day_of_week.to_i - 1
+ OpeningHours.new(opening_hours[index]).is_open? now
+ end
+ end
+end
diff --git a/drivers/place/chat/health_rooms.cr b/drivers/place/chat/health_rooms.cr
new file mode 100644
index 00000000000..e9854970145
--- /dev/null
+++ b/drivers/place/chat/health_rooms.cr
@@ -0,0 +1,963 @@
+require "placeos-driver"
+require "placeos-driver/interface/sms"
+require "placeos-driver/interface/mailer"
+require "./health_rooms_models.cr"
+require "./health_notification_models.cr"
+
+class Place::Chat::HealthRooms < PlaceOS::Driver
+ descriptive_name "Health Chat Rooms"
+ generic_name :ChatRoom
+
+ EXAMPLE_SMS_TEMPLATE = "patient %{patient_name} is waiting in %{room_name} for an appointment at %{appointment_time}"
+
+ default_settings({
+ is_spec: true,
+ domain_id: "domain",
+ pool_size: 10,
+ # disconnect time in minutes
+ disconnect_timeout: 3,
+
+ sms_source: "Health",
+ sms_template: EXAMPLE_SMS_TEMPLATE,
+ notify_no_time: "no time specified",
+ })
+
+ def on_load
+ spawn { meeting_state_perform_save }
+ on_update
+ end
+
+ @disconnect_timeout : Time::Span = 3.minutes
+ @sms_source : String? = nil
+ @sms_template : String = ""
+ @notify_no_time : String = "no time specified"
+
+ def on_update
+ @update_mutex.synchronize do
+ if @update_expected > 0
+ @update_expected -= 1
+ logger.debug { "[admin] updating settings..." }
+ return
+ end
+ end
+
+ logger.debug { "[admin] updating settings..." }
+ is_spec = setting?(Bool, :is_spec) || false
+
+ domain = setting(String, :domain_id)
+ @sms_source = setting?(String, :sms_source)
+ @sms_template = setting?(String, :sms_template) || EXAMPLE_SMS_TEMPLATE
+ @notify_no_time = setting?(String, :notify_no_time) || "no time specified"
+ @pool_target_size = setting?(Int32, :pool_size) || 10
+ system_id = config.control_system.not_nil!.id
+
+ @disconnect_timeout = (setting?(Int32, :disconnect_timeout) || 3).minutes
+ @timezone_default = nil
+
+ schedule.clear
+ schedule.every(@disconnect_timeout / 3) { cleanup_disconnected }
+ schedule.every(5.minutes) { pool_cleanup }
+ schedule.in(1.second) { pool_cleanup } unless is_spec
+
+ monitoring = "#{domain}/chat/#{system_id}/guest/entry"
+ self[:monitoring] = monitoring
+
+ subscriptions.clear
+ meeting_state_restore
+ monitor(monitoring) { |_subscription, payload| new_guest(payload) }
+ monitor("#{domain}/chat/user/joined") { |_subscription, payload| user_joined(payload) }
+ monitor("#{domain}/chat/user/exited") do |_subscription, payload|
+ logger.debug { "[signal] user exited: #{payload}" }
+ user_id = NamedTuple(user_id: String).from_json(payload)[:user_id]
+ user_exited(user_id)
+ end
+ monitor("#{domain}/chat/user/left") { |_subscription, payload| user_left(payload) }
+ logger.debug { "[admin] settings update success!" }
+ end
+
+ # ================================================
+ # Save and restore meeting details
+ # ================================================
+
+ # logic to ensure we trigger saving as few times as possible
+ @save_requested : Channel(Nil) = Channel(Nil).new(10)
+ @state_save_mutex : Mutex = Mutex.new
+ @state_save_requested : Int32 = 0
+
+ # use this to trigger a save
+ def meeting_state_request_save
+ @save_requested.send nil
+ end
+
+ # ensures only a single save is ever queued
+ protected def meeting_state_perform_save
+ loop do
+ begin
+ @save_requested.receive
+ @state_save_mutex.synchronize do
+ if @state_save_requested >= 2
+ next
+ else
+ @state_save_requested += 1
+ end
+ end
+ spawn { meeting_state_save }
+ rescue error
+ logger.warn(exception: error) { "[state] performing save" }
+ end
+ break if terminated?
+ end
+ end
+
+ # ensures we ignore updates due to state save
+ @update_mutex : Mutex = Mutex.new
+ @update_expected : Int32 = 0
+
+ # ensures only one state save occurs at any one time
+ @meeting_state_save_mutex : Mutex = Mutex.new
+
+ protected def meeting_state_save
+ @meeting_state_save_mutex.synchronize do
+ begin
+ rooms = uninitialized Hash(SystemId, Array(SessionId))
+ meetings = uninitialized Hash(SessionId, Meeting)
+
+ # grab the data
+ @meeting_mutex.synchronize do
+ @room_mutex.synchronize do
+ rooms = @rooms.dup
+ meetings = @meetings.dup
+ end
+ end
+
+ logger.debug { "[state] performing state save" }
+
+ # save the state
+ @update_mutex.synchronize { @update_expected += 1 }
+ define_setting(:last_known_rooms, rooms)
+ @update_mutex.synchronize { @update_expected += 1 }
+ define_setting(:last_known_meetings, meetings)
+ ensure
+ @state_save_mutex.synchronize { @state_save_requested -= 1 }
+ end
+ end
+ end
+
+ protected def meeting_state_restore
+ restored = false
+ sessions = [] of String
+ @meeting_mutex.synchronize do
+ @room_mutex.synchronize do
+ return unless @meetings.empty?
+
+ if rooms = setting?(Hash(SystemId, Array(SessionId)), :last_known_rooms)
+ if meetings = setting?(Hash(SessionId, Meeting), :last_known_meetings)
+ @rooms = rooms
+ @meetings = meetings
+ sessions = meetings.keys
+ restored = true
+ end
+ end
+ end
+ end
+
+ return unless restored
+
+ logger.warn { "[state] last known meeting state restored" }
+ sessions.each { |session_id| update_meeting_state(session_id) }
+ check_disconnected
+ end
+
+ # ================================================
+ # Expose meeting details via bindings
+ # ================================================
+
+ protected def update_meeting_state(session_id, system_id = nil, old_system_id = nil) : Nil
+ self[session_id] = @meeting_mutex.synchronize { @meetings[session_id]?.try(&.dup) }
+ if old_system_id
+ self[old_system_id] = @room_mutex.synchronize { @rooms[old_system_id]?.try(&.dup) } || [] of String
+ end
+ if system_id
+ self[system_id] = @room_mutex.synchronize { @rooms[system_id]?.try(&.dup) } || [] of String
+ end
+
+ # update the overview of all the meetings
+ # NOTE:: locks must be obtained in the same order as other places
+ # in code to prevent deadlocks / invalid state
+ summary = Array(MeetingSummary).new(@rooms.size)
+ @meeting_mutex.synchronize do
+ @room_mutex.synchronize do
+ time_now = Time.utc.to_unix
+
+ @rooms.each do |sys_id, sessions|
+ total_participants = 0
+ waiting = 0
+ longest_wait = 0_i64
+ number_calls = sessions.size
+
+ sessions.each do |sesh_id|
+ call_details = @meetings[sesh_id]
+ num_participants = call_details.participants.size
+ total_participants += num_participants
+ initiator = call_details.participants[call_details.created_by_user_id]?
+ if num_participants == 1 && initiator && !initiator.contacted
+ waiting += 1
+ waiting_time = call_details.updated_at.to_unix
+ longest_wait = waiting_time if longest_wait == 0_i64 || waiting_time < longest_wait
+ end
+ end
+
+ summary << MeetingSummary.new(sys_id, number_calls, total_participants, waiting, longest_wait)
+ end
+ end
+ end
+
+ meeting_state_request_save
+
+ self[:chat_summary] = {
+ value: summary,
+ ts_hint: "complex",
+ ts_tag_keys: {"pos_system"},
+ measurement: "chat_summary",
+ }
+ end
+
+ # ================================================
+ # CHAT ENTRY SIGNAL
+ # ================================================
+
+ accessor staff_api : StaffAPI_1
+
+ protected def new_guest(payload : String)
+ logger.debug { "[signal] new guest arrived: #{payload}" }
+ room_guest = Hash(String, Participant).from_json payload
+ system_id, guest = room_guest.first
+ begin
+ conference = pool_checkout_conference
+ # guest JWT's are not needed
+ # webex_guest_jwt = video_conference.create_guest_bearer(guest.user_id, guest.name).get.as_s
+
+ register_new_guest(system_id, guest, conference)
+ rescue error
+ logger.error(exception: error) { "[meet] failed to obtain meeting details, kicking guest #{guest.name} (#{guest.user_id})" }
+ staff_api.kick_user(guest.user_id, guest.session_id, "failed to allocate meeting")
+ end
+ end
+
+ protected def register_new_guest(system_id, guest, conference)
+ meeting = Meeting.new(system_id, conference, guest)
+ meeting.timezone = timezone_system(system_id)
+ session_id = meeting.session_id
+ logger.info { "[meet] new guest has entered chat: #{guest.name}, user_id: #{guest.user_id}, session: #{session_id}" }
+
+ # update the session hash
+ @meeting_mutex.synchronize { @meetings[session_id] = meeting }
+
+ # update the room
+ sessions = [] of SessionId
+ @room_mutex.synchronize do
+ sessions = @rooms[system_id]? || sessions
+ sessions << meeting.session_id
+ @rooms[system_id] = sessions
+ end
+
+ # send the meeting details to the user
+ schedule.in(2.seconds) do
+ staff_api.transfer_user(guest.user_id, session_id, {
+ space_id: conference.space_id,
+ guest_pin: conference.guest_pin,
+ })
+ end
+
+ # notify that the user has joined
+ spawn { notify_entry(meeting) }
+
+ # update status
+ update_meeting_state(session_id, system_id)
+ rescue error
+ logger.fatal(exception: error) { "[meet] failure to setup guest conference" }
+
+ # remove the user at from the chat
+ session_id = guest.session_id.not_nil!
+ staff_api.kick_user(guest.user_id, session_id, "failed to allocate meeting")
+
+ # remove the user from the UI
+ meeting_remove_user(guest.user_id, session_id)
+ end
+
+ # chat user id unique => disconnect time
+ @recent_lock = Mutex.new
+ @concurrent_flag = false
+ @recently_disconnected = {} of String => Int64
+
+ # if we missed a signal we want to check twice, to avoid race conditions
+ # user => session_id
+ @missed_connections = {} of String => String
+
+ # in case we missed any signals from the backend (i.e. service crash)
+ # and the users have not re-connected
+ protected def check_disconnected
+ # grab the current state of meetings
+ connected = {} of String => Array(String)
+ disconnected = {} of String => Array(String)
+
+ @meeting_mutex.synchronize do
+ @meetings.each do |session_id, meeting|
+ conn = [] of String
+ disc = [] of String
+ meeting.participants.values.each do |par|
+ if par.connected
+ conn << par.user_id
+ else
+ disc << par.user_id
+ end
+ end
+ connected[session_id] = conn
+ disconnected[session_id] = disc
+ end
+ end
+
+ # ensure we haven't missed any signals, compare API to our state
+ new_missed = {} of String => String
+ old_missed = @missed_connections.dup
+
+ connected.each do |session_id, connected_users|
+ disconnected_users = disconnected[session_id]
+
+ begin
+ actual_users = staff_api.chat_members(session_id).get.as_a.map(&.as_s)
+
+ # reject any users that have actually just moved
+ connected_not_found = connected_users - actual_users
+ disconnected_found = disconnected_users & actual_users
+
+ # build a new not found list
+ @recent_lock.synchronize do
+ connected_not_found.each do |user_id|
+ new_missed[user_id] = session_id
+ end
+ end
+
+ disconnected_found.each do |user_id|
+ old_missed.delete(user_id)
+ mark_user_as_connected(session_id, user_id)
+ end
+ rescue error
+ logger.warn(exception: error) { "[cleanup] failed to obtain member list for #{session_id}" }
+ end
+ end
+
+ # mark as disconnected users that are not connected
+ disconnected = new_missed.keys & old_missed.keys
+ disconnected.each do |user_id|
+ # remove from new missed as it was found missing a second time
+ new_missed.delete user_id
+
+ # mark the user as deleted
+ begin
+ mark_user_as_disconnected(old_missed[user_id], user_id)
+ rescue
+ end
+ end
+
+ logger.debug { "[cleanup] complete, #{disconnected.size} disconnections, #{new_missed.size} pending disconnect" }
+
+ # update missed connections
+ @missed_connections = new_missed
+ end
+
+ protected def cleanup_disconnected : Nil
+ logger.debug { "[cleanup] removing disconnected clients from meetings..." }
+
+ # prevent concurrent running of this function
+ @recent_lock.synchronize do
+ return if @concurrent_flag
+ @concurrent_flag = true
+ end
+
+ # find disconnected users who have timed out
+ remove = [] of String
+ expired = @disconnect_timeout.ago.to_unix
+ @recent_lock.synchronize do
+ @recently_disconnected.each do |user_id, disconnected|
+ remove << user_id if disconnected <= expired
+ end
+ remove.each do |user_id|
+ @recently_disconnected.delete user_id
+ end
+ end
+
+ # find the sessions the users who are to be removed
+ remove.each { |user_id| user_exited(user_id) }
+ logger.debug { "[cleanup] removed #{remove.size} users who have timed out" }
+ check_disconnected
+ ensure
+ @recent_lock.synchronize { @concurrent_flag = false }
+ end
+
+ # finds all the session_ids that includes the specified user_id
+ def sessions_with_user(user_id : String) : Array(String)
+ sessions = [] of String
+ @meeting_mutex.synchronize do
+ @meetings.each do |session_id, meeting|
+ sessions << session_id if meeting.participants.has_key? user_id
+ end
+ end
+ sessions
+ end
+
+ protected def user_joined(payload : String) : Nil
+ logger.debug { "[signal] user joined: #{payload}" }
+ session_user = Hash(String, String).from_json payload
+ mark_user_as_connected *session_user.first
+ end
+
+ protected def mark_user_as_connected(session_id, user_id)
+ # check if we have seen this user and re-instate the user
+ @recent_lock.synchronize do
+ @missed_connections.delete(user_id)
+ @recently_disconnected.delete(user_id)
+ end
+
+ system_id = @meeting_mutex.synchronize do
+ @meetings[session_id]?.try &.mark_participant_connected(user_id, true)
+ end
+
+ # update the state
+ if system_id
+ update_meeting_state(session_id, system_id)
+ end
+ end
+
+ protected def user_left(payload : String)
+ logger.debug { "[signal] user left: #{payload}" }
+ session_user = Hash(String, String).from_json payload
+ mark_user_as_disconnected *session_user.first
+ end
+
+ protected def user_exited(user_id : String)
+ @recent_lock.synchronize do
+ @missed_connections.delete(user_id)
+ @recently_disconnected.delete(user_id)
+ end
+
+ sessions_with_user(user_id).each do |session_id|
+ begin
+ meeting_remove_user(user_id, session_id)
+ rescue error
+ logger.warn(exception: error) { "failed to remove user #{user_id} from #{session_id}" }
+ end
+ end
+ end
+
+ protected def mark_user_as_disconnected(session_id, user_id)
+ # check if the user is related to this system
+ system_id = @meeting_mutex.synchronize do
+ @meetings[session_id]?.try &.mark_participant_connected(user_id, false)
+ end
+
+ # update the state
+ if system_id
+ now = Time.utc.to_unix
+ @recent_lock.synchronize { @recently_disconnected[user_id] = now }
+ update_meeting_state(session_id, system_id)
+ end
+ end
+
+ # ================================================
+ # NOTIFICATIONS
+ # ================================================
+
+ getter timezone_default : String { system.timezone.presence || "UTC" }
+
+ def timezone_system(system_id : String)
+ staff_api.get_system(system_id).get["timezone"]?.try(&.as_s.presence) || timezone_default
+ rescue error
+ logger.error(exception: error) { "[notify] failed to obtain timezone information for #{system_id}" }
+ timezone_default
+ end
+
+ @[Security(Level::Support)]
+ def notify_inspect_meeting(session_id : String)
+ meeting = @meeting_mutex.synchronize { @meetings[session_id]?.try &.dup }
+ raise "meeting #{session_id} not found" unless meeting
+ system_info, room_settings = notify_load_notifications(meeting)
+ members = room_settings.try &.members.map do |member|
+ {member: member, notifications: member.notifications}
+ end
+ {settings: room_settings, members: members}
+ end
+
+ def notify_config(system_id : String, timezone : String)
+ timezone = Time::Location.load timezone
+
+ # system metadata settings => notifications
+ raw_settings = staff_api.metadata(system_id, "settings").get["settings"]?.try(&.to_json)
+ settings = raw_settings ? RoomSettings.from_json(raw_settings, root: "details") : RoomSettings.new
+ default_notifications = settings.notifications
+
+ # Grab the user notification settings
+ room_users = settings.members.compact_map do |member|
+ next unless member.available?
+ begin
+ user_data = staff_api.user(member.id).get.as_h
+ member.name = (user_data["nickname"]? || user_data["name"]).as_s
+ member.email = user_data["email"].as_s
+ member.phone = user_data["phone"]?.try &.as_s
+ notify_settings = if user_settings = staff_api.metadata(member.id, "settings").get["settings"]?.try(&.[]?("details")).try(&.to_json)
+ begin
+ NotificationSettings.from_json(user_settings, root: "notifications")
+ rescue parse_error
+ logger.warn(exception: parse_error) { "failed to parse user #{member.id} notification settings" }
+ # defaults if there is an error parsing settings
+ default_notifications
+ end
+ else
+ # defaults if the user hasn't explicitily configured any
+ default_notifications
+ end
+ next unless notify_settings.enabled?
+
+ # notify_settings.member = member
+ member.notifications = notify_settings
+ member
+ rescue error
+ logger.error(exception: error) { "[notify] failed to obtain user #{member.id} metadata" }
+ nil
+ end
+ end
+
+ settings.members = room_users
+ settings.timezone = timezone
+ settings
+ end
+
+ protected def notify_load_notifications(meeting)
+ system_info = meeting.system || PlaceOS::Driver::DriverModel::ControlSystem.from_json(staff_api.get_system(meeting.system_id).get.to_json)
+ room_settings = meeting.room_settings || notify_config(meeting.system_id, meeting.timezone)
+ meeting.room_settings = room_settings
+ meeting.system = system_info
+ {system_info, room_settings}
+ end
+
+ protected def notify_entry(meeting, delay = true)
+ system_info, room_settings = notify_load_notifications(meeting)
+ contact_members = meeting.notify_members_on_entry
+ participant = meeting.created_by_participant
+
+ contacted = @meeting_mutex.synchronize { meeting.creator_contacted? }
+ if contacted
+ logger.debug { "[notify] ignoring entry event as user has been contacted" }
+ return
+ end
+
+ sys = system
+ sms = sys.implementing(PlaceOS::Driver::Interface::SMS).first?
+ mailer = sys.implementing(PlaceOS::Driver::Interface::SMS).first?
+
+ logger.debug { "[notify] entry event found #{contact_members.size} members to contact. SMS #{!!sms}, email #{!!mailer}" }
+
+ contact_members.each do |member|
+ notify = member.notifications.on_enter
+ args = {
+ member_name: member.name,
+ member_email: member.email,
+ member_phone: member.phone,
+ member_id: member.id,
+ patient_name: participant.name,
+ patient_email: participant.email,
+ patient_phone: participant.phone,
+ patient_chat_only: participant.text_chat_only,
+ room_name: system_info.display_name,
+ room_code: system_info.code,
+ room_id: system_info.id,
+ appointment_time: participant.appointment_time.presence || @notify_no_time,
+ }
+
+ # sms
+ if sms && (phone = member.phone.presence) && notify.sms?
+ string = @sms_template
+ args.each { |key, value| string = string.gsub("%{#{key}}", value) }
+ sms.send_sms(phone, string, source: @sms_source)
+ end
+
+ # email
+ if mailer && (email = member.email.presence) && notify.email?
+ mailer.send_template(
+ to: {member.email},
+ template: {"chat_rooms", "notify_entry"},
+ args: args
+ )
+ end
+ end
+ rescue error
+ logger.error(exception: error) { "[notify] error notifying entry" }
+ end
+
+ # ================================================
+ # MEETING MANAGEMENT
+ # ================================================
+
+ # session id == the webrtc session id
+ alias SessionId = String
+
+ # system id == room
+ alias SystemId = String
+
+ # session_id => connection_details
+ @meetings : Hash(SessionId, Meeting) = {} of SessionId => Meeting
+ @meeting_mutex = Mutex.new
+
+ # system ids => session ids
+ @rooms = Hash(SystemId, Array(SessionId)).new { |hash, key| hash[key] = [] of SessionId }
+ @room_mutex = Mutex.new
+
+ def meeting_move_room(session_id : String, system_id : String) : Bool
+ old_system_id = nil
+ moved = false
+
+ @meeting_mutex.synchronize do
+ # grab the room id from the meeting details
+ if meeting = @meetings[session_id]?
+ old_system_id = meeting.system_id
+ meeting.system_id = system_id
+ moved = true
+
+ @room_mutex.synchronize do
+ # move the meeting to a new room
+ if room_sessions = @rooms[old_system_id]?
+ room_sessions.delete(session_id)
+
+ if room_sessions.empty?
+ @rooms.delete(old_system_id)
+ self[old_system_id] = nil
+ end
+
+ sessions = @rooms[system_id]? || [] of SessionId
+ sessions << session_id
+ @rooms[system_id] = sessions
+ end
+ end
+ end
+ end
+
+ logger.debug { "[meet] moving session: #{session_id} to system #{system_id} from #{old_system_id}" }
+
+ # update the system state
+ update_meeting_state(session_id, system_id, old_system_id) if moved
+ moved
+ end
+
+ # this is how staff members create a meeting room
+ # or join an existing meeting
+ def meeting_join(rtc_user_id : String, session_id : String, type : String? = nil, system_id : String? = nil, text_chat_only : Bool? = nil) : ConferenceDetails
+ placeos_user_id = invoked_by_user_id
+ user_details = staff_api.user(placeos_user_id).get
+ user_name = user_details["name"].as_s
+
+ participant = Participant.new(
+ user_id: rtc_user_id,
+ name: user_name,
+ email: user_details["email"].as_s,
+ type: type,
+ staff_user_id: placeos_user_id,
+ text_chat_only: text_chat_only
+ )
+
+ # TODO:: ensure the user has left any other room they might be in
+ @recent_lock.synchronize { @recently_disconnected.delete(rtc_user_id) }
+
+ # check we have the information we need
+ meeting = nil
+ @meeting_mutex.synchronize do
+ # check if we're joining an existing session
+ if meeting = @meetings[session_id]?
+ system_id = meeting.system_id
+ end
+ end
+
+ raise "must provide a system id if there is not an existing session" unless system_id
+ system_id = system_id.as(String)
+ timezone = meeting.try(&.timezone) || timezone_system(system_id)
+
+ logger.debug do
+ if meeting
+ "[meet] joining existing meeting: staff #{placeos_user_id}, session: #{session_id} in #{system_id}"
+ else
+ "[meet] creating new meeting: staff #{placeos_user_id}, session: #{session_id} in #{system_id}"
+ end
+ end
+
+ # guest JWT's are not needed
+ # webex_guest_jwt = video_conference.create_guest_bearer(placeos_user_id, user_name).get.as_s
+ conference = pool_checkout_conference unless meeting
+
+ @meeting_mutex.synchronize do
+ # create a new meeting if required
+ meeting = if meet = @meetings[session_id]?
+ system_id = meet.system_id
+ meet.add participant
+ meet
+ else
+ # most likely won't have to checkout a conference here
+ conference = conference || pool_checkout_conference
+ meet = Meeting.new(system_id.as(String), session_id, conference, participant)
+ meet.timezone = timezone
+ meet
+ end
+ @meetings[session_id] = meeting
+ conference = meeting.conference
+
+ @room_mutex.synchronize do
+ sessions = @rooms[system_id]? || [] of SessionId
+ sessions << session_id unless sessions.includes?(session_id)
+ @rooms[system_id] = sessions
+ end
+ end
+
+ # update status
+ update_meeting_state(session_id, system_id.as(String))
+ conference.as(ConferenceDetails)
+ end
+
+ protected def meeting_remove_user(rtc_user_id : String, session_id : String, placeos_user_id : String? = nil)
+ system_id = nil
+
+ @recent_lock.synchronize { @recently_disconnected.delete(rtc_user_id) }
+ @meeting_mutex.synchronize do
+ # grab the meeting details
+ meeting = @meetings[session_id]?
+ raise "meeting not found" unless meeting
+ system_id = meeting.system_id
+
+ # ensure the current place user is the rtc_user_id
+ if placeos_user_id
+ participant = meeting.participants[rtc_user_id]
+ owner_user_id = participant.staff_user_id
+ raise "user #{placeos_user_id} attempting to leave on behalf of #{owner_user_id}" unless owner_user_id == placeos_user_id
+ end
+
+ # remove the participant
+ meeting.remove rtc_user_id
+ if meeting.empty?
+ @meetings.delete session_id
+ @room_mutex.synchronize do
+ if sessions = @rooms[system_id]?
+ sessions.delete(session_id)
+ @rooms.delete(system_id) if sessions.empty?
+ end
+ end
+ end
+ end
+
+ # update status
+ update_meeting_state(session_id, system_id.as(String))
+ end
+
+ # the user is planning of leaving the meeting or has left
+ def meeting_leave(rtc_user_id : String, session_id : String) : Nil
+ placeos_user_id = invoked_by_user_id
+ logger.debug { "[meet] user leaving #{rtc_user_id} (#{placeos_user_id}) session #{session_id}" }
+
+ meeting_remove_user(rtc_user_id, session_id, placeos_user_id)
+ end
+
+ # kicks an individual from a meeting
+ def meeting_kick(rtc_user_id : String, session_id : String)
+ placeos_user_id = invoked_by_user_id
+ logger.warn { "[meet] kicking user #{rtc_user_id} from session #{session_id}, kicked by: #{placeos_user_id}" }
+
+ # remove the user at from the chat
+ staff_api.kick_user(rtc_user_id, session_id, "kicked by host")
+
+ # remove the user from the UI
+ user_exited(rtc_user_id)
+ end
+
+ # removes the meeting from the list and kicks anyone left in the meeting
+ def meeting_end(session_id : String)
+ placeos_user_id = invoked_by_user_id
+ system_id = nil
+ meeting = nil
+ logger.debug { "[meet] ending meeting #{session_id} ended by #{placeos_user_id}" }
+
+ # remove the meeting
+ @meeting_mutex.synchronize do
+ # grab the meeting details
+ meeting = @meetings.delete session_id
+ raise "meeting not found" unless meeting
+ system_id = meeting.system_id
+
+ @room_mutex.synchronize do
+ if sessions = @rooms[system_id]?
+ sessions.delete(session_id)
+ @rooms.delete(system_id) if sessions.empty?
+ end
+ end
+ end
+
+ # kick the users to notify them that the meeting has ended
+ meeting.not_nil!.participants.keys.each do |rtc_user_id|
+ staff_api.kick_user(rtc_user_id, session_id, "meeting ended")
+ end
+
+ # update status
+ update_meeting_state(session_id, system_id.as(String))
+ end
+
+ def guest_mark_as_contacted(rtc_user_id : String, session_id : String, contacted : Bool = true) : Bool
+ found = false
+ @meeting_mutex.synchronize do
+ if meeting = @meetings[session_id]?
+ if participant = meeting.participants[rtc_user_id]?
+ found = true
+ participant.contacted = contacted
+ end
+ end
+ end
+ logger.debug { "[meet] marking guest #{rtc_user_id} as contacted: #{contacted} in session #{session_id}" }
+ update_meeting_state(session_id) if found
+ found
+ end
+
+ def guest_move_session(rtc_user_id : String, session_id : String, new_session_id : String) : Bool
+ system_id = nil
+ new_meeting = nil
+
+ if @recent_lock.synchronize { @recently_disconnected[rtc_user_id]? }
+ logger.warn { "[meet] failed to move guest #{rtc_user_id} as disconnected" }
+ raise "can't move disconnected users, please wait for reconnection or kick"
+ end
+
+ # move the meeting
+ @meeting_mutex.synchronize do
+ if (meeting = @meetings[session_id]?) && (new_meeting = @meetings[new_session_id]?)
+ if participant = meeting.remove(rtc_user_id)
+ system_id = meeting.system_id
+ new_meeting.add participant
+
+ if meeting.empty?
+ @meetings.delete session_id
+ @room_mutex.synchronize { @rooms[system_id].try(&.delete(session_id)) }
+ end
+ end
+ end
+ end
+
+ # update state if the meeting was moved
+ if system_id && new_meeting
+ logger.debug { "[meet] moving user #{rtc_user_id} into #{new_session_id} from #{session_id}" }
+ update_meeting_state(session_id, system_id)
+ update_meeting_state(new_session_id)
+
+ # POST the update so the guest is aware of the new meeting details
+ conference = new_meeting.conference
+ staff_api.transfer_user(rtc_user_id, new_session_id, {
+ space_id: conference.space_id,
+ guest_pin: conference.guest_pin,
+ })
+ else
+ logger.warn { "[meet] failed to move guest #{rtc_user_id} as could not find session" }
+ end
+ !!system_id
+ end
+
+ # ================================================
+ # MEETING POOL
+ # ================================================
+ accessor video_conference : InstantConnect_1
+
+ @pool_lock : Mutex = Mutex.new
+ @pool_meet : Array(ConferenceDetails) = [] of ConferenceDetails
+ getter pool_size : Int32 = 0
+ getter pool_target_size : Int32 = 10
+
+ # how many new meetings do we need in the pool?
+ # set the pool size counter eagerly and then get to work
+ # this way concurrent calls to this function can occur
+ # and we don't block anything with the fiber.
+ #
+ # if a new user joins and they don't have meeting details then we can
+ # create them on the fly and not update the pool
+ protected def new_conference
+ logger.debug { "[pool] Creating new conference..." }
+ room_id = UUID.random.to_s
+ details = video_conference.create_meeting(room_id).get
+ ConferenceDetails.new room_id, details["space_id"].as_s, details["host_token"].as_s, details["guest_token"].as_s
+ end
+
+ protected def pool_cleanup
+ logger.debug { "[pool] Checking for expired meetings..." }
+ expired = 4.hours.ago
+ @pool_lock.synchronize do
+ @pool_meet = @pool_meet.reject do |meeting|
+ rejected = meeting.created_at < expired
+ # we reduce the size of the pool here as technically we
+ # could be running pool_ensure_size at the same time
+ if rejected
+ @pool_size -= 1
+ new_size = @pool_size
+ logger.debug { "[pool] --> Cleaning up expired meeting, pool size #{new_size}" }
+ end
+ rejected
+ end
+ end
+
+ pool_ensure_size
+ end
+
+ def pool_ensure_size : Nil
+ # calculate the number of meetings required
+ required = 0
+ @pool_lock.synchronize do
+ required = @pool_target_size - @pool_size
+ @pool_size = @pool_target_size
+ end
+
+ logger.debug { "[pool] Maintaining meeting pool size, #{required} new meetings required" }
+ return if required <= 0
+
+ # create the desired number of meetings
+ created = 0
+ begin
+ required.times do
+ meeting = new_conference
+ @pool_lock.synchronize { @pool_meet << meeting }
+ created += 1
+ end
+ rescue error
+ logger.error(exception: error) { "[pool] error creating pool meetings" }
+
+ # adjust size if pool update failed
+ if created != required
+ diff = required - created
+ @pool_lock.synchronize { @pool_size = @pool_size - diff }
+ end
+ end
+ end
+
+ def pool_checkout_conference : ConferenceDetails
+ meeting = @pool_lock.synchronize do
+ if @pool_meet.size > 0
+ @pool_size -= 1
+ @pool_meet.shift
+ end
+ end
+
+ logger.debug { "[pool] Checking out meeting, available in pool? #{!meeting.nil?}" }
+ spawn { pool_ensure_size }
+
+ meeting || new_conference
+ end
+
+ def pool_clear_conferences : Nil
+ logger.debug { "[pool] Clearing #{@pool_size} meetings from pool" }
+
+ @pool_lock.synchronize do
+ @pool_size = 0
+ @pool_meet = [] of ConferenceDetails
+ end
+
+ pool_ensure_size
+ end
+end
diff --git a/drivers/place/chat/health_rooms_models.cr b/drivers/place/chat/health_rooms_models.cr
new file mode 100644
index 00000000000..679ffef234e
--- /dev/null
+++ b/drivers/place/chat/health_rooms_models.cr
@@ -0,0 +1,185 @@
+require "json"
+require "./health_notification_models"
+
+module Place::Chat
+ struct ConferenceDetails
+ include JSON::Serializable
+
+ getter place_id : String
+ getter space_id : String
+ getter host_pin : String
+ getter guest_pin : String
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ getter created_at : Time
+
+ def initialize(@place_id, @space_id, @host_pin, @guest_pin)
+ @created_at = Time.utc
+ end
+ end
+
+ class Participant
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ property name : String
+ property email : String?
+ property phone : String?
+
+ # the type of guest (additional information)
+ @[JSON::Field(key: "role")]
+ property type : String?
+ property text_chat_only : Bool? = false
+
+ # the placeos user id we would like to notify if we have the user details
+ @[JSON::Field(key: "staff_id")]
+ property chat_to_user_id : String?
+ getter appointment_time : String? = nil
+
+ # the users chat id. This purely generated on the frontend
+ # not a placeos user_id, we use it to track browser instances
+ property user_id : String
+
+ # the chat session id the user is planning to use, the initial chat room
+ property session_id : String? = nil
+ property contacted : Bool = false
+ property staff_user_id : String? = nil
+
+ # as we don't care about this field anymore and don't want it saved in unmapped
+ @[JSON::Field(ignore: true)]
+ property captcha : String? = nil
+
+ property connected : Bool = true
+
+ def initialize(@user_id, @name, @email = nil, @phone = nil, @type = nil, @staff_user_id = nil, @text_chat_only = nil)
+ end
+ end
+
+ struct MeetingSummary
+ include JSON::Serializable
+
+ getter pos_system : String
+ getter call_count : Int32
+ getter waiting_count : Int32
+ getter participant_count : Int32
+ getter longest_wait_time : Int64
+
+ def initialize(@pos_system, @call_count, @participant_count, @waiting_count, @longest_wait_time)
+ end
+ end
+
+ class Meeting
+ include JSON::Serializable
+
+ # webrtc_user_id => participant
+ getter participants : Hash(String, Participant)
+ getter session_id : String
+ property system_id : String
+ property! timezone : String
+
+ # webrtc_user_id that created the meeting
+ getter created_by_user_id : String
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ getter created_at : Time = Time.utc
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ getter updated_at : Time
+
+ property conference : ConferenceDetails
+
+ @[JSON::Field(ignore: true)]
+ property room_settings : RoomSettings? = nil
+
+ @[JSON::Field(ignore: true)]
+ property system : PlaceOS::Driver::DriverModel::ControlSystem? = nil
+
+ protected def filter_members(clinician_selected : String?)
+ room_settings.not_nil!.members.compact_map do |member|
+ begin
+ next unless (member.clinician? || member.coordinator?) && member.notifications.enabled?
+ next if clinician_selected && member.notifications.chosen_provider? && member.id != clinician_selected
+ member
+ rescue error
+ # logger.warn(exception: error) { "checking user #{member.id} notification settings" }
+ member
+ end
+ end
+ end
+
+ def notify_members_on_entry : Array(RoomMember)
+ settings = room_settings
+ return [] of RoomMember unless settings
+
+ patient = participants[created_by_user_id]
+
+ # check for clinicians with on_enter notifications
+ clinician_selected = patient.chat_to_user_id.presence
+ contact = filter_members(clinician_selected)
+
+ # the clinician might not be in today
+ contact = filter_members(nil) if contact.empty? && clinician_selected
+
+ # contact the admin if there are no clinicians or coordinators
+ contact = settings.members if contact.empty?
+ contact
+ end
+
+ def initialize(@system_id, @conference, participant : Participant)
+ session_id = participant.session_id
+ raise "no session id provided for participant" unless session_id
+ @session_id = session_id
+ @created_at = @updated_at = Time.utc
+ @created_by_user_id = participant.user_id
+ @participants = {
+ participant.user_id => participant,
+ }
+ end
+
+ def initialize(@system_id, @session_id, @conference, participant : Participant)
+ @created_at = @updated_at = Time.utc
+ @created_by_user_id = participant.user_id
+ @participants = {
+ participant.user_id => participant,
+ }
+ end
+
+ def add(participant : Participant) : Participant
+ @participants[participant.user_id] = participant
+ @participants[@created_by_user_id]?.try(&.contacted=(true))
+ @updated_at = Time.utc
+ participant
+ end
+
+ def remove(webrtc_user_id : String) : Participant?
+ if participant = @participants.delete(webrtc_user_id)
+ @updated_at = Time.utc
+ participant
+ end
+ end
+
+ def created_by_participant
+ @participants[created_by_user_id]
+ end
+
+ def creator_contacted?
+ @participants[created_by_user_id]?.try &.contacted
+ end
+
+ def has_participant?(webrtc_user_id : String) : Participant?
+ @participants[webrtc_user_id]?
+ end
+
+ def mark_participant_connected(webrtc_user_id : String, state : Bool) : String?
+ if participant = has_participant?(webrtc_user_id)
+ old_state = participant.connected
+ participant.connected = state
+ return system_id unless old_state == state
+ end
+ end
+
+ def empty? : Bool
+ @participants.empty?
+ end
+ end
+end
diff --git a/drivers/place/chat/health_rooms_spec.cr b/drivers/place/chat/health_rooms_spec.cr
new file mode 100644
index 00000000000..08fed882df4
--- /dev/null
+++ b/drivers/place/chat/health_rooms_spec.cr
@@ -0,0 +1,35 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Chat::HealthRooms" do
+ system({
+ InstantConnect: {InstantConnectMock},
+ })
+
+ exec(:pool_size).get.should eq 0
+ exec(:pool_target_size).get.should eq 10
+
+ meeting = exec(:pool_checkout_conference).get
+ raise "no meeting returned" unless meeting
+
+ meeting["host_pin"].should eq "host-1234"
+ meeting["guest_pin"].should eq "guest-1234"
+
+ sleep 0.5
+
+ system(:InstantConnect_1)[:created].should eq(11)
+end
+
+# :nodoc:
+class InstantConnectMock < DriverSpecs::MockDriver
+ @called = 0
+
+ def create_meeting(room_id : String)
+ @called += 1
+ self[:created] = @called
+ {
+ space_id: room_id,
+ host_token: "host-1234",
+ guest_token: "guest-1234",
+ }
+ end
+end
diff --git a/drivers/place/demo/display.cr b/drivers/place/demo/display.cr
new file mode 100644
index 00000000000..4001f6de73c
--- /dev/null
+++ b/drivers/place/demo/display.cr
@@ -0,0 +1,60 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+class Place::Demo::Display < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ udp_port 6137
+
+ enum Input
+ DVI = 1
+ HDMI = 10
+ HDMI2 = 13
+ HDMI3 = 18
+ DisplayPort = 14
+ VGA = 2
+ VGA2 = 16
+ Component = 3
+ end
+
+ include Interface::InputSelection(Input)
+
+ descriptive_name "PlaceOS Demo Display"
+ generic_name :Display
+
+ def power(state : Bool)
+ self[:power] = state
+ end
+
+ def power?(**options)
+ self[:power].as_bool
+ end
+
+ def switch_to(input : Input)
+ self[:input] = input
+ end
+
+ getter? volume : Float64 = 0.0
+
+ def volume(level : Int32 | Float64)
+ self[:volume] = @volume = level.to_f64
+ end
+
+ def test_setting(key : String, payload : JSON::Any)
+ define_setting(key, payload)
+ payload
+ end
+
+ # There seems to only be audio mute available
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ self[:audio_mute] = state
+ self[:volume] = state ? 0 : @volume
+ end
+end
diff --git a/drivers/place/demo/location_services.cr b/drivers/place/demo/location_services.cr
new file mode 100644
index 00000000000..af959439f05
--- /dev/null
+++ b/drivers/place/demo/location_services.cr
@@ -0,0 +1,58 @@
+require "placeos-driver"
+
+class Place::Demo::LocationServices < PlaceOS::Driver
+ descriptive_name "Demo Location Services"
+ generic_name :LocationServices
+ description %(a mock version of location services for demos and testing)
+
+ default_settings({
+ building_zone: "zone_123",
+ level_zone: "zone_456",
+ system_id: "sys-123",
+ })
+
+ @building_zone : String = ""
+ @level_zone : String = ""
+ @system_id : String = ""
+
+ def on_update
+ @building_zone = setting(String, :building_zone)
+ @level_zone = setting(String, :level_zone)
+ @system_id = setting(String, :system_id)
+ end
+
+ def locate_user(email : String? = nil, username : String? = nil)
+ case rand(3)
+ when 0
+ [{
+ location: "wireless",
+ coordinates_from: "bottom-left",
+ x: 27.113065326953013,
+ y: 36.85052447328469,
+ lon: 55.27498749637098,
+ lat: 25.20090608906493,
+ mac: "66e0fd1279ce",
+ variance: 4.5194575835650745,
+ last_seen: 1601555879,
+ building: @building_zone,
+ level: @level_zone,
+ map_width: 1234.2,
+ map_height: 123.8,
+ }]
+ when 1
+ [{
+ location: "meeting",
+ mac: "meeting.room@resource.org.com",
+ event_id: "meet-1234567",
+ map_id: "map-1234",
+ sys_id: @system_id,
+ ends_at: 1.hour.from_now,
+ private: false,
+ level: @level_zone,
+ building: @building_zone,
+ }]
+ else
+ [] of String
+ end
+ end
+end
diff --git a/drivers/place/demo/lockers.cr b/drivers/place/demo/lockers.cr
new file mode 100644
index 00000000000..cb7eb497827
--- /dev/null
+++ b/drivers/place/demo/lockers.cr
@@ -0,0 +1,317 @@
+require "placeos-driver"
+require "placeos-driver/interface/lockers"
+require "../bookings/locker_models"
+
+class Place::Demo::Lockers < PlaceOS::Driver
+ include Interface::Lockers
+ include Place::LockerMetadataParser
+ alias PlaceLocker = PlaceOS::Driver::Interface::Lockers::PlaceLocker
+
+ descriptive_name "Locker Testing"
+ generic_name :DemoLockers
+ description %(used for end to end testing of locker interfaces)
+
+ accessor staff_api : StaffAPI_1
+ accessor locations : LocationServices_1
+
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+
+ getter levels : Array(String) do
+ staff_api.systems_in_building(building_id).get.as_h.keys
+ end
+
+ # re-open class to add some helpers
+ class ::Place::Locker
+ # for tracking, not part of metadata
+ property allocated_to : String? = nil
+ property allocated_at : Time? = nil
+ property allocated_until : Time? = nil
+ property shared_with : Array(String) = [] of String
+
+ def release
+ @allocated_to = nil
+ @allocated_at = nil
+ @allocated_until = nil
+ @shared_with = [] of String
+ end
+
+ def allocated? : Bool
+ if time = self.allocated_until
+ if time > Time.utc
+ true
+ else
+ false
+ end
+ elsif self.allocated_to.presence
+ true
+ else
+ false
+ end
+ end
+
+ def not_allocated? : Bool
+ !allocated?
+ end
+ end
+
+ class ::PlaceOS::Driver::Interface::Lockers::PlaceLocker
+ def initialize(@bank_id, locker : ::Place::Locker, @building = nil)
+ @locker_id = locker.id
+ @locker_name = locker.name
+ @mac = "lb=#{@bank_id}&lk=#{locker.id}"
+ if time = locker.allocated_until
+ if time > Time.utc
+ in_use = true
+ @expires_at = time
+ else
+ in_use = false
+ @expires_at = nil
+ end
+ elsif allocated_to = locker.allocated_to
+ in_use = true
+ @expires_at = nil
+ else
+ in_use = false
+ @expires_at = nil
+ end
+ @allocated = in_use
+ @allocation_id = "#{locker.allocated_to}--#{locker.id}--#{locker.allocated_at.try(&.to_unix_ns)}" if in_use
+ @level = locker.level_id
+ end
+ end
+
+ # allocates a locker now, the allocation may expire
+ @[Security(Level::Support)]
+ def locker_allocate(
+ # PlaceOS user id
+ user_id : String,
+
+ # the locker location
+ bank_id : String | Int64,
+
+ # allocates a random locker if this is nil
+ locker_id : String | Int64? = nil,
+
+ # attempts to create a booking that expires at the time specified
+ expires_at : Int64? = nil
+ ) : PlaceLocker
+ bank = locker_banks[bank_id.to_s]
+ locker_id = locker_id ? locker_id : bank.locker_hash.values.select(&.not_allocated?).sample.id
+ locker = bank.locker_hash[locker_id.to_s]
+ locker.allocated_to = user_id
+ locker.allocated_at = Time.utc
+ locker.allocated_until = Time.unix(expires_at) if expires_at
+ PlaceLocker.new(bank_id, locker, building_id)
+ rescue
+ raise "no available lockers"
+ end
+
+ # return the locker to the pool
+ @[Security(Level::Support)]
+ def locker_release(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # release / unshare just this user - otherwise release the whole locker
+ owner_id : String? = nil
+ ) : Nil
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ if locker.allocated_to == owner_id
+ locker.release
+ else
+ locker.shared_with.delete(owner_id)
+ end
+ end
+
+ # a list of lockers that are allocated to the user
+ @[Security(Level::Support)]
+ def lockers_allocated_to(user_id : String) : Array(PlaceLocker)
+ now = Time.utc
+ building = building_id
+
+ locker_banks.values.flat_map do |bank|
+ bank.locker_hash.values.compact_map do |locker|
+ if locker.allocated_to == user_id
+ if time = locker.allocated_until
+ PlaceLocker.new(bank.id, locker, building) if time > now
+ else
+ PlaceLocker.new(bank.id, locker, building)
+ end
+ end
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def locker_share(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ share_with : String
+ ) : Nil
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ perform_share = false
+ if locker.allocated_to == owner_id
+ if time = locker.allocated_until
+ perform_share = time > Time.utc
+ else
+ perform_share = true
+ end
+ end
+
+ if perform_share
+ locker.shared_with << share_with
+ locker.shared_with.uniq!
+ end
+ end
+
+ @[Security(Level::Support)]
+ def locker_unshare(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ # the individual you previously shared with (optional)
+ shared_with_id : String? = nil
+ ) : Nil
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ perform_share = false
+ if locker.allocated_to == owner_id
+ if time = locker.allocated_until
+ perform_share = time > Time.utc
+ else
+ perform_share = true
+ end
+ end
+
+ if perform_share
+ if shared_with_id
+ locker.shared_with.delete shared_with_id
+ else
+ locker.shared_with = [] of String
+ end
+ end
+ end
+
+ # a list of user-ids that the locker is shared with.
+ # this can be placeos user ids or emails
+ @[Security(Level::Support)]
+ def locker_shared_with(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String
+ ) : Array(String)
+ locker = locker_banks[bank_id.to_s].locker_hash[locker_id.to_s]
+ perform_share = false
+ if locker.allocated_to == owner_id
+ if time = locker.allocated_until
+ perform_share = time > Time.utc
+ else
+ perform_share = true
+ end
+ end
+
+ if perform_share
+ locker.shared_with
+ else
+ [] of String
+ end
+ end
+
+ @[Security(Level::Support)]
+ def locker_unlock(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # sometimes required by locker systems
+ owner_id : String? = nil,
+ # time in seconds the locker should be unlocked
+ # (can be ignored if not implemented)
+ open_time : Int32 = 60,
+ # optional pin code - if user entered from a kiosk
+ pin_code : String? = nil
+ ) : Nil
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ # we could find the floorsense user, grab the reservations the user has
+ # and list them here, but probably not amazingly useful
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ # "lb=#{@bank_id}&lk=#{locker.id}"
+ return nil unless mac_address.starts_with?("lb=")
+ floor_mac = URI::Params.parse mac_address
+ locker_bank = floor_mac["lb"]
+ locker_key = floor_mac["lk"]
+ locker = locker_banks[locker_bank].locker_hash[locker_key]
+
+ has_reservation = false
+ if user_id = locker.allocated_to
+ if time = locker.allocated_until
+ has_reservation = time > Time.utc
+ else
+ has_reservation = true
+ end
+ end
+
+ if has_reservation
+ {
+ location: "locker",
+ assigned_to: staff_api.user(locker.allocated_to).get["email"].as_s,
+ mac_address: mac_address,
+ }
+ end
+ rescue
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching lockers in zone #{zone_id}" }
+ return [] of Nil if location && location != "locker"
+
+ building = building_id
+ level_zone = zone_id == building ? nil : zone_id
+ return [] of Nil if level_zone && !level_zone.in?(levels)
+
+ now = Time.utc
+ locker_banks.values.flat_map do |bank|
+ next [] of PlaceLocker if level_zone && bank.level_id != level_zone
+
+ bank.locker_hash.values.compact_map do |locker|
+ if locker.allocated_to
+ if time = locker.allocated_until
+ PlaceLocker.new(bank.id, locker, building) if time > now
+ else
+ PlaceLocker.new(bank.id, locker, building)
+ end
+ end
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def release_all_lockers : Int32
+ released = 0
+ locker_banks.values.flat_map do |bank|
+ bank.locker_hash.values.compact_map do |locker|
+ if locker.allocated_to
+ locker.release
+ released += 1
+ end
+ end
+ end
+ released
+ end
+end
diff --git a/drivers/place/demo/lockers_spec.cr b/drivers/place/demo/lockers_spec.cr
new file mode 100644
index 00000000000..de880250e72
--- /dev/null
+++ b/drivers/place/demo/lockers_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Demo::Lockers" do
+end
diff --git a/drivers/place/demo/room_sensor.cr b/drivers/place/demo/room_sensor.cr
new file mode 100644
index 00000000000..391e70a9c08
--- /dev/null
+++ b/drivers/place/demo/room_sensor.cr
@@ -0,0 +1,169 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "placeos-driver/interface/locatable"
+
+class Place::Demo::RoomSensor < PlaceOS::Driver
+ include Interface::Sensor
+ include Interface::Locatable
+
+ # Discovery Information
+ descriptive_name "Demo Room Sensor"
+ generic_name :Sensor
+
+ default_settings({
+ sensor_id: "1234",
+ capacity: 2,
+ default_count: 0,
+ })
+
+ @sensor_id : String = "1234"
+ @capacity : Int32 = 2
+ getter! count : Int32
+ @timestamp : Int64 = 0_i64
+
+ def on_update
+ @capacity = setting?(Int32, :capacity) || 2
+ @count ||= setting?(Int32, :default_count) || 0
+ @sensor_id = setting?(String, :sensor_id) || module_id
+ @timestamp = Time.utc.to_unix
+ update_state
+ end
+
+ def set_sensor(new_count : Int32)
+ @timestamp = Time.utc.to_unix
+ @count = new_count
+ update_state
+ end
+
+ protected def update_state
+ self["people"] = count
+ self["presence"] = count > 0
+ end
+
+ # Finds the building ID for the current location services object
+ getter building_id : String do
+ zone_ids = system["StaffAPI"].zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ raise error
+ end
+
+ # Finds the level ID for the current location services object
+ getter level_id : String do
+ zone_ids = system["StaffAPI"].zones(tags: "level").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ raise error
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if mac && mac != "demo-#{@sensor_id}"
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+ return NO_MATCH if zone_id && !system.zones.includes?(zone_id)
+
+ if sensor_type
+ sensor = build_sensor_details(sensor_type)
+ return NO_MATCH unless sensor
+ [sensor]
+ else
+ space_sensors
+ end
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+ return nil unless mac == "demo-#{@sensor_id}"
+
+ case id
+ when "people"
+ build_sensor_details(:people_count)
+ when "presence"
+ build_sensor_details(:presence)
+ end
+ end
+
+ protected def build_sensor_details(sensor : SensorType) : Detail?
+ time = @timestamp
+ id = "people"
+
+ value = case sensor
+ when .people_count?
+ count.to_f64
+ when .presence?
+ id = "presence"
+ count > 0 ? 1.0 : 0.0
+ else
+ raise "sensor type unavailable: #{sensor}"
+ end
+ return nil unless value
+
+ Detail.new(
+ type: sensor,
+ value: value,
+ last_seen: time,
+ mac: "demo-#{@sensor_id}",
+ id: id,
+ name: "Demo Sensor (#{@sensor_id})",
+ module_id: module_id,
+ binding: id
+ )
+ end
+
+ protected def space_sensors
+ [
+ build_sensor_details(:people_count),
+ build_sensor_details(:presence),
+ ].compact
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "sensor incapable of tracking #{mac_address}" }
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+ return [] of Nil unless {building_id, level_id}.includes?(zone_id)
+ return [] of Nil if location && location != "area"
+
+ [{
+ location: "area",
+ at_location: count,
+ map_id: system.map_id,
+ level: level_id,
+ building: building_id,
+ capacity: @capacity,
+
+ module_id: module_id,
+
+ }]
+ end
+end
diff --git a/drivers/place/demo/room_sensor_spec.cr b/drivers/place/demo/room_sensor_spec.cr
new file mode 100644
index 00000000000..ff5ad5af668
--- /dev/null
+++ b/drivers/place/demo/room_sensor_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Demo::RoomSensor" do
+end
diff --git a/drivers/place/demo/switcher.cr b/drivers/place/demo/switcher.cr
new file mode 100644
index 00000000000..725a15ccb8b
--- /dev/null
+++ b/drivers/place/demo/switcher.cr
@@ -0,0 +1,39 @@
+require "placeos-driver"
+require "placeos-driver/interface/switchable"
+
+# This is a mock switch for demoing AV interfaces
+
+class Place::Demo::Switcher < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::Switchable(Int32, Int32)
+
+ descriptive_name "PlaceOS Demo Switcher"
+ generic_name :Switcher
+
+ default_settings({
+ inputs: 6,
+ outputs: 6,
+ })
+
+ getter inputs : Int32 { setting(Int32, :inputs) }
+ getter outputs : Int32 { setting(Int32, :outputs) }
+
+ def on_update
+ @inputs = nil
+ @outputs = nil
+ end
+
+ def switch_to(input : Int32)
+ raise "invalid input #{input}, supported values 0 -> #{inputs}" if input < 0 || input > inputs
+ logger.debug { "switching all outputs to input #{input}" }
+ (1..outputs).each { |outp| self["output#{outp}"] = input }
+ true
+ end
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ logger.debug { "switching #{map} on layer #{layer || SwitchLayer::All}" }
+ map.each do |input, outputs|
+ outputs.each { |outp| self["output#{outp}"] = input }
+ end
+ true
+ end
+end
diff --git a/drivers/place/demo/test_ssh.cr b/drivers/place/demo/test_ssh.cr
new file mode 100644
index 00000000000..0446806a3d4
--- /dev/null
+++ b/drivers/place/demo/test_ssh.cr
@@ -0,0 +1,30 @@
+require "placeos-driver"
+
+class Place::Demo::TestSSH < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "SSH Testing Tool"
+ generic_name :TestSSH
+ tcp_port 22
+
+ default_settings({
+ ssh: {
+ username: :root,
+ password: :password,
+ },
+ })
+
+ def ls(dir : String = "./", modifiers : String = "")
+ exec("ls -#{modifiers} #{dir}").gets_to_end
+ end
+
+ def run(command : String, wait : Bool = true)
+ logger.debug { "SSH command:\n#{command}" }
+ send "#{command}\n", wait: wait
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "SSH response:\n#{data}" }
+ task.try &.success(data)
+ end
+end
diff --git a/drivers/place/demo/test_ssh_spec.cr b/drivers/place/demo/test_ssh_spec.cr
new file mode 100644
index 00000000000..23aa904cb4e
--- /dev/null
+++ b/drivers/place/demo/test_ssh_spec.cr
@@ -0,0 +1,8 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Demo::TestSSH" do
+ resp = exec(:run, "ls")
+ should_send "ls\n"
+ responds "bin docker-compose.yml examples lib\n"
+ resp.get.should eq "bin docker-compose.yml examples lib\n"
+end
diff --git a/drivers/place/desk/control.cr b/drivers/place/desk/control.cr
new file mode 100644
index 00000000000..5d836494b06
--- /dev/null
+++ b/drivers/place/desk/control.cr
@@ -0,0 +1,123 @@
+require "placeos-driver"
+require "placeos-driver/interface/desk_control"
+require "placeos"
+require "json"
+
+class Place::Desk::Control < PlaceOS::Driver
+ descriptive_name "PlaceOS Desk Control"
+ generic_name :DeskControl
+ description %(helper for handling desk control)
+
+ default_settings({
+ desk_id_key: "id",
+ })
+
+ accessor area_manager : AreaManagement_1
+ accessor staff_api : StaffAPI_1
+
+ METADATA_KEY = "desks"
+
+ def on_load
+ # cache desk ids periodically
+ schedule.every(1.hour) { @desk_ids = nil }
+ on_update
+ end
+
+ def on_update
+ @desk_id_key = setting?(String, :desk_id_key) || "id"
+ end
+
+ getter desk_id_key : String = "id"
+
+ def desk_lookup(desk_id : String) : String
+ # if it's not id, then there is a mapping to another id
+ if desk_id_key != "id"
+ mapped_id = desk_ids[desk_id]?
+ raise "mapped id not found" unless mapped_id
+ mapped_id
+ else
+ desk_id
+ end
+ end
+
+ protected def desk_control
+ system.implementing(PlaceOS::Driver::Interface::DeskControl)
+ end
+
+ # ===================================
+ # Desk control functions
+ # ===================================
+
+ def set_desk_height(desk_key : String, desk_height : Int32)
+ desk_key = desk_lookup(desk_key)
+ desk_control.set_desk_height(desk_key, desk_height).get
+ end
+
+ def get_desk_height(desk_key : String)
+ desk_control.get_desk_height(desk_key).get
+ end
+
+ def set_desk_power(desk_key : String, desk_power : Bool?)
+ desk_key = desk_lookup(desk_key)
+ desk_control.set_desk_power(desk_key, desk_power).get
+ end
+
+ def get_desk_power(desk_key : String)
+ desk_control.get_desk_power(desk_key).get
+ end
+
+ # ===================================
+ # Desk zone queries
+ # ===================================
+
+ struct DeskId
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter id : String
+ end
+
+ struct Details
+ include JSON::Serializable
+
+ property details : Array(DeskId)
+ end
+
+ alias Zone = PlaceOS::Client::API::Models::Zone
+ alias Metadata = Hash(String, Details)
+ alias ChildMetadata = Array(NamedTuple(zone: Zone, metadata: Metadata))
+
+ getter desk_ids : Hash(String, String) do
+ metadatas = level_buildings.values.uniq.map do |zone_id|
+ ChildMetadata.from_json(staff_api.metadata_children(
+ zone_id,
+ METADATA_KEY
+ ).get.to_json)
+ end
+
+ desks = {} of String => String
+ key = desk_id_key
+
+ metadatas.each do |metadata|
+ metadata.each do |level|
+ zone = level[:zone]
+ if ids = level[:metadata][METADATA_KEY]?.try(&.details)
+ ids.each do |desk_details|
+ if mapped_id = desk_details.json_unmapped[key]?.try(&.as_s?)
+ desks[desk_details.id] = mapped_id
+ end
+ end
+ end
+ end
+ end
+
+ desks
+ end
+
+ # level_zone_id => building_zone_id
+ getter level_buildings : Hash(String, String) do
+ hash = area_manager.level_buildings.get.as_h.transform_values(&.as_s)
+ raise "level cache not loaded yet" unless hash.size > 0
+ hash
+ end
+end
diff --git a/drivers/place/desk/control_spec.cr b/drivers/place/desk/control_spec.cr
new file mode 100644
index 00000000000..25c31035af3
--- /dev/null
+++ b/drivers/place/desk/control_spec.cr
@@ -0,0 +1,116 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Desk::Control" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ AreaManagement: {AreaManagementMock},
+ })
+
+ settings({
+ desk_id_key: "map_id",
+ })
+
+ exec(:desk_ids).get.should eq({
+ "desk_77123" => "desk-77123",
+ "desk_006-17" => "desk-006-17",
+ })
+
+ exec(:desk_lookup, "desk_77123").get.should eq("desk-77123")
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def metadata_children(id : String, key : String? = nil)
+ logger.info { "requesting zone #{id} and key #{key}" }
+
+ [
+ {
+ "zone": {
+ "created_at": 1668744303,
+ "updated_at": 1668744303,
+ "id": "zone-FlWVOXv9yY",
+ "name": "PlaceOS Dev Sydney Catering Enabled",
+ "display_name": "Catering ",
+ "location": "",
+ "description": "",
+ "code": "",
+ "type": "",
+ "count": 0,
+ "capacity": 0,
+ "map_id": "",
+ "tags": [] of String,
+ "triggers": [] of String,
+ "parent_id": "zone-DnTcV5ZeEq",
+ },
+ "metadata": {} of String => String,
+ },
+ {
+ "zone": {
+ "created_at": 1691972553,
+ "updated_at": 1701398359,
+ "id": "zone-EYEnrhbaQz",
+ "name": "LEVEL Parking",
+ "display_name": "Parking",
+ "location": "",
+ "description": "",
+ "code": "",
+ "type": "",
+ "count": 0,
+ "capacity": 0,
+ "map_id": "https://s3-ap-southeast-2.amazonaws.com/os.place.tech/placeos-dev.aca.im/169197263162476823.svg",
+ "tags": [
+ "level",
+ "parking",
+ ],
+ "triggers": [] of String,
+ "parent_id": "zone-DnTcV5ZeEq",
+ },
+ "metadata": {
+ "desks": {
+ "name": "desks",
+ "description": "List of available desks",
+ "details": [
+ {
+ "id": "desk_77123",
+ "name": "test2",
+ "groups": [] of String,
+ "images": [] of String,
+ "map_id": "desk-77123",
+ "bookable": false,
+ "features": [
+ "test",
+ ],
+ },
+ {
+ "id": "desk_006-17",
+ "name": "test3",
+ "groups": [] of String,
+ "images": [] of String,
+ "map_id": "desk-006-17",
+ "bookable": true,
+ "features": [] of String,
+ },
+ ],
+ "parent_id": "zone-FlWVOXv9yY",
+ "editors": [] of String,
+ "modified_by_id": "user-DGLTbVU8eqiSRn",
+ },
+ },
+ },
+ ]
+ end
+end
+
+# :nodoc:
+class AreaManagementMock < DriverSpecs::MockDriver
+ def update_available(zones : Array(String))
+ logger.info { "requested update to #{zones}" }
+ nil
+ end
+
+ def level_buildings
+ {
+ "zone-EYEnrhbaQz": "zone-DnTcV5ZeEq",
+ }
+ end
+end
diff --git a/drivers/place/desk_booking_webhook.cr b/drivers/place/desk_booking_webhook.cr
new file mode 100644
index 00000000000..78f653cb40a
--- /dev/null
+++ b/drivers/place/desk_booking_webhook.cr
@@ -0,0 +1,69 @@
+require "placeos-driver"
+require "http/client"
+
+class Place::DeskBookingWebhook < PlaceOS::Driver
+ descriptive_name "Desk Booking Webhook"
+ generic_name :DeskBookingWebhook
+ description %(sends a webhook with booking information as it changes)
+
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ post_uri: "https://remote-server/path",
+ building: "zone-id",
+
+ custom_headers: {
+ "API_KEY" => "123456",
+ },
+
+ # how many days from now do we want to send
+ days_from_now: 14,
+
+ booking_category: "desk",
+
+ debug: false,
+ })
+
+ def on_load
+ monitor("staff/booking/changed") do |_subscription, payload|
+ logger.debug { "received booking changed event #{payload}" }
+ fetch_and_post
+ end
+ schedule.every(24.hours) { fetch_and_post }
+ on_update
+ end
+
+ @time_period : Time::Span = 14.days
+ @booking_category : String = "desk"
+ @custom_headers = {} of String => String
+ @building = ""
+ @post_uri = ""
+ @debug : Bool = false
+
+ def on_update
+ @post_uri = setting(String, :post_uri)
+ @building = setting(String, :building)
+ @custom_headers = setting(Hash(String, String), :custom_headers)
+ @time_period = setting(Int32, :days_from_now).days
+ @booking_category = setting(String, :booking_category)
+ @debug = setting(Bool, :debug)
+
+ fetch_and_post
+ end
+
+ def fetch_and_post
+ period_start = Time.utc.to_unix
+ period_end = @time_period.from_now.to_unix
+ zones = [@building]
+ payload = staff_api.query_bookings(@booking_category, period_start, period_end, zones).get.to_json
+
+ headers = HTTP::Headers.new
+ @custom_headers.each { |key, value| headers[key] = value }
+ headers["Content-Type"] = "application/json; charset=UTF-8"
+
+ logger.debug { "Posting: #{payload} \n with Headers: #{headers}" } if @debug
+ response = HTTP::Client.post @post_uri, headers, body: payload
+ raise "Request failed with #{response.status_code}: #{response.body}" unless response.status_code < 300
+ "#{response.status_code}: #{response.body}"
+ end
+end
diff --git a/drivers/place/desk_bookings_locations.cr b/drivers/place/desk_bookings_locations.cr
new file mode 100644
index 00000000000..0b81ff31236
--- /dev/null
+++ b/drivers/place/desk_bookings_locations.cr
@@ -0,0 +1,270 @@
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "./booking_model"
+require "json"
+require "set"
+
+class Place::DeskBookingsLocations < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "PlaceOS Desk Bookings Locations"
+ generic_name :DeskBookings
+ description %(collects desk booking data from the staff API for visualising on a map)
+
+ accessor area_manager : AreaManagement_1
+ accessor staff_api : StaffAPI_1
+ accessor location_service : LocationServices_1
+
+ default_settings({
+ zone_filter: [] of String,
+
+ # time in seconds
+ poll_rate: 20,
+ booking_type: "desk",
+
+ _expose_for_analytics: {"output_key" => "booking_key->subkey"},
+ })
+
+ @expose_for_analytics : Hash(String, String) = {} of String => String
+ @zone_filter : Array(String) = [] of String
+ @poll_rate : Time::Span = 60.seconds
+ @booking_type : String = "desk"
+
+ def on_load
+ monitor("staff/booking/changed") do |_subscription, payload|
+ logger.debug { "received booking changed event #{payload}" }
+ booking = Booking.from_json(payload)
+ booking.user_email = booking.user_email.downcase
+ booking_changed(booking)
+ end
+ on_update
+ end
+
+ def on_update
+ @zone_filter = setting?(Array(String), :zone_filter) || [] of String
+ @zones = nil
+ @building_id = nil
+ @map_ids = nil
+ @poll_rate = (setting?(Int32, :poll_rate) || 60).seconds
+
+ @booking_type = setting?(String, :booking_type).presence || "desk"
+ @expose_for_analytics = setting?(Hash(String, String), :expose_for_analytics) || {} of String => String
+
+ map_zones
+ schedule.clear
+ schedule.every(@poll_rate) { query_desk_bookings }
+ schedule.in(5.seconds) { query_desk_bookings }
+ end
+
+ getter zones : Array(String) do
+ filtered = @zone_filter
+ if filtered.empty?
+ location_service.systems.get.as_h.keys
+ else
+ filtered
+ end
+ end
+
+ getter building_id : String { location_service.building_id.get.as_s }
+
+ # asset_id => map_id
+ getter map_ids : Hash(String, String) do
+ levels = staff_api.metadata_children(building_id, "desks").get.as_a
+ id_map = {} of String => String
+
+ levels.each do |level|
+ if desks = level["metadata"]["desks"]?
+ desks["details"].as_a.each do |desk|
+ if map_id = desk["map_id"]?.try(&.as_s?).presence
+ id_map[desk["id"].as_s] = map_id
+ end
+ end
+ end
+ end
+ id_map
+ end
+
+ # ===================================
+ # Monitoring desk bookings
+ # ===================================
+ protected def booking_changed(event)
+ return unless event.booking_type == @booking_type
+ matching_zones = zones & event.zones
+ return if matching_zones.empty?
+
+ logger.debug { "booking event is in a matching zone" }
+
+ case event.action
+ when "create"
+ return unless event.in_progress?
+ # Check if this event is happening now
+ logger.debug { "adding new booking" }
+ @bookings[event.user_email] << event
+ when "cancelled", "rejected"
+ # delete the booking from the levels
+ found = false
+ @bookings[event.user_email].reject! { |booking| found = true if booking.id == event.id }
+ return unless found
+ when "check_in"
+ return unless event.in_progress?
+ @bookings[event.user_email].each { |booking| booking.checked_in = true if booking.id == event.id }
+ when "changed"
+ # Check if this booking is for today and update as required
+ @bookings[event.user_email].reject! { |booking| booking.id == event.id }
+ @bookings[event.user_email] << event if event.in_progress?
+ else
+ # ignore the update (approve)
+ logger.debug { "booking event was ignored" }
+ return
+ end
+
+ area_manager.update_available(matching_zones)
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "searching for #{email}, #{username}" }
+ bookings = @bookings[email]? || [] of Booking
+ map_bookings(bookings)
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "listing MAC addresses assigned to #{email}, #{username}" }
+ found = [] of String
+ @known_users.each { |user_id, (user_email, _name)|
+ found << user_id if email == user_email
+ }
+ found
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "searching for owner of #{mac_address}" }
+ if user_details = @known_users[mac_address]?
+ email, _name = user_details
+ {
+ location: "booking",
+ assigned_to: email,
+ mac_address: mac_address,
+ }
+ end
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching devices in zone #{zone_id}" }
+ return [] of Nil if location && location != "booking"
+
+ bookings = [] of Booking
+ @bookings.each_value(&.each { |booking|
+ next unless zone_id.in?(booking.zones)
+ bookings << booking
+ })
+ map_bookings(bookings)
+ end
+
+ protected def map_bookings(bookings)
+ map_mappings = map_ids rescue {} of String => String
+ bookings.map do |booking|
+ level = nil
+ building = nil
+ booking.zones.each do |zone_id|
+ tags = @zone_mappings[zone_id]
+ level = zone_id if tags.includes? "level"
+ building = zone_id if tags.includes? "building"
+ break if level && building
+ end
+
+ # We specify location as JSON::Any so we don't have to
+ # explicitly define the type of this object
+ payload = {
+ "location" => JSON::Any.new("booking"),
+ "type" => @booking_type,
+ "checked_in" => booking.checked_in,
+ "asset_id" => booking.asset_id,
+ "booking_id" => booking.id,
+ "building" => building,
+ "level" => level,
+ "ends_at" => booking.booking_end,
+ "started_at" => booking.booking_start,
+ "duration" => booking.booking_end - booking.booking_start,
+ "mac" => booking.user_id,
+ "staff_email" => booking.user_email,
+ "staff_name" => booking.user_name,
+ "map_id" => map_mappings[booking.asset_id]?
+ }.compact!
+
+ # check for any custom data we want to include
+ if !booking.extension_data.empty? && (init_data = JSON::Any.new(booking.extension_data))
+ @expose_for_analytics.each do |binding, path|
+ begin
+ binding_keys = path.split("->")
+ data = init_data
+ binding_keys.each do |key|
+ next if key == "extension_data"
+
+ data = data.dig? key
+ break unless data
+ end
+ payload[binding] = data
+ rescue error
+ logger.warn(exception: error) { "failed to expose #{binding}: #{path} for analytics" }
+ end
+ end
+ end
+
+ payload
+ end
+ end
+
+ # ===================================
+ # DESK AND ZONE QUERIES
+ # ===================================
+ # zone id => tags
+ @zone_mappings = {} of String => Array(String)
+
+ protected def map_zones
+ @zone_mappings = Hash(String, Array(String)).new do |hash, zone_id|
+ # Map zones_ids to tags (level, building etc)
+ hash[zone_id] = staff_api.zone(zone_id).get["tags"].as_a.map(&.as_s)
+ end
+ end
+
+ # Email => Array of bookings
+ @bookings : Hash(String, Array(Booking)) = Hash(String, Array(Booking)).new
+
+ # UserID => {Email, Name}
+ @known_users : Hash(String, Tuple(String, String)) = Hash(String, Tuple(String, String)).new
+
+ def query_desk_bookings : Nil
+ ids = Set(Int64).new
+ bookings = [] of JSON::Any
+ zones.each do |zone|
+ bookings.concat staff_api.query_bookings(type: @booking_type, zones: {zone}).get.as_a
+ rescue error
+ logger.warn(exception: error) { "failed to query bookings in zone: #{zone}" }
+ end
+ bookings = bookings.flat_map do |booking|
+ booking = Booking.from_json(booking.to_json)
+ next [] of Booking if ids.includes?(booking.id)
+ ids << booking.id
+
+ booking.user_email = booking.user_email.downcase
+ booking.expand
+ end
+
+ logger.debug { "queried desk bookings, found #{bookings.size}" }
+
+ new_bookings = Hash(String, Array(Booking)).new do |hash, key|
+ hash[key] = [] of Booking
+ end
+
+ bookings.each do |booking|
+ next if booking.rejected
+ new_bookings[booking.user_email] << booking
+ @known_users[booking.user_id] = {booking.user_email, booking.user_name}
+ end
+
+ @bookings = new_bookings
+ end
+end
diff --git a/drivers/place/desk_bookings_locations_spec.cr b/drivers/place/desk_bookings_locations_spec.cr
new file mode 100644
index 00000000000..7c83616fd12
--- /dev/null
+++ b/drivers/place/desk_bookings_locations_spec.cr
@@ -0,0 +1,91 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::DeskBookingsLocations" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ AreaManagement: {AreaManagementMock},
+ })
+
+ settings({
+ zone_filter: ["placeos-zone-id"],
+ })
+
+ now = Time.local
+ start = now.at_beginning_of_day.to_unix
+ ending = now.at_end_of_day.to_unix
+
+ exec(:query_desk_bookings).get
+ resp = exec(:device_locations, "placeos-zone-id").get
+ puts resp
+ resp.should eq([
+ {"location" => "booking", "type" => "desk", "checked_in" => true, "asset_id" => "desk-123", "booking_id" => 1, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-1234", "staff_email" => "user1234@org.com", "staff_name" => "Bob Jane"},
+ {"location" => "booking", "type" => "desk", "checked_in" => false, "asset_id" => "desk-456", "booking_id" => 2, "building" => "zone-building", "level" => "placeos-zone-id", "ends_at" => ending, "started_at" => start, "duration" => 86399, "mac" => "user-456", "staff_email" => "zdoo@org.com", "staff_name" => "Zee Doo"},
+ ])
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def query_bookings(type : String, zones : Array(String))
+ logger.debug { "Querying desk bookings!" }
+
+ now = Time.local
+ start = now.at_beginning_of_day.to_unix
+ ending = now.at_end_of_day.to_unix
+ [
+ {
+ id: 1,
+ booking_type: type,
+ booking_start: start,
+ booking_end: ending,
+ asset_id: "desk-123",
+ user_id: "user-1234",
+ user_email: "user1234@org.com",
+ user_name: "Bob Jane",
+ zones: zones + ["zone-building"],
+ checked_in: true,
+ rejected: false,
+ booked_by_name: "Bob Jane",
+ booked_by_email: "user1234@org.com",
+ },
+ {
+ id: 2,
+ booking_type: type,
+ booking_start: start,
+ booking_end: ending,
+ asset_id: "desk-456",
+ user_id: "user-456",
+ user_email: "zdoo@org.com",
+ user_name: "Zee Doo",
+ zones: zones + ["zone-building"],
+ checked_in: false,
+ rejected: false,
+ booked_by_name: "Zee Doo",
+ booked_by_email: "zdoo@org.com",
+ },
+ ]
+ end
+
+ def zone(zone_id : String)
+ logger.info { "requesting zone #{zone_id}" }
+
+ if zone_id == "placeos-zone-id"
+ {
+ id: zone_id,
+ tags: ["level"],
+ }
+ else
+ {
+ id: zone_id,
+ tags: ["building"],
+ }
+ end
+ end
+end
+
+# :nodoc:
+class AreaManagementMock < DriverSpecs::MockDriver
+ def update_available(zones : Array(String))
+ logger.info { "requested update to #{zones}" }
+ nil
+ end
+end
diff --git a/drivers/place/event_mailer.cr b/drivers/place/event_mailer.cr
new file mode 100644
index 00000000000..772eb7d9a38
--- /dev/null
+++ b/drivers/place/event_mailer.cr
@@ -0,0 +1,232 @@
+require "placeos-driver"
+require "place_calendar"
+require "placeos-driver/interface/mailer_templates"
+
+require "./password_generator_helper"
+
+class Place::EventMailer < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "PlaceOS Event Mailer"
+ generic_name :EventMailer
+ description %(Subscribe to Events and send emails to attendees)
+
+ default_settings({
+ zone_ids_to_target: ["zone-id-here"],
+ module_to_target: "Bookings_1",
+ module_status_to_scrape: "bookings",
+ event_filter: "occurs_today",
+ email_template_group: "events",
+ email_template: "welcome",
+ send_network_credentials: false,
+ network_password_length: DEFAULT_PASSWORD_LENGTH,
+ network_password_exclude: DEFAULT_PASSWORD_EXCLUDE,
+ network_password_minimum_lowercase: DEFAULT_PASSWORD_MINIMUM_LOWERCASE,
+ network_password_minimum_uppercase: DEFAULT_PASSWORD_MINIMUM_UPPERCASE,
+ network_password_minimum_numbers: DEFAULT_PASSWORD_MINIMUM_NUMBERS,
+ network_password_minimum_symbols: DEFAULT_PASSWORD_MINIMUM_SYMBOLS,
+ network_group_ids: [] of String,
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+ debug: false,
+ })
+
+ accessor staff_api : StaffAPI_1
+ accessor mailer : Mailer_1
+ accessor network_provider : NetworkAccess_1 # Written for Cisco ISE Driver, but ideally compatible with others
+
+ @target_zones = [] of String
+ @target_module = "Bookings_1"
+ @target_status = "bookings"
+ @event_filter = "occurs_today"
+ @email_template_group = "events"
+ @email_template = "welcome"
+ @send_network_credentials = false
+ @network_password_length : Int32 = DEFAULT_PASSWORD_LENGTH
+ @network_password_exclude : String = DEFAULT_PASSWORD_EXCLUDE
+ @network_password_minimum_lowercase : Int32 = DEFAULT_PASSWORD_MINIMUM_LOWERCASE
+ @network_password_minimum_uppercase : Int32 = DEFAULT_PASSWORD_MINIMUM_UPPERCASE
+ @network_password_minimum_numbers : Int32 = DEFAULT_PASSWORD_MINIMUM_NUMBERS
+ @network_password_minimum_symbols : Int32 = DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+ @network_group_ids = [] of String
+
+ # See: https://crystal-lang.org/api/0.35.1/Time/Format.html
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+
+ @debug = false
+ @events = {} of String => Array(PlaceCalendar::Event) # {sys_id: [event]}
+
+ def on_update
+ @target_zones = setting?(Array(String), :zone_ids_to_target) || [] of String
+ @target_module = setting?(String, :module_to_target) || "Bookings_1"
+ @target_status = setting?(String, :module_status_to_target) || "bookings"
+ @event_filter = setting?(String, :event_filter) || ""
+ @email_template_group = setting?(String, :email_template_group) || "events"
+ @email_template = setting?(String, :email_template) || "welcome"
+
+ @send_network_credentials = setting?(Bool, :send_network_credentials) || false
+ @network_password_length = setting?(Int32, :password_length) || DEFAULT_PASSWORD_LENGTH
+ @network_password_exclude = setting?(String, :password_exclude) || DEFAULT_PASSWORD_EXCLUDE
+ @network_password_minimum_lowercase = setting?(Int32, :password_minimum_lowercase) || DEFAULT_PASSWORD_MINIMUM_LOWERCASE
+ @network_password_minimum_uppercase = setting?(Int32, :password_minimum_uppercase) || DEFAULT_PASSWORD_MINIMUM_UPPERCASE
+ @network_password_minimum_numbers = setting?(Int32, :password_minimum_numbers) || DEFAULT_PASSWORD_MINIMUM_NUMBERS
+ @network_password_minimum_symbols = setting?(Int32, :password_minimum_symbols) || DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+ @network_group_ids = setting?(Array(String), :network_group_ids) || [] of String
+
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+ @debug = setting?(Bool, :debug) || false
+
+ self[:events] = @events.clear
+
+ subscribe_to_all_modules
+ end
+
+ private def subscribe_to_all_modules
+ # Subscribe to the targetted state of all matching modules and update our state when they change
+ subscriptions.clear
+ list_target_systems.map do |sys|
+ sys_id = sys["id"].to_s
+ system(sys_id).subscribe(@target_module, @target_status) do |_subscription, new_value|
+ process_updated_events(sys_id, Array(PlaceCalendar::Event).from_json(new_value))
+ end
+ end
+ end
+
+ def list_target_systems
+ @target_zones.flat_map { |zone_id| list_systems_in_zone(zone_id) }
+ end
+
+ def list_systems_in_zone(zone_id : String)
+ staff_api.systems(zone_id: zone_id).get.as_a
+ end
+
+ def inspect_event_store
+ @events
+ end
+
+ private def process_updated_events(system_id : String, events : Array(PlaceCalendar::Event))
+ logger.debug { "Detected #{events.size} new Events in #{system_id}" } if @debug
+ selected_events = apply_filter(events)
+ logger.debug { "Filtered to #{selected_events.size} events with filter #{@event_filter}" } if @debug
+
+ new_events = selected_events
+ # new_events = selected_events - @events[system_id] # Don't process events we've already seen in the past
+ # @events[system_id] = new_events # Store the updated list of events
+ # self[:events] = @events
+
+ logger.debug { "Sending emails for #{new_events.size} events in #{system_id}" }
+ new_events.each { |event| send_event_email(event, system_id) }
+ end
+
+ def template_fields : Array(TemplateFields)
+ time_now = Time.utc.in(Time::Location.local)
+ [
+ TemplateFields.new(
+ trigger: {@email_template_group, @email_template},
+ name: "Event welcome",
+ description: "Welcome email sent to event organizers when their event is coming up today",
+ fields: [
+ {name: "host_name", description: "Name of the event organizer"},
+ {name: "host_email", description: "Email address of the event organizer"},
+ {name: "room_name", description: "Location or room where the event is being held"},
+ {name: "event_title", description: "Title or subject of the event"},
+ {name: "event_start", description: "Start time of the event (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "event_date", description: "Date of the event (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "network_username", description: "Username for network access (only if network credentials enabled)"},
+ {name: "network_password", description: "Generated password for network access (only if network credentials enabled)"},
+ ]
+ ),
+ ]
+ end
+
+ private def send_event_email(event : PlaceCalendar::Event, system_id : String)
+ # Don't send welcome email more than once
+ # Surely there's a tidier way to do these 2 lines?
+ extension_data = event.extended_properties # https://github.com/PlaceOS/calendar/blob/master/src/models/event.cr#L38
+ return "Event email was already sent at #{extension_data[:event_mailer_email_sent_at]}" if extension_data && extension_data.not_nil![:event_mailer_email_sent_at]
+
+ organizer_email = event.host
+ organizer_name = event.attendees.find { |a| a.email == organizer_email }.try &.name || "Name Unknown"
+ network_username = network_password = ""
+ network_username, network_password = update_network_user_password(
+ organizer_email.not_nil!,
+ generate_password(
+ length: @network_password_length,
+ exclude: @network_password_exclude,
+ minimum_lowercase: @network_password_minimum_lowercase,
+ minimum_uppercase: @network_password_minimum_uppercase,
+ minimum_numbers: @network_password_minimum_numbers,
+ minimum_symbols: @network_password_minimum_symbols
+ ),
+ @network_group_ids
+ ) if @send_network_credentials
+
+ email_data = {
+ host_name: organizer_name,
+ host_email: organizer_email,
+ room_name: event.location,
+ event_title: event.title,
+ event_start: event.event_start.to_s(@time_format),
+ event_date: event.event_end.not_nil!.to_s(@date_format),
+ network_username: network_username,
+ network_password: network_password,
+ }
+ begin
+ logger.debug { "SENDING welcome email: #{email_data}" }
+ mailer.send_template(
+ to: [organizer_email],
+ template: {@email_template_group, @email_template},
+ args: email_data
+ ).get
+ rescue
+ logger.error { "ERROR when attempting to send welcome email" }
+ else
+ staff_api.patch_event_metadata(system_id, event.id, {"event_mailer_email_sent_at": Time.local}).get
+ end
+ end
+
+ private def apply_filter(events : Array(PlaceCalendar::Event))
+ # Additional event filters can be added in the future
+ case @event_filter
+ when "occurs_today"
+ logger.debug { "Event filter: occurs today" } if @debug
+ select_todays_events(events)
+ else
+ logger.debug { "Event filter: NONE" } if @debug
+ events
+ end
+ end
+
+ private def select_todays_events(events : Array(PlaceCalendar::Event))
+ events.select do |event|
+ logger.debug { "Processing event #{event.inspect}" } if @debug
+ timezone = event.timezone ? Time::Location.load(event.timezone.not_nil!) : Time::Location.local
+ now = Time.local(location: timezone)
+ event.event_start >= now.at_beginning_of_day && event.event_start <= now.at_end_of_day
+ end
+ end
+
+ # # For Cisco ISE network credentials
+
+ def update_network_user_password(user_email : String, password : String, network_group_ids : Array(String) = [] of String)
+ # Check if they already exist
+ response = network_provider.update_internal_user_password_by_name(user_email, password).get
+ logger.debug { "Response from Network Identity provider for lookup of #{user_email} was:\n#{response}" } if @debug
+ rescue # todo: catch the specific error where the user already exists, instead of any error. Catch other errors in seperate rescue
+ # Create them if they don't already exist
+ create_network_user(user_email, password, network_group_ids)
+ else
+ {user_email, password}
+ end
+
+ def create_network_user(user_email : String, password : String, group_ids : Array(String) = [] of String)
+ response = network_provider.create_internal_user(email: user_email, name: user_email, password: password, identity_groups: group_ids).get
+ logger.debug { "Response from Network Identity provider for creating user #{user_email} was:\n #{response}\n\nDetails:\n#{response.inspect}" } if @debug
+ {response["name"], password}
+ end
+end
diff --git a/drivers/place/event_setup_breakdown_time.cr b/drivers/place/event_setup_breakdown_time.cr
new file mode 100644
index 00000000000..e56aa66fe09
--- /dev/null
+++ b/drivers/place/event_setup_breakdown_time.cr
@@ -0,0 +1,220 @@
+require "placeos-driver"
+require "place_calendar"
+
+class Place::EventSetupBreakdownTime < PlaceOS::Driver
+ descriptive_name "PlaceOS Event Setup/Breakdown Time"
+ generic_name :EventSetupBreakdownTime
+ description %(Manages setup/breakdown time before/after events)
+
+ accessor staff_api : StaffAPI_1
+ accessor calendar : Calendar_1
+
+ @event_change_mutex : Mutex = Mutex.new
+
+ def on_load
+ monitor("staff/event/changed") do |_subscription, payload|
+ begin
+ logger.debug { "received event changed signal #{payload}" }
+
+ @event_change_mutex.synchronize do
+ event_changed(EventChangedSignal.from_json(payload))
+ end
+ rescue error
+ logger.warn(exception: error) { "error processing event changed signal" }
+ end
+ end
+
+ on_update
+ end
+
+ def on_update
+ end
+
+ private def event_changed(signal : EventChangedSignal)
+ system_id = signal.system_id
+ event = signal.event
+ calendar_id = signal.resource
+ cancelled = event.status == "cancelled" || signal.action == "cancelled"
+
+ # delete setup/breakdown events if event is cancelled
+ if cancelled
+ if setup_event_id = event.setup_event_id
+ calendar.delete_event(
+ calendar_id: calendar_id,
+ event_id: setup_event_id,
+ )
+ logger.debug { "deleted setup event #{setup_event_id}" }
+ end
+ if breakdown_event_id = event.breakdown_event_id
+ calendar.delete_event(
+ calendar_id: calendar_id,
+ event_id: breakdown_event_id,
+ )
+ logger.debug { "deleted breakdown event #{breakdown_event_id}" }
+ end
+ return
+ end
+
+ # skip if no changes
+ if meta = Array(EventMetadata).from_json(staff_api.query_metadata(system_id: system_id, event_ref: [signal.event_id, signal.event_ical_uid]).get.to_json).first?
+ if meta.setup_time == event.setup_time &&
+ meta.setup_event_id == event.setup_event_id &&
+ (
+ (meta.setup_time > 0 && meta.setup_event_id) ||
+ (meta.setup_time == 0 && !meta.setup_event_id.presence)
+ ) &&
+ meta.breakdown_time == event.breakdown_time &&
+ meta.breakdown_event_id == event.breakdown_event_id &&
+ (
+ (meta.breakdown_time > 0 && meta.breakdown_event_id) ||
+ (meta.breakdown_time == 0 && !meta.breakdown_event_id.presence)
+ )
+ logger.debug { "skipping event #{signal.event_id} on #{calendar_id} as no changes" }
+ return
+ end
+ end
+
+ raise "missing event_start time" unless event_start = event.event_start
+ raise "missing event_end time" unless event_end = event.event_end
+
+ linked_events = LinkedEvents.new(main_event_ical: event.ical_uid, main_event_id: event.id)
+ linked_events.setup_event_id = event.setup_event_id if event.setup_event_id
+ linked_events.breakdown_event_id = event.breakdown_event_id if event.breakdown_event_id
+
+ linked_events = LinkedEvents.new(main_event_ical: event.ical_uid, main_event_id: event.id)
+ linked_events.setup_event_id = event.setup_event_id if event.setup_event_id
+ linked_events.breakdown_event_id = event.breakdown_event_id if event.breakdown_event_id
+
+ # create/update setup event
+ if (setup_time = event.setup_time) && setup_time > 0
+ if setup_event_id = event.setup_event_id
+ setup_event = PlaceCalendar::Event.from_json calendar.get_event(calendar_id: calendar_id, event_id: setup_event_id).get.to_json
+ setup_event.event_start = event_start - setup_time.minutes
+ setup_event.event_end = event_start
+ setup_event.body = "<<<#{linked_events.to_json}}>>>"
+ calendar.update_event(event: setup_event, calendar_id: calendar_id)
+ logger.debug { "updated setup event #{setup_event_id} on #{calendar_id}" }
+ else
+ setup_event = PlaceCalendar::Event.from_json calendar.create_event(
+ calendar_id: calendar_id,
+ title: "Setup for #{event.title}",
+ event_start: (event_start - setup_time.minutes).to_unix,
+ event_end: event_start.to_unix,
+ description: "<<<#{linked_events.to_json}}>>>",
+ attendees: [PlaceCalendar::Event::Attendee.new(name: calendar_id, email: calendar_id, response_status: "accepted", resource: true, organizer: true)],
+ ).get.to_json
+
+ linked_events.setup_event_id = setup_event.id
+ logger.debug { "created setup event #{setup_event.id} on #{calendar_id}" }
+ event.setup_event_id = setup_event.id
+ end
+ elsif (setup_time = event.setup_time) && (setup_event_id = event.setup_event_id) && setup_time == 0
+ calendar.delete_event(
+ calendar_id: calendar_id,
+ event_id: setup_event_id,
+ )
+ logger.debug { "deleted setup event #{setup_event_id} on #{calendar_id}" }
+ event.setup_event_id = ""
+ end
+
+ # create/update breakdown event
+ if (breakdown_time = event.breakdown_time) && breakdown_time > 0
+ if breakdown_event_id = event.breakdown_event_id
+ breakdown_event = PlaceCalendar::Event.from_json calendar.get_event(calendar_id: calendar_id, event_id: breakdown_event_id).get.to_json
+ breakdown_event.event_start = event_end
+ breakdown_event.event_end = event_end + breakdown_time.minutes
+ breakdown_event.body = "<<<#{linked_events.to_json}}>>>"
+ calendar.update_event(event: breakdown_event, calendar_id: calendar_id)
+ logger.debug { "updated breakdown event #{breakdown_event_id} on #{calendar_id}" }
+ else
+ breakdown_event = PlaceCalendar::Event.from_json calendar.create_event(
+ calendar_id: calendar_id,
+ title: "Breakdown for #{event.title}",
+ event_start: event_end.to_unix,
+ event_end: (event_end + breakdown_time.minutes).to_unix,
+ description: "<<<#{linked_events.to_json}}>>>",
+ attendees: [PlaceCalendar::Event::Attendee.new(name: calendar_id, email: calendar_id, response_status: "accepted", resource: true, organizer: true)],
+ ).get.to_json
+
+ logger.debug { "created breakdown event #{breakdown_event.id} on #{calendar_id}" }
+ event.breakdown_event_id = breakdown_event.id
+ end
+ elsif (breakdown_time = event.breakdown_time) && (breakdown_event_id = event.breakdown_event_id) && breakdown_time == 0
+ calendar.delete_event(
+ calendar_id: calendar_id,
+ event_id: breakdown_event_id,
+ )
+ logger.debug { "deleted breakdown event #{breakdown_event_id} on #{calendar_id}" }
+ event.breakdown_event_id = ""
+ end
+
+ # save metadata
+ staff_api.patch_event_metadata(system_id: system_id, event_id: signal.event_id, metadata: NamedTuple.new, ical_uid: signal.event_ical_uid, setup_time: event.setup_time, breakdown_time: event.breakdown_time, setup_event_id: event.setup_event_id, breakdown_event_id: event.breakdown_event_id).get
+ end
+
+ class PlaceCalendar::Event
+ property setup_time : Int64? = nil
+ property breakdown_time : Int64? = nil
+ property setup_event_id : String? = nil
+ property breakdown_event_id : String? = nil
+ end
+
+ struct LinkedEvents
+ include JSON::Serializable
+
+ property main_event_ical : String?
+ property main_event_id : String?
+ property setup_event_id : String?
+ property breakdown_event_id : String?
+
+ def initialize(@main_event_ical : String?, @main_event_id : String?)
+ end
+ end
+
+ struct LinkedEvents
+ include JSON::Serializable
+
+ property main_event_ical : String?
+ property main_event_id : String?
+ property setup_event_id : String?
+ property breakdown_event_id : String?
+
+ def initialize(@main_event_ical : String?, @main_event_id : String?)
+ end
+ end
+
+ struct EventChangedSignal
+ include JSON::Serializable
+
+ property action : String
+ property system_id : String
+ property event_id : String
+ property event_ical_uid : String
+ property host : String?
+ property resource : String
+ property event : PlaceCalendar::Event
+ property ext_data : JSON::Any?
+ end
+
+ struct EventMetadata
+ include JSON::Serializable
+
+ property system_id : String
+ property event_id : String
+ property recurring_master_id : String?
+ property ical_uid : String
+
+ property host_email : String
+ property resource_calendar : String
+ property event_start : Int64
+ property event_end : Int64
+ property cancelled : Bool = false
+
+ property ext_data : JSON::Any?
+
+ property setup_time : Int64 = 0
+ property breakdown_time : Int64 = 0
+ property setup_event_id : String?
+ property breakdown_event_id : String?
+ end
+end
diff --git a/drivers/place/event_setup_breakdown_time_spec.cr b/drivers/place/event_setup_breakdown_time_spec.cr
new file mode 100644
index 00000000000..0b274bc8510
--- /dev/null
+++ b/drivers/place/event_setup_breakdown_time_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::EventSetupBreakdownTime" do
+end
diff --git a/drivers/place/http_pinger.cr b/drivers/place/http_pinger.cr
new file mode 100644
index 00000000000..82ff180e43b
--- /dev/null
+++ b/drivers/place/http_pinger.cr
@@ -0,0 +1,87 @@
+require "placeos-driver"
+
+class Place::HTTPPinger < PlaceOS::Driver
+ # Discovery Information
+ generic_name :Ping
+ descriptive_name "Service Status Check"
+ uri_base "https://192.168.0.2/api/healthcheck"
+
+ default_settings({
+ basic_auth: {
+ username: "srvc_acct",
+ password: "password!",
+ },
+ ping_every: 60,
+ expected_response_code: 200,
+ http_max_requests: 0,
+ request_verb: "GET",
+ request_headers: {
+ "Accept" => "application/json",
+ },
+ })
+
+ getter response_mismatch_count : UInt64 = 0_u64
+ getter response_failure_count : UInt64 = 0_u64
+
+ getter expected_response_code : Int32 = 200
+ getter request_verb : String = "GET"
+ @request_headers : HTTP::Headers = HTTP::Headers.new
+ alias HeaderJSON = Hash(String, Array(String) | String)
+
+ def on_load
+ transport.before_request do |request|
+ logger.debug { "using proxy #{!!transport.proxy_in_use} #{transport.proxy_in_use.inspect}\nconnecting to host: #{config.uri}\nperforming request: #{request.method} #{request.path}\nheaders: #{request.headers}\n#{!request.body.nil? ? String.new(request.body.as(IO::Memory).to_slice) : nil}" }
+ end
+
+ on_update
+ end
+
+ def on_update
+ schedule.clear
+ schedule.every((setting?(Int32, :ping_every) || 60).seconds) { check_status }
+ @request_verb = setting?(String, :request_verb) || "GET"
+ @expected_response_code = setting?(Int32, :expected_response_code) || 200
+
+ request_headers = HTTP::Headers.new
+ headers = setting?(HeaderJSON, :request_headers) || {} of String => Array(String) | String
+ headers.each { |key, value| request_headers.add(key, value) }
+ @request_headers = request_headers
+ end
+
+ def connected
+ check_status
+ end
+
+ def check_status : Bool
+ response = http(@request_verb, "/", headers: @request_headers)
+
+ if response.status_code == expected_response_code
+ self[:last_successful_check] = Time.utc.to_unix
+ self[:last_response_code] = response.status_code
+ true
+ else
+ self[:last_response_code] = response.status_code
+ @response_mismatch_count += 1
+ self[:response_mismatch_count] = @response_mismatch_count
+ queue.online = false
+ false
+ end
+ rescue error
+ logger.warn(exception: error) { "HTTP service not responding" }
+ @response_failure_count += 1
+ self[:response_failure_count] = @response_failure_count
+ self[:last_error] = error.message
+ false
+ end
+
+ DUMMY_CALLBACK = Proc(Task, Nil).new { nil }
+
+ @[Security(Level::Administrator)]
+ def curl(verb : String, path : String, headers : Hash(String, String) = {} of String => String, body : String? = nil)
+ response = http(verb, path, body, headers: headers)
+ logger.debug { "response #{response.status}: #{response.status_message}\nheaders: #{response.headers}\n#{response.body}" }
+
+ task = PlaceOS::Driver::Task.new(queue, DUMMY_CALLBACK, 0, 0.seconds, 0, false, nil, nil)
+ task.success response.body, response.status_code
+ end
+end
diff --git a/drivers/place/http_pinger_spec.cr b/drivers/place/http_pinger_spec.cr
new file mode 100644
index 00000000000..f1e5362c086
--- /dev/null
+++ b/drivers/place/http_pinger_spec.cr
@@ -0,0 +1,22 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::HTTPPinger" do
+ expect_http_request do |request, response|
+ response.status_code = 200
+ end
+
+ sleep 1
+
+ status[:last_response_code]?.should eq 200
+ status[:connected]?.should eq true
+
+ retval = exec(:check_status)
+ expect_http_request do |request, response|
+ response.status_code = 400
+ end
+ retval.get
+
+ status[:last_response_code]?.should eq 400
+ status[:response_mismatch_count]?.should eq 1
+ status[:connected]?.should eq false
+end
diff --git a/drivers/place/llm/llm.cr b/drivers/place/llm/llm.cr
new file mode 100644
index 00000000000..66e11088f53
--- /dev/null
+++ b/drivers/place/llm/llm.cr
@@ -0,0 +1,64 @@
+require "placeos-driver"
+require "placeos-driver/interface/chat_functions"
+
+class Place::LLM < PlaceOS::Driver
+ descriptive_name "PlaceOS LLM Interface"
+ generic_name :LLM
+ description %(an interface for LLMs such as ChatGPT for discovering capabilities)
+
+ default_settings({
+ prompt: %(you are an AI assistant in a smart building.
+Helping a staff member with every day tasks.
+Don't disclose that you're an AI
+Skip language that implies regret or apology
+say 'I don't know' for unknowns
+skip expert disclaimers
+no repetitive answers
+don't direct to other sources
+focus on key points in questions
+simplify complex issues with steps
+clarify unclear questions before answering
+request any missing details before running functions, such as meeting title etc
+bookings cannot be made in the past
+correct errors in previous answers
+end with follow up questions where applicable),
+
+ user_hint: "Hi! I'm your workplace assistant.\n" +
+ "I can get you instant answers for almost anything as well as perform actions such as booking a meeting room.\n" +
+ "How can I help?",
+ })
+
+ def on_update
+ @prompt = setting(String, :prompt)
+ @user_hint = setting?(String, :user_hint) || "Hi! I'm your workplace assistant."
+
+ schedule.clear
+ schedule.in(5.seconds) { update_prompt }
+ schedule.every(5.minutes) { update_prompt }
+ end
+
+ getter! user_hint : String
+ getter! prompt : String
+
+ def capabilities
+ system.implementing(Interface::ChatFunctions).map do |driver|
+ {
+ id: driver.module_name,
+ capability: driver[:capabilities].as_s,
+ }
+ end
+ end
+
+ def new_chat
+ {
+ prompt: @prompt,
+ capabilities: capabilities,
+ system_id: system.id,
+ }
+ end
+
+ protected def update_prompt
+ self[:prompt] = new_chat
+ self[:user_hint] = @user_hint
+ end
+end
diff --git a/drivers/place/llm/llm_spec.cr b/drivers/place/llm/llm_spec.cr
new file mode 100644
index 00000000000..525f5aabf56
--- /dev/null
+++ b/drivers/place/llm/llm_spec.cr
@@ -0,0 +1,94 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/chat_functions"
+
+DriverSpecs.mock_driver "Place::LLM" do
+ system({
+ DeskBookings: {DeskMock},
+ RoomBookings: {RoomMock},
+ })
+
+ sleep 200.milliseconds
+
+ exec(:capabilities).get.should eq [
+ {
+ "id" => "DeskBookings",
+ "capability" => "provides methods listing current desk bookings and booking or allocating a new desk booking",
+ },
+ {
+ "id" => "RoomBookings",
+ "capability" => "provides methods listing current meeting room bookings or events and booking new meetings or events",
+ },
+ ]
+
+ system(:DeskBookings_1).function_schemas.should eq([
+ {
+ function: "list_of_levels",
+ description: "returns the list of levels with available desks",
+ parameters: {} of String => JSON::Any,
+ },
+ {
+ function: "book",
+ description: "books a desk, you can optionally provide a preferred level or how many days from now if the booking is for tomorrow etc",
+ parameters: {
+ "level" => {
+ "anyOf" => [{"type" => "null"}, {"type" => "string"}],
+ "title" => "(String | Nil)",
+ "default" => nil,
+ },
+ "days_in_future" => {
+ "type" => "integer",
+ "format" => "Int32",
+ "title" => "Int32",
+ "default" => 0,
+ },
+ },
+ },
+ ])
+end
+
+# :nodoc:
+class DeskMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::ChatFunctions
+
+ def capabilities : String
+ "provides methods listing current desk bookings and booking or allocating a new desk booking"
+ end
+
+ @[Description("returns the list of levels with available desks")]
+ def list_of_levels
+ {"level 1", "level 2"}
+ end
+
+ @[Description("books a desk, you can optionally provide a preferred level or how many days from now if the booking is for tomorrow etc")]
+ def book(level : String? = nil, days_in_future : Int32 = 0)
+ "you've been allocated desk 123 on #{level || "level 2"}"
+ end
+end
+
+# :nodoc:
+class RoomMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::ChatFunctions
+
+ def capabilities : String
+ "provides methods listing current meeting room bookings or events and booking new meetings or events"
+ end
+
+ def my_bookings(days_in_future : Int32 = 0)
+ [{
+ room: "room@site.com",
+ booking_id: "12345",
+ title: "Blah",
+ organizer: "Janis",
+ }]
+ end
+
+ @[Description("returns the list of levels with available rooms")]
+ def list_of_levels(number_of_attendees : Int32)
+ {"level 1", "level 2"}
+ end
+
+ @[Description("books a room, you can optionally provide a preferred level or how many days from now if the booking is for tomorrow etc")]
+ def book(number_of_attendees : Int32, level : String? = nil, days_in_future : Int32 = 0)
+ "you've been allocated room studio 2 on #{level || "level 2"}"
+ end
+end
diff --git a/drivers/place/llm/schedule.cr b/drivers/place/llm/schedule.cr
new file mode 100644
index 00000000000..196c7e3b71b
--- /dev/null
+++ b/drivers/place/llm/schedule.cr
@@ -0,0 +1,393 @@
+require "placeos-driver"
+require "placeos-driver/interface/chat_functions"
+require "place_calendar"
+
+class Place::Schedule < PlaceOS::Driver
+ include Interface::ChatFunctions
+
+ descriptive_name "LLM Users Schedule"
+ generic_name :Schedule
+ description %(provides calendaring functions to a LLM)
+
+ default_settings({
+ platform: "office365", # or "google"
+ email_domain: "org.com",
+ conference_type: "teamsForBusiness",
+
+ # fallback if there isn't one on the zone
+ time_zone: "Australia/Sydney",
+ })
+
+ @platform : String = "office365"
+ @email_domain : String = "org.com"
+ @conference_type : String? = "teamsForBusiness"
+ @fallback_timezone : Time::Location = Time::Location::UTC
+
+ def on_update
+ timezone = config.control_system.not_nil!.timezone.presence || setting?(String, :time_zone).presence || "Australia/Sydney"
+ @fallback_timezone = Time::Location.load(timezone)
+ @platform = setting?(String, :platform) || "office365"
+ @email_domain = setting?(String, :email_domain) || "org.com"
+ @conference_type = setting?(String, :conference_type)
+ end
+
+ # =========================
+ # The LLM Interface
+ # =========================
+
+ getter capabilities : String do
+ String.build do |str|
+ str << "lookup or search for the email and phone numbers of other staff members if you haven't been provided their details. Do not guess.\n"
+ str << "provides details of my daily schedule, meeting room bookings and events I'm attending.\n"
+ str << "meeting room bookings must have a resource as an attendee.\n"
+ str << "my meeting room bookings will have me as the host or creator.\n"
+ str << "meeting rooms are the attendees marked as resources.\n"
+ str << "all day events may not have an ending time.\n"
+ str << "internal staff have the following email domain: #{@email_domain}. We can only obtain the schedules of internal staff\n"
+ str << "check schedules before booking or moving meetings to ensure no one is busy at that time\n"
+ end
+ end
+
+ @[Description("returns my schedule with event details with attendees and their response status. day_offset: 0 will return todays schedule, day_offset: 1 will return tomorrows schedule etc. If you provide a date, in ISO 8601 format and the correct timezone, the date will be used.")]
+ def my_schedule(day_offset : Int32 = 0, date : Time? = nil)
+ cal_client = place_calendar_client
+ me = current_user
+
+ if date
+ starting = date.in(timezone).at_beginning_of_day
+ else
+ now = Time.local(timezone)
+ days = day_offset.days
+ starting = now.at_beginning_of_day + days
+ end
+ ending = starting.at_end_of_day
+
+ logger.debug { "requesting events for #{me.name} (#{me.email}) @ #{starting} -> #{ending}" }
+
+ events = cal_client.list_events(me.email, period_start: starting, period_end: ending)
+ events = Array(Event).from_json(events.to_json)
+ events.each { |event| event.configure_times(timezone) }
+
+ events
+ end
+
+ @[Description("search for a staff members phone and email addresses using odata filter queries, don't include `$filter=`, for example: `givenName eq 'mary' or startswith(surname,'smith')`, confrim with the user when there are multiple results, search for both givenName and surname using `or` if there is ambiguity")]
+ def search_staff_member(filter : String)
+ logger.debug { "searching for staff member: #{filter}" }
+ cal_client = place_calendar_client
+ cal_client.list_users(filter: filter)
+ end
+
+ @[Description("look up a staff members name and phone number by providing their email address. Use search if you only have their name")]
+ def lookup_staff_member(email : String)
+ logger.debug { "looking up staff member: #{email}" }
+ cal_client = place_calendar_client
+ user = cal_client.get_user_by_email(email)
+ return "could not find a staff member with email #{email}. Try searching for their name?" unless user
+ user
+ end
+
+ @[Description("returns busy periods of the emails specified. Search for staff first if you haven't been given their email address. This can be a person or a resource like a room. An empty schedules array means they are available")]
+ def get_schedules(emails : Array(String), day_offset : Int32 = 0, date : Time? = nil)
+ cal_client = place_calendar_client
+ me = current_user
+
+ if date
+ starting = date.in(timezone).at_beginning_of_day
+ else
+ now = Time.local(timezone)
+ days = day_offset.days
+ starting = now.at_beginning_of_day + days
+ end
+ ending = starting.at_end_of_day
+ return "past schedules are not useful" if ending < Time.utc
+
+ duration = ending - starting
+
+ logger.debug { "getting schedules for #{emails} @ #{starting} -> #{ending}" }
+
+ availability_view_interval = {duration, 30.minutes}.min.total_minutes.to_i!
+
+ # format the data that helps the LLM make sense of it
+ tz = timezone
+ cal_client.get_availability(me.email, emails, starting, ending, view_interval: availability_view_interval).map do |avail|
+ {
+ email: avail.calendar,
+ schedule: avail.availability.map do |sched|
+ {
+ status: sched.status,
+ starting: sched.starts_at.in(tz),
+ ending: sched.ends_at.in(tz),
+ }
+ end,
+ }
+ end
+ end
+
+ @[Description("create a calendar entry with the provided event details. Make sure the attendees are available by getting their schedules first, remember to include the host in the attendees list. An ending time is required except for all day bookings. You can specify an alternate host if booking on behalf of someone else. Don't provide a response_status for attendees when using this function. Starting and ending date times must be ISO 8601 formatted with the timezone")]
+ def create(event : CreateEvent)
+ cal_client = place_calendar_client
+ me = current_user
+ my_email = me.email.downcase
+ host_email = (event.host.presence || me.email).downcase
+ i_am_host = host_email == my_email
+ host_name = host_email
+
+ attendees = event.attendees.uniq.reject do |attendee|
+ attend_email = attendee.email.downcase
+ if attend_email == host_email
+ host_name = attendee.name
+ true
+ elsif attend_email == my_email
+ attendee.organizer = true
+ false
+ end
+ end
+ attendees << PlaceCalendar::Event::Attendee.new(name: i_am_host ? me.name : host_name, email: host_email, response_status: "accepted", organizer: i_am_host)
+
+ return "error: ending time required unless this is an all_day event" if event.ending.nil? && event.all_day == false
+
+ # create the calendar event
+ new_event = PlaceCalendar::Event.new
+ new_event.attendees = attendees
+ new_event.title = event.title
+ new_event.location = event.location
+ new_event.all_day = event.all_day
+ new_event.event_start = event.starting.in(timezone)
+ new_event.event_end = event.ending.try &.in(timezone)
+ new_event.body = event.title
+ new_event.timezone = timezone.name
+ new_event.creator = my_email
+ new_event.host = host_email
+
+ logger.debug { "creating booking: #{new_event.inspect}" }
+
+ # convert to the simplified view
+ created_event = cal_client.create_event(user_id: my_email, event: new_event, calendar_id: host_email)
+ Event.from_json(created_event.to_json).configure_times(timezone)
+ end
+
+ @[Description("update the details of an existing event. The original id is required, otherwise you only need to provide the changes. You must provide the complete list of attendees if that list is being modified. Don't provide a response_status for attendees when using this function. You can't modify events where the start time is in the past")]
+ def modify(event : UpdateEvent)
+ cal_client = place_calendar_client
+ me = current_user
+
+ # fetch existing event
+ existing = cal_client.get_event(me.email, id: event.id)
+ return "error: could not find event with id '#{event.id}', it may have been cancelled?" unless existing
+
+ # update with these new details
+ {% for param in %w(title location host attendees) %}
+ existing.{{param.id}} = event.{{param.id}}.nil? ? existing.{{param.id}} : event.{{param.id}}.not_nil!
+ {% end %}
+
+ existing.event_start = event.starting.nil? ? existing.event_start.in(timezone) : event.starting.not_nil!.in(timezone)
+ if event.all_day
+ existing.all_day = true
+ existing.event_end = nil
+ else
+ existing.all_day = false
+ existing.event_end = event.ending.nil? ? existing.event_end.try(&.in(timezone)) : event.ending.not_nil!.in(timezone)
+ return "error: ending time required unless this is an all_day event" if event.ending.nil? && event.all_day == false
+ end
+
+ logger.debug { "updating event: #{existing.inspect}" }
+
+ # update the event
+ updated_event = cal_client.update_event(user_id: me.email, event: existing, calendar_id: existing.host)
+ Event.from_json(updated_event.to_json).configure_times(timezone)
+ end
+
+ @[Description("cancels an event with an optional reason")]
+ def cancel(event_id : String, reason : String? = nil)
+ cal_client = place_calendar_client
+ me = current_user
+
+ logger.debug { "declining event: #{event_id}" }
+
+ cal_client.decline_event(
+ user_id: me.email,
+ id: event_id,
+ notify: !!reason,
+ comment: reason
+ )
+
+ "cancelled"
+ end
+
+ enum Attendance
+ Attend
+ Decline
+ end
+
+ @[Description("use to confirm your attendance at a meeting this will update your attendee response_status in the specified meeting from your schedule. You should probably provide a reason when declining, however this is optional")]
+ def update_attending_status(event_id : String, attendance : Attendance, reason : String? = nil)
+ cal_client = place_calendar_client
+ me = current_user
+
+ logger.debug { "updating attendance: #{attendance} #{reason} -> #{event_id}" }
+
+ case attendance
+ in .decline?
+ cal_client.decline_event(
+ user_id: me.email,
+ id: event_id,
+ notify: true,
+ comment: reason
+ )
+
+ "declined"
+ in .attend?
+ cal_client.accept_event(
+ user_id: me.email,
+ id: event_id,
+ notify: true,
+ comment: reason
+ )
+
+ "attending"
+ end
+ end
+
+ # =========================
+ # Support functions
+ # =========================
+
+ struct CreateEvent
+ include JSON::Serializable
+
+ getter title : String
+ getter location : String?
+ getter host : String?
+ getter attendees : Array(PlaceCalendar::Event::Attendee) = [] of PlaceCalendar::Event::Attendee
+ getter starting : Time
+ getter ending : Time?
+ getter all_day : Bool = false
+ end
+
+ struct UpdateEvent
+ include JSON::Serializable
+
+ getter id : String
+ getter title : String?
+ getter location : String?
+ getter host : String?
+ getter attendees : Array(PlaceCalendar::Event::Attendee)?
+ getter starting : Time?
+ getter ending : Time?
+ getter all_day : Bool?
+ end
+
+ class Event
+ include JSON::Serializable
+
+ getter id : String?
+ getter title : String?
+ getter location : String?
+ getter status : String?
+ getter host : String?
+ getter creator : String?
+ getter all_day : Bool
+ getter attendees : Array(PlaceCalendar::Event::Attendee)
+ getter online_meeting_url : String?
+
+ # We convert unix time into something more readable for a human or AI
+ @[JSON::Field(converter: Time::EpochConverter, type: "integer", format: "Int64", ignore_serialize: true)]
+ getter event_start : Time
+
+ @[JSON::Field(converter: Time::EpochConverter, type: "integer", format: "Int64", ignore_serialize: true)]
+ getter event_end : Time?
+
+ getter starting : Time?
+ getter ending : Time?
+
+ # these are used to configure the JSON times correctly
+ @[JSON::Field(ignore_serialize: true)]
+ getter timezone : String?
+
+ @[JSON::Field(ignore: true)]
+ getter! time_zone : Time::Location
+
+ def configure_times(tz : Time::Location)
+ @time_zone = tz
+ @starting = event_start.in(tz)
+ @ending = event_end.try &.in(tz)
+ self
+ end
+ end
+
+ struct User
+ include JSON::Serializable
+
+ getter name : String
+ getter email : String
+ end
+
+ protected def staff_api
+ system["StaffAPI_1"]
+ end
+
+ def current_user : User
+ User.from_json staff_api.user(invoked_by_user_id).get.to_json
+ end
+
+ getter timezone : Time::Location do
+ building.time_zone || @fallback_timezone
+ end
+
+ struct Zone
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String
+ getter display_name : String?
+
+ @[JSON::Field(key: "timezone")]
+ getter tz : String?
+
+ @[JSON::Field(ignore: true)]
+ getter time_zone : Time::Location? do
+ if tz = @tz.presence
+ Time::Location.load(tz)
+ end
+ end
+ end
+
+ getter building : Zone { get_building }
+
+ # Finds the building ID for the current location services object
+ def get_building : Zone
+ zones = staff_api.zones(tags: "building").get.as_a
+ zone_ids = zones.map(&.[]("id").as_s)
+ building_id = (zone_ids & system.zones).first
+
+ building = zones.find! { |zone| zone["id"].as_s == building_id }
+ Zone.from_json building.to_json
+ rescue error
+ msg = "unable to determine building zone"
+ logger.warn(exception: error) { msg }
+ raise msg
+ end
+
+ record AccessToken, token : String, expires : Int64? { include JSON::Serializable }
+
+ protected def get_users_access_token
+ AccessToken.from_json staff_api.user_resource_token.get.to_json
+ end
+
+ protected def place_calendar_client : ::PlaceCalendar::Client
+ token = get_users_access_token
+
+ case @platform
+ when "office365"
+ cal = ::PlaceCalendar::Office365.new(token.token, @conference_type, delegated_access: true)
+ ::PlaceCalendar::Client.new(cal)
+ when "google"
+ auth = ::Google::TokenAuth.new(token.token, token.expires || 5.hours.from_now.to_unix)
+ cal = ::PlaceCalendar::Google.new(auth, @email_domain, conference_type: @conference_type, delegated_access: true)
+ ::PlaceCalendar::Client.new(cal)
+ else
+ raise "unknown platform: #{@platform}, expecting google or office365"
+ end
+ end
+end
diff --git a/drivers/place/llm/schedule_spec.cr b/drivers/place/llm/schedule_spec.cr
new file mode 100644
index 00000000000..2d9133076c6
--- /dev/null
+++ b/drivers/place/llm/schedule_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Schedule" do
+end
diff --git a/drivers/place/llm/todo_list.cr b/drivers/place/llm/todo_list.cr
new file mode 100644
index 00000000000..2e60fd4d338
--- /dev/null
+++ b/drivers/place/llm/todo_list.cr
@@ -0,0 +1,38 @@
+require "placeos-driver"
+require "placeos-driver/interface/chat_functions"
+
+class Place::TODOs < PlaceOS::Driver
+ include Interface::ChatFunctions
+
+ descriptive_name "PlaceOS TODO list"
+ generic_name :TODO
+ description %(an example driver providing functions to a LLM)
+
+ @todos = [] of NamedTuple(complete: Bool, task: String)
+
+ def on_load
+ self[:loaded] = true
+ end
+
+ def capabilities : String
+ "manages the list of tasks a user needs to complete throughout the day"
+ end
+
+ @[Description("returns the list of tasks and their current status")]
+ def list_tasks
+ @todos
+ end
+
+ @[Description("adds a new task to the list")]
+ def add_task(description : String)
+ task = {complete: false, task: description}
+ @todos << task
+ task
+ end
+
+ @[Description("marks a task as completed")]
+ def complete_task(index : Int32)
+ task = @todos[index]
+ @todos[index] = {complete: true, task: task[:task]}
+ end
+end
diff --git a/drivers/place/llm/todo_list_spec.cr b/drivers/place/llm/todo_list_spec.cr
new file mode 100644
index 00000000000..a7770d24d3e
--- /dev/null
+++ b/drivers/place/llm/todo_list_spec.cr
@@ -0,0 +1,50 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::TODOs" do
+ exec(:add_task, "my task").get.should eq({
+ "complete" => false,
+ "task" => "my task",
+ })
+
+ exec(:list_tasks).get.should eq([{
+ "complete" => false,
+ "task" => "my task",
+ }])
+
+ exec(:complete_task, 0).get.should eq({
+ "complete" => true,
+ "task" => "my task",
+ })
+
+ exec(:list_tasks).get.should eq([{
+ "complete" => true,
+ "task" => "my task",
+ }])
+
+ exec(:function_schemas).get.should eq([
+ {
+ "function" => "list_tasks",
+ "description" => "returns the list of tasks and their current status",
+ "parameters" => {} of String => JSON::Any,
+ },
+ {
+ "function" => "add_task",
+ "description" => "adds a new task to the list",
+ "parameters" => {
+ "description" => {"type" => "string", "title" => "String"},
+ },
+ },
+ {
+ "function" => "complete_task",
+ "description" => "marks a task as completed",
+ "parameters" => {
+ "index" => {"type" => "integer", "format" => "Int32", "title" => "Int32"},
+ },
+ },
+ ])
+
+ # Test the interface
+ status[:capabilities].should eq exec(:capabilities).get
+ status[:function_schemas].should eq exec(:function_schemas).get
+ status[:loaded].should eq(true)
+end
diff --git a/drivers/place/llm/workplace.cr b/drivers/place/llm/workplace.cr
new file mode 100644
index 00000000000..a17684e38c1
--- /dev/null
+++ b/drivers/place/llm/workplace.cr
@@ -0,0 +1,501 @@
+require "placeos-driver"
+require "placeos-driver/interface/chat_functions"
+require "set"
+
+# metadata class
+require "placeos"
+
+class Place::Workplace < PlaceOS::Driver
+ include Interface::ChatFunctions
+
+ descriptive_name "LLM Workplace interface"
+ generic_name :Workplace
+ description %(provides workplace introspection functions to a LLM)
+
+ default_settings({
+ # fallback if there isn't one on the zone
+ time_zone: "Australia/Sydney",
+ })
+
+ @fallback_timezone : Time::Location = Time::Location::UTC
+
+ def on_update
+ timezone = config.control_system.not_nil!.timezone.presence || setting?(String, :time_zone).presence || "Australia/Sydney"
+ @fallback_timezone = Time::Location.load(timezone)
+ end
+
+ # =========================
+ # The LLM Interface
+ # =========================
+
+ getter capabilities : String do
+ String.build do |str|
+ str << "functions for listing building levels to obtain level names and level ids\n"
+ str << "find meeting rooms, filtering by capacity and or level id\n"
+ str << "my current desk, car parking and guest visitor bookings\n"
+ str << "Note: when booking a meeting room, preference one on the same level or closest level to my desk booking, if I have one, unless I specify a specific level. Also try to pick a room with an appropriate capacity.\n"
+ str << "once candidate meeting rooms have been found, you can include the list of resource emails when getting schedules to see which rooms are available\n"
+ str << "this capability also supports managing desk bookings and inviting visitors to the building\n"
+ str << "please cancel any bookings made on the incorrect day"
+ end
+ end
+
+ @[Description("returns desks, car parking spaces and visitors I have booked. day_offset: 0 will return todays schedule, day_offset: 1 will return tomorrows schedule etc. If you provide a date, in ISO 8601 format and the correct timezone, the date will be used.")]
+ def my_bookings(day_offset : Int32 = 0, date : Time? = nil)
+ me = current_user
+
+ if date
+ starting = date.in(timezone).at_beginning_of_day
+ logger.debug { "listing bookings for #{current_user.email}, on day #{starting}" }
+ else
+ logger.debug { "listing bookings for #{current_user.email}, day offset #{day_offset}" }
+ now = Time.local(timezone)
+ days = day_offset.days
+ starting = now.at_beginning_of_day + days
+ end
+ ending = starting.at_end_of_day
+
+ {"desk", "visitor", "parking", "asset-request"}.flat_map do |booking_type|
+ staff_api.query_bookings(
+ type: booking_type,
+ period_start: starting.to_unix,
+ period_end: ending.to_unix,
+ zones: {building.id},
+ user: invoked_by_user_id,
+ email: me.email
+ ).get.as_a.compact_map { |b| to_friendly_booking(b) }
+ end
+ end
+
+ @[Description("returns the building details and list of levels. Use this to obtain level_ids")]
+ def levels : Array(Zone)
+ logger.debug { "getting list of levels" }
+ l = all_levels
+ l.each do |level|
+ all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details")
+ if all_desks
+ desks = all_desks.as_a
+ level.bookable_desk_count = desks.size
+ features = Set(String).new
+ desks.each do |desk|
+ if feat = desk["features"]?
+ feat.as_a.each { |f| features << f.as_s.downcase }
+ end
+ end
+ level.desk_features = features.to_a unless features.empty?
+ else
+ level.bookable_desk_count = 0
+ end
+ end
+ l
+ end
+
+ getter all_levels : Array(Zone) do
+ [building] + Array(Zone).from_json(staff_api.zones(parent: building.id, tags: {"level"}).get.to_json).sort_by(&.name)
+ end
+
+ @[Description("returns the list of meeting rooms in the building filtering by capacity or level")]
+ def meeting_rooms(minimum_capacity : Int32 = 1, level_id : String? = nil)
+ logger.debug { "listing meeting rooms on level #{level_id} with capacity #{minimum_capacity}" }
+
+ # ensure the level id exists if provided
+ if level_id
+ level = levels.find { |l| l.id == level_id }
+ raise "could not find level_id #{level_id} in the building. Make sure you've obtained the list of levels." unless level
+ end
+
+ zone_id = level_id || building.id
+ staff_api.systems(zone_id: zone_id, capacity: minimum_capacity, bookable: true).get.as_a.compact_map { |s| to_friendly_system(s) }
+ end
+
+ alias PlaceZone = PlaceOS::Client::API::Models::Zone
+ alias Metadata = Hash(String, PlaceOS::Client::API::Models::Metadata)
+ alias ChildMetadata = Array(NamedTuple(zone: PlaceZone, metadata: Metadata))
+
+ @[Description("returns the list of desks available for booking on the level and day specified. If the level has desk features then you can also filter by features.")]
+ def desks(level_id : String, day_offset : Int32 = 0, date : Time? = nil, feature : String? = nil)
+ logger.debug { "listing desks on level #{level_id}, day offset #{day_offset}" }
+
+ # ensure the level id exists
+ level = levels.find { |l| l.id == level_id }
+ raise "could not find level_id #{level_id} in the building. Make sure you've obtained the list of levels." unless level
+
+ # get the list of desks for the level
+ all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details")
+ raise "no bookable desks on this level, please try another." unless all_desks
+ desks = Array(Desk).from_json(all_desks.to_json)
+
+ # calculate the offset time
+ if date
+ starting = date.in(timezone).at_beginning_of_day
+ else
+ now = Time.local(timezone)
+ days = day_offset.days
+ starting = now.at_beginning_of_day + days
+ end
+ ending = starting.at_end_of_day
+
+ # need current user so we can filter out desks limited to certain groups
+ me = current_user
+
+ # get the current bookings for the level
+ bookings = staff_api.query_bookings(type: "desk", period_start: starting.to_unix, period_end: ending.to_unix, zones: {level_id}).get.as_a
+ bookings = bookings.map(&.[]("asset_id").as_s)
+
+ # filter out desks that are not available to the user
+ feature = feature.try(&.downcase)
+ desks.reject! do |desk|
+ next true if desk.id.in?(bookings)
+ next true if feature && !desk.features.map!(&.downcase).includes?(feature)
+ if !desk.groups.empty?
+ (desk.groups & me.groups).empty?
+ end
+ end
+
+ # need to limit the results as the LLM runs out of memory
+ logger.debug { "found #{desks.size} available desks" }
+ desks.sample(5)
+ end
+
+ @[Description("books an asset, such as a desk or car parking space, for the number of days specified, starting on the day offset. For desk bookings use booking_type: desk")]
+ def book_relative(booking_type : String, asset_id : String, level_id : String, day_offset : Int32 = 0, number_of_days : Int32 = 1)
+ logger.debug { "booking relative #{booking_type}, asset #{asset_id} on level #{level_id}, day offset #{day_offset} for num days #{number_of_days}" }
+
+ # ensure the level id exists
+ level = levels.find { |l| l.id == level_id }
+ raise "could not find level_id #{level_id} in the building. Make sure you've obtained the list of levels." unless level
+
+ user_id = invoked_by_user_id
+ me = current_user
+ current_time = Time.local(timezone)
+ now = current_time.at_beginning_of_day
+
+ raise "booking in the past is not permitted" unless day_offset > 0 || (day_offset == 0 && current_time.hour < 18)
+
+ # ensure the asset exists if we can check for it
+ case booking_type
+ when "desk"
+ all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details")
+ raise "no desks found on level #{level_id}, ensure this id is correct" unless all_desks
+ desks = Array(Desk).from_json(all_desks.to_json)
+ desk = desks.find { |d| d.id == asset_id }
+
+ raise "could not find a desk with id: #{asset_id}" unless desk
+ end
+
+ ids = (day_offset...(day_offset + number_of_days)).map do |offset|
+ # calculate the offset time
+ days = offset.days
+ starting = now + days + 8.hours
+ ending = now.at_end_of_day + days - 4.hours
+
+ resp = staff_api.create_booking(
+ booking_type: booking_type,
+ asset_id: asset_id,
+ user_id: user_id,
+ user_email: me.email,
+ user_name: me.name,
+ zones: {level_id, building.id},
+ booking_start: starting.to_unix,
+ booking_end: ending.to_unix,
+ time_zone: timezone.to_s,
+ utm_source: "chatgpt"
+ )
+ resp.get["id"].as_i64
+ end
+ starting = now + day_offset.days
+
+ {
+ booking_ids: ids,
+ details: "booking for #{asset_id} created on #{starting.day_of_week}, #{starting.to_s("%F")} for #{number_of_days} #{number_of_days > 1 ? "days" : "day"}",
+ }
+ end
+
+ @[Description("books an asset, such as a desk or car parking space, for the number of days specified, the start date must be in ISO 8601 format with the correct timezone. For desk bookings use booking_type: desk")]
+ def book_on(booking_type : String, asset_id : String, level_id : String, date : Time, number_of_days : Int32 = 1)
+ logger.debug { "booking on #{booking_type}, asset #{asset_id} on level #{level_id}, date #{date} for num days #{number_of_days}" }
+
+ # ensure the level id exists
+ level = levels.find { |l| l.id == level_id }
+ raise "could not find level_id #{level_id} in the building. Make sure you've obtained the list of levels." unless level
+
+ user_id = invoked_by_user_id
+ me = current_user
+ now = date.in(timezone).at_beginning_of_day
+ current_time = Time.local(timezone)
+ raise "booking in the past is not permitted" unless current_time < now || (current_time - now) < 18.hours
+
+ # ensure the asset exists if we can check for it
+ case booking_type
+ when "desk"
+ all_desks = staff_api.metadata(level.id, "desks").get.dig?("desks", "details")
+ raise "no desks found on level #{level_id}, ensure this id is correct" unless all_desks
+ desks = Array(Desk).from_json(all_desks.to_json)
+ desk = desks.find { |d| d.id == asset_id }
+
+ raise "could not find a desk with id: #{asset_id}" unless desk
+ end
+
+ ids = (0...number_of_days).map do |offset|
+ # calculate the offset time
+ days = offset.days
+ starting = now + days + 8.hours
+ ending = now.at_end_of_day + days - 4.hours
+
+ resp = staff_api.create_booking(
+ booking_type: booking_type,
+ asset_id: asset_id,
+ user_id: user_id,
+ user_email: me.email,
+ user_name: me.name,
+ zones: {level_id, building.id},
+ booking_start: starting.to_unix,
+ booking_end: ending.to_unix,
+ time_zone: timezone.to_s,
+ utm_source: "chatgpt"
+ )
+ resp.get["id"].as_i64
+ end
+
+ {
+ booking_ids: ids,
+ details: "booking for #{asset_id} created on #{now.day_of_week}, #{now.to_s("%F")} for #{number_of_days} #{number_of_days > 1 ? "days" : "day"}",
+ }
+ end
+
+ @[Description("cancels the given booking ids")]
+ def cancel_bookings(booking_ids : Array(Int64))
+ logger.debug { "cancel bookings #{booking_ids}" }
+ booking_ids.each do |booking_id|
+ booking = staff_api.get_booking(booking_id).get
+ user_id = invoked_by_user_id
+ me = current_user
+ unless (user_id == booking["user_id"]?.try(&.as_s)) || me.email.downcase.in?({booking["user_email"].as_s, booking["booked_by_email"].as_s})
+ raise "can only cancel bookings owned by #{me.email} - this booking is owned by #{booking["user_email"]}"
+ end
+ staff_api.booking_delete(booking_id, "chatgpt")
+ end
+ "bookings have been removed"
+ end
+
+ @[Description("book a visitor to the building")]
+ def invite(visitor_name : String, visitor_email : String, day_offset : Int32 = 0, date : Time? = nil, number_of_days : Int32 = 1)
+ logger.debug { "inviting visitor to the building #{visitor_name}: #{visitor_email}, day offset #{day_offset} for num days #{number_of_days}" }
+
+ # select a random level
+ level = levels.first
+ user_id = invoked_by_user_id
+ me = current_user
+ current_time = Time.local(timezone)
+ now = current_time.at_beginning_of_day
+
+ # adjust the offset if a date has been selected
+ if date
+ desired_date = date.in(timezone).at_beginning_of_day
+ day_offset = (desired_date - now).total_days.round_away.to_i
+ end
+
+ raise "booking in the past is not permitted" unless day_offset > 0 || (day_offset == 0 && current_time.hour < 16)
+
+ visitor_email = visitor_email.downcase
+
+ ids = (day_offset...(day_offset + number_of_days)).map do |offset|
+ # calculate the offset time
+ days = offset.days
+ starting = now + days + 8.hours
+ ending = now.at_end_of_day + days - 4.hours
+
+ resp = staff_api.create_booking(
+ booking_type: "visitor",
+ asset_id: visitor_email,
+ user_id: user_id,
+ user_email: me.email,
+ user_name: me.name,
+ zones: {level.id, building.id},
+ booking_start: starting.to_unix,
+ booking_end: ending.to_unix,
+ time_zone: timezone.to_s,
+ utm_source: "chatgpt",
+
+ attendees: [{
+ name: visitor_name,
+ email: visitor_email,
+ }]
+ )
+ resp.get["id"].as_i64
+ end
+ starting = now + day_offset.days
+
+ {
+ booking_ids: ids,
+ details: "invited #{visitor_email} to the office on #{starting.day_of_week}, #{starting.to_s("%F")} for #{number_of_days} #{number_of_days > 1 ? "days" : "day"}",
+ }
+ end
+
+ # =========================
+ # Support functions
+ # =========================
+
+ struct Desk
+ include JSON::Serializable
+
+ getter id : String
+ getter groups : Array(String) = [] of String
+ getter features : Array(String) = [] of String
+ end
+
+ protected def to_friendly_system(system : JSON::Any) : System?
+ the_levels = levels
+
+ zone_ids = system["zones"].as_a.map(&.as_s)
+ level = the_levels.find do |l|
+ next nil unless l.tags.includes?("level")
+ zone_ids.find { |z| z == l.id }
+ end
+
+ if level
+ System.new level, system
+ end
+ end
+
+ struct System
+ include JSON::Serializable
+
+ getter id : String? = nil
+ getter name : String
+ getter features : Array(String)
+ getter email : String?
+ getter capacity : Int32 = 0
+
+ getter level_id : String
+ getter level_name : String
+ getter map_id : String? = nil
+
+ # getter images : Array(String)
+
+ def initialize(level : Zone, system : JSON::Any)
+ sys = system.as_h
+ @id = sys["id"].as_s
+ @name = sys["display_name"]?.try(&.as_s?) || sys["name"].as_s
+ # @description = sys["description"]?.try &.as_s? || ""
+ @features = sys["features"].as_a.map(&.as_s)
+ # @images = sys["images"].as_a.map(&.as_s)
+ @email = sys["email"]?.try &.as_s?
+ @capacity = sys["capacity"].as_i
+ @map_id = sys["map_id"]?.try &.as_s?
+ @level_id = level.id
+ @level_name = level.display_name || level.name
+ end
+ end
+
+ protected def to_friendly_booking(booking : JSON::Any) : Booking?
+ the_levels = levels
+
+ zone_ids = booking["zones"].as_a.map(&.as_s)
+ level = the_levels.find do |l|
+ next nil unless l.tags.includes?("level")
+ zone_ids.find { |z| z == l.id }
+ end
+
+ if level
+ Booking.new level, booking, timezone
+ end
+ end
+
+ struct Booking
+ include JSON::Serializable
+
+ getter id : Int64? = nil
+ getter starting : Time
+ getter ending : Time
+
+ getter booking_type : String
+ getter asset_id : String
+ getter user_id : String?
+ getter user_email : String
+ getter user_name : String
+ getter level_id : String
+ getter level_name : String
+
+ # getter booked_by_email : String
+ # getter booked_by_name : String
+
+ getter checked_in : Bool = false
+
+ def initialize(level : Zone, book : JSON::Any, timezone : Time::Location)
+ b = book.as_h
+ @id = b["id"].as_i64
+ @starting = Time.unix(b["booking_start"].as_i64).in(timezone)
+ @ending = Time.unix(b["booking_end"].as_i64).in(timezone)
+ @booking_type = b["booking_type"].as_s
+ @asset_id = b["asset_id"].as_s
+ @user_id = b["user_id"]?.try &.as_s?
+ @user_email = b["user_email"].as_s
+ @user_name = b["user_name"].as_s
+ @checked_in = b["checked_in"].as_bool
+ @level_id = level.id
+ @level_name = level.display_name || level.name
+
+ # @booked_by_email = b["booked_by_email"].as_s
+ # @booked_by_name = b["booked_by_name"].as_s
+ end
+ end
+
+ protected def staff_api
+ system["StaffAPI_1"]
+ end
+
+ def current_user : User
+ User.from_json staff_api.user(invoked_by_user_id).get.to_json
+ end
+
+ getter timezone : Time::Location do
+ building.time_zone || @fallback_timezone
+ end
+
+ struct User
+ include JSON::Serializable
+
+ getter name : String
+ getter email : String
+ getter groups : Array(String)
+ end
+
+ getter building : Zone { get_building }
+
+ class Zone
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String
+ getter display_name : String?
+ getter tags : Array(String)
+
+ property bookable_desk_count : Int32? = nil
+ property desk_features : Array(String)? = nil
+
+ @[JSON::Field(key: "timezone")]
+ getter tz : String?
+
+ @[JSON::Field(ignore: true)]
+ getter time_zone : Time::Location? do
+ if tz = @tz.presence
+ Time::Location.load(tz)
+ end
+ end
+ end
+
+ # Finds the building ID for the current location services object
+ def get_building : Zone
+ zones = staff_api.zones(tags: "building").get.as_a
+ zone_ids = zones.map(&.[]("id").as_s)
+ building_id = (zone_ids & system.zones).first
+
+ building = zones.find! { |zone| zone["id"].as_s == building_id }
+ Zone.from_json building.to_json
+ rescue error
+ msg = "unable to determine building zone"
+ logger.warn(exception: error) { msg }
+ raise msg
+ end
+end
diff --git a/drivers/place/llm/workplace_spec.cr b/drivers/place/llm/workplace_spec.cr
new file mode 100644
index 00000000000..4e62708aeca
--- /dev/null
+++ b/drivers/place/llm/workplace_spec.cr
@@ -0,0 +1,8 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Workplace" do
+end
+
+# :nodoc:
+class StaffMock < DriverSpecs::MockDriver
+end
diff --git a/drivers/place/location_services.cr b/drivers/place/location_services.cr
new file mode 100644
index 00000000000..017e50e4d7b
--- /dev/null
+++ b/drivers/place/location_services.cr
@@ -0,0 +1,359 @@
+require "set"
+require "json"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "placeos-driver/interface/sensor"
+
+class Place::LocationServices < PlaceOS::Driver
+ descriptive_name "PlaceOS Location Services"
+ generic_name :LocationServices
+ description %(collects location data from compatible services and combines the data)
+
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ debug_webhook: false,
+ search_building: false,
+
+ # various groups of people one might be interested in contacting
+ _emergency_contacts: {
+ "Fire Wardens" => "5542c9f-eaa7-4e74",
+ "First Aid" => "ed9f7608-488f-aeef",
+ },
+ })
+
+ @debug_webhook : Bool = false
+ @emergency_contacts : Hash(String, String) = {} of String => String
+ @search_building : Bool = true
+ @include_room_locations : Bool = false
+
+ getter building_id : String { get_building_id.not_nil! }
+ getter systems : Hash(String, Array(String)) { get_systems_list.not_nil! }
+
+ def on_update
+ @debug_webhook = setting?(Bool, :debug_webhook) || false
+ @emergency_contacts = setting?(Hash(String, String), :emergency_contacts) || Hash(String, String).new
+ @search_building = setting?(Bool, :search_building) || false
+ @include_room_locations = setting?(Bool, :include_room_locations) || false
+ @building_id = nil
+ @systems = nil
+
+ schedule.clear
+
+ # Keep mappings up to date
+ if @search_building || @include_room_locations
+ schedule.every(1.hour) { @systems = get_systems_list.not_nil! if @systems }
+ end
+
+ if !@emergency_contacts.empty?
+ schedule.every(6.hours, immediate: true) { update_contacts_list }
+ end
+ end
+
+ # Finds the building ID for the current location services object
+ def get_building_id
+ building_setting = setting?(String, :building_zone_override)
+ return building_setting if building_setting.presence
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ nil
+ end
+
+ # Grabs the list of systems in the building
+ def get_systems_list
+ staff_api.systems_in_building(building_id).get.as_h.transform_values(&.as_a.map(&.as_s))
+ rescue error
+ logger.warn(exception: error) { "unable to obtain list of systems in the building" }
+ nil
+ end
+
+ # Runs through all the services that support the Locatable interface
+ # requests location information on the identifier for all of them
+ # concatenates the results and returns them as a single array
+ def locate_user(email : String? = nil, username : String? = nil)
+ email = email.try &.downcase
+ logger.debug { "searching for #{email}, #{username}" }
+ located = [] of JSON::Any
+ system.implementing(Interface::Locatable).locate_user(email, username).get.each do |locations|
+ located.concat locations.as_a
+ end
+
+ if @search_building
+ building = JSON::Any.new building_id
+ results = [] of Tuple(JSON::Any, PlaceOS::Driver::Proxy::Drivers::Responses)
+
+ # Map
+ systems.each do |level_id, system_ids|
+ level_id = JSON::Any.new level_id
+ system_ids.each do |system_id|
+ results << {level_id, system(system_id).implementing(Interface::Locatable).locate_user(email, username)}
+ end
+ end
+
+ # reduce
+ results.each do |(level_id, result)|
+ result.get.each do |locations|
+ located.concat(locations.as_a.tap &.each { |location|
+ location = location.as_h
+ location["level"] = level_id
+ location["building"] = building
+ })
+ end
+ end
+ end
+
+ located
+ end
+
+ # Will return an array of MAC address strings
+ # lowercase with no seperation characters abcdeffd1234 etc
+ def macs_assigned_to(email : String? = nil, username : String? = nil)
+ email = email.try &.downcase
+ logger.debug { "listing MAC addresses assigned to #{email}, #{username}" }
+ macs = [] of String
+ system.implementing(Interface::Locatable).macs_assigned_to(email, username).get.each do |found|
+ macs.concat found.as_a.map(&.as_s)
+ end
+
+ if @search_building
+ results = [] of PlaceOS::Driver::Proxy::Drivers::Responses
+
+ # Map
+ systems.each do |_level_id, system_ids|
+ system_ids.each do |system_id|
+ results << system(system_id).implementing(Interface::Locatable).macs_assigned_to(email, username)
+ end
+ end
+
+ # reduce
+ results.each &.get.each { |found| macs.concat found.as_a.map(&.as_s) }
+ end
+
+ macs
+ end
+
+ # Will return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}`
+ def check_ownership_of(mac_address : String)
+ logger.debug { "searching for owner of #{mac_address}" }
+ owner = nil
+ system.implementing(Interface::Locatable).check_ownership_of(mac_address).get.each do |result|
+ if result != nil
+ owner = result
+ break
+ end
+ end
+
+ if owner.nil? && @search_building
+ results = [] of PlaceOS::Driver::Proxy::Drivers::Responses
+
+ # Map
+ systems.each do |_level_id, system_ids|
+ system_ids.each do |system_id|
+ results << system(system_id).implementing(Interface::Locatable).check_ownership_of(mac_address)
+ end
+ end
+
+ # reduce
+ results.each do |sys_results|
+ sys_results.get.each do |result|
+ if result != nil
+ owner = result
+ break
+ end
+ end
+ break unless owner.nil?
+ end
+ end
+
+ owner
+ end
+
+ # Will return an array of devices and their x, y coordinates
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching devices in zone #{zone_id}" }
+ located = [] of JSON::Any
+ system.implementing(Interface::Locatable).device_locations(zone_id, location).get.each do |locations|
+ located.concat locations.as_a
+ end
+
+ if @include_room_locations
+ results = [] of PlaceOS::Driver::Proxy::Drivers::Responses
+
+ # Map
+ systems.each do |_level_id, system_ids|
+ system_ids.each do |system_id|
+ results << system(system_id).implementing(Interface::Locatable).device_locations(zone_id, location)
+ end
+ end
+
+ # reduce
+ results.each &.get.each { |locations| located.concat locations.as_a }
+ end
+
+ located
+ end
+
+ # ===============================
+ # Sensor data collection
+ # ===============================
+
+ # sensor search + filtered search
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil)
+ logger.debug { "searching sensors of type: #{type.inspect}, mac: #{mac.inspect}, zone_id: #{zone_id}" }
+ located = [] of JSON::Any
+
+ drivers = system.implementing(Interface::Sensor)
+ drivers.sensors(type, mac, zone_id).get.each do |locations|
+ located.concat locations.as_a
+ end
+
+ driver_ids = Set.new drivers.map(&.@module_id)
+
+ if @search_building
+ building = JSON::Any.new building_id
+ results = [] of Tuple(JSON::Any, PlaceOS::Driver::Proxy::Drivers::Responses)
+
+ # Map
+ systems.each do |level_id, system_ids|
+ next if zone_id && zone_id != level_id
+ level_id = JSON::Any.new level_id
+ system_ids.each do |system_id|
+ # ensure we don't query modules more than once
+ drivers = system(system_id).implementing(Interface::Sensor)
+ drivers = PlaceOS::Driver::Proxy::Drivers.new(drivers.reject { |driver| driver.@module_id.in?(driver_ids) })
+ driver_ids.concat drivers.map(&.@module_id)
+
+ results << {level_id, drivers.sensors(type, mac, zone_id)}
+ end
+ end
+
+ # reduce
+ results.each do |(level_id, result)|
+ result.get.each do |locations|
+ located.concat(locations.as_a.tap &.each { |location|
+ location = location.as_h
+ location["level"] = level_id
+ location["building"] = building
+ })
+ end
+ end
+ end
+
+ located
+ end
+
+ def sensor(mac : String, id : String? = nil)
+ logger.debug { "querying sensor with mac: #{mac}, id: #{id.inspect}" }
+ located = [] of JSON::Any
+ drivers = system.implementing(Interface::Sensor)
+ drivers.sensor(mac, id).get.each do |locations|
+ located.concat locations.as_a
+ end
+
+ return located.first unless located.empty?
+
+ driver_ids = Set.new drivers.map(&.@module_id)
+
+ if @search_building
+ building = JSON::Any.new building_id
+ results = [] of Tuple(JSON::Any, PlaceOS::Driver::Proxy::Drivers::Responses)
+
+ # Map
+ systems.each do |level_id, system_ids|
+ level_id = JSON::Any.new level_id
+ system_ids.each do |system_id|
+ # ensure we don't query modules more than once
+ drivers = system(system_id).implementing(Interface::Sensor)
+ drivers = PlaceOS::Driver::Proxy::Drivers.new(drivers.reject { |driver| driver.@module_id.in?(driver_ids) })
+ driver_ids.concat drivers.map(&.@module_id)
+
+ results << {level_id, drivers.sensor(mac, id)}
+ end
+ end
+
+ # reduce
+ results.each do |(level_id, result)|
+ result.get.each do |locations|
+ located.concat(locations.as_a.tap &.each { |location|
+ location = location.as_h
+ location["level"] = level_id
+ location["building"] = building
+ })
+ end
+ end
+ end
+
+ located.first unless located.empty?
+ end
+
+ # ===============================
+ # IP ADDRESS => MAC ADDRESS
+ # ===============================
+ SUCCESS_RESPONSE = {HTTP::Status::OK, {} of String => String, nil}
+
+ # Webhook handler for accepting IP address to username mappings
+ # This data is typically obtained via domain controller logs
+ def ip_mappings(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "IP mappings webhook received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+ logger.debug { body } if @debug_webhook
+
+ # ip, username, domain, hostname
+ ip_map = Array(Tuple(String, String, String, String?)).from_json(body)
+ system.implementing(Interface::Locatable).ip_username_mappings(ip_map)
+
+ SUCCESS_RESPONSE
+ end
+
+ def mac_address_mappings(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "MAC mappings webhook received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+ logger.debug { body } if @debug_webhook
+
+ # username, macs, domain
+ username, macs, domain = Tuple(String, Array(String), String?).from_json(body)
+ username = username.strip
+ macs = macs.compact_map do |mac|
+ mac = mac.strip.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ mac if mac.size == 12
+ end
+ return {HTTP::Status::NOT_ACCEPTABLE, {} of String => String, nil} if username.empty? || macs.empty?
+
+ system.implementing(Interface::Locatable).mac_address_mappings(username, macs, domain)
+
+ SUCCESS_RESPONSE
+ end
+
+ @[Security(Level::Support)]
+ def update_contacts_list
+ if @emergency_contacts.empty?
+ self[:emergency_contacts] = nil
+ return
+ end
+
+ if !system.exists?(:Calendar)
+ logger.warn { "contacts requested however no directory service available" }
+ return
+ end
+
+ directory = system[:Calendar]
+ self[:emergency_contacts] = @emergency_contacts.transform_values { |id|
+ directory.get_members(id).get.as(JSON::Any)
+ }
+ end
+
+ # locates all the of the emergency contacts
+ def locate_contacts(list_name : String)
+ contacts = status(Hash(String, Array(NamedTuple(
+ email: String,
+ username: String))), :emergency_contacts)
+
+ list = contacts[list_name]
+ results = {} of String => Array(JSON::Any)
+ list.each do |person|
+ email = person[:email]
+ results[email] = locate_user(email, person[:username])
+ end
+ results
+ end
+end
diff --git a/drivers/place/location_services_spec.cr b/drivers/place/location_services_spec.cr
new file mode 100644
index 00000000000..cfb304cd37c
--- /dev/null
+++ b/drivers/place/location_services_spec.cr
@@ -0,0 +1,86 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/locatable"
+
+WIRELESS_LOC = {
+ "location" => "wireless",
+ "coordinates_from" => "bottom-left",
+ "x" => 16.764784482481577,
+ "y" => 25.435735950388988,
+ "lng" => 55.274935030154325,
+ "lat" => 25.201036346211698,
+ "variance" => 7.944837533996209,
+ "last_seen" => 1601526474,
+ "building" => "zone_1234",
+ "level" => "zone_1234",
+}
+
+DESK_LOC = {
+ "location" => "desk",
+ "at_location" => true,
+ "map_id" => "desk-4-1006",
+ "building" => "zone_1234",
+ "level" => "zone_1234",
+}
+
+DriverSpecs.mock_driver "Place::LocationServices" do
+ system({
+ Dashboard: {WirelessLocation},
+ DeskManagement: {DeskLocation},
+ })
+
+ exec(:locate_user, "Steve").get.should eq([WIRELESS_LOC, DESK_LOC])
+end
+
+# :nodoc:
+class WirelessLocation < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Locatable
+
+ def locate_user(email : String? = nil, username : String? = nil)
+ [WIRELESS_LOC]
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ [] of String
+ end
+
+ alias OwnershipMAC = NamedTuple(
+ location: String,
+ assigned_to: String,
+ mac_address: String,
+ )
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ nil
+ end
+end
+
+# :nodoc:
+class DeskLocation < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Locatable
+
+ def locate_user(email : String? = nil, username : String? = nil)
+ [DESK_LOC]
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ [] of String
+ end
+
+ alias OwnershipMAC = NamedTuple(
+ location: String,
+ assigned_to: String,
+ mac_address: String,
+ )
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ nil
+ end
+end
diff --git a/drivers/place/logic_example.cr b/drivers/place/logic_example.cr
new file mode 100644
index 00000000000..f8091791260
--- /dev/null
+++ b/drivers/place/logic_example.cr
@@ -0,0 +1,35 @@
+require "placeos-driver"
+
+# use for testing some basic functionality
+class Place::LogicExample < PlaceOS::Driver
+ descriptive_name "Example Logic"
+ generic_name :ExampleLogic
+
+ accessor main_lcd : Display_1
+
+ def on_update
+ logger.info { "woot! an update #{setting?(String, :name)}" }
+ end
+
+ def power_state?
+ main_lcd[:power]
+ end
+
+ def power(state : Bool)
+ system.all(:Display).power(state)
+ end
+
+ def webhook(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "webhook executed" }
+ power(true)
+ {HTTP::Status::OK.to_i, {} of String => String, ""}
+ end
+
+ def display_count
+ system.count(:Display)
+ end
+
+ def not_implemented
+ raise "not implemented"
+ end
+end
diff --git a/drivers/place/logic_example_spec.cr b/drivers/place/logic_example_spec.cr
new file mode 100644
index 00000000000..9f55e1183db
--- /dev/null
+++ b/drivers/place/logic_example_spec.cr
@@ -0,0 +1,93 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+# :nodoc:
+class Display < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Powerable
+ include PlaceOS::Driver::Interface::Muteable
+
+ enum Inputs
+ HDMI
+ HDMI2
+ VGA
+ VGA2
+ Miracast
+ DVI
+ DisplayPort
+ HDBaseT
+ Composite
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(Inputs)
+
+ # Configure initial state in on_load
+ def on_load
+ self[:power] = false
+ self[:input] = Inputs::HDMI
+ end
+
+ # implement the abstract methods required by the interfaces
+ def power(state : Bool)
+ self[:power] = state
+ end
+
+ def switch_to(input : Inputs)
+ mute(false)
+ self[:input] = input
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ self[:mute] = state
+ self[:mute0] = state
+ end
+end
+
+# :nodoc:
+class Switcher < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::InputSelection(Int32)
+
+ def switch_to(input : Input)
+ self[:output] = input
+ end
+end
+
+DriverSpecs.mock_driver "Place::LogicExample" do
+ system({
+ Display: {Display, Display},
+ Switcher: {Switcher},
+ })
+
+ exec(:power_state?).get.should eq(false)
+
+ # Should allow updating of settings
+ settings({
+ name: "Steve",
+ })
+
+ # Updating emulated module state
+ system(:Display_1)[:power] = true
+ exec(:power_state?).get.should eq(true)
+
+ # Expecting a function call
+ exec(:power, false)
+ exec(:power_state?).get.should eq(false)
+ system(:Display_1)[:power].should eq(false)
+
+ # Expecting a function call to return a result
+ exec(:power, true).get.should eq(true)
+
+ exec(:display_count).get.should eq(2)
+
+ system({
+ Display: {Display},
+ Switcher: {Switcher},
+ })
+
+ exec(:display_count).get.should eq(1)
+end
diff --git a/drivers/place/meet.cr b/drivers/place/meet.cr
new file mode 100644
index 00000000000..949f69bbcb3
--- /dev/null
+++ b/drivers/place/meet.cr
@@ -0,0 +1,1465 @@
+require "placeos-driver"
+require "placeos-driver/interface/chat_functions"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/lighting"
+require "./meet/qsc_phone_dialing"
+require "./meet/help"
+require "./meet/tab"
+require "./router/core"
+
+class Place::Meet < PlaceOS::Driver
+ include Interface::ChatFunctions
+ include Interface::Muteable
+ include Interface::Powerable
+ include Router::Core
+
+ generic_name :System
+ descriptive_name "Meeting room logic"
+ description <<-DESC
+ Room level state and behaviours.
+
+ This driver provides a high-level API for interaction with devices, systems \
+ and integrations found within common workplace collaboration spaces
+ DESC
+
+ default_settings({
+ help: {
+ "help-id" => {
+ "title" => "Video Conferencing",
+ "content" => "markdown",
+ },
+ },
+ tabs: [
+ {
+ name: "VC",
+ icon: "conference",
+ inputs: ["VidConf_1"],
+ help: "help-id",
+ controls: "vidconf-controls",
+ merge_on_join: false,
+ },
+ ],
+
+ # if we want to display the selected tab on displays meant only for the presenter
+ preview_outputs: ["Display_2"],
+ vc_camera_in: "switch_camera_output_id",
+ join_lockout_secondary: true,
+ unjoin_on_shutdown: false,
+ mute_on_unlink: true,
+ auto_route_on_join: false,
+
+ # only required in joining rooms
+ local_outputs: ["Display_1"],
+ local_cameras: ["Camera_1"],
+
+ screens: {
+ "Projector_1" => "Screen_1",
+ },
+
+ # change to false if there is a joining flag
+ lighting_independent: true,
+ lighting_area: {
+ # see interface/lighting for options
+ id: 34,
+ join: 0x01,
+ },
+ lighting_scenes: [
+ {
+ name: "Full",
+ id: 1,
+ icon: "lightbulb",
+ opacity: 1.0,
+ },
+ {
+ name: "Medium",
+ id: 2,
+ icon: "lightbulb",
+ opacity: 0.5,
+ },
+ {
+ name: "Off",
+ id: 3,
+ icon: "lightbulb_outline",
+ opacity: 0.8,
+ },
+ ],
+ _channel_details: [
+ {
+ name: "Al Jazeera",
+ icon: "https://url-to-svg-or-png",
+ channel: "udp://239.192.10.170:5000?hwchan=0",
+ },
+ ],
+ })
+
+ class ChannelDetail
+ include JSON::Serializable
+
+ getter name : String
+ getter icon : String?
+ getter channel : String
+ end
+
+ # =========================
+ # The LLM Interface
+ # =========================
+
+ getter capabilities : String do
+ String.build do |str|
+ str << "provides meeting room audio visual control such as controlling video source to be presented\n"
+ str << "check for available inputs and outputs before switching to present a source to a display.\n"
+ str << "output volume and microphone fader controls are floats between 0.0 to 100.0\n"
+ str << "query output volume to change it by a relative amount, if asked to increase or decrease volume, change it by 10.0\n"
+ str << "audio can be muted and you unroute video to blank displays.\n"
+ str << "you can also shutdown, startup, power off, power on, start or end the meeting using the set_power_state function available in this capability.\n"
+ str << "some rooms may have lighting control, make sure to check what levels are available before changing state\n"
+ str << "some rooms may have accessories such as blinds or projector screen controls. Check for available accessories when asked about something not explicitly controllable\n"
+ end
+ end
+
+ EXT_INIT = [] of Symbol
+ EXT_POWER = [] of Symbol
+
+ # extensions:
+ include Place::QSCPhoneDialing
+
+ def on_load
+ system.load_complete do
+ init_previous_join_state
+ on_update
+ end
+ end
+
+ @tabs : Array(Tab) = [] of Tab
+ getter local_help : Help = Help.new
+ getter local_tabs : Array(Tab) = [] of Tab
+
+ @outputs : Array(String) = [] of String
+ getter linked_outputs = {} of String => Hash(String, String)
+ getter local_outputs : Array(String) = [] of String
+ getter local_cameras : Array(String) = [] of String
+
+ @preview_outputs : Array(String) = [] of String
+ getter local_preview_outputs : Array(String) = [] of String
+
+ @shutdown_devices : Array(String)? = nil
+ @local_vidconf : String = "VidConf_1"
+ @ignore_update : Int64 = 0_i64
+ @unjoin_on_shutdown : Bool? = nil
+ @mute_on_unlink : Bool = true
+ @auto_route_on_join : Bool = false
+
+ # core includes: 'current_routes' hash
+ # but we override it here for LLM integration
+ @[Description("obtain the current routes, output => input")]
+ getter current_routes : Hash(String, String?) = {} of String => String?
+
+ def on_update
+ return if (Time.utc.to_unix - @ignore_update) < 3
+
+ self[:name] = system.display_name.presence || system.name
+ self[:local_help] = @local_help = setting?(Help, :help) || Help.new
+ self[:local_tabs] = @local_tabs = setting?(Array(Tab), :tabs) || [] of Tab
+ self[:local_outputs] = @local_outputs = setting?(Array(String), :local_outputs) || [] of String
+ self[:local_cameras] = @local_cameras = setting?(Array(String), :local_cameras) || [] of String
+ self[:local_preview_outputs] = @local_preview_outputs = setting?(Array(String), :preview_outputs) || [] of String
+ self[:voice_control] = setting?(Bool, :voice_control) || false
+ self[:channel_details] = setting?(Array(ChannelDetail), :channel_details)
+ @shutdown_devices = setting?(Array(String), :shutdown_devices)
+ @local_vidconf = setting?(String, :local_vidconf) || "VidConf_1"
+ @unjoin_on_shutdown = setting?(Bool, :unjoin_on_shutdown)
+ @mute_on_unlink = setting?(Bool, :mute_on_unlink) || false
+ @auto_route_on_join = setting?(Bool, :auto_route_on_join) || false
+
+ @join_lock.synchronize do
+ subscriptions.clear
+
+ reset_remote_cache
+ init_signal_routing
+ init_projector_screens
+ init_master_audio
+ init_microphones
+ init_accessories
+ init_lighting
+ init_vidconf
+ init_joining
+ end
+
+ # initialize all the extentsions
+ {% for func in EXT_INIT %}
+ begin
+ {{func.id}}
+ rescue error
+ logger.warn(exception: error) { "error in init function: #{ {{func.id.stringify}} }" }
+ end
+ {% end %}
+ end
+
+ # link screen control to power state
+ protected def init_projector_screens
+ screens = setting?(Hash(String, String), :screens) || {} of String => String
+
+ subscribe(:active) do |_sub, active_state|
+ if active_state == "true"
+ sys = system
+ screens.each do |display, screen|
+ system[screen].down if sys[display][:power] == true
+ end
+ end
+ end
+
+ screens.each do |display, screen|
+ system.subscribe(display, :power) do |_sub, power_state|
+ logger.debug { "power-state changed on #{display}: #{power_state.inspect}" }
+ if power_state == "false"
+ logger.debug { "updating screen position: up" }
+ system[screen].up
+ elsif power_state == "true"
+ logger.debug { "updating screen position: down" }
+ system[screen].down
+ end
+ end
+ end
+ end
+
+ @[Description("power on or off the meeting room. Send true for power on (startup) or false for power off (shutdown)")]
+ def set_power_state(state : Bool)
+ power state
+ end
+
+ # Sets the overall room power state.
+ def power(state : Bool, unlink : Bool = false)
+ return if state == status?(Bool, :active)
+ logger.debug { "Powering #{state ? "up" : "down"}" }
+ self[:active] = state
+ unlink = @unjoin_on_shutdown.nil? ? unlink : !!@unjoin_on_shutdown
+
+ remotes_before = remote_rooms
+ sys = system
+
+ if state
+ @local_preview_outputs.each { |device| sys[device].power true } # Power on preview displays
+ apply_master_audio_default
+ apply_camera_defaults
+ apply_default_routes
+ apply_mic_defaults
+
+ if first_output = @tabs.first?.try &.inputs.first
+ selected_input first_output
+ end
+ else
+ unlink_systems if unlink
+ audio_mute(true) rescue nil
+
+ @local_outputs.each { |output| unroute(output) }
+ @local_preview_outputs.each { |output| unroute(output) }
+
+ mute_microphones
+
+ if devices = @shutdown_devices
+ devices.each { |device| sys[device].power false }
+ else
+ sys.implementing(Interface::Powerable).power false
+ end
+ sys[@local_vidconf].hangup if sys.exists?(@local_vidconf)
+ end
+
+ remotes_before.each { |room| room.power(state, unlink) }
+
+ # perform power state actions
+ {% for func in EXT_POWER %}
+ begin
+ {{func.id}}(state, unlink)
+ rescue error
+ logger.warn(exception: error) { "error in power state function: #{ {{func.id.stringify}} }" }
+ end
+ {% end %}
+
+ state
+ end
+
+ @[Description("query the system power state?")]
+ def power? : Bool
+ status?(Bool, :active) || false
+ end
+
+ # =====================
+ # System IO management
+ # ====================
+
+ @default_routes : Hash(String, String) = {} of String => String
+
+ protected def init_signal_routing
+ @default_routes = setting?(Hash(String, String), :default_routes) || {} of String => String
+
+ logger.debug { "loading signal graph..." }
+ load_siggraph
+ logger.debug { "signal graph loaded" }
+ update_available_tabs
+ update_available_help
+ update_available_outputs
+ rescue error
+ logger.warn(exception: error) { "failed to init signal graph" }
+ end
+
+ protected def on_siggraph_loaded(inputs, outputs)
+ outputs.each &.watch { |node| on_output_change node }
+ end
+
+ protected def on_output_change(output)
+ case output.source
+ when Router::SignalGraph::Mute, nil
+ # nothing to do here
+ else
+ output.proxy.power true
+ end
+ end
+
+ def apply_default_routes
+ @default_routes.each { |output, input| route_signal(input, output) }
+ rescue error
+ logger.warn(exception: error) { "error applying default routes" }
+ end
+
+ @[Description("available inputs and outputs. Route using id keys")]
+ def inputs_and_outputs
+ inps = all_inputs
+ outs = all_outputs
+
+ results = [] of NamedTuple(type: Symbol, name: String, id: String)
+ inps.each do |input|
+ name = status?(NamedTuple(name: String), "input/#{input}")
+ if name
+ results << {type: :input, name: name[:name], id: input}
+ end
+ end
+ outs.each do |output|
+ name = status?(NamedTuple(name: String), "output/#{output}")
+ if name
+ results << {type: :output, name: name[:name], id: output}
+ end
+ end
+ results
+ end
+
+ @[Description("route to present an input to an output / display. Don't guess, look up available input and output ids")]
+ def route_input(input_id : String, output_id : String)
+ # obtain input ID
+ keys = all_inputs
+ hash = keys.each_with_object({} of String => String) do |input, memo|
+ memo[input.downcase] = input
+ end
+ input_actual = hash[input_id.downcase]?
+ raise "invalid input #{input_id}, must be one of #{keys.join(", ")}" unless input_actual
+
+ # obtain output ID
+ keys = all_outputs
+ hash = keys.each_with_object({} of String => String) do |output, memo|
+ memo[output.downcase] = output
+ end
+ output_actual = hash[output_id.downcase]?
+ raise "invalid output #{output_id}, must be one of: #{keys.join(", ")}" unless output_actual
+
+ power true
+ selected_input(input_actual)
+ route(input_actual, output_actual)
+ end
+
+ def route(input : String, output : String, max_dist : Int32? = nil, simulate : Bool = false, follow_additional_routes : Bool = true, called_from_join : Bool = false)
+ route_signal(input, output, max_dist, simulate, follow_additional_routes)
+
+ if links = @linked_outputs[output]?
+ links.each { |_sys_id, remote_out| route_signal(input, remote_out, max_dist, simulate, follow_additional_routes) }
+ end
+
+ if !called_from_join
+ remote_systems.each do |remote_system|
+ room = remote_system.room_logic
+ sys_id = remote_system.system_id
+ if links = @linked_outputs[output]?
+ if remote_out = links[sys_id]?
+ room.route(input, remote_out, max_dist, simulate, follow_additional_routes, true)
+ end
+ end
+ end
+ end
+ end
+
+ # we want to unroute any signal going to the display
+ # or if it's a direct connection, we want to mute the display.
+ @[Description("blank a display / output, sometimes called a video mute")]
+ def unroute(output : String)
+ route("MUTE", output)
+ rescue error
+ logger.debug(exception: error) { "failed to unroute #{output}" }
+ end
+
+ @[Description("blank all displays / outputs")]
+ def unroute_all
+ all_outputs.each do |output|
+ route("MUTE", output)
+ end
+ end
+
+ # This is the currently selected input
+ # if the user selects an output then this will be routed to it
+ def selected_input(name : String, simulate : Bool = false) : Nil
+ selected_tab = @tabs.find(&.inputs.includes?(name)).try &.name
+ if selected_tab || !simulate
+ self[:selected_input] = name
+ self[:selected_tab] = selected_tab || @tabs.first
+
+ # ensure inputs are powered on (mostly to bring VC out of standby)
+ sys = system
+ if sys.exists? name
+ mod = sys[name]
+ mod.power(true) if mod.implements? Interface::Powerable
+ end
+ end
+
+ # Perform any desired routing
+ if !simulate
+ if @preview_outputs.empty?
+ route_signal(name, @outputs.first) if @outputs.size == 1
+ else
+ @preview_outputs.each { |output| route_signal(name, output) }
+ end
+
+ remote_rooms.each { |room| room.selected_input(name, true) }
+ end
+ end
+
+ protected def all_outputs
+ status(Array(String), :outputs)
+ end
+
+ protected def all_inputs
+ status(Array(String), :inputs)
+ end
+
+ protected def update_available_help
+ help = @local_help.dup
+
+ # merge in joined room help
+ remote_rooms.each do |room|
+ help.merge! Help.from_json(room.local_help.get.to_json)
+ end
+
+ self[:help] = help
+ end
+
+ protected def update_available_tabs
+ available_cameras = @local_cameras.dup
+ seen_cameras = @local_cameras.dup
+ tabs = @local_tabs.dup.map(&.clone)
+
+ # merge in joined room tabs
+ remote_rooms.each do |room|
+ remote_tabs = Array(Tab).from_json(room.local_tabs.get.to_json)
+ remote_tabs.each do |remote_tab|
+ next if remote_tab.merge_on_join == false
+
+ if local_tab = tabs.find { |loc_tab| loc_tab.name == remote_tab.name }
+ local_tab.merge!(remote_tab)
+ else
+ tabs << remote_tab
+ end
+ end
+
+ remote_cameras = room.local_cameras.get.as_a.map(&.as_s)
+ remote_cameras.each_with_index do |remote_cam, index|
+ next if seen_cameras.includes?(remote_cam)
+ available_cameras << remote_cam
+ seen_cameras << remote_cam
+ end
+ end
+
+ # local_inputs configured for backwards compatibility with older versions of the UI
+ self[:local_inputs] = self[:available_inputs] = tabs.flat_map(&.inputs)
+ self[:tabs] = @tabs = tabs
+
+ if available_cameras.empty? && @join_modes.empty?
+ if inputs_raw = setting?(::Place::Router::Core::Settings::IOMeta, "inputs")
+ available_cameras = inputs_raw.select! { |_key, values| values["type"]?.try(&.as_s?) == "cam" }.keys
+ end
+ end
+ self[:available_cameras] = available_cameras
+ end
+
+ protected def update_available_outputs
+ available_outputs = @local_outputs.dup
+ seen_outputs = @local_outputs.dup
+
+ preview_outputs = @local_preview_outputs.dup
+
+ new_linked_outputs = Hash(String, Hash(String, String)).new { |hash, key| hash[key] = {} of String => String }
+
+ # Grab the join mode if any
+ if join_mode = @join_modes[@join_selected]?
+ # merge in joined room settings
+ remote_systems.each do |remote_system|
+ room = remote_system.room_logic
+ remote_system_id = remote_system.system_id
+ preview_outputs.concat room.local_preview_outputs.get.as_a.map(&.as_s)
+
+ # merge in outputs from remote rooms
+ remote_outputs = room.local_outputs.get.as_a.map(&.as_s)
+ remote_outputs.each_with_index do |remote_out, index|
+ if join_mode.merge_outputs? && (local_out = available_outputs[index]?)
+ seen_outputs << remote_out
+ new_linked_outputs[local_out][remote_system_id] = remote_out
+ end
+
+ next if seen_outputs.includes?(remote_out)
+ available_outputs << remote_out
+ seen_outputs << remote_out
+ end
+ end
+ end
+
+ @linked_outputs = new_linked_outputs
+ self[:preview_outputs] = @preview_outputs = preview_outputs
+
+ if available_outputs.empty?
+ if preview_outputs.empty?
+ self[:available_outputs] = @outputs = all_outputs
+ else
+ self[:available_outputs] = @outputs = all_outputs - preview_outputs
+ end
+ else
+ self[:available_outputs] = @outputs = available_outputs
+ end
+ end
+
+ # =======================
+ # Primary volume controls
+ # =======================
+
+ class RoomMutes
+ include JSON::Serializable
+
+ getter name : String
+ getter binding : String
+ getter control : String | Array(String)? = nil
+ getter falsy_value : String | Bool = false
+
+ # maps ids to commands to send
+ getter route : Array(AccessoryComplex::Exec)? = nil
+ getter unroute : Array(AccessoryComplex::Exec)? = nil
+ end
+
+ class AudioFader
+ include JSON::Serializable
+
+ def initialize
+ end
+
+ getter name : String? = nil
+ property level_id : String | Array(String)? = nil
+ getter mute_id : String | Array(String)? { level_id }
+
+ getter default_muted : Bool? = nil
+ getter default_level : Float64? = nil
+
+ getter level_index : Int32? = nil
+ getter mute_index : Int32? = nil
+
+ getter min_level : Float64 { 0.0 }
+ getter max_level : Float64 { 100.0 }
+
+ property level_feedback : String do
+ id = level_id
+ "fader#{id.is_a?(Array) ? id.first : id}"
+ end
+ property mute_feedback : String do
+ id = mute_id || level_id
+ "fader#{id.is_a?(Array) ? id.first : id}_mute"
+ end
+ property module_id : String { "Mixer_1" }
+
+ getter? level_feedback, mute_feedback
+
+ getter rooms : Array(RoomMutes)? = nil
+
+ def use_defaults?
+ @module_id.nil? && (level_id.nil? || level_id.try &.empty?) && (mute_id.nil? || mute_id.try &.empty?)
+ end
+
+ def implements_volume?
+ level_id == "\e"
+ end
+ end
+
+ @master_audio : AudioFader? = nil
+
+ protected def init_master_audio
+ audio = setting?(AudioFader, :master_audio)
+ output = @outputs.first?
+ unless audio || output
+ logger.warn { "no audio configuration found" }
+ @master_audio = nil
+ return
+ end
+ audio ||= AudioFader.new
+
+ # if nothing defined then we want to use the first output
+ # we might have configured default levels
+ if audio.use_defaults?
+ unless output
+ logger.warn { "audio partially conigured, no output found" }
+ return
+ end
+
+ mod = signal_node(output).ref.mod.not_nil!
+
+ # proxy = (mod.sys == system.id ? system : system mod.sys).get mod.name, mod.idx
+ audio.module_id = "#{mod.name}_#{mod.idx}"
+ audio.level_feedback = "volume" unless audio.level_feedback?
+ audio.mute_feedback = "audio_mute" unless audio.mute_feedback?
+ audio.level_id = "\e"
+ end
+
+ # we can subscribe to feedback before we're sure all the modules are running
+ system.subscribe(audio.module_id, audio.level_feedback) do |_sub, level|
+ raw_level = Float64.from_json(level) if level && level != "null"
+ if raw_level
+ range = audio.min_level..audio.max_level
+ vol_percent = ((raw_level.to_f - range.begin.to_f) / (range.end - range.begin)) * 100.0
+ self[:volume] = vol_percent
+ end
+ end
+
+ system.subscribe(audio.module_id, audio.mute_feedback) do |_sub, muted|
+ self[:mute] = muted == "true" if muted && muted != "null"
+ end
+
+ @master_audio = audio
+ rescue error
+ logger.warn(exception: error) { "failed to init master audio" }
+ end
+
+ protected def apply_master_audio_default
+ audio = @master_audio
+ return unless audio
+ mixer = system[audio.module_id]
+
+ case audio.default_muted
+ in Bool
+ set_master_mute(mixer, audio, audio.default_muted)
+ in Nil
+ mixer.query_mutes(audio.level_id) unless audio.implements_volume?
+ end
+
+ case audio.default_level
+ in Float64
+ set_master_volume(mixer, audio, audio.default_level)
+ in Nil
+ mixer.query_faders(audio.level_id) unless audio.implements_volume?
+ end
+ end
+
+ protected def set_master_volume(mixer, audio, level)
+ if level_index = audio.level_index
+ mixer.fader(audio.level_id, level, level_index)
+ elsif audio.implements_volume?
+ mixer.volume(level)
+ else
+ mixer.fader(audio.level_id, level)
+ end
+ end
+
+ protected def set_master_mute(mixer, audio, state)
+ if mute_index = audio.mute_index
+ mixer.mute(audio.level_id, state, mute_index)
+ elsif audio.implements_volume?
+ mixer.mute_audio(state)
+ else
+ mixer.mute(audio.mute_id, state)
+ end
+ end
+
+ @[Description("change the room volume")]
+ def set_volume(level : Int32 | Float64)
+ power true
+ if level.zero?
+ audio_mute true
+ "audio was muted"
+ else
+ audio_mute false
+ volume level, ""
+ "volume set to #{level.to_f.clamp(0.0, 100.0)}"
+ end
+ end
+
+ @[Description("query the current volume, useful to know when asked to change the volume relatively")]
+ def volume?
+ status?(Float64, :volume) || 0.0
+ end
+
+ @[Description("mute or unmute the room audio")]
+ def audio_mute(state : Bool)
+ mute state
+ state ? "audio is muted" : "audio is unmuted"
+ end
+
+ @[Description("check if the room audio is muted")]
+ def audio_muted?
+ status?(Bool, :mute) || false
+ end
+
+ # Set the volume of a signal node within the system.
+ def volume(level : Int32 | Float64, input_or_output : String, push_to_remotes : Bool = true)
+ audio = @master_audio
+ if audio
+ logger.debug { "setting master volume to #{level}" }
+ else
+ logger.debug { "no master output configured" }
+ return
+ end
+
+ level = level.to_f.clamp(0.0, 100.0)
+ percentage = level / 100.0
+ range = audio.min_level..audio.max_level
+
+ # adjust into range
+ level_actual = percentage * (range.end - range.begin)
+ level_actual = (level_actual + range.begin.to_f).round(1)
+
+ mixer = system[audio.module_id]
+ set_master_volume(mixer, audio, level_actual)
+
+ # We are not using a join mode, so we need to set the lighting scene in joined rooms
+ if push_to_remotes
+ remote_rooms.each { |room| room.volume(level, input_or_output, false) }
+ end
+
+ level
+ end
+
+ # Sets the mute state on a signal node within the system.
+ def mute(state : Bool = true, index : Int32 | String = 0, layer : MuteLayer = MuteLayer::AudioVideo)
+ input_or_output = index
+ audio = @master_audio
+ if audio
+ logger.debug { "setting master mute to #{state}" }
+ else
+ logger.debug { "no master output configured" }
+ return
+ end
+
+ mixer = system[audio.module_id]
+ set_master_mute(mixer, audio, state)
+ end
+
+ # =================
+ # Lighting Controls
+ # =================
+
+ alias LightingArea = Interface::Lighting::Area
+ alias LightingScene = NamedTuple(name: String, id: UInt32, icon: String, opacity: Float64)
+
+ DEFAULT_LIGHT_MOD = "Lighting_1"
+
+ getter local_lighting_area : LightingArea? = nil
+ getter lighting_independent : Bool = false
+ @light_area : LightingArea? = nil
+ @light_scenes : Hash(String, UInt32) = {} of String => UInt32
+ @light_module : String = DEFAULT_LIGHT_MOD
+
+ @light_subscription : PlaceOS::Driver::Subscriptions::Subscription? = nil
+
+ protected def init_lighting
+ # deal with `false`
+ lights_independent = setting?(Bool, :lighting_independent)
+ @lighting_independent = lights_independent.nil? ? true : lights_independent
+ @light_area = @local_lighting_area = setting?(LightingArea, :lighting_area)
+ light_scenes = setting?(Array(LightingScene), :lighting_scenes)
+ @light_module = setting?(String, :lighting_module) || DEFAULT_LIGHT_MOD
+
+ local_scenes = {} of String => UInt32
+
+ light_scenes.try(&.each { |scene| local_scenes[scene[:name].downcase] = scene[:id] })
+ @light_scenes = local_scenes
+ self[:lighting_scenes] = light_scenes
+
+ @light_subscription = nil
+ update_available_lighting
+ end
+
+ protected def update_available_lighting
+ if sub = @light_subscription
+ subscriptions.unsubscribe sub
+ @light_subscription = nil
+ end
+
+ return if @light_scenes.empty?
+
+ # Check current join state
+ if light_area = @local_lighting_area
+ unless lighting_independent
+ # merge in joined room mics
+ remote_rooms.each do |room|
+ begin
+ remote_area = LightingArea.from_json(room.local_lighting_area.get.to_json)
+ light_area = light_area.join_with(remote_area)
+ rescue error
+ logger.warn(exception: error) { "ignoring lighting config in room #{room.name} (#{room.id})" }
+ end
+ end
+ end
+
+ # perform lighting linking if it's available
+ @light_area = light_area
+ lighting = system[@light_module]
+ if lighting.implements? "link_area"
+ if remote_rooms.empty?
+ lighting.unlink_area light_area.id
+ else
+ lighting.link_area light_area.id, light_area.join
+ end
+ end
+ end
+
+ @light_subscription = system.subscribe(@light_module, @light_area.to_s) do |_sub, scene|
+ self[:lighting_scene] = scene.to_i if scene && scene != "null"
+ end
+ end
+
+ def select_lighting_scene(scene : String, push_to_remotes : Bool = true)
+ scene_id = @light_scenes[scene.downcase]?
+ raise ArgumentError.new("invalid scene '#{scene}', valid scenes are: #{@light_scenes.keys.join(", ")}") unless scene_id
+
+ system[@light_module].set_lighting_scene(scene_id, @light_area)
+
+ # We are not using a join mode, so we need to set the lighting scene in joined rooms
+ if push_to_remotes && lighting_independent
+ remote_rooms.each { |room| room.select_lighting_scene(scene, false) }
+ end
+ end
+
+ @[Description("returns the list of available lighting scenes")]
+ def lighting_scenes
+ scenes = status?(Array(NamedTuple(name: String)), :lighting_scenes)
+ raise "no lighting control available" unless scenes
+ scenes.map { |scene| scene[:name].downcase }
+ end
+
+ @[Description("query the current lighting scene")]
+ def lighting_scene?
+ scenes = status?(Array(NamedTuple(name: String, id: Int32)), :lighting_scenes)
+ raise "no lighting control available" unless scenes
+ current = status?(Int32, :lighting_scene)
+ scene = scenes.find { |available| available[:id] == current }
+ scene ? "current lighting scene: #{scene[:name]}" : "lights in unknown state"
+ end
+
+ @[Description("set a new lighting scene. Remember to list available lighting scenes before calling")]
+ def set_lighting_scene(scene : String)
+ scenes = lighting_scenes
+ raise "invalid scene #{scene}, must be one of: #{scenes.join(", ")}" unless scenes.includes?(scene.downcase)
+ select_lighting_scene scene
+ "current lighting scene: #{scene}"
+ end
+
+ # ================
+ # Room Accessories
+ # ================
+
+ struct AccessoryBasic
+ include JSON::Serializable
+
+ struct Control
+ include JSON::Serializable
+
+ getter name : String
+ getter icon : String
+ getter function_name : String
+ getter arguments : Array(JSON::Any) = [] of JSON::Any
+ end
+
+ getter name : String
+ getter module : String
+ getter controls : Array(Control)
+
+ @[JSON::Field(ignore: true)]
+ property remote : String? = nil
+ end
+
+ struct AccessoryComplex
+ include JSON::Serializable
+
+ struct Exec
+ include JSON::Serializable
+
+ getter module : String { "System_1" }
+ getter function_name : String
+ getter arguments : Array(JSON::Any) = [] of JSON::Any
+
+ # used with mic room selection controls, do we want to augment the arguments
+ # with a toggle value? i.e. true / false value
+ getter augment : Bool? = nil
+ end
+
+ struct Control
+ include JSON::Serializable
+
+ getter name : String
+ getter icon : String
+ getter exec : Array(Exec)
+ end
+
+ getter name : String
+ getter controls : Array(Control)
+
+ @[JSON::Field(ignore: true)]
+ property remote : String? = nil
+ end
+
+ alias Accessory = AccessoryBasic | AccessoryComplex
+
+ getter local_accessories : Array(Accessory) = [] of Accessory
+ getter available_accessories : Array(Accessory) = [] of Accessory
+
+ protected def init_accessories
+ @local_accessories = setting?(Array(Accessory), :room_accessories) || [] of Accessory
+ update_available_accessories
+ end
+
+ protected def update_available_accessories
+ accessories = @local_accessories.dup
+ remote_systems.each do |remote|
+ remote_accessories = Array(Accessory).from_json(remote.room_logic.local_accessories.get.to_json)
+ accessories.concat(remote_accessories.map! { |acc|
+ acc.remote = remote.system_id
+ acc
+ })
+ end
+ self[:room_accessories] = @available_accessories = accessories
+ end
+
+ def accessory_exec(accessory : String, control : String) : Bool
+ accessory_inst = @available_accessories.find { |access| access.name == accessory }
+ return false unless accessory_inst
+
+ control_inst = accessory_inst.controls.find { |ctrl| ctrl.name == control }
+ return false unless control_inst
+
+ # execute accessories on the system they came from
+ # some are merged in if a room is joined
+ if system_id = accessory_inst.remote
+ system(system_id).get("System", 1).accessory_exec(accessory, control)
+ return true
+ end
+
+ case control_inst
+ in AccessoryBasic::Control
+ mod_name = accessory_inst.as(AccessoryBasic).module
+ system[mod_name].__send__(control_inst.function_name, control_inst.arguments)
+ in AccessoryComplex::Control
+ control_inst.exec.each do |exec|
+ system[exec.module].__send__(exec.function_name, exec.arguments)
+ end
+ end
+
+ true
+ end
+
+ # ===================
+ # Microphone Controls
+ # ===================
+
+ alias Microphone = AudioFader
+
+ getter local_mics : Array(Microphone) = [] of Microphone
+ @available_mics : Array(Microphone) = [] of Microphone
+
+ protected def init_microphones
+ @local_mics = setting?(Array(Microphone), :local_microphones) || [] of Microphone
+ update_available_mics
+ rescue error
+ logger.warn(exception: error) { "failed to init microphones" }
+ end
+
+ protected def update_available_mics
+ local = @local_mics.dup
+
+ # merge in joined room mics
+ remote_rooms.each do |room|
+ local.concat Array(Microphone).from_json(room.local_mics.get.to_json)
+ end
+
+ # expose the details to the UI
+ @available_mics = local
+ self[:microphones] = @available_mics.map do |mic|
+ level_id = mic.level_id
+ mute_id = mic.mute_id
+ level_array = level_id.is_a?(Array) ? level_id : [level_id]
+ mute_array = mute_id.is_a?(Array) ? mute_id : [mute_id]
+
+ {
+ name: mic.name,
+ level_id: level_array.compact,
+ mute_id: mute_array.compact,
+ level_index: mic.level_index,
+ mute_index: mic.mute_index,
+ level_feedback: mic.level_feedback,
+ mute_feedback: mic.mute_feedback,
+ module_id: mic.module_id,
+ min_level: mic.min_level,
+ max_level: mic.max_level,
+ rooms: mic.rooms,
+ }
+ end
+ end
+
+ protected def apply_mic_defaults
+ @local_mics.each do |mic|
+ mixer = system[mic.module_id]
+
+ case mic.default_muted
+ in Bool
+ if mute_index = mic.mute_index
+ mixer.mute(mic.mute_id, mic.default_muted, mute_index)
+ else
+ mixer.mute(mic.mute_id, mic.default_muted)
+ end
+ in Nil
+ mixer.query_mutes(mic.level_id)
+ end
+
+ case mic.default_level
+ in Float64
+ if level_index = mic.level_index
+ mixer.fader(mic.level_id, mic.default_level, level_index)
+ else
+ mixer.fader(mic.level_id, mic.default_level)
+ end
+ in Nil
+ mixer.query_faders(mic.level_id)
+ end
+ end
+ end
+
+ # level is a percentage 0.0->100.0
+ def set_microphone(level : Float64, mute : Bool = false)
+ @local_mics.each do |mic|
+ mixer = system[mic.module_id]
+
+ if level_index = mic.level_index
+ mixer.fader(mic.level_id, level, level_index)
+ else
+ mixer.fader(mic.level_id, level)
+ end
+
+ if mute_index = mic.mute_index
+ mixer.mute(mic.level_id, mute, mute_index)
+ else
+ mixer.mute(mic.level_id, mute)
+ end
+ end
+ end
+
+ def mute_microphones(mute : Bool = true) : Nil
+ @local_mics.each do |mic|
+ begin
+ mixer = system[mic.module_id]
+
+ if mute_index = mic.mute_index
+ mixer.mute(mic.level_id, mute, mute_index)
+ else
+ mixer.mute(mic.level_id, mute)
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to mute microphone: #{mic}" }
+ end
+ end
+ end
+
+ # set_string: zone_id, index
+ # 22 for false
+ def mic_room_selection(mic_name : String, room_name : String, selected : Bool)
+ mic = @available_mics.find { |match| match.name == mic_name }
+ raise "no matching mic name: #{mic_name}" unless mic
+
+ rooms = mic.rooms
+ raise "no rooms for mic: #{mic_name}" unless rooms
+
+ room = rooms.find { |match| match.name == room_name }
+ raise "no matching room name for: #{room_name}" unless room
+
+ execs = selected ? room.route : room.unroute
+ if execs
+ execs.each do |action|
+ if action.augment
+ args = action.arguments.dup
+ args << JSON::Any.new(selected)
+ system[action.@module || mic.module_id].__send__(action.function_name, args)
+ else
+ system[action.@module || mic.module_id].__send__(action.function_name, action.arguments)
+ end
+ end
+ else
+ mixer = system[mic.module_id]
+ ctrl = room.control
+ case ctrl
+ in Nil
+ mixer.mute(room.binding, !selected)
+ in String, Array(String)
+ mixer.mute(ctrl, !selected)
+ end
+ end
+ end
+
+ # ====================
+ # VC Camera Management
+ # ====================
+
+ class CamDetails
+ include JSON::Serializable
+
+ getter mod : String
+ getter index : String | Int32? # if multiple cams on the one device (VidConf mod for instance)
+ getter vc_camera_input : String | Int32?
+ end
+
+ @vc_camera_in : String | Array(String)? = nil
+ protected getter vc_camera_module : String { "Camera" }
+
+ def init_vidconf
+ @vc_camera_in = setting?(String | Array(String), :vc_camera_in)
+ @vc_camera_module = setting?(String, :vc_camera_module)
+ end
+
+ # run on system power on
+ def apply_camera_defaults
+ system.all(vc_camera_module).power true
+ end
+
+ # This is the camera input that is currently selected so we can switch between
+ # different cameras
+ def selected_camera(camera : String)
+ self[:selected_camera] = camera
+
+ cam = camera_details(camera)
+ system[cam.mod].power(true)
+
+ # route the camera
+ case camera_in = @vc_camera_in
+ in String
+ route_signal(camera, camera_in)
+ in Array(String)
+ camera_in.each { |cin| route_signal(camera, cin) }
+ in Nil
+ end
+
+ # switch to the correct VC input
+ if camera_vc_in = cam.vc_camera_input
+ system[@local_vidconf].camera_select(camera_vc_in)
+ end
+ end
+
+ def add_preset(preset : String, camera : String)
+ cam = camera_details(camera)
+ system[cam.mod].save_position preset, cam.index || 0
+ end
+
+ def remove_preset(preset : String, camera : String)
+ cam = camera_details(camera)
+ system[cam.mod].remove_position preset, cam.index || 0
+ end
+
+ protected def camera_details(camera : String)
+ status CamDetails, "input/#{camera}"
+ end
+
+ # =========================
+ # Room Joining Coordination
+ # =========================
+ enum JoinType
+ # only rooms part of the join need to be notified
+ Independent
+
+ # even rooms not part of the join, need to be notified
+ FullyAware
+ end
+
+ class JoinAction
+ include JSON::Serializable
+
+ getter module_id : String
+ getter function_name : String
+ getter arguments : Array(JSON::Any) { [] of JSON::Any }
+ getter named_args : Hash(String, JSON::Any) { {} of String => JSON::Any }
+ getter? master_only : Bool { true }
+ end
+
+ class JoinDetail
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String
+ getter room_ids : Array(String)
+ getter join_actions : Array(JoinAction) { [] of JoinAction }
+ getter breakdown : Array(JoinAction) { [] of JoinAction }
+
+ # Do we want to merge the outputs (all outputs on all screens)
+ # or do we want them as seperate displays
+ getter? merge_outputs : Bool = true
+
+ @[JSON::Field(ignore: true)]
+ getter? linked : Bool { !room_ids.empty? }
+ end
+
+ class JoinSetting
+ include JSON::Serializable
+
+ getter type : JoinType { JoinType::Independent }
+ getter lock_remote : Bool { false }
+ getter modes : Array(JoinDetail)
+
+ @[JSON::Field(ignore: true)]
+ getter all_rooms : Set(String) do
+ modes.reduce(Set(String).new) { |rooms, mode| rooms.concat(mode.room_ids) }
+ end
+ end
+
+ @join_lock : Mutex = Mutex.new(:reentrant)
+ @join_selected : String? = nil
+ @join_confirmed : Bool = false
+ @join_settings : JoinSetting? = nil
+ @join_modes : Hash(String, JoinDetail) = {} of String => JoinDetail
+
+ # this is called on_load, before settings are loaded to setup any previous state
+ protected def init_previous_join_state
+ init_joining
+ master = setting?(Bool, :join_master)
+ self[:join_master] = @join_master = master.nil? ? true : master
+ self[:joined] = @join_selected = setting?(String, :join_selected)
+ self[:join_confirmed] = @join_confirmed = true
+
+ if @join_modes[@join_selected]?.nil?
+ self[:join_master] = @join_master = true
+ self[:joined] = @join_selected = nil
+ self[:join_confirmed] = @join_confirmed = true
+ end
+ end
+
+ protected def init_joining
+ @join_settings = join_settings = setting?(JoinSetting, :join_modes)
+ join_lookup = {} of String => JoinDetail
+ join_settings.try &.modes.each { |mode| join_lookup[mode.id] = mode }
+ self[:join_modes] = @join_modes = join_lookup
+
+ self[:join_lockout_secondary] = setting?(Bool, :join_lockout_secondary) || false
+ self[:join_hide_button] = setting?(Bool, :join_hide_button) || false
+ end
+
+ protected def perform_breakdown_actions(old_mode)
+ old_mode.breakdown.each do |action|
+ # dynamic function invocation
+ system[action.module_id].__send__(action.function_name, action.arguments, action.named_args)
+ end
+ rescue error
+ logger.error(exception: error) { "error performing join breakdown actions" }
+ end
+
+ def join_mode(mode_id : String, master : Bool = true)
+ mode = @join_modes[mode_id]
+ old_mode = @join_modes[@join_selected]? if @join_selected
+ join_settings = @join_settings.not_nil!
+ this_room = config.control_system.not_nil!.id
+
+ begin
+ @join_lock.synchronize do
+ # check this room is included in the join
+ if master
+ notify_rooms = join_settings.type.fully_aware? ? join_settings.all_rooms : mode.room_ids
+ if mode.linked?
+ raise "unable to perform join from this system" unless notify_rooms.includes?(this_room)
+ end
+
+ @join_selected = mode.id
+ @join_master = true
+
+ if old_mode && old_mode.linked?
+ # Perform any breakdown actions
+ perform_breakdown_actions(old_mode)
+
+ # unlink independent rooms
+ # find the rooms not incuded in this join and unlink them
+ if join_settings.type.independent?
+ unlink(old_mode.room_ids - mode.room_ids)
+ end
+ end
+
+ # unlink fully aware systems (empty array for independent rooms, unlinked above)
+ return unlink(notify_rooms) if !mode.linked?
+
+ reset_remote_cache
+ self[:join_confirmed] = @join_confirmed = false
+
+ notify_rooms.each do |room_id|
+ next if room_id == this_room
+ system(room_id).get("System", 1).join_mode(mode_id, master: false).get
+ end
+ persist_join_state
+
+ self[:join_master] = master
+ self[:joined] = @join_selected
+ self[:join_confirmed] = @join_confirmed = true
+ else
+ @join_selected = mode.id
+ @join_master = false
+ reset_remote_cache
+
+ persist_join_state
+
+ self[:join_master] = master
+ self[:joined] = mode.id
+ self[:join_confirmed] = @join_confirmed = true
+ end
+ end
+ ensure
+ update_available_ui
+
+ # ensure the system is powered on
+ power(true) if mode.linked? && !power?
+
+ # send the current input to the remote rooms
+ begin
+ if @auto_route_on_join && master && (selected_inp = status?(String, :selected_input))
+ routes = current_routes.compact.keys
+ all_outputs.each do |outp|
+ # skip if a display has something routed to it
+ next if routes.includes?(outp)
+ route(selected_inp, outp)
+ end
+ end
+ rescue error
+ logger.error(exception: error) { "error applying routes during join" }
+ end
+
+ # perform the custom actions
+ mode.join_actions.each do |action|
+ if master || !action.master_only?
+ # dynamic function invocation
+ system[action.module_id].__send__(action.function_name, action.arguments, action.named_args)
+ end
+ end
+
+ # recall the first lighting preset
+ if !@light_scenes.empty? && master
+ select_lighting_scene(@light_scenes.keys.first)
+ end
+ end
+ end
+
+ def unlink_systems
+ if unlink_mode = @join_modes.find { |_id, mode| !mode.linked? }
+ join_mode(unlink_mode[0])
+ else
+ currrent_selected = @join_selected
+ if currrent_selected && (current_mode = @join_modes[currrent_selected]?)
+ perform_breakdown_actions(current_mode)
+ unlink(current_mode.room_ids)
+ end
+ unlink_internal_use
+ end
+ rescue error
+ logger.warn(exception: error) { "unlink failed" }
+ end
+
+ def unlink_internal_use
+ @join_lock.synchronize do
+ @join_selected = nil unless @join_modes[@join_selected]?.try(&.room_ids.empty?)
+ @join_master = true
+ self[:join_confirmed] = @join_confirmed = false
+ self[:join_master] = true
+ self[:joined] = @join_selected
+ reset_remote_cache
+
+ persist_join_state
+ update_available_ui
+
+ self[:join_confirmed] = @join_confirmed = true
+ end
+
+ # only mute on unlink if we're not powering off
+ if @mute_on_unlink && status?(Bool, :active)
+ @local_outputs.each { |output| unroute(output) }
+ @local_preview_outputs.each { |output| unroute(output) }
+ end
+ rescue error
+ logger.error(exception: error) { "ui state failed to be applied unjoining room" }
+ end
+
+ protected def persist_join_state
+ @ignore_update = Time.utc.to_unix
+ define_setting(:join_master, @join_master)
+ define_setting(:join_selected, @join_selected)
+ rescue error
+ logger.error(exception: error) { "failed to persist join state" }
+ end
+
+ protected def update_available_ui
+ update_available_help
+ # VC tab to not be merged
+ update_available_tabs
+ update_available_outputs
+ update_available_mics
+ update_available_lighting
+ update_available_accessories
+ rescue error
+ logger.error(exception: error) { "ui state failed to be applied in room join" }
+ end
+
+ protected def unlink(rooms : Enumerable(String))
+ this_room = config.control_system.not_nil!.id
+ rooms.each do |room|
+ if room == this_room
+ unlink_internal_use
+ next
+ end
+ system(room).get("System", 1).unlink_internal_use
+ end
+ end
+
+ struct RemoteSystem
+ getter system_id : String
+ getter room_logic : PlaceOS::Driver::Proxy::Driver
+
+ def initialize(@system_id : String, @room_logic : PlaceOS::Driver::Proxy::Driver)
+ end
+ end
+
+ protected getter remote_systems : Array(RemoteSystem) do
+ if selected = @join_selected
+ if mode = @join_modes[selected]
+ this_room = config.control_system.not_nil!.id
+ if mode.room_ids.includes? this_room
+ mode.room_ids.compact_map do |room|
+ next if room == this_room
+ RemoteSystem.new(room, system(room).get("System", 1))
+ end
+ else
+ [] of RemoteSystem
+ end
+ else
+ [] of RemoteSystem
+ end
+ else
+ [] of RemoteSystem
+ end
+ end
+
+ # cache the proxies for performance reasons
+ protected getter remote_rooms : Array(PlaceOS::Driver::Proxy::Driver) do
+ remote_systems.map(&.room_logic)
+ end
+
+ protected def reset_remote_cache
+ @remote_systems = nil
+ @remote_rooms = nil
+ end
+end
diff --git a/drivers/place/meet/help.cr b/drivers/place/meet/help.cr
new file mode 100644
index 00000000000..24c1f812f51
--- /dev/null
+++ b/drivers/place/meet/help.cr
@@ -0,0 +1,14 @@
+require "json"
+
+module Place
+ struct HelpPage
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ getter icon : String?
+ getter title : String
+ getter content : String
+ end
+
+ alias Help = Hash(String, HelpPage)
+end
diff --git a/drivers/place/meet/qsc_phone_dialing.cr b/drivers/place/meet/qsc_phone_dialing.cr
new file mode 100644
index 00000000000..386b0deb9aa
--- /dev/null
+++ b/drivers/place/meet/qsc_phone_dialing.cr
@@ -0,0 +1,78 @@
+# Code for handling QSC phone dialing, if available
+module Place::QSCPhoneDialing
+ # This data will be stored in the tab
+ class QscPhone
+ include JSON::Serializable
+
+ getter number_id : String
+ getter dial_id : String
+ getter hangup_id : String
+ getter status_id : String
+ getter ringing_id : String
+ getter offhook_id : String
+ getter dtmf_id : String
+ end
+
+ macro included
+ {% EXT_INIT << :qsc_phone_dialing_init %}
+ {% EXT_POWER << :qsc_phone_dialing_power %}
+ end
+
+ @qsc_dial_settings : QscPhone? = nil
+ @dial_string : String = ""
+
+ protected def qsc_phone_dialing_init
+ @qsc_dial_settings = setting?(QscPhone, :qsc_phone)
+ self[:qsc_dial_number] = @dial_string
+ self[:qsc_dial_bindings] = @qsc_dial_settings
+ end
+
+ protected def qsc_phone_dialing_power(state : Bool, unlink : Bool)
+ if state
+ qsc_dial_pad_clear
+ else
+ qsc_dial_hangup
+ qsc_dial_pad_clear
+ end
+ end
+
+ protected def qsc_dial_pad_sync : Nil
+ dial_settings = @qsc_dial_settings
+ return unless dial_settings
+ system[:Mixer].set_string(dial_settings.number_id, @dial_string)
+ self[:qsc_dial_number] = @dial_string
+ end
+
+ def qsc_dial_pad(number : String)
+ return unless number.size > 0
+ char = number[0]
+
+ case char
+ when '\b'
+ @dial_string = @dial_string[0..-2] unless @dial_string.size == 0
+ when '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'
+ @dial_string = "#{@dial_string}#{char}"
+ else
+ logger.info { "unsupported dial char provided #{char}" }
+ end
+
+ qsc_dial_pad_sync
+ end
+
+ def qsc_dial_pad_clear : Nil
+ @dial_string = ""
+ qsc_dial_pad_sync
+ end
+
+ def qsc_dial_makecall
+ dial_settings = @qsc_dial_settings
+ return unless dial_settings
+ system[:Mixer].trigger(dial_settings.dial_id)
+ end
+
+ def qsc_dial_hangup
+ dial_settings = @qsc_dial_settings
+ return unless dial_settings
+ system[:Mixer].trigger(dial_settings.hangup_id)
+ end
+end
diff --git a/drivers/place/meet/sensor_shutdown.cr b/drivers/place/meet/sensor_shutdown.cr
new file mode 100644
index 00000000000..f85752aebd6
--- /dev/null
+++ b/drivers/place/meet/sensor_shutdown.cr
@@ -0,0 +1,147 @@
+require "placeos-driver"
+
+class Place::SensorShutdown < PlaceOS::Driver
+ descriptive_name "PlaceOS Idle Shutdown"
+ generic_name :IdleShutdown
+ description "works in conjunction with the Bookings driver to decide when a room should shutdown"
+
+ accessor bookings : Bookings_1
+ accessor av_control : System_1
+
+ default_settings({
+ timeout_ad_hoc: 15,
+ timeout_booked: 30,
+ })
+
+ @sensor_stale : Bool = false
+
+ getter? event_in_progress : Bool = false
+ getter? people_present : Bool = false
+ getter? sensor_stale : Bool = false
+ getter? room_powered_on : Bool = false
+
+ getter? timer_active : Bool = false
+ @timer_time : Time::Span? = nil
+ @timer_started : Time::Span = 0.seconds
+ @shutdown_count : Int64 = 0_i64
+
+ @timeout_ad_hoc : Time::Span = 15.minutes
+ @timeout_booked : Time::Span = 30.minutes
+ @state_change_mutex : Mutex = Mutex.new(:reentrant)
+
+ def on_update
+ timeout_ad_hoc = setting?(UInt32, :timeout_ad_hoc) || 15_u32.minutes
+ timeout_booked = setting?(UInt32, :timeout_booked) || 30_u32.minutes
+
+ subscriptions.clear
+ bookings.subscribe(:status) { |_sub, status| update_status(status != "\"free\"") }
+ bookings.subscribe(:sensor_stale) { |_sub, sensor_stale| update_stale_state(sensor_stale == "true") }
+ bookings.subscribe(:presence) { |_sub, presence| update_presence(presence == "true") }
+ av_control.subscribe(:active) { |_sub, active| update_room_power_state(active == "true") }
+ end
+
+ protected def update_status(busy : Bool)
+ return if event_in_progress? == busy
+
+ logger.debug { "> event in progress: #{busy}" }
+ self[:event_in_progress] = @event_in_progress = busy
+ @state_change_mutex.synchronize { apply_state_changes }
+ end
+
+ protected def update_presence(state : Bool)
+ return if people_present? == state
+
+ logger.debug { "> people present: #{state}" }
+ self[:people_present] = @people_present = state
+ @state_change_mutex.synchronize { apply_state_changes }
+ end
+
+ protected def update_stale_state(stale : Bool)
+ return if sensor_stale? == stale
+
+ logger.debug { "> sensor state change: #{stale}" }
+ @sensor_stale = stale
+ @state_change_mutex.synchronize { apply_state_changes }
+ end
+
+ protected def update_room_power_state(powered : Bool)
+ return if room_powered_on? == powered
+
+ logger.debug { "> power state change: #{powered}" }
+ @room_powered_on = powered
+ @state_change_mutex.synchronize { apply_state_changes }
+ end
+
+ protected def clear_timer(update_status : Bool = true)
+ @state_change_mutex.synchronize do
+ @timer_active = false
+ @timer_time = nil
+ schedule.clear
+
+ if update_status
+ self[:timer_active] = false
+ self[:timer_started] = nil
+ end
+ end
+ end
+
+ protected def apply_state_changes
+ if sensor_stale?
+ clear_timer
+ logger.warn { "possible sensor failure, ignoring state" }
+ return
+ end
+
+ if !room_powered_on?
+ clear_timer
+ logger.debug { "room powered off, clearing schedule" }
+ return
+ end
+
+ if people_present?
+ clear_timer
+ logger.debug { "people detected, clearing schedule" }
+ return
+ end
+
+ timeout = if event_in_progress?
+ @timeout_booked
+ else
+ @timeout_ad_hoc
+ end
+
+ if timer_active?
+ if @timer_time == timeout
+ logger.debug { "timer already active, ignoring event" }
+ return
+ else
+ elapsed = Time.monotonic - @timer_started
+ remaining = timeout - elapsed
+
+ if remaining.positive?
+ timeout = remaining
+ else
+ logger.info { "new timeout period and already idle for that amount of time" }
+ return perform_shutdown
+ end
+ end
+ end
+
+ clear_timer(update_status: false)
+ schedule.in(timeout) { perform_shutdown }
+ self[:timer_active] = @timer_active = true
+ self[:timer_started] = Time.utc.to_unix
+ @timer_time = timeout
+ @timer_started = Time.monotonic
+ logger.debug { "timer started, shutdown in #{timeout}" }
+ end
+
+ protected def perform_shutdown
+ clear_timer
+ av_control.power false
+ @shutdown_count += 1_i64
+ self[:last_idle_shutdown] = Time.utc.to_unix
+ self[:idle_shutdowns] = @shutdown_count
+ logger.info { "System ilde timeout, shutdown requested" }
+ end
+end
diff --git a/drivers/place/meet/sensor_shutdown_spec.cr b/drivers/place/meet/sensor_shutdown_spec.cr
new file mode 100644
index 00000000000..4ff71b9a160
--- /dev/null
+++ b/drivers/place/meet/sensor_shutdown_spec.cr
@@ -0,0 +1,40 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::SensorShutdown" do
+ system({
+ Bookings: {BookingsMock},
+ System: {SystemMock},
+ })
+
+ sleep 1
+
+ status[:timer_active].should eq(true)
+ system(:Bookings_1).as(BookingsMock).sensor_stale
+
+ sleep 1
+ status[:timer_active].should eq(false)
+end
+
+# :nodoc:
+class BookingsMock < DriverSpecs::MockDriver
+ def on_load
+ self[:status] = "free"
+ self[:sensor_stale] = false
+ self[:presence] = false
+ end
+
+ def sensor_stale
+ self[:sensor_stale] = true
+ end
+end
+
+# :nodoc:
+class SystemMock < DriverSpecs::MockDriver
+ def on_load
+ self[:active] = true
+ end
+
+ def power(state : Bool)
+ self[:active] = state
+ end
+end
diff --git a/drivers/place/meet/tab.cr b/drivers/place/meet/tab.cr
new file mode 100644
index 00000000000..45a542cd5e8
--- /dev/null
+++ b/drivers/place/meet/tab.cr
@@ -0,0 +1,38 @@
+require "json"
+
+class Place::Tab
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ def initialize(@icon, @name, @inputs, @help = nil, @controls = nil, @merge_on_join = nil, @presentation_source = nil, @json_unmapped = Hash(String, JSON::Any).new)
+ end
+
+ getter icon : String
+ getter name : String
+ getter inputs : Array(String)
+
+ getter help : String?
+
+ # such as: vidconf-controls
+ getter controls : String?
+ getter merge_on_join : Bool?
+
+ # For the VC controls
+ getter presentation_source : String?
+
+ def clone : Tab
+ Tab.new(@icon, @name, inputs.dup, @help, @controls, @merge_on_join, @presentation_source, @json_unmapped.dup)
+ end
+
+ def merge(tab : Tab) : Tab
+ input = inputs.dup.concat(tab.inputs).uniq!
+ new_unmapped = tab.json_unmapped.merge json_unmapped
+ Tab.new(@icon, @name, input, @help, @controls, @merge_on_join, @presentation_source, new_unmapped)
+ end
+
+ def merge!(tab : Tab) : Tab
+ @json_unmapped.merge! tab.json_unmapped
+ @inputs.concat(tab.inputs).uniq!
+ self
+ end
+end
diff --git a/drivers/place/meet_readme.md b/drivers/place/meet_readme.md
new file mode 100644
index 00000000000..fb2ad973125
--- /dev/null
+++ b/drivers/place/meet_readme.md
@@ -0,0 +1,620 @@
+# Meet Readme
+
+Docs on how to configure a tabbed control UI
+
+* available icons for controls are: https://fonts.google.com/icons?selected=Material+Icons
+
+## Routing
+
+The router is designed to graph signal paths in a system between devices.
+https://docs.google.com/document/d/1DG2s9jjMVhiW65YGPDkUnOYYpFDeW42SkGvHyp1BEjQ/
+
+* devices can be represented by modules `Display_1`
+* virutal devices can be representated by a `*`: `*Laptop_HDMI`
+
+Connections are then defined by a flat map of Output => Inputs
+There are two styles of switching supported:
+
+* `switch_to`: an output that multiple inputs
+* `switch`: multiple outputs and multiple inputs
+
+NOTE:: when an input is presented to an output via `route("Input_id", "Output_id")`
+if the input and output support the `Powerable` interface, they will be powered on
+
+### Examples
+
+A basic single display system
+
+```yaml
+
+# A switch_to style of output where the inputs are virtual devices
+# virtual devices as no modules in the system represent the inputs
+connections:
+ Display_1:
+ hdmi: '*HDMI_Cable'
+ hdmi2: '*Wireless_Presenter'
+
+```
+
+A switcher and multiple displays
+
+* Switcher outputs are represented by `.` i.e. `Switcher_1.2` (output 2)
+* Switcher inputs can be represented by `:` i.e. `Switcher_1:2` (input 2)
+ * Switcher inputs in this format are only required if chaining multiple switchers
+
+```yaml
+
+# A typical single switcher setup
+connections:
+ # Display 1 is connected to Switcher 1 ouput 1
+ Display_1:
+ hdmi: Switcher_1.1
+ Display_2:
+ hdmi: Switcher_1.2
+
+ # We have a virtual output connected to output 5
+ '*AUX_Output': Switcher_1.5
+
+ # The switcher inputs are hooked up using a hash
+ Switcher_1:
+ '1': '*Wireless_Presenter' # always on, wireless presenter
+ '2': IPTV_1 # set top box or streaming input that can be powered on
+ '5': '*Desk_HDMI_1' # i.e. laptop inputs on a table in the room
+ '6': '*Desk_HDMI_2'
+
+```
+
+If you have a situation where audio and video need to be switched separately then you can also define layers on the switcher outputs.
+
+```yaml
+
+# A weird audio setup
+connections:
+ # Front of house audio split from the camera video input (real world example!)
+ Display_1:
+ hdmi: Switcher_1.12
+ '*VC_Camera_1': Switcher_1.1!video
+ '*FOH_Audio': Switcher_1.1!audio
+
+ # The switcher inputs are hooked up using a hash
+ Switcher_1:
+ '1': '*Wireless_Presenter' # always on, wireless presenter
+ '2': IPTV_1 # set top box or streaming input that can be powered on
+ '5': '*Desk_HDMI_1' # i.e. laptop inputs on a table in the room
+ '6': '*Desk_HDMI_2'
+
+# as we also want the audio to follow anything being presented to the display
+# you can ensure the sources follow one another
+outputs:
+ Display_1:
+ name: Projector
+ followers: ["FOH_Audio"]
+
+```
+
+### Default routes
+
+these are applied at system startup
+
+```yaml
+
+# output id => input id (as defined in the router)
+default_routes:
+ VC_Camera_1: Camera_1
+ VC_Camera_2: Camera_2
+
+```
+
+## Naming Inputs and Outputs
+
+Inputs and outputs are all referenced from their IDs which are either:
+
+* DeviceMod_1
+* Virtual_Device
+
+However you can apply metadata to these inputs and outputs, such as name for display on the user interface. This configuration is split between the inputs and outputs.
+
+```yaml
+
+# Input meta data
+inputs:
+ Desk_HDMI_1:
+ name: Table Box HDMI Cable
+ icon: input
+ Wireless_Presenter:
+ name: Wireless
+ icon: connected_tv
+
+ # Inputs of type cam are collected for camera control
+ # index is optional (only where a single module controls multiple cameras)
+ Camera_1:
+ name: Camera 1
+ icon: video_camera_front
+ type: cam
+ mod: Camera_1
+ index: 1 # only use this index on VC systems, single mod, mutliple cameras
+
+ # Inputs that have `presentable: false` are ignored as possible inputs for VC presenations
+ VidConf_1:
+ name: Video Conference
+ icon: video_camera_front
+ presentable: false
+
+```
+
+Output config is typically less interesting
+
+```yaml
+
+outputs:
+ Display_1:
+ name: Display Left
+ Display_2:
+ name: Display Right
+
+ # this display is hidden on the UI when the room is joined, preventing its use
+ Display_3:
+ name: Middle of room
+ hide_on_join: true
+
+```
+
+## Laying out Tabs
+
+Spaces can have more inputs and outputs defined then you want to display on the panel. Some things are auto switched etc so you need define you tab layouts.
+
+```yaml
+
+# a single tab UI with the optional help link
+tabs:
+ - name: Laptop
+ icon: computer
+ help: laptop-help
+
+ # Multiple inputs can be on a single tab
+ inputs:
+ - HDMI_Cable
+ - Wireless_Presenter
+
+```
+
+### Cisco Video Conferencing
+
+Configuring a tab with Cisco VC controls
+
+```yaml
+
+tabs:
+ - name: Conference
+ icon: video_camera_front
+ # The controls we want to see on the tab
+ controls: vidconf-controls
+ # this defines the switch output representing the presentation input on the VC
+ presentation_source: Virtual_VC_Presentation_Output
+ inputs:
+ - VidConf_1
+
+```
+
+configuring camera switching where cameras are connected via a switcher (single input on the VC unit)
+
+```yaml
+
+connections:
+ # outputs:
+ '*VC_Camera_Input': Switcher_1.1!video
+ '*Recorder_Camera_Input': Switcher_1.2!video
+ Switcher_1: # inputs:
+ '35': Camera_1
+ '36': Camera_2
+
+# specify which input on the VC unit the inputs are connected
+inputs:
+ Camera_1:
+ name: Camera 1
+ icon: video_camera_front
+ type: cam
+ mod: Camera_1
+ presentable: false # don't appear as VC content
+ Camera_2:
+ name: Karijini I Camera 2
+ icon: video_camera_front
+ type: cam # this indicated we want to have this camera manually controllable
+ mod: Camera_2
+ presentable: false # don't appear as VC content
+
+# When camera 1 or 2 is selected, we'll switch it to this output
+vc_camera_in: VC_Camera_Input
+
+# If the camera needs to be switched to multiple sources (i.e. a recording device and a VC system)
+vc_camera_in:
+ - VC_Camera_Input
+ - Recorder_Camera_Input
+
+# where there are joining rooms you must define which cameras are local to the system
+local_cameras:
+ - Camera_1
+ - Camera_2
+```
+
+configuring camera switching where cameras are connected via a switcher but also multiple cameras are connected to the VC at once
+
+```yaml
+
+# Configure the camera inputs and outputs on the switcher
+connections:
+ # outputs:
+ '*VC_Camera_1': Switcher_1.1!video
+ '*VC_Camera_2': Switcher_1.2!video
+ Switcher_1: # inputs:
+ '35': Camera_1
+ '36': Camera_2
+
+# auto switch these these to the VC on startup
+default_routes:
+ VC_Camera_1: Camera_1
+ VC_Camera_2: Camera_2
+
+# specify which input on the VC unit the inputs are connected
+inputs:
+ Camera_1:
+ name: Camera 1
+ icon: video_camera_front
+ type: cam
+ mod: Camera_1
+ presentable: false
+ vc_camera_input: 1 # This is input on the VC codec we want to select
+ Camera_2:
+ name: Karijini I Camera 2
+ icon: video_camera_front
+ type: cam # this indicated we want to have this camera manually controllable
+ mod: Camera_2
+ presentable: false # don't appear as VC content
+ vc_camera_input: 2
+
+```
+
+### IPTV Control
+
+Configuring IPTV controls for a page
+
+```yaml
+
+tabs:
+ - name: TV
+ icon: live_tv
+
+ # the controls we want to show (expects IPTV_1 mod to expose channel details)
+ controls: tv-channels
+ mod: IPTV_1
+ inputs:
+ - IPTV_1
+
+```
+
+example channel detail config (see [Exterity M93xx](https://github.com/PlaceOS/drivers/blob/master/drivers/exterity/avedia_player/m93xx.cr#L15) for an example driver)
+
+```yaml
+channel_details:
+ - name: Al Jazeera
+ channel: 'udp://239.192.10.170:5000?hwchan=0'
+ icon: 'https://os.place.tech/placeos.com/16335767803641925864.svg'
+```
+
+## Help pages
+
+This is custom HTML content that is embedded on the UI.
+The help key (`laptop-help` in the example below) is used to link the help to a tab
+
+* Help pages are inlined onto tabs when there are no controls defined.
+* Where there are controls defined a button is placed on the tab that links to the help pop-up
+
+```yaml
+
+help:
+ laptop-help:
+ title: Swytch
+ icon: computer
+ content: >
+ Follow the instructions below on how to connect your laptop in a meeting
+ room:
+
+ 1. Plug the ‘Y’ shaped connector into the USB-C port on your laptop.
+
+
+
+
+```
+
+You can drag and drop images and videos into backoffice so they are available for embedding.
+
+## Defining Outputs to display
+
+You need to define which ouputs will be displayed on the panel.
+
+* when there is a single output, it'll automatically be switched
+* where there are two outputs, the user must manually switch by selecting the output
+
+```yaml
+
+# named local outputs as when joining rooms we'll merge these with joined rooms
+local_outputs:
+ - Display_1
+ - Display_2
+
+```
+
+Where you have Preview Monitor(s) for previewing sources before presenting them, you configure them using:
+
+```yaml
+
+# these will show the currently selected input
+preview_outputs:
+ - Display_3
+
+```
+
+NOTE:: there is a setting `mute_on_unlink: true` that can be set to ensure outputs are muted when rooms are unlinked - ensuring routes are reset
+
+## Front of House Audio
+
+By default the first display in the output list is assumed to be managing audio
+However you may want to configure defaults or use Mixer controls instead of the output device
+
+```yaml
+
+# This is a mixer configuration
+master_audio:
+ name: FOH Speakers
+ level_id: ["FOH-1234", "FOH-1235"]
+ mute_id: 'FOH-123-45-mute'
+
+ level_index: 4,
+ mute_index: 4,
+ level_feedback: 'faderFOH-1234'
+ mute_feedback: 'faderFOH-1234_mute'
+ module_id: 'Mixer_2'
+
+ default_muted: false
+ default_level: 60
+
+ min_level: 40
+ max_level: 90
+
+```
+
+You can just customise defaults if you want to continue using the default output
+
+```yaml
+
+master_audio:
+ default_muted: false
+ default_level: 60
+
+```
+
+## Projector Screen Linking
+
+Linking a projector screen to a displays power state
+
+```yaml
+
+screens:
+ Karijini_IV_Projector_1: Screen_1
+
+```
+
+## QSC Phone Dialing controls
+
+The places a dialing phone icon at the top of the screen that can be used to dial a phone number. Does not effect other aspects of the UI / Switching.
+
+```yaml
+
+phone_settings:
+ module: "Mixer_1",
+ number_id: "Status/Control16-17-VoIPCallControlDialString",
+ dial_id: "Status/Control16-17-VoIPCallControlConnect",
+ hangup_id: "Status/Control16-17-VoIPCallControlDisconnect",
+ status_id: "Status/Control16-17-VoIPCallStatusProgress",
+ ringing_id: "Status/Control16-17-VoIPCallStatusRinging(state)",
+ offhook_id: "Status/Control16-17-VoIPCallStatusOffHook",
+ dtmf_id: "16.17:dtmftx1"
+
+```
+
+Then in the QSC driver you want to define which controls QSC should poll and report changes:
+
+```yaml
+
+# keeps the phone status in sync
+change_groups: {
+ "room123_phone" => {
+ id: 1,
+ controls: ["VoIPCallStatusProgress", "VoIPCallStatusRinging", "VoIPCallStatusOffHook"],
+ },
+},
+
+```
+
+## Microphone configuration
+
+A basic list of fader and mute values that represent the microphones available
+
+```yaml
+
+local_microphones:
+ - name: Hand Held Microphone
+ level_id: ["HH-1234", "HH-1235"]
+ mute_id: 'HH-123-45-mute'
+
+ # optional keys
+ level_index: 4,
+ mute_index: 4,
+ level_feedback: 'faderHH-1234'
+ mute_feedback: 'faderHH-1234_mute'
+ module_id: 'Mixer_2'
+
+ default_muted: true
+ default_level: 55.8
+
+ min_level: 40
+ max_level: 90
+
+```
+
+Where a microphone might be shared between rooms or spill into a lobby, you can add a rooms configuration with mute ids that when unmuted will route the microphone audio to various spaces.
+
+```yaml
+
+local_microphones:
+ - name: Hand Held Microphone
+ # as above
+
+ rooms:
+ - name: "Room 1"
+ binding: "hh_room1_mute_id_mute"
+ control: "hh_room1_mute_id"
+ # this uses the `mute` function
+
+ - name: "Room 2"
+ binding: "hh_room2_mute_id_mute"
+ route:
+ - module_id: Mixer_1
+ function_name: trigger
+ arguments: ["hh_room2_add"]
+ unroute:
+ - module_id: Mixer_1
+ function_name: trigger
+ arguments: ["hh_room2_remove"]
+
+```
+
+## Joining Config
+
+Where systems can be merged you can define the various modes that are supported between the rooms.
+
+* there are two types `fully_aware` or `independent` (default)
+ * fully_aware: (typically something like 4 rooms where you can have independent, 1:3, 2:2 or all 4 etc)
+ * rooms that are not technically part of the join are aware of the join (1:3 combination for example)
+ * this might be because the DSP has a preset for each join combination (to combine microphones)
+ * independent: (typically something like 3 rooms that share a video switcher)
+ * only rooms part of the join are notified of the join
+ * this makes sense where audio and video come off an output of a shared switch
+ * so if room1 wants to present in room2 all it has to do is present to both outputs and keep audio levels in sync
+
+```yaml
+
+join_modes:
+ type: fully_aware
+ # input 1 in both rooms is merged (versus becoming a seperate input)
+ merge_outputs: true # default true
+ modes:
+ # id links mode across rooms.
+ # useful in fully linked where joining room_ids might differ between systems
+ - id: unlinked
+ name: Independent
+ room_ids: []
+ # join actions are optional
+ join_actions:
+ - module_id: Mixer_1
+ function_name: trigger
+ # supports named arguments too
+ arguments: ["UnjoinAll"]
+ - id: join-all
+ name: All Rooms
+ room_ids: ["sys-Cia-PnmTLC", "sys-Cia-ehhm8h", "sys-Cia-_e0jWQ"]
+ join_actions:
+ - module_id: Mixer_1
+ function_name: trigger
+ arguments: ["Join-all"]
+
+ # sometimes you may need to run an action to break a join (typically not required)
+ breakdown:
+ - module_id: Mixer_1
+ function_name: trigger
+ arguments: ["UnjoinAll"]
+
+```
+
+## Lighting Config
+
+There are two lighting modes:
+
+* independent rooms (even when linked, each room controls it's lighting area, we manually sync them)
+* joining rooms (a single request will change the scene in all linked rooms)
+
+```yaml
+
+# scenes are often the same across many rooms, so should be applied to a zone
+lighting_scenes:
+ - id: 1
+ name: "Full"
+ icon: lightbulb
+ opacity: 1.0
+ - id: 2
+ name: "Medium"
+ icon: lightbulb
+ opacity: 0.5
+ - id: 3
+ name: "Off"
+ icon: lightbulb_outline
+ opacity: 0.8
+
+# Each room typically will have its own area or grouping, so apply this at the system level
+lighting_area:
+ id: 23
+ # join: 0xFF (only if required by the driver)
+ # channel: 3 (only if required by the driver)
+ # component: "Lighting" (only if required by the driver)
+
+# Typically set to false if using the `join` field in `lighting_area`
+lighting_independent: true
+```
+
+## Additional Room Accessories
+
+These are things like blinds or air-conditioning that usually have limited controls and obsucre interfaces
+
+```yaml
+
+room_accessories:
+ - name: Shade blind
+ module: Blinds_1
+ controls:
+ - name: Up
+ icon: vertical_align_top
+ function_name: position
+ arguments: [1, "up"]
+ - name: Down
+ icon: vertical_align_bottom
+ function_name: position
+ arguments: [1, "down"]
+
+```
+
+If a control performs multiple actions you can use the `exec` block
+
+```yaml
+
+room_accessories:
+ - name: Projector Screen
+ controls:
+ - name: Up
+ icon: vertical_align_top
+ exec:
+ - module: Screen_1
+ function_name: position
+ arguments: [1, "up"]
+ - module: Projector_1
+ function_name: power
+ arguments: [false]
+ - name: Down
+ icon: vertical_align_bottom
+ exec:
+ - module: Screen_1
+ function_name: position
+ arguments: [1, "down"]
+
+```
diff --git a/drivers/place/meet_spec.cr b/drivers/place/meet_spec.cr
new file mode 100644
index 00000000000..e9a25e0a853
--- /dev/null
+++ b/drivers/place/meet_spec.cr
@@ -0,0 +1,98 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+# :nodoc:
+class DisplayMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Powerable
+ include PlaceOS::Driver::Interface::Muteable
+
+ enum MockInputs
+ HDMI
+ HDMI2
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(MockInputs)
+
+ # implement the abstract methods required by the interfaces
+ def power(state : Bool)
+ self[:power] = state
+ end
+
+ def switch_to(input : MockInputs)
+ mute(false)
+ self[:input] = input
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo,
+ )
+ self[:audio_mute] = state
+ end
+
+ def volume(level : Int32 | Float64)
+ self[:volume] = level
+ end
+end
+
+# :nodoc:
+class SwitcherMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Switchable(Int32, Int32)
+
+ def switch_to(input : Int32)
+ self[:input] = input
+ end
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ map.each do |(input, outputs)|
+ outputs.each do |output|
+ self["output#{output}"] = input
+ end
+ end
+ end
+end
+
+DriverSpecs.mock_driver "Place::Meet" do
+ system({
+ Display: {DisplayMock},
+ Switcher: {SwitcherMock},
+ })
+
+ settings({
+ connections: {
+ Display_1: {
+ hdmi: "Switcher_1.1",
+ },
+ Switcher_1: ["*Foo", "*Bar"],
+ },
+ local_outputs: ["Display_1"],
+ })
+
+ # Give the settings time to load
+ sleep 0.5
+
+ status["inputs"].as_a.should contain("Foo")
+ status["inputs"].as_a.should contain("Bar")
+ status["outputs"].as_a.should contain("Display_1")
+ status["output/Display_1"]["inputs"].should eq(["Foo", "Bar"])
+
+ exec(:power, true).get
+ status["active"]?.should eq true
+
+ exec(:route, "Foo", "Display_1").get
+ status["output/Display_1"]["source"].should eq(status["input/Foo"]["ref"])
+ system(:Display_1)["power"].should be_true
+
+ exec(:volume, 50, "Display_1").get
+ system(:Display_1)["volume"].should eq(50)
+ status["volume"]?.should eq(50)
+
+ exec(:mute, true, "Display_1").get
+ system(:Display_1)["audio_mute"].should be_true
+ status["mute"]?.should be_true
+
+ puts "Spec completed successfully"
+end
diff --git a/drivers/place/models/workplace_subscriptions.cr b/drivers/place/models/workplace_subscriptions.cr
new file mode 100644
index 00000000000..46874778f88
--- /dev/null
+++ b/drivers/place/models/workplace_subscriptions.cr
@@ -0,0 +1,201 @@
+require "placeos-driver"
+require "place_calendar"
+
+module Place::WorkplaceSubscription
+ enum NotifyType
+ # resource event changes
+ Created # a resource was created (MS only)
+ Updated # a resource was updated (in Google this could also mean created)
+ Deleted # a resource was deleted
+
+ # subscription lifecycle event (MS only)
+ Renew # subscription was deleted
+ Missed # MS sends this to mean resource event changes were not sent
+ Reauthorize # subscription needs reauthorization
+ end
+
+ struct NotifyEvent
+ include JSON::Serializable
+
+ getter event_type : NotifyType
+ getter resource_id : String?
+ getter resource_uri : String
+ getter subscription_id : String
+ getter client_secret : String
+
+ @[JSON::Field(converter: Time::EpochConverter)]
+ getter expiration_time : Time
+ end
+
+ abstract def subscription_on_crud(notification : NotifyEvent) : Nil
+ abstract def subscription_on_missed : Nil
+
+ enum ServiceName
+ Google
+ Office365
+ end
+
+ # should return the resource URI for monitoring, for example:
+ #
+ # case service_name
+ # in .google?
+ # resource = "/calendars/#{calendar_id}/events"
+ # in .office365?
+ # resource = "/users/#{calendar_id}/events"
+ abstract def subscription_resource(service_name : ServiceName) : String
+
+ @subscription : PlaceCalendar::Subscription? = nil
+ @push_notification_url : String? = nil
+ @push_authority : String? = nil
+ @push_service_name : ServiceName? = nil
+ @push_monitoring : PlaceOS::Driver::Subscriptions::ChannelSubscription? = nil
+ @push_mutex : Mutex = Mutex.new(:reentrant)
+
+ # the API reports that 6 days is the max:
+ # Subscription expiration can only be 10070 minutes in the future.
+ SUBSCRIPTION_LENGTH = 3.hours
+
+ protected def workplace_accessor
+ system["Calendar"]
+ end
+
+ protected def push_notificaitons_configure
+ @push_notification_url = setting?(String, :push_notification_url).presence
+ @push_authority = setting?(String, :push_authority).presence
+
+ # load any existing subscriptions
+ subscription = setting?(PlaceCalendar::Subscription, :push_subscription)
+
+ if @push_notification_url
+ # clear the monitoring if authority changed
+ if subscription && subscription.try(&.id) != @subscription.try(&.id) && (monitor = @push_monitoring)
+ subscriptions.unsubscribe(monitor)
+ @push_monitoring = nil
+ end
+ @subscription = subscription
+ schedule.every(5.minutes + rand(120).seconds) { push_notificaitons_maintain }
+ schedule.in(rand(30).seconds) { push_notificaitons_maintain(true) }
+ elsif subscription
+ push_notificaitons_cleanup(subscription)
+ end
+ end
+
+ # delete a subscription
+ protected def push_notificaitons_cleanup(sub)
+ @push_mutex.synchronize do
+ logger.debug { "removing subscription" }
+
+ workplace_accessor.delete_notifier(sub) if sub
+ @subscription = nil
+ define_setting(:push_subscription, nil)
+ end
+ end
+
+ getter sub_renewed_at : Time = 21.minutes.ago
+
+ # creates and maintains a subscription
+ protected def push_notificaitons_maintain(force_renew = false) : Nil
+ should_force = force_renew && @sub_renewed_at < 20.minutes.ago
+
+ @push_mutex.synchronize do
+ subscription = @subscription
+
+ logger.debug { "maintaining push subscription, monitoring: #{!!@push_monitoring}, subscription: #{subscription ? !subscription.expired? : "none"}" }
+
+ return create_subscription unless subscription
+
+ if should_force || subscription.expired?
+ # renew subscription
+ begin
+ logger.debug { "renewing subscription" }
+ expires = SUBSCRIPTION_LENGTH.from_now
+ sub = workplace_accessor.renew_notifier(subscription, expires.to_unix).get
+ @subscription = PlaceCalendar::Subscription.from_json(sub.to_json)
+
+ # save the subscription details for processing
+ define_setting(:push_subscription, @subscription)
+ @sub_renewed_at = Time.local
+ rescue error
+ logger.error(exception: error) { "failed to renew expired subscription, creating new subscription" }
+ @subscription = nil
+ schedule.in(1.second) { push_notificaitons_maintain; nil }
+ end
+
+ configure_push_monitoring
+ return
+ end
+
+ configure_push_monitoring if @push_monitoring.nil?
+ end
+ end
+
+ protected def configure_push_monitoring
+ subscription = @subscription.as(PlaceCalendar::Subscription)
+ channel_path = "#{subscription.id}/event"
+
+ if old = @push_monitoring
+ subscriptions.unsubscribe old
+ end
+
+ @push_monitoring = monitor(channel_path) { |_subscription, payload| push_event_occured(payload) }
+ logger.debug { "monitoring channel: #{channel_path}" }
+ end
+
+ protected def push_event_occured(payload : String)
+ logger.debug { "push notification received! #{payload}" }
+
+ notification = NotifyEvent.from_json payload
+
+ secret = @subscription.try &.client_secret
+ unless secret && secret == notification.client_secret
+ logger.warn { "ignoring notify event with mismatched secret: #{notification.inspect}" }
+ return
+ end
+
+ case notification.event_type
+ in .created?, .updated?, .deleted?
+ logger.debug { "polling events as received #{notification.event_type} notification" }
+ if resource_id = notification.resource_id
+ self[:last_event_notification] = {notification.event_type, resource_id, Time.utc.to_unix}
+ end
+
+ subscription_on_crud(notification)
+ in .missed?
+ # we don't know the exact event id that changed
+ logger.debug { "polling events as a notification was previously missed" }
+ subscription_on_missed
+ in .renew?
+ # we need to create a new subscription as the old one has expired
+ logger.debug { "a subscription renewal is required" }
+ create_subscription
+ in .reauthorize?
+ logger.debug { "a subscription reauthorization is required" }
+ expires = SUBSCRIPTION_LENGTH.from_now
+ workplace_accessor.reauthorize_notifier(@subscription, expires.to_unix)
+ end
+ rescue error
+ logger.error(exception: error) { "error processing push notification" }
+ end
+
+ protected def create_subscription
+ @push_mutex.synchronize do
+ @push_service_name = service_name = @push_service_name || ServiceName.parse(workplace_accessor.calendar_service_name.get.as_s)
+
+ # different resource routes for the different services
+ resource = subscription_resource(service_name)
+ logger.debug { "registering for push notifications! #{resource}" }
+
+ # create a new secret and subscription
+ expires = SUBSCRIPTION_LENGTH.from_now
+ push_secret = "a#{Random.new.hex(4)}"
+ sub = workplace_accessor.create_notifier(resource, @push_notification_url, expires.to_unix, push_secret, @push_notification_url).get
+ @subscription = PlaceCalendar::Subscription.from_json(sub.to_json)
+
+ # save the subscription details for processing
+ define_setting(:push_subscription, @subscription)
+ @sub_renewed_at = Time.local
+
+ configure_push_monitoring
+ end
+ end
+end
diff --git a/drivers/place/mqtt.cr b/drivers/place/mqtt.cr
new file mode 100644
index 00000000000..42ce6f9ffe7
--- /dev/null
+++ b/drivers/place/mqtt.cr
@@ -0,0 +1,155 @@
+require "placeos-driver"
+require "./mqtt_transport_adaptor"
+
+class Place::MQTT < PlaceOS::Driver
+ descriptive_name "Generic MQTT"
+ generic_name :GenericMQTT
+
+ tcp_port 1883
+ description %(makes MQTT data available to other drivers in PlaceOS, for use with String payloads)
+
+ default_settings({
+ username: "user",
+ password: "pass",
+ keep_alive: 60,
+ client_id: "placeos",
+ subscriptions: ["root/#"],
+
+ # requests per-second
+ rate_limit: 100,
+ queue_size: 1000,
+ })
+
+ @rate_limited : Bool = true
+ @queue_size : Int32 = 1000
+ @queue_count : Int32 = 0
+ @channel : Channel(Nil) = Channel(Nil).new(1)
+ @queue_lock : Mutex = Mutex.new
+ @wait_time : Time::Span = 300.milliseconds
+
+ @keep_alive : Int32 = 60
+ @username : String? = nil
+ @password : String? = nil
+ @client_id : String = "placeos"
+
+ @mqtt : ::MQTT::V3::Client? = nil
+ @subs : Array(String) = [] of String
+ @transport : Place::TransportAdaptor? = nil
+ @sub_proc : Proc(String, Bytes, Nil) = Proc(String, Bytes, Nil).new { |_key, _payload| nil }
+
+ def on_load
+ spawn { rate_limiter }
+ @sub_proc = Proc(String, Bytes, Nil).new { |key, payload| on_message(key, payload) }
+ on_update
+ end
+
+ def on_unload
+ @channel.close
+ end
+
+ def on_update
+ @username = setting?(String, :username)
+ @password = setting?(String, :password)
+ @keep_alive = setting?(Int32, :keep_alive) || 60
+ @client_id = setting?(String, :client_id) || ::MQTT.generate_client_id("placeos_")
+
+ @queue_size = setting?(Int32, :queue_size) || 1000
+ if rate_limit = setting?(Int32, :rate_limit)
+ @rate_limited = true
+ @wait_time = (1.0 / rate_limit.to_f).seconds
+ else
+ @rate_limited = false
+ end
+
+ existing = @subs
+ @subs = setting?(Array(String), :subscriptions) || [] of String
+
+ schedule.clear
+ schedule.every((@keep_alive // 3).seconds) { ping }
+
+ if client = @mqtt
+ unsub = existing - @subs
+ newsub = @subs - existing
+
+ unsub.each do |sub|
+ logger.debug { "unsubscribing to #{sub}" }
+ perform_operation { client.unsubscribe(sub) }
+ end
+
+ newsub.each do |sub|
+ logger.debug { "subscribing to #{sub}" }
+ perform_operation { client.subscribe(sub, &@sub_proc) }
+ end
+ end
+ end
+
+ def connected
+ transp = Place::TransportAdaptor.new(transport, queue)
+ client = ::MQTT::V3::Client.new(transp)
+ @transport = transp
+ @mqtt = client
+
+ logger.debug { "sending connect message" }
+ client.connect(@username, @password, @keep_alive, @client_id)
+ @subs.each do |sub|
+ logger.debug { "subscribing to #{sub}" }
+ perform_operation { client.subscribe(sub, &@sub_proc) }
+ end
+ end
+
+ def disconnected
+ @transport = nil
+ @mqtt = nil
+ end
+
+ protected def on_message(key : String, playload : Bytes) : Nil
+ self[key] = String.new(playload)
+ end
+
+ def publish(key : String, payload : String) : Nil
+ logger.debug { "publishing payload to #{key}" }
+ perform_operation { @mqtt.not_nil!.publish(key, payload) }
+ nil
+ end
+
+ def ping
+ logger.debug { "sending ping" }
+ perform_operation { @mqtt.not_nil!.ping }
+ end
+
+ def received(data, task)
+ logger.debug { "received #{data.size} bytes: 0x#{data.hexstring}" }
+ @transport.try &.process(data)
+ task.try &.success
+ end
+
+ protected def perform_operation
+ return yield unless @rate_limited
+
+ if @queue_count >= @queue_size
+ raise "queue size #{@queue_size} requests already queued, backpressure being applied"
+ end
+
+ @queue_lock.synchronize { @queue_count += 1 }
+ @channel.receive
+ @queue_lock.synchronize { @queue_count -= 1 }
+
+ yield
+ end
+
+ protected def rate_limiter
+ loop do
+ break if @channel.closed?
+ begin
+ @channel.send(nil)
+ rescue error
+ logger.error(exception: error) { "issue with rate limiter" }
+ ensure
+ sleep @wait_time
+ end
+ end
+ rescue
+ # Possible error with logging exception, restart rate limiter silently
+ spawn { rate_limiter } unless @channel.closed?
+ end
+end
diff --git a/drivers/place/mqtt_spec.cr b/drivers/place/mqtt_spec.cr
new file mode 100644
index 00000000000..d658439744d
--- /dev/null
+++ b/drivers/place/mqtt_spec.cr
@@ -0,0 +1,71 @@
+require "placeos-driver/spec"
+require "mqtt"
+
+DriverSpecs.mock_driver "Place::MQTT" do
+ # ============================
+ # CONNECTION
+ # ============================
+ puts "===== CONNECTION NEGOTIATION ====="
+ connect = MQTT::V3::Connect.new
+ connect.id = MQTT::RequestType::Connect
+ connect.keep_alive_seconds = 60_u16
+ connect.client_id = "placeos"
+ connect.clean_start = true
+ connect.username = "user"
+ connect.password = "pass"
+ connect.packet_length = connect.calculate_length
+ should_send(connect.to_slice)
+
+ connack = MQTT::V3::Connack.new
+ connack.id = MQTT::RequestType::Connack
+ connack.packet_length = connack.calculate_length
+ responds(connack.to_slice)
+
+ # ============================
+ # SUBSCRIPTION
+ # ============================
+ puts "===== SUBSCRIPTION REQUESTED ====="
+ packet = MQTT::V3::Subscribe.new
+ packet.id = MQTT::RequestType::Subscribe
+ packet.qos = MQTT::QoS::BrokerReceived
+ packet.message_id = 2_u16
+ packet.topic = "root/#"
+ packet.packet_length = packet.calculate_length
+ should_send(packet.to_slice)
+
+ suback = MQTT::V3::Suback.new
+ suback.id = MQTT::RequestType::Suback
+ suback.message_id = 2_u16
+ suback.return_codes = [MQTT::QoS::FireAndForget]
+ suback.packet_length = suback.calculate_length
+ responds(suback.to_slice)
+
+ # ============================
+ # REMOTE PUBLISH
+ # ============================
+ puts "===== REMOTE PUBLISH ====="
+ publish = MQTT::V3::Publish.new
+ publish.id = MQTT::RequestType::Publish
+ publish.message_id = 3_u16
+ publish.topic = "root/topic"
+ publish.payload = "testing"
+ publish.packet_length = publish.calculate_length
+
+ transmit publish.to_slice
+ sleep 0.1 # wait a bit for processing
+ status["root/topic"].should eq("testing")
+
+ # ============================
+ # DRIVER PUBLISH
+ # ============================
+ puts "===== DRIVER PUBLISH ====="
+ exec(:publish, "root/topic/action", "value")
+
+ publish = MQTT::V3::Publish.new
+ publish.id = MQTT::RequestType::Publish
+ publish.message_id = 3_u16
+ publish.topic = "root/topic/action"
+ publish.payload = "value"
+ publish.packet_length = publish.calculate_length
+ should_send(publish.to_slice)
+end
diff --git a/drivers/place/mqtt_transport_adaptor.cr b/drivers/place/mqtt_transport_adaptor.cr
new file mode 100644
index 00000000000..a3fef439df5
--- /dev/null
+++ b/drivers/place/mqtt_transport_adaptor.cr
@@ -0,0 +1,28 @@
+require "mqtt"
+
+class Place::TransportAdaptor < MQTT::Transport
+ def initialize(@driver, @queue)
+ super()
+ end
+
+ @driver : PlaceOS::Driver::Transport
+ @queue : PlaceOS::Driver::Queue
+
+ def close! : Nil
+ @driver.disconnect
+ end
+
+ def closed? : Bool
+ !@queue.online
+ end
+
+ def send(message) : Nil
+ @driver.send(message)
+ end
+
+ def process(data : Bytes)
+ @tokenizer.extract(data).each do |bytes|
+ spawn { @on_message.try &.call(bytes) }
+ end
+ end
+end
diff --git a/drivers/place/parking/locations.cr b/drivers/place/parking/locations.cr
new file mode 100644
index 00000000000..9cacab24dbf
--- /dev/null
+++ b/drivers/place/parking/locations.cr
@@ -0,0 +1,341 @@
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "../booking_model"
+require "placeos"
+require "json"
+
+# reserved parking spaces
+# check if any of these have been made available
+# fetch the parking bookings
+
+class Place::Parking::Locations < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "PlaceOS Parking Locations"
+ generic_name :ParkingLocations
+ description %(helper for handling parking bookings)
+
+ accessor area_manager : AreaManagement_1
+ accessor staff_api : StaffAPI_1
+
+ default_settings({
+ # time in seconds
+ poll_rate: 60,
+
+ # expose_for_analytics: {"output_key" => "booking_key->subkey"},
+ })
+
+ @timezone : Time::Location = Time::Location::UTC
+ @expose_for_analytics : Hash(String, String) = {} of String => String
+ @poll_rate : Time::Span = 60.seconds
+
+ BOOKING_TYPE = "parking"
+ RESERVED_RELEASED = "parking-released"
+ METADATA_KEY = "parking-spaces"
+
+ def on_load
+ monitor("staff/booking/changed") do |_subscription, payload|
+ logger.debug { "received booking changed event #{payload}" }
+ booking = Booking.from_json(payload)
+ booking.user_email = booking.user_email.downcase
+ booking_changed(booking)
+ end
+ on_update
+ end
+
+ def on_update
+ @poll_rate = (setting?(Int32, :poll_rate) || 60).seconds
+ @expose_for_analytics = setting?(Hash(String, String), :expose_for_analytics) || {} of String => String
+
+ timezone = config.control_system.not_nil!.timezone.presence || setting?(String, :time_zone).presence || "Australia/Sydney"
+ @timezone = Time::Location.load(timezone)
+
+ schedule.clear
+ schedule.every(@poll_rate) { query_parking_bookings }
+ schedule.in(5.seconds) { query_parking_bookings }
+ end
+
+ # level_zone_id => building_zone_id
+ getter level_buildings : Hash(String, String) do
+ hash = area_manager.level_buildings.get.as_h.transform_values(&.as_s)
+ raise "level cache not loaded yet" unless hash.size > 0
+ hash
+ end
+
+ getter zone_filter : Array(String) do
+ lvb = level_buildings
+ (lvb.keys + lvb.values).uniq
+ end
+
+ # ===================================
+ # Monitoring desk bookings
+ # ===================================
+ protected def booking_changed(event)
+ return unless event.booking_type == BOOKING_TYPE
+ matching_zones = zone_filter & event.zones
+ return if matching_zones.empty?
+
+ logger.debug { "booking event is in a matching zone" }
+
+ case event.action
+ when "create"
+ return unless event.in_progress?
+ # Check if this event is happening now
+ logger.debug { "adding new booking" }
+ @bookings[event.user_email] << event
+ when "cancelled", "rejected"
+ # delete the booking from the levels
+ found = false
+ @bookings[event.user_email].reject! { |booking| found = true if booking.id == event.id }
+ return unless found
+ when "check_in"
+ return unless event.in_progress?
+ @bookings[event.user_email].each { |booking| booking.checked_in = true if booking.id == event.id }
+ when "changed"
+ # Check if this booking is for today and update as required
+ @bookings[event.user_email].reject! { |booking| booking.id == event.id }
+ @bookings[event.user_email] << event if event.in_progress?
+ else
+ # ignore the update (approve)
+ logger.debug { "booking event was ignored" }
+ return
+ end
+
+ area_manager.update_available(matching_zones)
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "searching for #{email}, #{username}" }
+ bookings = @bookings[email]? || [] of Booking
+ map_bookings(bookings)
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "listing MAC addresses assigned to #{email}, #{username}" }
+ found = [] of String
+ @known_users.each { |user_id, (user_email, _name)|
+ found << user_id if email == user_email
+ }
+ found
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "searching for owner of #{mac_address}" }
+ if user_details = @known_users[mac_address]?
+ email, _name = user_details
+ {
+ location: "booking",
+ assigned_to: email,
+ mac_address: mac_address,
+ }
+ end
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching devices in zone #{zone_id}" }
+ return [] of Nil if location && location != "booking"
+
+ bookings = [] of Booking
+ @bookings.each_value(&.each { |booking|
+ next unless zone_id.in?(booking.zones)
+ bookings << booking
+ })
+ map_bookings(bookings)
+ end
+
+ protected def map_bookings(bookings)
+ level_to_building = level_buildings
+
+ bookings.map do |booking|
+ level = nil
+ building = nil
+ booking.zones.each do |zone_id|
+ if build = level_to_building[zone_id]?
+ building = build
+ level = zone_id
+ break
+ end
+ end
+
+ # We specify location as JSON::Any so we don't have to
+ # explicitly define the type of this object
+ payload = {
+ "location" => JSON::Any.new("booking"),
+ "type" => BOOKING_TYPE,
+ "checked_in" => booking.checked_in,
+ "asset_id" => booking.asset_id,
+ "booking_id" => booking.id,
+ "building" => building,
+ "level" => level,
+ "ends_at" => booking.booking_end,
+ "started_at" => booking.booking_start,
+ "duration" => booking.booking_end - booking.booking_start,
+ "mac" => booking.user_email,
+ "staff_name" => booking.user_name,
+ }
+
+ # check for any custom data we want to include
+ if !booking.extension_data.empty? && (init_data = JSON::Any.new(booking.extension_data))
+ @expose_for_analytics.each do |binding, path|
+ begin
+ binding_keys = path.split("->")
+ data = init_data
+ binding_keys.each do |key|
+ next if key == "extension_data"
+
+ data = data.dig? key
+ break unless data
+ end
+ payload[binding] = data
+ rescue error
+ logger.warn(exception: error) { "failed to expose #{binding}: #{path} for analytics" }
+ end
+ end
+ end
+
+ payload
+ end
+ end
+
+ # ===================================
+ # Parking and zone queries
+ # ===================================
+
+ struct ParkingSpace
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property map_id : String
+ property assigned_to : String?
+ property assigned_name : String?
+
+ def reserved?
+ !!@assigned_to
+ end
+ end
+
+ struct Details
+ include JSON::Serializable
+
+ property details : Array(ParkingSpace)
+ end
+
+ alias Zone = PlaceOS::Client::API::Models::Zone
+ alias Metadata = Hash(String, Details)
+ alias ChildMetadata = Array(NamedTuple(zone: Zone, metadata: Metadata))
+
+ # Email => Array of bookings
+ @bookings : Hash(String, Array(Booking)) = Hash(String, Array(Booking)).new
+
+ # UserID => {Email, Name}
+ @known_users : Hash(String, Tuple(String, String)) = Hash(String, Tuple(String, String)).new
+
+ def parking_spaces : Hash(String, Array(ParkingSpace))
+ metadatas = level_buildings.values.uniq.map do |zone_id|
+ ChildMetadata.from_json(staff_api.metadata_children(
+ zone_id,
+ METADATA_KEY
+ ).get.to_json)
+ end
+
+ zone_parking = Hash(String, Array(ParkingSpace)).new
+
+ metadatas.each do |metadata|
+ metadata.each do |level|
+ zone = level[:zone]
+ if spaces = level[:metadata][METADATA_KEY]?.try(&.details)
+ zone_parking[zone.id] = spaces
+ end
+ end
+ end
+
+ zone_parking
+ end
+
+ def query_parking_bookings : Nil
+ # find all the reserved parking
+ reserved_spaces = parking_spaces.tap(&.each_value(&.select!(&.reserved?)))
+
+ logger.debug do
+ count = 0
+ reserved_spaces.each_value { |space| count += space.size }
+ "queried reserved spaces, found #{count}"
+ end
+
+ parking_zones = reserved_spaces.keys
+
+ # bookings for general access spaces
+ bookings = [] of JSON::Any
+ parking_zones.each { |zone| bookings.concat staff_api.query_bookings(type: BOOKING_TYPE, zones: {zone}).get.as_a }
+ bookings = bookings.map do |booking|
+ booking = Booking.from_json(booking.to_json)
+ booking.user_email = booking.user_email.downcase
+ booking
+ end
+
+ logger.debug { "queried parking bookings, found #{bookings.size}" }
+
+ # check if any of the reserved spaces have been made available
+ release_bookings = [] of JSON::Any
+ parking_zones.each { |zone| release_bookings.concat staff_api.query_bookings(type: RESERVED_RELEASED, zones: {zone}).get.as_a }
+ release_bookings = release_bookings.map do |booking|
+ booking = Booking.from_json(booking.to_json)
+ booking.user_email = booking.user_email.downcase
+ booking
+ end
+
+ logger.debug { "queried released spaces, found #{release_bookings.size}" }
+
+ # for all reserved spaces that haven't been released, we need to
+ # create a virtual booking for them
+ release_bookings.each do |booking|
+ parking_space = booking.asset_id
+ reserved_spaces.each_value do |spaces|
+ spaces.reject! { |space| space.id == parking_space }
+ end
+ end
+
+ now = Time.local(@timezone)
+ res_start = now.at_beginning_of_day.to_unix
+ res_end = now.at_end_of_day.to_unix
+ level_to_building = level_buildings
+
+ reserved_spaces.each do |level_zone, reservations|
+ building_zone = level_to_building[level_zone]?
+ next unless building_zone
+
+ reservations.each do |reservation|
+ bookings << Place::Booking.new(
+ id: -1,
+ booking_type: BOOKING_TYPE,
+ booking_start: res_start,
+ booking_end: res_end,
+ user_id: reservation.assigned_to.as(String),
+ user_email: reservation.assigned_to.as(String),
+ user_name: reservation.assigned_name.as(String),
+ zones: [level_zone, building_zone],
+ booked_by_name: reservation.assigned_name.as(String),
+ booked_by_email: reservation.assigned_to.as(String),
+ asset_id: reservation.id,
+ checked_in: true,
+ )
+ end
+ end
+
+ new_bookings = Hash(String, Array(Booking)).new do |hash, key|
+ hash[key] = [] of Booking
+ end
+
+ bookings.each do |booking|
+ next if booking.rejected
+ new_bookings[booking.user_email] << booking
+ @known_users[booking.user_id] = {booking.user_email, booking.user_name}
+ end
+
+ @bookings = new_bookings
+ end
+end
diff --git a/drivers/place/parking/locations_spec.cr b/drivers/place/parking/locations_spec.cr
new file mode 100644
index 00000000000..deddad83165
--- /dev/null
+++ b/drivers/place/parking/locations_spec.cr
@@ -0,0 +1,161 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Parking::Locations" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ AreaManagement: {AreaManagementMock},
+ })
+
+ exec(:parking_spaces).get.should eq({
+ "zone-EYEnrhbaQz" => [
+ {
+ "id" => "park-001",
+ "name" => "Bay 001",
+ "map_id" => "park-001",
+ },
+ {
+ "id" => "parking-zone-FlWVOXv9yY.472179",
+ "name" => "Bay 005 Test",
+ "map_id" => "park-005",
+ "assigned_to" => "AdeleV@0cbfs.onmicrosoft.com",
+ "assigned_name" => "Adele Vance",
+ },
+ ],
+ })
+end
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def query_bookings(type : String, zones : Array(String))
+ logger.debug { "Querying desk bookings!" }
+
+ now = Time.local
+ start = now.at_beginning_of_day.to_unix
+ ending = now.at_end_of_day.to_unix
+ [
+ {
+ id: 1,
+ booking_type: type,
+ booking_start: start,
+ booking_end: ending,
+ asset_id: "desk-123",
+ user_id: "user-1234",
+ user_email: "user1234@org.com",
+ user_name: "Bob Jane",
+ zones: zones + ["zone-building"],
+ checked_in: true,
+ rejected: false,
+ booked_by_name: "Bob Jane",
+ booked_by_email: "user1234@org.com",
+ },
+ {
+ id: 2,
+ booking_type: type,
+ booking_start: start,
+ booking_end: ending,
+ asset_id: "desk-456",
+ user_id: "user-456",
+ user_email: "zdoo@org.com",
+ user_name: "Zee Doo",
+ zones: zones + ["zone-building"],
+ checked_in: false,
+ rejected: false,
+ booked_by_name: "Zee Doo",
+ booked_by_email: "zdoo@org.com",
+ },
+ ]
+ end
+
+ def metadata_children(id : String, key : String? = nil)
+ logger.info { "requesting zone #{id} and key #{key}" }
+
+ [
+ {
+ "zone": {
+ "created_at": 1668744303,
+ "updated_at": 1668744303,
+ "id": "zone-FlWVOXv9yY",
+ "name": "PlaceOS Dev Sydney Catering Enabled",
+ "display_name": "Catering ",
+ "location": "",
+ "description": "",
+ "code": "",
+ "type": "",
+ "count": 0,
+ "capacity": 0,
+ "map_id": "",
+ "tags": [] of String,
+ "triggers": [] of String,
+ "parent_id": "zone-DnTcV5ZeEq",
+ },
+ "metadata": {} of String => String,
+ },
+ {
+ "zone": {
+ "created_at": 1691972553,
+ "updated_at": 1701398359,
+ "id": "zone-EYEnrhbaQz",
+ "name": "LEVEL Parking",
+ "display_name": "Parking",
+ "location": "",
+ "description": "",
+ "code": "",
+ "type": "",
+ "count": 0,
+ "capacity": 0,
+ "map_id": "https://s3-ap-southeast-2.amazonaws.com/os.place.tech/placeos-dev.aca.im/169197263162476823.svg",
+ "tags": [
+ "level",
+ "parking",
+ ],
+ "triggers": [] of String,
+ "parent_id": "zone-DnTcV5ZeEq",
+ },
+ "metadata": {
+ "parking-spaces": {
+ "name": "parking-spaces",
+ "description": "List of available parking spaces",
+ "details": [
+ {
+ "id": "park-001",
+ "name": "Bay 001",
+ "notes": "notes new",
+ "map_id": "park-001",
+ "assigned_to": nil,
+ "map_rotation": 0,
+ "assigned_name": nil,
+ "assigned_user": nil,
+ },
+ {
+ "id": "parking-zone-FlWVOXv9yY.472179",
+ "name": "Bay 005 Test",
+ "notes": "",
+ "map_id": "park-005",
+ "assigned_to": "AdeleV@0cbfs.onmicrosoft.com",
+ "map_rotation": 0,
+ "assigned_name": "Adele Vance",
+ },
+ ],
+ "parent_id": "zone-FlWVOXv9yY",
+ "editors": [] of String,
+ "modified_by_id": "user-DGLTbVU8eqiSRn",
+ },
+ },
+ },
+ ]
+ end
+end
+
+# :nodoc:
+class AreaManagementMock < DriverSpecs::MockDriver
+ def update_available(zones : Array(String))
+ logger.info { "requested update to #{zones}" }
+ nil
+ end
+
+ def level_buildings
+ {
+ "zone-EYEnrhbaQz": "zone-DnTcV5ZeEq",
+ }
+ end
+end
diff --git a/drivers/place/password_generator_helper.cr b/drivers/place/password_generator_helper.cr
new file mode 100644
index 00000000000..91ac343af92
--- /dev/null
+++ b/drivers/place/password_generator_helper.cr
@@ -0,0 +1,58 @@
+# Password defaults
+DEFAULT_PASSWORD_LENGTH = 6
+DEFAULT_PASSWORD_EXCLUDE = "0Oo1Il`'\\/"
+DEFAULT_PASSWORD_MINIMUM_LOWERCASE = 1
+DEFAULT_PASSWORD_MINIMUM_UPPERCASE = 0
+DEFAULT_PASSWORD_MINIMUM_NUMBERS = 1
+DEFAULT_PASSWORD_MINIMUM_SYMBOLS = 0
+
+PASSWORD_LOWERCASE_CHARACTERS = ('a'..'z').to_a
+PASSWORD_UPPERCASE_CHARACTERS = ('A'..'Z').to_a
+PASSWORD_NUMBER_CHARACTERS = ('0'..'9').to_a
+PASSWORD_SYMBOL_CHARACTERS = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '-', '=', '{', '}', '[', ']', '|', '\\', ':', ';', '"', '\'', '<', '>', ',', '.', '?', '/', '`', '~']
+
+def generate_password(
+ length : Int32? = DEFAULT_PASSWORD_LENGTH,
+ exclude : String? = DEFAULT_PASSWORD_EXCLUDE,
+ minimum_lowercase : Int32? = DEFAULT_PASSWORD_MINIMUM_LOWERCASE,
+ minimum_uppercase : Int32? = DEFAULT_PASSWORD_MINIMUM_UPPERCASE,
+ minimum_numbers : Int32? = DEFAULT_PASSWORD_MINIMUM_NUMBERS,
+ minimum_symbols : Int32? = DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+) : String
+ length ||= DEFAULT_PASSWORD_LENGTH
+ exclude ||= DEFAULT_PASSWORD_EXCLUDE
+ minimum_lowercase ||= DEFAULT_PASSWORD_MINIMUM_LOWERCASE
+ minimum_uppercase ||= DEFAULT_PASSWORD_MINIMUM_UPPERCASE
+ minimum_numbers ||= DEFAULT_PASSWORD_MINIMUM_NUMBERS
+ minimum_symbols ||= DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+
+ # Make sure the lenght is at least the minimums
+ minimums = minimum_lowercase + minimum_uppercase + minimum_numbers + minimum_symbols
+ length = minimums if length < minimums
+
+ characters = [] of Char
+ characters = PASSWORD_LOWERCASE_CHARACTERS if minimum_lowercase > 0
+ characters += PASSWORD_UPPERCASE_CHARACTERS if minimum_uppercase > 0
+ characters += PASSWORD_NUMBER_CHARACTERS if minimum_numbers > 0
+ characters += PASSWORD_SYMBOL_CHARACTERS if minimum_symbols > 0
+ characters = characters - exclude.chars
+
+ # make sure we have some characters to work with
+ if characters.empty?
+ characters = (PASSWORD_LOWERCASE_CHARACTERS + PASSWORD_NUMBER_CHARACTERS) - DEFAULT_PASSWORD_EXCLUDE.chars
+ end
+
+ password = [] of Char
+
+ # Add the minimums
+ minimum_lowercase.times { password << (PASSWORD_LOWERCASE_CHARACTERS - exclude.chars).sample(random: Random::Secure) }
+ minimum_uppercase.times { password << (PASSWORD_UPPERCASE_CHARACTERS - exclude.chars).sample(random: Random::Secure) }
+ minimum_numbers.times { password << (PASSWORD_NUMBER_CHARACTERS - exclude.chars).sample(random: Random::Secure) }
+ minimum_symbols.times { password << (PASSWORD_SYMBOL_CHARACTERS - exclude.chars).sample(random: Random::Secure) }
+
+ # Add the rest
+ (length - minimums).times { password << characters.sample(random: Random::Secure) }
+
+ # Shuffle the password
+ password.shuffle(random: Random::Secure).join
+end
diff --git a/drivers/place/pinger.cr b/drivers/place/pinger.cr
new file mode 100644
index 00000000000..f210ae7041d
--- /dev/null
+++ b/drivers/place/pinger.cr
@@ -0,0 +1,38 @@
+require "placeos-driver"
+require "pinger"
+
+class Place::Pinger < PlaceOS::Driver
+ descriptive_name "Device Pinger"
+ generic_name :Ping
+
+ # Discard port
+ udp_port 9
+ description %(periodically pings a device)
+
+ default_settings({
+ ping_every: 60,
+ })
+
+ def on_update
+ # Use quite a large random value to spread load
+ period = setting?(Int32, :ping_every) || 60
+ period = period * 1000 + rand(1000)
+
+ schedule.clear
+ schedule.every(period.milliseconds) { ping }
+ end
+
+ def ping
+ hostname = config.ip.not_nil!
+ pinger = ::Pinger.new(hostname, count: 3)
+ pinger.ping
+
+ pingable = pinger.pingable
+ if !pingable
+ self[:last_error] = pinger.exception || pinger.warning || "unknown error"
+ end
+
+ set_connected_state pingable
+ self[:pingable] = pingable
+ end
+end
diff --git a/drivers/place/pinger_spec.cr b/drivers/place/pinger_spec.cr
new file mode 100644
index 00000000000..b76080508fb
--- /dev/null
+++ b/drivers/place/pinger_spec.cr
@@ -0,0 +1,6 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::Pinger" do
+ exec(:ping).get.should eq(true)
+ status[:pingable].should eq(true)
+end
diff --git a/drivers/place/rbp_remote_logger.cr b/drivers/place/rbp_remote_logger.cr
new file mode 100644
index 00000000000..05905001380
--- /dev/null
+++ b/drivers/place/rbp_remote_logger.cr
@@ -0,0 +1,61 @@
+require "placeos-driver"
+require "json"
+
+class Place::RbpRemoteLogger < PlaceOS::Driver
+ descriptive_name "Log Receiver for Room Booking Panel app"
+ generic_name :Logger
+ description %(Recieve logs streamed from Room Booking Panel app)
+
+ default_settings({
+ enabled: false,
+ max_log_entries: 1000,
+ debug: false,
+ })
+
+ @enabled : Bool = false
+ @max_log_entries : Int32 = 1000
+ @debug : Bool = false
+ @entries : Hash(String, Array(JSON::Any)) = {} of String => Array(JSON::Any)
+
+ def on_update
+ @logging_enabled = setting?(Bool, "enabled") || true
+ @max_log_entries = setting?(Int32, "max_log_entries") || 1000
+ @debug = setting?(Bool, "debug") || false
+
+ self[:enabled] = @logging_enabled
+ end
+
+ def post_event(payload : JSON::Any | String)
+ logger.debug { "Received: #{payload}" } if @debug
+
+ payload = payload.to_json if payload.is_a?(JSON::Any)
+ payload = payload.to_s if payload.is_a?(String)
+
+ entry = Entry.from_json(payload)
+
+ @entries[entry.device_id] ||= [] of JSON::Any
+
+ @entries[entry.device_id] =
+ @entries
+ .[entry.device_id]
+ .unshift(JSON.parse(payload))
+ .truncate(0, @max_log_entries)
+
+ self[:entries] = @entries
+
+ entry
+ end
+
+ class Entry
+ include JSON::Serializable
+
+ property id : String
+ property device_id : String
+ property type : String # Enum 'network' | 'console' | 'dom'
+ property subtype : String
+ property timestamp : Int64
+ property raw : JSON::Any
+ property data : JSON::Any
+ property metadata : JSON::Any
+ end
+end
diff --git a/drivers/place/rbp_remote_logger_spec.cr b/drivers/place/rbp_remote_logger_spec.cr
new file mode 100644
index 00000000000..239d67df95a
--- /dev/null
+++ b/drivers/place/rbp_remote_logger_spec.cr
@@ -0,0 +1,23 @@
+require "placeos-driver/spec"
+require "uuid"
+
+DriverSpecs.mock_driver "Place::RbpRemoteLogger" do
+ subpayload = {"id" => "1"}
+
+ payload = {
+ "id" => "1",
+ "type" => "3",
+ "subtype" => "4",
+ "timestamp" => 5,
+ "raw" => subpayload,
+ "data" => subpayload,
+ "metadata" => subpayload,
+ }
+
+ 5.times do
+ payload.merge!({"device_id" => UUID.random.to_s})
+ entry = exec(:post_event, payload.to_json).get.not_nil!
+ end
+
+ status.[:entries].as_h.keys.size.should eq 5
+end
diff --git a/drivers/place/room_at_capacity_mailer.cr b/drivers/place/room_at_capacity_mailer.cr
new file mode 100644
index 00000000000..3f321161775
--- /dev/null
+++ b/drivers/place/room_at_capacity_mailer.cr
@@ -0,0 +1,148 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+
+class Place::RoomAtCapacityMailer < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "PlaceOS Room at capacity mailer"
+ generic_name :RoomAtCapacityMailer
+ description %(notifies when a room is at capacity)
+
+ default_settings({
+ notify_email: ["concierge@place.com"],
+ email_template: "room_at_capacity",
+ debounce_time_minutes: 60, # the time to wait before sending another email
+ check_every_minutes: 5, # the frequency to check rooms
+ over_capactity_detected_count: 2, # the number of times over capacity before sending an email
+ })
+
+ accessor staff_api : StaffAPI_1
+ accessor locations : LocationServices_1
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ getter building_id : String do
+ locations.building_id.get.as_s
+ end
+
+ # Grabs the list of systems in the building
+ getter systems : Hash(String, Array(String)) do
+ staff_api.systems_in_building(building_id).get.as_h.transform_values(&.as_a.map(&.as_s))
+ end
+
+ def on_load
+ on_update
+ end
+
+ @notify_email : Array(String) = [] of String
+ @email_template : String = "room_at_capacity"
+ @debounce_time_minutes : Int32 = 60
+ @over_capactity_detected_count : Int32 = 2
+
+ @last_email_sent : Hash(String, Time) = {} of String => Time
+ @over_capacity : Hash(String, Int32) = Hash(String, Int32).new { |hash, sys_id| hash[sys_id] = 0 }
+
+ def on_update
+ @building_id = nil
+ @systems = nil
+
+ @notify_email = setting?(Array(String), :notify_email) || [] of String
+ @email_template = setting?(String, :email_template) || "room_at_capacity"
+ @debounce_time_minutes = setting?(Int32, :debounce_time_minutes) || 60
+ @over_capactity_detected_count = setting?(Int32, :over_capactity_detected_count) || 2
+
+ period = setting?(Int32, :check_every_minutes) || 5
+ schedule.clear
+ schedule.every(period.minutes) { check_capacity }
+ end
+
+ def check_capacity
+ systems.each do |level_id, system_ids|
+ system_ids.each do |system_id|
+ sys = system(system_id)
+ next unless sys.exists?("Bookings", 1)
+ next unless sys.capacity > 0
+
+ if people_count = sys.get("Bookings", 1).status?(Float64, "people_count")
+ logger.debug { "people count for #{system_id}: #{people_count}" }
+
+ if people_count >= sys.capacity
+ @over_capacity[system_id] += 1
+ else
+ @over_capacity[system_id] = 0
+ end
+
+ if (over = @over_capacity[system_id]?) && over == @over_capactity_detected_count
+ send_email(
+ sys.capacity,
+ people_count,
+ system_id,
+ sys.name,
+ sys.display_name,
+ sys.description,
+ sys.email,
+ )
+ end
+ end
+ end
+ end
+ end
+
+ @[Security(Level::Support)]
+ def send_email(
+ capacity : Int32,
+ people_count : Float64,
+ system_id : String,
+ name : String? = nil,
+ display_name : String? = nil,
+ description : String? = nil,
+ system_email : String? = nil,
+ )
+ if (last = @last_email_sent[system_id]?) && Time.utc - last < @debounce_time_minutes.minutes
+ logger.debug { "skipping email for #{system_id} due to debounce timer" }
+ return
+ end
+
+ args = {
+ system_id: system_id,
+ name: name,
+ display_name: display_name,
+ description: description,
+ system_email: system_email,
+ capacity: capacity,
+ people_count: people_count,
+ }
+
+ begin
+ mailer.send_template(
+ to: @notify_email,
+ template: {"room_at_capacity", @email_template},
+ args: args)
+ @last_email_sent[system_id] = Time.utc
+ rescue error
+ logger.warn(exception: error) { "failed to send at capacity email for zone #{system_id}" }
+ end
+ end
+
+ def template_fields : Array(TemplateFields)
+ [
+ TemplateFields.new(
+ trigger: {"room_at_capacity", @email_template},
+ name: "Room at capacity",
+ description: "Notification when a room is at capacity",
+ fields: [
+ {name: "system_id", description: "Identifier of the room/system"},
+ {name: "name", description: "Room name"},
+ {name: "display_name", description: "Room display name"},
+ {name: "description", description: "Room description"},
+ {name: "system_email", description: "System/room email address"},
+ {name: "capacity", description: "Capacity of the room"},
+ {name: "people_count", description: "Number of people in the room"},
+ ]
+ ),
+ ]
+ end
+end
diff --git a/drivers/place/room_booking_approval.cr b/drivers/place/room_booking_approval.cr
new file mode 100644
index 00000000000..d7c7c8d610b
--- /dev/null
+++ b/drivers/place/room_booking_approval.cr
@@ -0,0 +1,72 @@
+require "placeos-driver"
+require "place_calendar"
+
+class Place::RoomBookingApproval < PlaceOS::Driver
+ descriptive_name "PlaceOS Room Booking Approval"
+ generic_name :RoomBookingApproval
+ description %(Room Booking approval for tentative events)
+
+ default_settings({} of String => String)
+
+ accessor calendar : Calendar_1
+
+ getter building_id : String { get_building_id.not_nil! }
+ getter systems : Hash(String, Array(String)) { get_systems_list.not_nil! }
+
+ def on_update
+ @building_id = nil
+ @systems = nil
+
+ schedule.clear
+ # used to detect changes in building configuration
+ schedule.every(1.hour) { @systems = get_systems_list.not_nil! }
+
+ # The search
+ schedule.every(5.minutes) { find_bookings_for_approval }
+ end
+
+ # Finds the building ID for the current location services object
+ def get_building_id
+ zone_ids = system["StaffAPI"].zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ nil
+ end
+
+ # Grabs the list of systems in the building
+ def get_systems_list
+ system["StaffAPI"].systems_in_building(building_id).get.as_h.transform_values(&.as_a.map(&.as_s))
+ rescue error
+ logger.warn(exception: error) { "unable to obtain list of systems in the building" }
+ nil
+ end
+
+ def find_bookings_for_approval : Hash(String, Array(PlaceCalendar::Event))
+ results = {} of String => Array(PlaceCalendar::Event)
+
+ systems.each do |level_id, system_ids|
+ system_ids.each do |system_id|
+ sys = system(system_id)
+ if sys.exists?("Bookings", 1)
+ if bookings = sys.get("Bookings", 1).status?(Array(PlaceCalendar::Event), "bookings")
+ bookings.select! { |event| event.status == "tentative" }
+ results[system_id] = bookings unless bookings.empty?
+ end
+ end
+ end
+ end
+
+ self[:approval_required] = results
+ end
+
+ @[Security(Level::Support)]
+ def accept_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil)
+ calendar.accept_event(calendar_id: calendar_id, event_id: event_id, user_id: user_id, notify: notify, comment: comment)
+ end
+
+ @[Security(Level::Support)]
+ def decline_event(calendar_id : String, event_id : String, user_id : String? = nil, notify : Bool = false, comment : String? = nil)
+ calendar.decline_event(calendar_id: calendar_id, event_id: event_id, user_id: user_id, notify: notify, comment: comment)
+ end
+end
diff --git a/drivers/place/room_booking_approval_spec.cr b/drivers/place/room_booking_approval_spec.cr
new file mode 100644
index 00000000000..f87901b0d22
--- /dev/null
+++ b/drivers/place/room_booking_approval_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::RoomBookingApprovalAltnerative" do
+end
diff --git a/drivers/place/router.cr b/drivers/place/router.cr
new file mode 100644
index 00000000000..82a9765bbbe
--- /dev/null
+++ b/drivers/place/router.cr
@@ -0,0 +1,46 @@
+require "placeos-driver"
+
+# this driver only exists to test router functionality
+# please use the meet driver
+
+# :nodoc:
+class Place::RouterTest < PlaceOS::Driver
+ generic_name :Switcher
+ descriptive_name "Signal router"
+ description <<-DESC
+ A virtual matrix switcher for arbitrary signal networks.
+
+ Following configuration, this driver can be used to perform simple input → \
+ output routing, regardless of intermediate hardware. Drivers it interacts \
+ with _must_ implement the `Switchable`, `InputSelection` or `Muteable` \
+ interfaces.
+
+ Configuration is specified as a map of devices and their attached inputs. \
+ This must exist under a top-level `connections` key.
+
+ Inputs can be either named:
+
+ Display_1:
+ hdmi: VidConf_1
+
+ Or, index based:
+
+ Switcher_1:
+ - Camera_1
+ - Camera_2
+
+ If an input is not a device with an associated module, prefix with an \
+ asterisk (`*`) to create a named alias.
+
+ Display_1:
+ hdmi: *Laptop
+
+ DESC
+end
+
+require "./router/core"
+
+# :nodoc:
+class Place::RouterTest < PlaceOS::Driver
+ include Router::Core
+end
diff --git a/drivers/place/router/core.cr b/drivers/place/router/core.cr
new file mode 100644
index 00000000000..56d00cbb0e5
--- /dev/null
+++ b/drivers/place/router/core.cr
@@ -0,0 +1,275 @@
+require "placeos-driver"
+require "levenshtein"
+require "promise"
+
+require "./settings"
+require "./signal_graph"
+
+# Core routing methods and functionality. This exists as module to enable
+# inclusion in other drivers, such as room logic, that provide auxillary
+# functionality to signal distribution.
+module Place::Router::Core
+ alias NodeRef = SignalGraph::Node::Ref
+
+ # Wrapper for providng simple interaction with a signal node and it's
+ # associated driver.
+ struct SignalNode
+ @label : SignalGraph::Node::Label
+ @proxy : Future::Compute(PlaceOS::Driver::Proxy::Driver)
+
+ def initialize(@label, @proxy)
+ end
+
+ forward_missing_to @label
+
+ def proxy
+ @proxy.get
+ end
+
+ def to_s(io)
+ io << ref
+ end
+
+ def watch(&handler : self ->)
+ @label.watch { handler.call self }
+ end
+ end
+
+ private getter! siggraph : SignalGraph
+
+ private getter! resolver : Hash(String, NodeRef)
+
+ getter current_routes : Hash(String, String?) = {} of String => String?
+
+ def on_update
+ load_siggraph
+ end
+
+ protected def load_siggraph
+ logger.debug { "loading signal graph from settings" }
+ connections = setting(Settings::Connections::Map, :connections)
+ load_siggraph connections
+ end
+
+ protected def load_siggraph(connections : Settings::Connections::Map)
+ nodes, links, aliases = Settings::Connections.parse connections, system.id
+ @siggraph = SignalGraph.build nodes, links
+ @resolver = init_resolver aliases
+ on_siggraph_load
+ end
+
+ protected def init_resolver(seed)
+ resolver = Hash(String, NodeRef).new(initial_capacity: seed.size) do |cache, key|
+ if ref = NodeRef.resolve? key, system.id
+ cache[key] = ref
+ else
+ alt = Levenshtein.find key, cache.keys, 3
+ msg = String.build do |err|
+ err << %(unknown signal node "#{key}")
+ err << %( - did you mean "#{alt}"?) if alt
+ end
+ raise KeyError.new msg
+ end
+ end
+ resolver.merge! seed
+ resolver
+ end
+
+ # Reads settings with node metadata into the graph.
+ protected def load_io(key : Symbol) : Enumerable(SignalGraph::Node::Label)?
+ logger.debug { "loading #{key}" }
+ if io = setting?(Settings::IOMeta, key)
+ io.map do |(key, meta)|
+ ref = resolver[key]
+ node = siggraph[ref]
+ node.meta = meta
+ node
+ end
+ else
+ logger.debug { "no #{key} configured" }
+ nil
+ end
+ end
+
+ protected def on_siggraph_load
+ aliases = resolver.invert
+ to_name = ->(ref : NodeRef) { aliases[ref]? || ref.local(system.id) }
+
+ inputs = load_io(:inputs) || siggraph.inputs.to_a
+ outputs = load_io(:outputs) || siggraph.outputs.to_a
+
+ # Persist previous state across module restarts or settings load
+ persist = ->(key : String, node : SignalGraph::Node::Label) do
+ self[key]?.try(&.as_h?).try &.each do |attr, value|
+ case attr
+ when "ref"
+ # Ignore
+ when "source"
+ node.source = signal_node(value.as_s).ref
+ when "locked"
+ node.locked = value.as_bool
+ else
+ node[attr] = value
+ end
+ rescue e
+ logger.info(exception: e) { "when loading previous #{key}/#{attr}" }
+ end
+ end
+
+ # Expose a list of input keys, along with an `input/` with a hash of
+ # metadata and state info for each.
+ self[:inputs] = inputs.map do |node|
+ key = to_name.call node.ref
+ persist.call "input/#{key}", node
+ node["name"] ||= key
+ node.watch { self["input/#{key}"] = node }
+ key
+ end
+
+ # As above, but for the outputs.
+ self[:outputs] = outputs.map do |node|
+ key = to_name.call node.ref
+
+ persist.call "output/#{key}", node
+ source_ref = node.source
+ current_routes[key] = source_ref ? to_name.call(source_ref.as(NodeRef)) : nil
+ node["name"] ||= key
+
+ # Discover inputs available to each output
+ reachable = siggraph.inputs(node.ref).select &.in?(inputs)
+ node["inputs"] = reachable.map(&.ref).map(&to_name).to_a
+
+ node.watch do
+ source_ref = node.source
+ current_routes[key] = source_ref ? to_name.call(source_ref.as(NodeRef)) : nil
+ self["output/#{key}"] = node
+ end
+ key
+ end
+
+ inodes, onodes = {inputs, outputs}.map &.each.map { |n| signal_node n.ref }
+ on_siggraph_loaded inodes, onodes
+ end
+
+ # Optional callback for overriding by driver extending this.
+ protected def on_siggraph_loaded(input, outputs)
+ end
+
+ protected def signal_node(key : String)
+ ref = resolver[key]
+ signal_node ref
+ end
+
+ protected def signal_node(ref : NodeRef)
+ node = siggraph[ref]
+
+ proxy = lazy do
+ case ref
+ when SignalGraph::Device, SignalGraph::Input, SignalGraph::Output
+ proxy_for ref.mod
+ else
+ raise "no device associated with #{ref}"
+ end
+ end
+
+ SignalNode.new node, proxy
+ end
+
+ protected def proxy_for(mod : SignalGraph::Mod)
+ (mod.sys == system.id ? system : system mod.sys).get mod.name, mod.idx
+ end
+
+ # Routes signal from *input* to *output*.
+ #
+ # Performs all intermediate device interaction based on current system
+ # config.
+ def route_signal(input : String, output : String, max_dist : Int32? = nil, simulate : Bool = false, follow_additional_routes : Bool = true)
+ logger.debug { "requesting route from #{input} to #{output}" }
+
+ src, dst = resolver.values_at input, output
+ dst_node = siggraph[dst]
+ src_node = siggraph[src]
+
+ path = siggraph.route(src, dst, max_dist) || raise "no route found"
+
+ execs = path.compact_map do |(node, edge, next_node)|
+ logger.debug { "#{node} → #{next_node}" }
+
+ raise "#{next_node} is locked, aborting" if next_node.locked
+
+ case edge
+ in SignalGraph::Edge::Static
+ nil
+ in SignalGraph::Edge::Active
+ Promise.defer(timeout: 1.second) do
+ next_node.source = siggraph[src].source
+
+ # OPTIMIZE: split this to perform an inital pass to build a hash
+ # from Driver::Proxy => [Edge::Active] then form the minimal set of
+ # execs that satisfies these.
+ if !simulate
+ mod = proxy_for edge.mod
+ case func = edge.func
+ in SignalGraph::Edge::Func::Mute
+ # check if we want to mute a video or audio layer
+ dst_layer = dst_node.ref.layer.downcase
+ case dst_layer
+ when "audio", "video"
+ mod.mute func.state, func.index, dst_layer
+ else
+ mod.mute func.state, func.index
+ end
+ in SignalGraph::Edge::Func::Select
+ mod.switch_to func.input
+
+ # ensure the device is unmuted
+ mod.mute(false, layer: :video) if mod.implements?(::PlaceOS::Driver::Interface::Muteable)
+ in SignalGraph::Edge::Func::Switch
+ mod.switch({func.input => [func.output]}, func.layer)
+ end
+ end
+ nil
+ end
+ end
+ end
+
+ # are there any additional switching actions to perform (combined outputs)
+ if follow_additional_routes
+ routes = {} of String => Tuple(String, String, Int32?, Bool, Bool)
+
+ if following_outputs = dst_node["followers"]?.try(&.as_a)
+ logger.debug { "routing #{following_outputs.size} additional followers" }
+ following_outputs.each { |output_follow| routes[output_follow.as_s] = {input, output_follow.as_s, max_dist, simulate, false} }
+ end
+
+ ignore_source_routes = dst_node["ignore_source_routes"]?.try(&.as_bool) || false
+
+ # perform_routes: {output: input}
+ if !ignore_source_routes && (additional_routes = src_node["perform_routes"]?.try(&.as_h))
+ logger.debug { "perfoming #{additional_routes.size} additional routes" }
+ additional_routes.each { |ad_output, ad_input| routes[ad_output] = {ad_input.as_s, ad_output, max_dist, simulate, false} }
+ end
+
+ spawn {
+ routes.each_value do |route|
+ begin
+ route_signal(*route)
+ rescue error
+ logger.warn(exception: error) { "issue routing: #{route[0]}=>#{route[1]}" }
+ end
+ end
+ }
+ end
+
+ logger.debug { "awaiting responses" }
+ execs.each do |promise|
+ begin
+ promise.get
+ rescue error
+ logger.warn(exception: error) { "processing route" }
+ end
+ end
+
+ :ok
+ end
+end
diff --git a/drivers/place/router/digraph.cr b/drivers/place/router/digraph.cr
new file mode 100644
index 00000000000..455475684f9
--- /dev/null
+++ b/drivers/place/router/digraph.cr
@@ -0,0 +1,224 @@
+# Labelled digraph. Holds node labels of type *N* and edge labels of type *E*.
+#
+# Nodes are stored on UInt64 ID's. This provides an interface that should feel
+# similar to `Indexable` for interacting with nodes labels. Similarly edges can
+# be placed and retrieved by using a dual index of {predescessor, successor}.
+#
+# OPTIMIZE: replace with a sparse matrix and graphBLAS operations.
+class Place::Router::Digraph(N, E)
+ class Error < Exception; end
+
+ record Node(N, E),
+ attr : N,
+ succ : Hash(UInt64, E)
+
+ @nodes : Hash(UInt64, Node(N, E))
+
+ delegate clear, to: @nodes
+
+ def initialize(initial_capacity = nil)
+ @nodes = Hash(UInt64, Node(N, E)).new initial_capacity: initial_capacity
+ end
+
+ private def node(id)
+ node(id) { raise Error.new "Node #{id} does not exist" }
+ end
+
+ private def node(id, &)
+ id = id.to_u64
+ @nodes.fetch(id) { yield id }
+ end
+
+ private def check_node_exists(id)
+ check_node_exists(id) { raise Error.new "Node #{id} does not exist" }
+ end
+
+ private def check_node_exists(id, &)
+ id = id.to_u64
+ @nodes.has_key?(id) ? id : yield id
+ end
+
+ # Retrieves the label attached to node *id*.
+ def [](id)
+ node(id).attr
+ end
+
+ # Retrieves the label attached to node *id*. Yields if it does not exist.
+ def fetch(id, &) : N
+ node = node(id) { return yield id }
+ node.attr
+ end
+
+ # Insert a new node.
+ def []=(id, attr)
+ insert(id, attr) { raise Error.new "Node #{attr.inspect} (#{id}) already exists" }
+ end
+
+ # Inserts a node. Yields if it already exists.
+ def insert(id, attr : N, &)
+ id = id.to_u64
+ if @nodes.has_key? id
+ yield id
+ else
+ @nodes[id] = Node(N, E).new attr, {} of UInt64 => E
+ end
+ end
+
+ # Retrieves the label attached to the edge that joins *pred_id* and *succ_id*.
+ def [](pred_id, succ_id)
+ fetch(pred_id, succ_id) do
+ raise Error.new "Edge #{pred_id} -> #{succ_id} does not exist"
+ end
+ end
+
+ # :ditto:
+ def fetch(pred_id, succ_id, &) : E
+ succ_id = check_node_exists succ_id
+ node(pred_id).succ.fetch(succ_id) { yield pred_id, succ_id }
+ end
+
+ # Inserts an edge.
+ def []=(pred_id, succ_id, attr)
+ insert(pred_id, succ_id, attr) do
+ raise Error.new "Edge #{pred_id} -> #{succ_id} already exists"
+ end
+ end
+
+ # :ditto:
+ def insert(pred_id, succ_id, attr : E, &)
+ succ_id = check_node_exists succ_id
+ pred = node pred_id
+ if pred.succ.has_key? succ_id
+ yield pred_id, succ_id
+ else
+ pred.succ[succ_id] = attr
+ end
+ end
+
+ # Perform a breadth first search across the graph, starting at *from*.
+ #
+ # Each node id is yielded as it's traversed. The search will terminate when
+ # this block returns true. If `nil` is returned the node is skipped, but the
+ # traversal continues.
+ #
+ # Results are provided as a Hash that includes all reached nodes as the keys,
+ # and their predecessor as the associated value.
+ def breadth_first_search(from, & : UInt64 -> Bool?)
+ paths = Hash(UInt64, UInt64).new
+ queue = Deque(UInt64).new 1, from
+
+ while pred_id = queue.shift?
+ node(pred_id).succ.each_key do |succ_id|
+ # Already visited
+ next if paths.has_key? succ_id
+
+ done = yield succ_id
+
+ next if done.nil?
+
+ paths[succ_id] = pred_id
+
+ return paths if done
+
+ queue << succ_id
+ end
+ end
+ end
+
+ # Returns a list of node IDs that form the shortest path between the passed
+ # nodes or `nil` if no path exists.
+ def path(from, to, invert = false) : Enumerable(UInt64)?
+ from = check_node_exists from
+ to = check_node_exists to
+
+ paths = breadth_first_search from, &.== to
+ return if paths.nil?
+
+ # Unwind the path captured in the hash.
+ nodes = [to]
+ until nodes.last == from
+ nodes << paths[nodes.last]
+ end
+
+ invert ? nodes : nodes.reverse!
+ end
+
+ # Provides all nodes present within the graph.
+ #
+ # NOTE: ordering of nodes is _not_ defined.
+ def nodes : Enumerable(UInt64)
+ @nodes.each_key
+ end
+
+ # Checks if a node has incoming edges only.
+ def sink?(id) : Bool
+ outdegree(id).zero? && !indegree(id).zero?
+ end
+
+ # Provides all nodes with incoming edges only.
+ def sinks : Enumerable(UInt64)
+ nodes.select { |id| sink? id }
+ end
+
+ # Checks if a node has outgoing edges only.
+ def source?(id) : Bool
+ !outdegree(id).zero? && indegree(id).zero?
+ end
+
+ # Provides all nodes with outgoing edges only.
+ #
+ # OPTIMIZE: this is _very_ slow [O(V * E)], but works for testing purposes.
+ # Switching the sparse matrix should assist so not worth optimising for this
+ # setup.
+ def sources : Enumerable(UInt64)
+ nodes.select { |id| source? id }
+ end
+
+ # The outgoing edges from *id*.
+ def outdegree(id)
+ node(id).succ.size
+ end
+
+ # The number of incomming edges to *id*.
+ def indegree(id)
+ id = check_node_exists id
+ @nodes.reduce(0) do |count, (_, node)|
+ count += 1 if node.succ.has_key? id
+ count
+ end
+ end
+
+ # Provides all nodes reachable from *id*.
+ def subtree(id) : Enumerable(UInt64)
+ id = check_node_exists id
+ SubtreeIterator.new self, id
+ end
+
+ private class SubtreeIterator
+ include Iterator(UInt64)
+
+ @ch = Channel(UInt64).new
+
+ def initialize(g, id)
+ spawn do
+ g.breadth_first_search id do |node|
+ begin
+ @ch.send node
+ false
+ rescue Channel::ClosedError
+ true
+ end
+ end
+ @ch.close unless @ch.closed?
+ end
+ end
+
+ def finalize
+ @ch.close unless @ch.closed?
+ end
+
+ def next
+ @ch.receive? || stop
+ end
+ end
+end
diff --git a/drivers/place/router/digraph_spec.cr b/drivers/place/router/digraph_spec.cr
new file mode 100644
index 00000000000..11f64f9a7aa
--- /dev/null
+++ b/drivers/place/router/digraph_spec.cr
@@ -0,0 +1,147 @@
+require "spec"
+require "./digraph"
+
+alias Digraph = Place::Router::Digraph
+
+describe Digraph do
+ describe "node insertion / retrieval" do
+ it "inserts a node with the specified ID" do
+ g = Digraph(String, String).new
+ g[42] = "foo"
+ g[42].should eq("foo")
+ end
+
+ it "raises when inserting on ID conflict" do
+ g = Digraph(String, String).new
+ expect_raises(Digraph::Error) do
+ g[42] = "foo"
+ g[42] = "bar"
+ end
+ end
+
+ it "raises when retrieving if node does not exist" do
+ g = Digraph(String, String).new
+ expect_raises(Digraph::Error) do
+ g[123]
+ end
+ end
+ end
+
+ describe "edge insertion / retrieval" do
+ it "inserts between the specified IDs" do
+ g = Digraph(String, String).new
+ g[0] = "foo"
+ g[1] = "bar"
+ g[0, 1] = "foobar"
+ g[0, 1].should eq("foobar")
+ end
+
+ it "raises if setting an edge that already exists" do
+ g = Digraph(String, String).new
+ expect_raises(Digraph::Error) do
+ g.insert(0, "foo") { }
+ g.insert(1, "bar") { }
+ g[0, 1] = "foobar"
+ g[0, 1] = "foobar"
+ end
+ end
+
+ it "raises when reading an edge that does not exist" do
+ g = Digraph(String, String).new
+ expect_raises(Digraph::Error) do
+ g[1, 0]
+ end
+ end
+ end
+
+ describe "#path" do
+ it "works on a trival graph" do
+ g = Digraph(String, String).new
+ g[0] = "a"
+ g[1] = "b"
+ g[2] = "c"
+ g[0, 1] = "ab"
+ g[1, 2] = "bc"
+ g.path(0, 2).should eq([0, 1, 2])
+ g.path(2, 0).should be_nil
+ end
+
+ it "finds the shortest path" do
+ g = Digraph(String, String).new
+ g[0] = "a"
+ g[1] = "b"
+ g[2] = "c"
+ g[0, 1] = "ab"
+ g[1, 2] = "bc"
+
+ g[3] = "x"
+ g[4] = "y"
+ g[5] = "z"
+ g[0, 3] = "ax"
+ g[3, 4] = "xy"
+ g[4, 5] = "yz"
+ g[5, 2] = "zc"
+
+ g.path(0, 2).should eq([0, 1, 2])
+ end
+ end
+
+ describe "#nodes" do
+ it "provides all nodes" do
+ g = Digraph(String, String).new
+ g[0] = "a"
+ g[1] = "b"
+ g[2] = "c"
+ (g.nodes.to_a - [0, 1, 2]).should be_empty
+ end
+ end
+
+ describe "#outdegree" do
+ it "counts outgoing edges" do
+ g = Digraph(String, String).new
+ g[0] = "a"
+ g[1] = "b"
+ g[0, 1] = "ab"
+ g.outdegree(0).should eq(1)
+ g.outdegree(1).should eq(0)
+ end
+ end
+
+ describe "#indegree" do
+ it "counts incoming edges" do
+ g = Digraph(String, String).new
+ g[0] = "a"
+ g[1] = "b"
+ g[0, 1] = "ab"
+ g.indegree(0).should eq(0)
+ g.indegree(1).should eq(1)
+ end
+ end
+
+ describe "#subtree" do
+ g = Digraph(String, String).new
+ g[0] = "a"
+ g[1] = "b"
+ g[2] = "c"
+ g[0, 1] = "ab"
+ g[1, 2] = "bc"
+ g[3] = "x"
+
+ it "returns all reachable nodes" do
+ reachable = g.subtree(0).to_a
+ expected = [1, 2]
+ (expected - reachable).should be_empty
+ end
+
+ it "does not return disconnected nodes" do
+ reachable = g.subtree(0)
+ reachable.should_not contain(3_u64)
+ end
+
+ it "traverses lazilly" do
+ reachable = g.subtree 0
+ g[2, 3] = "cx"
+ reachable.should contain(3_u64)
+ end
+ end
+end
diff --git a/drivers/place/router/settings.cr b/drivers/place/router/settings.cr
new file mode 100644
index 00000000000..c7f1bfe3150
--- /dev/null
+++ b/drivers/place/router/settings.cr
@@ -0,0 +1,176 @@
+require "json"
+require "./signal_graph"
+
+module Place::Router::Core::Settings
+ # Types for representing the settings format for defining connections.
+ module Connections
+ module Deserializable
+ macro extended
+ def self.new(pull : JSON::PullParser)
+ parse?(pull.read_string) || pull.raise("Invalid #{self} (#{pull.string_value.inspect})")
+ end
+ end
+
+ abstract def parse?(raw : String)
+
+ def from_json_object_key?(key : String)
+ parse? key
+ end
+
+ def get_parts(module_id : String) : {String, Int32?}
+ mod_name, match, index = module_id.rpartition('_')
+ if match.empty?
+ {module_id, 1}
+ else
+ # we want the `.to_i` to be able to fail, this indicates it's probably a DeviceOutput
+ {mod_name, index.to_i?}
+ end
+ end
+ end
+
+ # Module name of a device within the local system e.g. `"Switcher_1"`.
+ record Device, mod : String, idx : Int32 do
+ extend Deserializable
+
+ def self.parse?(raw : String)
+ return if name = raw.lchop?('*')
+ mod, idx = get_parts(raw)
+ new mod, idx if idx
+ end
+ end
+
+ # Reference to a specific output on a device that has multiple outputs.
+ # This is a concatenation of the `Device` reference a `.` and the output.
+ # For example, output 3 of Switcher_1 is `"Switcher_1.3"`.
+ record DeviceOutput, mod : String, idx : Int32, output : String | Int32, layer : String? do
+ extend Deserializable
+
+ def self.parse?(raw : String)
+ return if name = raw.lchop?('*')
+ mod_name, match, outp = raw.rpartition(/_\d+\./)
+ if !match.empty?
+ mod, idx = mod_name, match[1..-2].to_i
+ outp_idx, match, layer = outp.rpartition('!')
+ if match.empty?
+ output = outp.to_i? || outp
+ new mod, idx, output, nil
+ else
+ output = outp_idx.to_i? || outp_idx
+ new mod, idx, output, layer
+ end
+ end
+ end
+ end
+
+ # Alias used to refer to a signal node that does not have an accompanying
+ # module. This can be useful for declaring the concept of a device that is
+ # attached to an input (e.g. `"*Laptop"`). All alias' must be prefixed with
+ # an asterisk ('*') within connections settings.
+ record Alias, name : String do
+ extend Deserializable
+
+ def self.parse?(raw : String)
+ if name = raw.lchop?('*')
+ new name
+ end
+ end
+ end
+
+ # The device a signal is originating from.
+ alias Source = Device | DeviceOutput | Alias
+
+ # The device that recieves the signal.
+ alias Sink = Device | Alias
+
+ # Identifier for the input on Sink.
+ alias Input = String
+
+ # Structure for a full connection map.
+ #
+ # ```json
+ # {
+ # "Display_1": {
+ # "hdmi": "Switcher_1.1"
+ # },
+ # "Switcher_1": ["*Foo", "*Bar"],
+ # "*FloorBox": "Switcher_1.2"
+ # }
+ # ```
+ alias Map = Hash(Sink, Hash(Input, Source) | Array(Source) | DeviceOutput)
+
+ # Parses a `Map` containing the system conectivity into a set of nodes and
+ # links that can be used for assembling the `SignalGraph`.
+ def self.parse(map : Map, sys : String)
+ nodes = [] of SignalGraph::Node::Ref
+ links = [] of {SignalGraph::Node::Ref, SignalGraph::Node::Ref}
+ aliases = {} of String => SignalGraph::Node::Ref
+
+ make_alias = ->(name : String, node : SignalGraph::Node::Ref) do
+ if prev = aliases[name]?
+ raise %(invalid configuration: "#{name}" refers to both #{prev} and #{node})
+ end
+ aliases[name] = node
+ end
+
+ map.each do |sink, inputs|
+ if sink.is_a? Alias
+ source = inputs
+ unless source.is_a? DeviceOutput
+ raise %(invalid configuration: "#{sink}" must link to a DeviceOutput)
+ end
+ onode = SignalGraph::Output.new sys, source.mod, source.idx, source.output, source.layer
+ nodes << onode
+ make_alias.call sink.name, onode
+ else
+ # Direct link only supported by output aliases.
+ if inputs.is_a? DeviceOutput
+ raise %(invalid configuration: "#{sink}" must specify inputs as either a hash or array)
+ end
+
+ nodes << SignalGraph::Device.new sys, sink.mod, sink.idx
+
+ # Iterate source arrays as 1-based input id's
+ inputs = inputs.each.with_index(1).map &.reverse if inputs.is_a? Array
+
+ inputs.each do |input, input_source|
+ inode = SignalGraph::Input.new sys, sink.mod, sink.idx, input
+ nodes << inode
+
+ if input_source.is_a? Alias
+ make_alias.call input_source.name, inode
+ next
+ end
+
+ onode = case input_source
+ in Device
+ SignalGraph::Device.new sys, input_source.mod, input_source.idx
+ in DeviceOutput
+ SignalGraph::Output.new sys, input_source.mod, input_source.idx, input_source.output, input_source.layer
+ end
+ nodes << onode
+
+ links << {onode, inode}
+ end
+ end
+ end
+
+ {nodes, links, aliases}
+ end
+ end
+
+ # Input/outputs and their associated metadata. Attributes specified here are
+ # progated to the assocated input status keys. This allows information such as
+ # name, type etc to be exposed to UI's.
+ alias IOMeta = Hash(String, Hash(String, JSON::Any))
+end
+
+# FIXME: submit as PR to crystal standard lib to support this neatly
+struct Union
+ def self.from_json_object_key?(key : String)
+ {% for t in T %}
+ instance = {{t}}.from_json_object_key? key
+ return instance unless instance.nil?
+ {% end %}
+ raise JSON::ParseException.new("Couldn't parse #{self} from #{key}", __LINE__, 0)
+ end
+end
diff --git a/drivers/place/router/settings_spec.cr b/drivers/place/router/settings_spec.cr
new file mode 100644
index 00000000000..78a8c65ddae
--- /dev/null
+++ b/drivers/place/router/settings_spec.cr
@@ -0,0 +1,77 @@
+require "spec"
+require "./settings"
+
+alias Settings = Place::Router::Core::Settings
+alias SignalGraph = Place::Router::SignalGraph
+
+class PlaceOS::Driver::Proxy::System
+ def self.module_id?(sys, name, idx) : String?
+ mock_id = {sys, name, idx}.hash
+ "mod-#{mock_id}"
+ end
+end
+
+describe Settings::Connections do
+ connections = <<-JSON
+ {
+ "Display_1": {
+ "hdmi": "Switcher_1.1"
+ },
+ "Switcher_1": ["*Foo", "*Bar"],
+ "*FloorBox": "Switcher_1.2"
+ }
+ JSON
+
+ describe "Map" do
+ it "deserializes from JSON" do
+ Settings::Connections::Map.from_json connections
+ end
+ end
+
+ describe ".parse" do
+ it "extracts nodes, links, aliases" do
+ map = Settings::Connections::Map.from_json connections
+ nodes, links, aliases = Settings::Connections.parse map, sys: "abc123"
+ nodes.size.should eq(7)
+ links.should contain({
+ SignalGraph::Output.new("abc123", "Switcher", 1, 1, "all"),
+ SignalGraph::Input.new("abc123", "Display", 1, "hdmi"),
+ })
+ aliases.keys.should contain "Foo"
+ aliases.keys.should contain "FloorBox"
+ end
+
+ it "extracts nodes, links, aliases and layers" do
+ connections2 = <<-JSON
+ {
+ "Display_1": {
+ "hdmi": "15.07_Switcher_1.1!video"
+ },
+ "Switcher_1": ["*Foo", "*Bar"],
+ "*FloorBox": "Switcher_1.2"
+ }
+ JSON
+
+ map = Settings::Connections::Map.from_json connections2
+ nodes, links, aliases = Settings::Connections.parse map, sys: "abc123"
+ nodes.size.should eq(7)
+ links.should contain({
+ SignalGraph::Output.new("abc123", "15.07_Switcher", 1, 1, "video"),
+ SignalGraph::Input.new("abc123", "Display", 1, "hdmi"),
+ })
+ aliases.keys.should contain "Foo"
+ aliases.keys.should contain "FloorBox"
+ end
+
+ it "detects alias conflicts" do
+ map = Settings::Connections::Map.from_json <<-JSON
+ {
+ "Switcher_1": ["*Foo", "*Foo"]
+ }
+ JSON
+ expect_raises(Exception) do
+ Settings::Connections.parse map, sys: "abc123"
+ end
+ end
+ end
+end
diff --git a/drivers/place/router/signal_graph.cr b/drivers/place/router/signal_graph.cr
new file mode 100644
index 00000000000..9751b32e67f
--- /dev/null
+++ b/drivers/place/router/signal_graph.cr
@@ -0,0 +1,185 @@
+require "set"
+require "./digraph"
+require "./signal_graph/*"
+
+# Structures and types for mapping between sys,mod,idx,io referencing and the
+# underlying graph structure.
+#
+# The SignalGraph class _does not_ perform any direct interaction with devices,
+# but does provide the ability to discover routes and available connectivity
+# when may then be acted on.
+class Place::Router::SignalGraph
+ alias Input = Node::DeviceInput
+
+ alias Output = Node::DeviceOutput
+
+ alias Device = Node::Device
+
+ Mute = Node::Mute.instance
+
+ private getter g : Digraph(Node::Label, Edge::Label)
+
+ private def initialize(initial_capacity = nil)
+ @g = Digraph(Node::Label, Edge::Label).new initial_capacity
+ end
+
+ # Inserts *node*.
+ protected def insert(node : Node::Ref)
+ g[node.id] = Node::Label.new node
+ end
+
+ # :ditto:
+ protected def insert(node : Node::Mute)
+ mute = Node::Label.new node
+ mute.source = Mute
+ mute.locked = true
+ g[node.id] = mute
+ end
+
+ # Defines a physical connection between two devices.
+ #
+ # *output* and *input* must both already exist within the underlying graph as
+ # signal nodes.
+ protected def connect(output : Node::Ref, input : Node::Ref)
+ g[input.id, output.id] = Edge::Static.instance
+ end
+
+ # Given a *mod* and sets of known *inputs* and *outputs* in use on it, wire up
+ # any active edges between these based on the interfaces available.
+ protected def link(mod : Mod, inputs : Enumerable(Input), outputs : Enumerable(Output))
+ if mod.switchable? && !outputs.empty?
+ inputs.each do |input|
+ outputs.each do |output|
+ func = Edge::Func::Switch.new input.input, output.output, output.layer
+ g[output.id, input.id] = Edge::Active.new mod, func
+ end
+ end
+ elsif mod.selectable?
+ inputs.each do |input|
+ output = Node::Device.new mod
+ func = Edge::Func::Select.new input.input
+ g[output.id, input.id] = Edge::Active.new mod, func
+ end
+ end
+
+ if mod.muteable?
+ if outputs.empty?
+ output = Node::Device.new mod
+ func = Edge::Func::Mute.new true
+ g[output.id, Mute.id] = Edge::Active.new mod, func
+ else
+ # ameba:disable Lint/ShadowingOuterLocalVar
+ outputs.each do |output|
+ func = Edge::Func::Mute.new true, output.output
+ g[output.id, Mute.id] = Edge::Active.new mod, func
+ end
+ end
+ end
+ end
+
+ # Construct a graph from a pre-parsed configuration.
+ #
+ # *nodes* must contain the set of all signal nodes that form the device inputs
+ # and outputs across the system. This includes those at the "edge" of the
+ # signal network (e.g. a input to a switcher) as well as inputs in use on
+ # intermediate devices (e.g. a input on a display, which in turn is attached to
+ # the switcher above).
+ #
+ # *links* declares the interconnections between devices.
+ #
+ # Modules associated with any of these nodes are then introspected for
+ # switching, input selection and mute control based on the interfaces they
+ # expose.
+ def self.build(nodes : Enumerable(Node::Ref), links : Enumerable({Node::Ref, Node::Ref}))
+ mod_io = Hash(Mod, {Set(Input), Set(Output)}).new do |h, k|
+ h[k] = {Set(Input).new, Set(Output).new}
+ end
+
+ siggraph = new initial_capacity: nodes.size
+
+ siggraph.insert Mute
+
+ # Create verticies for each signal node
+ nodes.each do |node|
+ siggraph.insert node
+
+ # Track device IO in use for building active edges
+ case node
+ when Input
+ inputs, _ = mod_io[node.mod]
+ inputs << node
+ when Output
+ _, outputs = mod_io[node.mod]
+ outputs << node
+ end
+ end
+
+ # Insert the static edges.
+ links.each { |source, dest| siggraph.connect source, dest }
+
+ # Wire up the active edges.
+ mod_io.each { |mod, (inputs, outputs)| siggraph.link mod, inputs, outputs }
+
+ # Set a loopback source on all inputs.
+ siggraph.inputs.each { |node| node.source = node.ref }
+
+ siggraph
+ end
+
+ # Retrieves the labelled state for *node*.
+ def [](node : Node::Ref)
+ g[node.id]
+ end
+
+ # Retrieves the labelled state for the signal node at *node_id*.
+ def [](node_id)
+ g[node_id]
+ end
+
+ # Find the signal path that connects *source* to *dest*, or `nil` if this is
+ # not possible.
+ #
+ # Provides an `Iterator` that provides labels across each node, the edge, and
+ # subsequent node.
+ def route(source : Node::Ref, destination : Node::Ref, max_dist = nil)
+ path = g.path destination.id, source.id, invert: true
+
+ return nil unless path
+
+ return nil if max_dist && path.size > max_dist
+
+ path.each_cons(2, true).map do |(succ, pred)|
+ {
+ g[succ], # source
+ g[pred, succ], # edge
+ g[pred], # next node
+ }
+ end
+ end
+
+ # Checks if *node* is a system input.
+ def input?(node : Node::Ref) : Bool
+ g.sink? node.id
+ end
+
+ # Provide the signal nodes that form system inputs.
+ def inputs
+ # Graph connectivity is inverse to signal direction, hence sinks here.
+ g.sinks.compact_map { |id| g[id] unless id == Mute.id }
+ end
+
+ # Provide all signal nodes that can be routed to *destination*.
+ def inputs(destination : Node::Ref)
+ g.subtree(destination.id).map { |id| g[id] }
+ end
+
+ # Checks if *node* is a system output.
+ def output?(node : Node::Ref) : Bool
+ g.source? node.id
+ end
+
+ # Provide the signal nodes that form system outputs.
+ def outputs
+ g.sources.compact_map { |id| g[id] unless id == Mute.id }
+ end
+end
diff --git a/drivers/place/router/signal_graph/edge.cr b/drivers/place/router/signal_graph/edge.cr
new file mode 100644
index 00000000000..cceb5e67a30
--- /dev/null
+++ b/drivers/place/router/signal_graph/edge.cr
@@ -0,0 +1,39 @@
+require "./mod"
+
+class Place::Router::SignalGraph
+ module Edge
+ alias Label = Static | Active
+
+ class Static
+ class_getter instance : self { new }
+
+ protected def initialize; end
+ end
+
+ record Active, mod : Mod, func : Func::Type
+
+ module Func
+ record Mute,
+ state : Bool,
+ index : Int32 | String = 0
+
+ record Select,
+ input : Int32 | String
+
+ record Switch,
+ input : Int32 | String,
+ output : Int32 | String,
+ layer : String
+
+ # NOTE: currently not supported. Requires interaction via
+ # Proxy::RemoteDriver to support dynamic method execution.
+ # record Custom,
+ # func : String,
+ # args : Hash(String, JSON::Any::Type)
+
+ macro finished
+ alias Type = {{ @type.constants.join(" | ").id }}
+ end
+ end
+ end
+end
diff --git a/drivers/place/router/signal_graph/mod.cr b/drivers/place/router/signal_graph/mod.cr
new file mode 100644
index 00000000000..488f5434359
--- /dev/null
+++ b/drivers/place/router/signal_graph/mod.cr
@@ -0,0 +1,60 @@
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+class Place::Router::SignalGraph
+ # Reference to a PlaceOS module that provides IO nodes within the graph.
+ class Mod
+ getter sys : String
+ getter name : String
+ getter idx : Int32
+
+ getter id : String
+
+ def initialize(@sys, @name, @idx)
+ id = PlaceOS::Driver::Proxy::System.module_id? sys, name, idx
+ @id = id || raise %("#{name}/#{idx}" does not exist in #{sys})
+ end
+
+ def metadata
+ if meta = PlaceOS::Driver::Proxy::System.driver_metadata?(id)
+ meta
+ else
+ # TODO:: warn about offline device
+ PlaceOS::Driver::DriverModel::Metadata.new(interface: {} of String => Hash(String, JSON::Any))
+ end
+ rescue error
+ raise RuntimeError.new("failed to obtain metadata for #{id}", cause: error)
+ end
+
+ # FIXME: drop if / after renaming InputSelection -> Selectable
+ def selectable?
+ interface = {{PlaceOS::Driver::Interface::InputSelection.name(generic_args: false).stringify}}
+ interface.in? metadata.implements
+ end
+
+ macro finished
+ {% for interface in PlaceOS::Driver::Interface.constants %}
+ {% type = PlaceOS::Driver::Interface.constant(interface) %}
+ def {{interface.underscore}}?
+ {{type.name(generic_args: false).stringify}}.in? metadata.implements
+ end
+ {% end %}
+ end
+
+ def_equals_and_hash @id
+
+ def to_s(io)
+ io << sys << '/' << name << '_' << idx
+ end
+
+ def self.parse?(ref)
+ if m = ref.match /^(.+)\/(.+)\_(\d+)$/
+ sys = m[1]
+ mod = m[2]
+ idx = m[3].to_i
+ new sys, mod, idx
+ end
+ end
+ end
+end
diff --git a/drivers/place/router/signal_graph/node.cr b/drivers/place/router/signal_graph/node.cr
new file mode 100644
index 00000000000..a8da7feab5a
--- /dev/null
+++ b/drivers/place/router/signal_graph/node.cr
@@ -0,0 +1,260 @@
+require "json"
+require "./mod"
+require "./watchable"
+
+class Place::Router::SignalGraph
+ module Node
+ # Metadata tracked against each signal node.
+ class Label
+ include JSON::Serializable
+ include Watchable
+
+ def initialize(@ref)
+ end
+
+ def to_s(io)
+ io << ref
+ end
+
+ # The `Node::Ref` used when creating this node.
+ getter ref : Ref
+
+ # `Ref` of the upstream signal source currently feeding this node.
+ property source : Ref? = nil
+
+ # Locked state. When `true` changes to signal routes that transit this
+ # are blocked.
+ property locked : Bool = false
+
+ # Additional metadata passed in from settings or dynamically applied.
+ # Information here is propogated to exposed state keys. May be used for
+ # any information needed by a user interface or external system.
+ @[JSON::Field(ignore: true)]
+ property meta : Hash(String, JSON::Any) { Hash(String, JSON::Any).new }
+
+ delegate :[], :[]?, to: meta
+
+ # Sets a metadata property of `self`.
+ def []=(key, value : JSON::Any)
+ meta[key] = value
+ self.notify
+ value
+ end
+
+ def []=(key, value)
+ self[key] = JSON::Any.new value
+ end
+
+ def []=(key, value : Int)
+ self[key] = JSON::Any.new value.to_i64
+ end
+
+ def []=(key, value : Float)
+ self[key] = JSON::Any.new value.to_f64
+ end
+
+ def []=(key, value : Array)
+ self[key] = JSON::Any.new value.map { |x| JSON::Any.new x }
+ end
+
+ def []=(key, value : Hash)
+ self[key] = JSON::Any.new value.transform_values { |x| JSON::Any.new x }
+ end
+
+ protected def on_to_json(json)
+ @meta.try &.each do |key, value|
+ json.field(key) { value.to_json(json) }
+ end
+ end
+ end
+
+ # Base structure for referring to a node within the graph.
+ abstract struct Ref
+ # Resolves a string-based node *key* to a fully-qualified reference.
+ #
+ # If a system component is not present within *key*, this is resolved
+ # within the context of *sys*. For example:
+ #
+ # Ref.resolve("Display_1:hdmi", "sys-abc123")
+ # # => DeviceInput(sys: "sys-abc123", mod: {"Display", 1}, input: "hdmi")
+ #
+ def self.resolve?(key : String, sys = nil)
+ ref = key.includes?('/') ? key : "#{sys}/#{key}"
+ {% begin %}
+ {% for type in @type.subclasses %}
+ {{type}}.parse?(ref) || \
+ {% end %}
+ nil
+ {% end %}
+ end
+
+ # Node identifier for usage as the graph ID.
+ def id
+ self.class.hash ^ self.hash
+ end
+
+ abstract def mod
+
+ DEFAULT_LAYER = "all"
+
+ def layer
+ DEFAULT_LAYER
+ end
+
+ def ==(other : Ref)
+ id == other.id
+ end
+
+ def local(sys : String)
+ to_s.lchop "#{sys}/"
+ end
+
+ def to_json(json)
+ json.string to_s
+ end
+
+ private module ClassMethods(T)
+ # Parses a string-based *ref* to {{@type}}.
+ abstract def parse?(ref : String) : T?
+ end
+
+ macro inherited
+ extend ClassMethods(self)
+ end
+ end
+
+ # Reference to the default / central node for a device.
+ #
+ # These take the cannonical string form of:
+ #
+ # sys-abc123/Display_1
+ # │ │ │
+ # │ │ └module index
+ # │ └module name
+ # └system
+ #
+ struct Device < Ref
+ getter mod : Mod
+
+ def initialize(sys, name, idx)
+ @mod = Mod.new sys, name, idx
+ end
+
+ def initialize(@mod)
+ end
+
+ def to_s(io)
+ io << mod
+ end
+
+ def self.parse?(ref) : self?
+ if mod = Mod.parse? ref
+ new mod
+ end
+ end
+ end
+
+ # Reference to a signal output from a device.
+ #
+ # These take the cannonical string form of:
+ #
+ # sys-abc123/Switcher_1.1!video
+ # │ │ │ │ │_layer
+ # │ │ │ └output
+ # │ │ └module index
+ # │ └module namme
+ # └system
+ #
+ struct DeviceOutput < Ref
+ getter mod : Mod
+ getter output : Int32 | String
+ getter layer : String
+
+ def initialize(sys, name, idx, @output, layer)
+ @mod = Mod.new sys, name, idx
+ @layer = layer.try(&.downcase) || DEFAULT_LAYER
+ end
+
+ def initialize(@mod, @output)
+ @layer = DEFAULT_LAYER
+ end
+
+ def to_s(io)
+ io << mod << '.' << output
+ io << '!' << @layer unless @layer == DEFAULT_LAYER
+ end
+
+ def self.parse?(ref) : self?
+ # as match == "_123."
+ mod_name, match, outp = ref.rpartition(/_\d+\./)
+ mod_name = "#{mod_name}_#{match[1..-2]}" if !match.empty?
+
+ if mod = Mod.parse? mod_name
+ output = outp.to_i? || outp
+ new mod, output
+ end
+ end
+ end
+
+ # Reference to a signal input to a device.
+ #
+ # These take the cannonical string form of:
+ #
+ # sys-abc123/Display_1:hdmi
+ # │ │ │ │
+ # │ │ │ └input
+ # │ │ └module index
+ # │ └module namme
+ # └system
+ #
+ struct DeviceInput < Ref
+ getter mod : Mod
+ getter input : Int32 | String
+
+ def initialize(sys, name, idx, @input)
+ @mod = Mod.new sys, name, idx
+ end
+
+ def initialize(@mod, @input)
+ end
+
+ def to_s(io)
+ io << mod << ':' << input
+ end
+
+ def self.parse?(ref) : self?
+ m, _, i = ref.rpartition ':'
+ if mod = Mod.parse? m
+ input = i.to_i? || i
+ new mod, input
+ end
+ end
+ end
+
+ # Virtual node representing (any) mute source.
+ #
+ # This may be refernced simply as `MUTE`.
+ struct Mute < Ref
+ class_getter instance : self { new }
+
+ protected def initialize
+ end
+
+ def id
+ 0_u64
+ end
+
+ def mod
+ end
+
+ def self.parse?(ref) : self?
+ # See Ref#resolve? on line 82 of this file for what is passed here
+ instance if ref.upcase.ends_with?("MUTE")
+ end
+
+ def to_s(io)
+ io << "MUTE"
+ end
+ end
+ end
+end
diff --git a/drivers/place/router/signal_graph/watchable.cr b/drivers/place/router/signal_graph/watchable.cr
new file mode 100644
index 00000000000..d862b5c05d7
--- /dev/null
+++ b/drivers/place/router/signal_graph/watchable.cr
@@ -0,0 +1,29 @@
+class Place::Router::SignalGraph
+ module Watchable
+ # Subscribe to updates.
+ def watch(initial = true, &handler : self ->) : Nil
+ subscribers << handler
+ handler.call(self) if initial
+ end
+
+ # Notify subscribers with current state.
+ def notify : Nil
+ @subscribers.try &.each &.call(self)
+ end
+
+ macro included
+ @[JSON::Field(ignore: true)]
+ private getter subscribers : Array(self ->) { Array(self ->).new }
+
+ {% verbatim do %}
+ macro finished
+ {% for method in @type.methods.select &.name.ends_with? '=' %}
+ def {{method.name}}({{method.args.splat}})
+ previous_def.tap { notify }
+ end
+ {% end %}
+ end
+ {% end %}
+ end
+ end
+end
diff --git a/drivers/place/router/signal_graph_spec.cr b/drivers/place/router/signal_graph_spec.cr
new file mode 100644
index 00000000000..33d73a0e4dd
--- /dev/null
+++ b/drivers/place/router/signal_graph_spec.cr
@@ -0,0 +1,251 @@
+abstract class PlaceOS::Driver; end
+
+require "spec"
+require "placeos-driver/driver_model"
+require "./signal_graph"
+
+alias SigGraph = Place::Router::SignalGraph
+
+abstract class PlaceOS::Driver
+ module Interface
+ module Switchable; end
+
+ module Selectable; end
+
+ module Muteable; end
+
+ # TODO: expand interfaces in `placeos-driver` to cover this
+ module InputMuteable; end
+ end
+
+ class Proxy::System
+ def self.module_id?(sys, name, idx) : String?
+ mock_id = {sys, name, idx}.hash
+ "mod-#{mock_id}"
+ end
+
+ def self.driver_metadata?(id) : DriverModel::Metadata?
+ m = DriverModel::Metadata.new
+ m.implements << {{Interface::Switchable.name(generic_args: false).stringify}}
+ m.implements << {{Interface::Selectable.name(generic_args: false).stringify}}
+ m.implements << {{Interface::Muteable.name(generic_args: false).stringify}}
+ m
+ end
+ end
+end
+
+# Settings:
+#
+# connections = {
+# Display_1: {
+# hdmi: "15.02_Switcher_1.1"
+# },
+# 15.02_Switcher_1: ["*foo", "*bar"],
+# Display_2: {
+# hdmi: "*baz"
+# }
+# }
+#
+# inputs = {
+# foo: "laptop",
+# bar: "pc"
+# }
+
+# Set of inputs in use
+# NOTE: alias are only used in the local system, no impact here
+nodes = [
+ SigGraph::Input.new("sys-123", "Display", 1, "hdmi"),
+ SigGraph::Input.new("sys-123", "Display", 2, "hdmi"),
+ SigGraph::Input.new("sys-123", "15.02_Switcher", 1, 1),
+ SigGraph::Input.new("sys-123", "15.02_Switcher", 1, 2),
+ SigGraph::Output.new("sys-123", "15.02_Switcher", 1, 1, "all"),
+ SigGraph::Device.new("sys-123", "Display", 1),
+ SigGraph::Device.new("sys-123", "Display", 2),
+ SigGraph::Device.new("sys-123", "15.02_Switcher", 1),
+]
+
+connections = [
+ {SigGraph::Output.new("sys-123", "15.02_Switcher", 1, 1, "all"), SigGraph::Input.new("sys-123", "Display", 1, "hdmi")},
+]
+
+inputs = {
+ foo: SigGraph::Input.new("sys-123", "15.02_Switcher", 1, 1),
+ bar: SigGraph::Input.new("sys-123", "15.02_Switcher", 1, 2),
+ baz: SigGraph::Input.new("sys-123", "Display", 2, "hdmi"),
+}
+
+outputs = {
+ display: SigGraph::Device.new("sys-123", "Display", 1),
+ display2: SigGraph::Device.new("sys-123", "Display", 2),
+}
+
+describe SigGraph do
+ describe ".build" do
+ it "builds from config" do
+ SigGraph.build nodes, connections
+ end
+ end
+
+ describe "#[]" do
+ n = SigGraph::Device.new("sys-123", "Display", 1)
+ g = SigGraph.build [n], [] of {SigGraph::Node::Ref, SigGraph::Node::Ref}
+
+ it "provides node details from a Ref" do
+ g[n].should be_a(SigGraph::Node::Label)
+ end
+
+ it "provides nodes details from an ID" do
+ g[n.id].should be_a(SigGraph::Node::Label)
+ end
+
+ it "provides the same label for both accessors" do
+ g[n].should eq(g[n.id])
+ end
+ end
+
+ describe "#route" do
+ g = SigGraph.build nodes, connections
+
+ it "returns nil if no path exists" do
+ path = g.route inputs[:foo], outputs[:display2]
+ path.should be_nil
+ end
+
+ it "provides the path connects a signal source to a destination" do
+ source = inputs[:foo]
+ dest = outputs[:display]
+
+ path = g.route source, dest
+ path = path.not_nil!
+
+ path.each_with_index do |(node, edge, next_node), step|
+ case step
+ when 0
+ node.should eq g[source]
+ edge = edge.as SigGraph::Edge::Active
+ edge.mod.name.should eq "15.02_Switcher"
+ edge.func.should eq SigGraph::Edge::Func::Switch.new 1, 1, "all"
+ when 1
+ edge.should be_a SigGraph::Edge::Static
+ next_node.should eq g[SigGraph::Input.new("sys-123", "Display", 1, "hdmi")]
+ when 2
+ edge = edge.as SigGraph::Edge::Active
+ edge.mod.name.should eq "Display"
+ edge.mod.idx.should eq 1
+ edge.func.should eq SigGraph::Edge::Func::Select.new "hdmi"
+ when 4
+ fail "path iterator did not terminate"
+ end
+ end
+ end
+
+ it "provides mute activation on an output device" do
+ source = SigGraph::Mute
+ dest = outputs[:display]
+
+ path = g.route source, dest
+ path = path.not_nil!
+
+ node, edge, next_node = path.first
+ node.should eq g[source]
+ edge = edge.as SigGraph::Edge::Active
+ edge.mod.name.should eq "Display"
+ edge.func.should eq SigGraph::Edge::Func::Mute.new true
+ next_node.should eq g[dest]
+ end
+
+ it "provide mute activate on an intermediate switcher" do
+ source = SigGraph::Mute
+ dest = SigGraph::Output.new("sys-123", "15.02_Switcher", 1, 1, "all")
+
+ path = g.route source, dest
+ path = path.not_nil!
+
+ node, edge, next_node = path.first
+ node.should eq g[source]
+ edge = edge.as SigGraph::Edge::Active
+ edge.mod.name.should eq "15.02_Switcher"
+ edge.func.should eq SigGraph::Edge::Func::Mute.new true, 1
+ next_node.should eq g[dest]
+ end
+ end
+
+ describe "#input?" do
+ g = SigGraph.build nodes, connections
+
+ it "returns true if the node is an input" do
+ g.input?(inputs.values.sample).should be_true
+ end
+
+ it "returns false otherwise" do
+ g.input?(outputs.values.sample).should be_false
+ end
+ end
+
+ describe "#inputs" do
+ it "provides a list of input nodes within the graph" do
+ g = SigGraph.build nodes, connections
+ expected = inputs.values
+ discovered = g.inputs.map(&.ref).to_a
+ expected.each { |input| discovered.should contain input }
+ end
+ end
+
+ describe "#inputs(destination)" do
+ it "provides a list of inputs nodes accessible to an output" do
+ g = SigGraph.build nodes, connections
+ reachable = g.inputs(outputs[:display]).map(&.ref).to_a
+ expected = {inputs[:foo], inputs[:bar]}
+ expected.each { |input| reachable.should contain input }
+ end
+ end
+
+ describe "#output?" do
+ g = SigGraph.build nodes, connections
+
+ it "returns true if the node is an output" do
+ g.output?(outputs.values.sample).should be_true
+ end
+
+ it "returns false otherwise" do
+ g.output?(inputs.values.sample).should be_false
+ end
+ end
+
+ describe "#outputs" do
+ it "list the output nodes present in the graph" do
+ g = SigGraph.build nodes, connections
+ expected = outputs.values
+ discovered = g.outputs.map(&.ref).to_a
+ expected.each { |output| discovered.should contain output }
+ end
+ end
+
+ pending "#to_json" do
+ end
+ pending ".from_json" do
+ end
+
+ pending "#merge" do
+ end
+
+ describe SigGraph::Node::Label do
+ it "supports change notification" do
+ n = SigGraph::Device.new("sys-123", "Display", 1)
+ g = SigGraph.build [n], [] of {SigGraph::Node::Ref, SigGraph::Node::Ref}
+
+ x = 0
+
+ g[n].watch(initial: false) { x += 1 }
+
+ x.should eq 0
+ g[n].notify
+ x.should eq 1
+
+ g[n].locked.should be_false
+ g[n].locked = true
+ x.should eq 2
+ g[n].locked.should be_true
+ end
+ end
+end
diff --git a/drivers/place/router_spec.cr b/drivers/place/router_spec.cr
new file mode 100644
index 00000000000..08a49570987
--- /dev/null
+++ b/drivers/place/router_spec.cr
@@ -0,0 +1,102 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/switchable"
+
+# :nodoc:
+class RouterDisplay < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Powerable
+ include PlaceOS::Driver::Interface::Muteable
+
+ enum Inputs
+ HDMI
+ HDMI2
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(Inputs)
+
+ # implement the abstract methods required by the interfaces
+ def power(state : Bool)
+ self[:power] = state
+ end
+
+ def switch_to(input : Inputs)
+ mute(false)
+ self[:input] = input
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ self[:mute] = state
+ end
+end
+
+# :nodoc:
+class RouterSwitcher < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Switchable(Int32, Int32)
+
+ def switch_to(input : Int32)
+ self[:input] = input
+ end
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ self["last_switched_layer"] = layer.to_s.downcase
+ map.each do |(input, outputs)|
+ outputs.each do |output|
+ self["output#{output}"] = input
+ end
+ end
+ end
+end
+
+DriverSpecs.mock_driver "Place::RouterTest" do
+ system({
+ Display: {RouterDisplay},
+ Switcher: {RouterSwitcher},
+ })
+
+ settings({
+ connections: {
+ Display_1: {
+ hdmi: "Switcher_1.1!video",
+ },
+ Switcher_1: ["*Foo", "*Bar"],
+ "*FloorBox": "Switcher_1.2",
+ },
+ })
+
+ # Give the settings time to load
+ sleep 2
+
+ status["inputs"].as_a.should contain("Foo")
+ status["inputs"].as_a.should contain("Bar")
+ status["outputs"].as_a.should contain("Display_1")
+ status["outputs"].as_a.should contain("FloorBox")
+ status["output/Display_1"]["inputs"].should eq(["Foo", "Bar"])
+
+ exec(:route_signal, "Foo", "Display_1").get
+ status["output/Display_1"]["source"].should eq(status["input/Foo"]["ref"])
+ system(:Switcher_1)["last_switched_layer"].should eq("video")
+
+ expect_raises(
+ PlaceOS::Driver::RemoteException,
+ %(unknown signal node "Baz" - did you mean "Bar"?)
+ ) do
+ exec(:route_signal, "Foo", "Baz").get
+ end
+
+ # Ensure previous status persists settings reloads for continuing nodes
+ settings({
+ connections: {
+ Display_1: {
+ hdmi: "Switcher_1.1",
+ },
+ Switcher_1: ["*Foo", "*Bar"],
+ },
+ })
+ sleep 2
+ status["output/Display_1"]["source"].should eq(status["input/Foo"]["ref"])
+end
diff --git a/drivers/place/smtp.cr b/drivers/place/smtp.cr
new file mode 100644
index 00000000000..6375355f4d4
--- /dev/null
+++ b/drivers/place/smtp.cr
@@ -0,0 +1,161 @@
+require "qr-code"
+require "qr-code/export/png"
+require "base64"
+require "email"
+require "uri"
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+
+class Place::Smtp < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::Mailer
+
+ descriptive_name "SMTP Mailer"
+ generic_name :Mailer
+ uri_base "https://smtp.host.com"
+ description %(sends emails via SMTP)
+
+ default_settings({
+ sender: "support@place.tech",
+ # host: "smtp.host",
+ # port: 587,
+ tls_mode: EMail::Client::TLSMode::STARTTLS.to_s,
+ ssl_verify_ignore: false,
+ username: "", # Username/Password for SMTP servers with basic authorization
+ password: "",
+
+ email_templates: {visitor: {checkin: {
+ subject: "%{name} has arrived",
+ text: "for your meeting at %{time}",
+ }}},
+ })
+
+ private def smtp_client : EMail::Client
+ @smtp_client ||= new_smtp_client
+ end
+
+ @smtp_client : EMail::Client?
+ @sender : String = "support@place.tech"
+ @username : String = ""
+ @password : String = ""
+ @host : String = "smtp.host"
+ @port : Int32 = 587
+ @tls_mode : EMail::Client::TLSMode = EMail::Client::TLSMode::STARTTLS
+ @send_lock : Mutex = Mutex.new
+ @ssl_verify_ignore : Bool = false
+
+ def on_update
+ defaults = URI.parse(config.uri.not_nil!)
+ tls_mode = if scheme = defaults.scheme
+ scheme.ends_with?('s') ? EMail::Client::TLSMode::SMTPS : EMail::Client::TLSMode::STARTTLS
+ else
+ EMail::Client::TLSMode::STARTTLS
+ end
+ port = defaults.port || 587
+ host = defaults.host || "smtp.host"
+
+ @username = setting?(String, :username) || ""
+ @password = setting?(String, :password) || ""
+ @sender = setting?(String, :sender) || "support@place.tech"
+ @host = setting?(String, :host) || host
+ @port = setting?(Int32, :port) || port
+ @tls_mode = setting?(EMail::Client::TLSMode, :tls_mode) || tls_mode
+ @ssl_verify_ignore = setting?(Bool, :ssl_verify_ignore) || false
+
+ @smtp_client = new_smtp_client
+
+ @templates = setting?(Templates, :email_templates) || Templates.new
+ end
+
+ # Create and configure an SMTP client
+ private def new_smtp_client
+ email_config = EMail::Client::Config.new(@host, @port)
+ email_config.log = logger
+ email_config.client_name = "PlaceOS"
+
+ unless @username.empty? || @password.empty?
+ email_config.use_auth(@username, @password)
+ end
+
+ email_config.use_tls(@tls_mode)
+ email_config.tls_context.verify_mode = OpenSSL::SSL::VerifyMode::None if @ssl_verify_ignore
+
+ EMail::Client.new(email_config)
+ end
+
+ def generate_svg_qrcode(text : String) : String
+ QRCode.new(text).as_svg
+ end
+
+ def generate_png_qrcode(text : String, size : Int32 = 128) : String
+ Base64.strict_encode QRCode.new(text).as_png(size: size)
+ end
+
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ ) : Bool
+ to = {to} unless to.is_a?(Array)
+
+ from = {from} unless from.nil? || from.is_a?(Array)
+ cc = {cc} unless cc.nil? || cc.is_a?(Array)
+ bcc = {bcc} unless bcc.nil? || bcc.is_a?(Array)
+ reply_to = {reply_to} unless reply_to.nil? || reply_to.is_a?(Array)
+
+ message = EMail::Message.new
+
+ message.subject(subject)
+
+ message.sender(@sender)
+
+ if from.nil? || from.empty?
+ message.from(@sender)
+ else
+ from.each { |_from| message.from(_from) }
+ end
+
+ to.each { |_to| message.to(_to) }
+ bcc.each { |_bcc| message.bcc(_bcc) }
+ cc.each { |_cc| message.cc(_cc) }
+
+ if reply_to
+ reply_to.each { |_reply| message.reply_to(_reply) }
+ end
+
+ message.message(message_plaintext.as(String)) unless message_plaintext.presence.nil?
+ message.message_html(message_html.as(String)) unless message_html.presence.nil?
+
+ # Traverse all attachments
+ {resource_attachments, attachments}.map(&.each).each.flatten.each do |attachment|
+ # Base64 decode to memory, then attach to email
+ attachment_io = IO::Memory.new
+ Base64.decode(attachment[:content], attachment_io)
+ attachment_io.rewind
+
+ case attachment
+ in Attachment
+ message.attach(io: attachment_io, file_name: attachment[:file_name])
+ in ResourceAttachment
+ message.message_resource(io: attachment_io, file_name: attachment[:file_name], cid: attachment[:content_id])
+ end
+ end
+
+ sent = false
+
+ # Ensure only a single send at a time
+ @send_lock.synchronize do
+ smtp_client.start do
+ sent = send(message)
+ end
+ end
+
+ sent
+ end
+end
diff --git a/drivers/place/smtp_spec.cr b/drivers/place/smtp_spec.cr
new file mode 100644
index 00000000000..989f6ad5218
--- /dev/null
+++ b/drivers/place/smtp_spec.cr
@@ -0,0 +1,41 @@
+require "placeos-driver/spec"
+require "email"
+
+# for local testing use: http://nilhcem.com/FakeSMTP/download.html
+
+DriverSpecs.mock_driver "Place::Smtp" do
+ settings({
+ sender: "support@place.tech",
+ host: ENV["PLACE_SMTP_HOST"]? || "localhost",
+ port: ENV["PLACE_SMTP_PORT"]?.try(&.to_i) || 25,
+ username: ENV["PLACE_SMTP_USER"]? || "", # Username/Password for SMTP servers with basic authorization
+ password: ENV["PLACE_SMTP_PASS"]? || "",
+ tls_mode: ENV["PLACE_SMTP_MODE"]? || "none",
+
+ email_templates: {visitor: {checkin: {
+ subject: "%{name} has arrived",
+ text: "for your meeting at %{time}",
+ }}},
+ })
+
+ response = exec(
+ :send_mail,
+ subject: "Test Email",
+ to: ENV["PLACE_TEST_EMAIL"]? || "support@place.tech",
+ message_plaintext: "Hello!",
+ ).get
+
+ response.should be_true
+
+ response = exec(
+ :send_template,
+ to: "steve@place.tech",
+ template: {"visitor", "checkin"},
+ args: {
+ name: "Bob",
+ time: "1:30pm",
+ }
+ ).get
+
+ response.should be_true
+end
diff --git a/drivers/place/spec_helper.cr b/drivers/place/spec_helper.cr
new file mode 100644
index 00000000000..ac332cfa843
--- /dev/null
+++ b/drivers/place/spec_helper.cr
@@ -0,0 +1,8 @@
+require "placeos-driver"
+
+class Place::SpecHelper < PlaceOS::Driver
+ # This method will be exposed on the module
+ def implemented_in_driver
+ "woot!"
+ end
+end
diff --git a/drivers/place/staff_api.cr b/drivers/place/staff_api.cr
new file mode 100644
index 00000000000..9a219c40d64
--- /dev/null
+++ b/drivers/place/staff_api.cr
@@ -0,0 +1,956 @@
+require "json-schema"
+require "placeos-driver"
+require "oauth2"
+require "placeos"
+require "link-header"
+require "simple_retry"
+require "place_calendar"
+require "./booking_model"
+
+# This comment is to force a recompile of the driver with updated models
+
+class Place::StaffAPI < PlaceOS::Driver
+ descriptive_name "PlaceOS Staff API"
+ generic_name :StaffAPI
+ description %(helpers for requesting data held in the staff API)
+
+ # The PlaceOS API
+ uri_base "https://staff"
+
+ default_settings({
+ # PlaceOS X-API-key, for simpler authentication
+ api_key: "",
+ disable_event_notify: false,
+ query_limit: 100,
+ period_end_default_in_min: 60,
+ })
+
+ @place_domain : URI = URI.parse("https://staff")
+ @host_header : String = ""
+ @api_key : String = ""
+
+ @authority_id : String = ""
+ @event_monitoring : PlaceOS::Driver::Subscriptions::ChannelSubscription? = nil
+ @notify_count : UInt64 = 0_u64
+ @notify_fails : UInt64 = 0_u64
+
+ @query_limit : Int32 = 100
+ @period_end_default_in_min : Int32? = nil
+
+ def on_update
+ # x-api-key is the preferred method for API access
+ @api_key = setting(String, :api_key) || ""
+ @access_expires = 30.years.from_now if @api_key.presence
+
+ @place_domain = URI.parse(config.uri.not_nil!)
+ @host_header = setting?(String, :host_header) || @place_domain.host.not_nil!
+
+ @placeos_client = nil
+ @query_limit = setting?(Int32, :query_limit) || 100
+ @period_end_default_in_min = setting?(Int32, :period_end_default_in_min)
+
+ # skip if not going to work
+ return unless @api_key.presence
+ return if setting?(Bool, :disable_event_notify)
+ schedule.clear
+ schedule.every(1.hour + rand(300).seconds) { lookup_authority_id }
+ schedule.in(1.second) { lookup_authority_id }
+ end
+
+ def lookup_authority_id(retry : Int32 = 0)
+ response = get("/auth/authority")
+ raise "unexpected response for /auth/authority: #{response.status_code}\n#{response.body}" unless response.success?
+
+ old_id = @authority_id
+ @authority_id = NamedTuple(id: String).from_json(response.body)[:id]
+ monitor_event_changes unless old_id == @authority_id
+ @authority_id
+ rescue error
+ logger.warn(exception: error) { "failed to lookup authority id" }
+ sleep rand(3).seconds
+ retry += 1
+ return if retry == 10
+ spawn { lookup_authority_id(retry) }
+ end
+
+ protected def monitor_event_changes
+ if monitor = @event_monitoring
+ subscriptions.unsubscribe(monitor)
+ @event_monitoring = nil
+ end
+
+ @event_monitoring = monitor("#{@authority_id}/bookings/event") { |_subscription, payload| push_event_occured(payload) }
+ end
+
+ struct PushEvent
+ include JSON::Serializable
+
+ getter event_id : String
+ getter change : String
+ getter system_id : String
+ getter event : JSON::Any?
+ end
+
+ protected def push_event_occured(payload)
+ @notify_count += 1
+ logger.debug { "new push event: #{payload}" }
+
+ event = PushEvent.from_json payload
+
+ response = post("/api/staff/v1/events/notify/#{event.change}/#{event.system_id}/#{event.event_id}",
+ body: event.event.to_json,
+ headers: authentication(HTTP::Headers{
+ "Content-Type" => "application/json",
+ })
+ )
+ if !response.success?
+ @notify_fails += 1
+ raise "unexpected response processing push event: #{response.status_code}\n#{payload}"
+ end
+ end
+
+ def push_event_status
+ {
+ authority_id: @authority_id,
+ monitoring: !!@event_monitoring,
+ events: @notify_count,
+ failures: @notify_fails,
+ }
+ end
+
+ def auth_authority
+ response = get("/auth/authority")
+ raise "unexpected response for /auth/authority: #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def domain : String
+ @place_domain.host.as(String)
+ end
+
+ def get_system(id : String, complete : Bool = false)
+ response = get("/api/engine/v2/systems/#{id}?complete=#{complete}", headers: authentication)
+ raise "unexpected response for system id #{id}: #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing system #{id}:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ def systems(
+ q : String? = nil,
+ zone_id : String? = nil,
+ capacity : Int32? = nil,
+ bookable : Bool? = nil,
+ features : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0
+ )
+ placeos_client.systems.search(
+ q: q,
+ limit: limit,
+ offset: offset,
+ zone_id: zone_id,
+ capacity: capacity,
+ bookable: bookable,
+ features: features
+ )
+ end
+
+ record Setting, keys : Array(String), settings_string : String? do
+ include JSON::Serializable
+ end
+
+ @[Security(Level::Support)]
+ def system_settings(id : String, key : String)
+ response = get("/api/engine/v2/systems/#{id}/settings", headers: authentication)
+ raise "settings request failed for #{id}: #{response.status_code}" unless response.success?
+ setting = Array(Setting).from_json(response.body).select { |sub_setting|
+ sub_setting.settings_string && sub_setting.keys.includes?(key)
+ }.last?
+ return nil unless setting
+ YAML.parse(setting.settings_string.as(String))[key]
+ end
+
+ def systems_in_building(zone_id : String, ids_only : Bool = true)
+ levels = zones(parent: zone_id, tags: ["level"])
+ if ids_only
+ hash = {} of String => Array(String)
+ levels.each { |level| hash[level.id] = systems(zone_id: level.id).map(&.id) }
+ else
+ hash = {} of String => Array(::PlaceOS::Client::API::Models::System)
+ levels.each { |level| hash[level.id] = systems(zone_id: level.id) }
+ end
+ hash
+ end
+
+ # Staff details returns the information from AD
+ def staff_details(email : String)
+ response = get("/api/staff/v1/people/#{email}", headers: authentication)
+ raise "unexpected response for staff #{email}: #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing staff #{email}:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ # ===================================
+ # User details
+ # ===================================
+ def user(id : String)
+ placeos_client.users.fetch(id)
+ end
+
+ @[Security(Level::Support)]
+ def create_user(body_json : String)
+ response = post("/api/engine/v2/users", body: body_json, headers: authentication(HTTP::Headers{
+ "Content-Type" => "application/json",
+ }))
+ raise "failed to create user: #{response.status_code}" unless response.success?
+ PlaceOS::Client::API::Models::User.from_json response.body
+ end
+
+ @[Security(Level::Support)]
+ def update_user(id : String, body_json : String) : Nil
+ response = patch("/api/engine/v2/users/#{id}", body: body_json, headers: authentication(HTTP::Headers{
+ "Content-Type" => "application/json",
+ }))
+
+ raise "failed to update user #{id}: #{response.status_code}" unless response.success?
+ end
+
+ @[Security(Level::Support)]
+ def delete_user(id : String, force_removal : Bool = false) : Nil
+ response = delete("/api/engine/v2/users/#{id}?force_removal=#{force_removal}", headers: authentication)
+ raise "failed to delete user #{id}: #{response.status_code}" unless response.success?
+ end
+
+ @[Security(Level::Support)]
+ def revive_user(id : String) : Nil
+ response = post("/api/engine/v2/users/#{id}/revive", headers: authentication)
+ raise "failed to revive user #{id}: #{response.status_code}" unless response.success?
+ end
+
+ @[Security(Level::Support)]
+ def resource_token
+ response = post("/api/engine/v2/users/resource_token", headers: authentication)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ # NOTE:: this function requires "users" scope to be specified explicity for access
+ @[Security(Level::Administrator)]
+ def user_resource_token
+ response = post("/api/engine/v2/users/#{invoked_by_user_id}/resource_token", headers: authentication)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ @[Security(Level::Support)]
+ def query_users(
+ q : String? = nil,
+ limit : Int32 = 20,
+ offset : Int32 = 0,
+ authority_id : String? = nil,
+ include_deleted : Bool = false
+ )
+ placeos_client.users.search(q: q, limit: limit, offset: offset, authority_id: authority_id, include_deleted: include_deleted)
+ end
+
+ # ===================================
+ # WebRTC Helper functions
+ # ===================================
+
+ @[Security(Level::Support)]
+ def transfer_user(user_id : String, session_id : String, payload : JSON::Any)
+ status = 200
+ payload_str = payload.to_json
+ SimpleRetry.try_to(
+ max_attempts: 5,
+ base_interval: 1.second,
+ max_interval: 10.seconds,
+ ) do
+ response = post("/api/engine/v2/webrtc/transfer/#{user_id}/#{session_id}", headers: authentication, body: payload_str)
+ # 200 == success
+ # 428 == client is not connected to received the message, should be retried
+ status = response.status_code
+ raise "client not yet connected" unless response.success?
+ end
+ status
+ end
+
+ @[Security(Level::Support)]
+ def kick_user(user_id : String, session_id : String, reason : String)
+ response = post("/api/engine/v2/webrtc/kick/#{user_id}/#{session_id}", headers: authentication, body: {
+ reason: reason,
+ }.to_json)
+ response.status_code
+ end
+
+ def chat_members(session_id : String) : Array(String)
+ SimpleRetry.try_to(
+ max_attempts: 3,
+ base_interval: 1.second,
+ max_interval: 5.seconds,
+ ) do
+ response = get("/api/engine/v2/webrtc/members/#{session_id}", headers: authentication)
+ raise "webrtc service possibly unavailable" unless response.success?
+ Array(String).from_json(response.not_nil!.body)
+ end
+ end
+
+ # ===================================
+ # Guest details
+ # ===================================
+ @[Security(Level::Support)]
+ def guest_details(guest_id : String)
+ response = get("/api/staff/v1/guests/#{guest_id}", headers: authentication)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ @[Security(Level::Support)]
+ def update_guest(id : String, body_json : String) : Nil
+ response = patch("/api/staff/v1/guests/#{id}", body: body_json, headers: authentication(HTTP::Headers{
+ "Content-Type" => "application/json",
+ }))
+
+ raise "failed to update guest #{id}: #{response.status_code}" unless response.success?
+ end
+
+ @[Security(Level::Support)]
+ def query_guests(period_start : Int64, period_end : Int64, zones : Array(String))
+ params = URI::Params.build do |form|
+ form.add "period_start", period_start.to_s
+ form.add "period_end", period_end.to_s
+ form.add "zone_ids", zones.join(",")
+ end
+
+ response = get("/api/staff/v1/guests?#{params}", headers: authentication)
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ # ===================================
+ # CALENDAR EVENT ACTIONS (via staff api)
+ # ===================================
+ @[Security(Level::Support)]
+ def query_events(
+ period_start : Int64,
+ period_end : Int64,
+ zones : Array(String)? = nil,
+ systems : Array(String)? = nil,
+ capacity : Int32? = nil,
+ features : String? = nil,
+ bookable : Bool? = nil,
+ include_cancelled : Bool? = nil
+ )
+ params = URI::Params.build do |form|
+ form.add "period_start", period_start.to_s
+ form.add "period_end", period_end.to_s
+ form.add "zone_ids", zones.join(",") if zones && !zones.empty?
+ form.add "system_ids", systems.join(",") if systems && !systems.empty?
+ form.add "capacity", capacity.to_s if capacity
+ form.add "features", features if features
+ form.add "bookable", bookable.to_s if !bookable.nil?
+ form.add "include_cancelled", include_cancelled.to_s if !include_cancelled.nil?
+ end
+
+ response = get("/api/staff/v1/events?#{params}", headers: authentication)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ # gets an event from either the `system_id` or `calendar` if only one is provided
+ # if both are provided, it gets the event from `calendar` and the metadata from `system_id`
+ # NOTE:: the use of `calendar` will typically not work from a driver unless the X-API-Key
+ # has read access to it. From a driver perspective you should probably use a
+ # dedicated Calendar driver with application access and the query_metadata function
+ # below if metadata is required: `query_metadata(system_id: "sys", event_ref: ["id", "uuid"])`
+ def get_event(event_id : String, system_id : String? = nil, calendar : String? = nil)
+ raise ArgumentError.new("requires system_id or calendar param") unless calendar.presence || system_id.presence
+ params = URI::Params.build do |form|
+ form.add "calendar", calendar.to_s if calendar.presence
+ form.add "system_id", system_id.to_s if system_id.presence
+ end
+
+ response = get("/api/staff/v1/events/#{event_id}?#{params}", headers: authentication)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ # NOTE:: https://docs.google.com/document/d/1OaZljpjLVueFitmFWx8xy8BT8rA2lITyPsIvSYyNNW8/edit#
+ # The service account making this request needs delegated access and hence you can only edit
+ # events associated with a resource calendar
+ def update_event(system_id : String, event : PlaceCalendar::Event)
+ response = patch("/api/staff/v1/events/#{event.id}?system_id=#{system_id}", headers: authentication, body: event.to_json)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ PlaceCalendar::Event.from_json(response.body)
+ end
+
+ def create_event(event : PlaceCalendar::Event)
+ response = post("/api/staff/v1/events", headers: authentication, body: event.to_json)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ PlaceCalendar::Event.from_json(response.body)
+ end
+
+ def delete_event(system_id : String, event_id : String)
+ response = delete("/api/staff/v1/events/#{event_id}?system_id=#{system_id}", headers: authentication)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? || response.status_code == 404
+ true
+ end
+
+ def patch_event_metadata(system_id : String, event_id : String, metadata : JSON::Any, ical_uid : String? = nil, setup_time : Int64? = nil, breakdown_time : Int64? = nil, setup_event_id : String? = nil, breakdown_event_id : String? = nil)
+ params = URI::Params.build do |form|
+ form.add "ical_uid", ical_uid.to_s if ical_uid.presence
+ form.add "setup_time", setup_time.to_s if setup_time
+ form.add "breakdown_time", breakdown_time.to_s if breakdown_time
+ form.add "setup_event_id", setup_event_id.to_s if setup_event_id
+ form.add "breakdown_event_id", breakdown_event_id.to_s if breakdown_event_id
+ end
+ response = patch("/api/staff/v1/events/#{event_id}/metadata/#{system_id}?#{params}", headers: authentication, body: metadata.to_json)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ JSON::Any.from_json(response.body)
+ end
+
+ def replace_event_metadata(system_id : String, event_id : String, metadata : JSON::Any, ical_uid : String? = nil, setup_time : Int64? = nil, breakdown_time : Int64? = nil, setup_event_id : String? = nil, breakdown_event_id : String? = nil)
+ params = URI::Params.build do |form|
+ form.add "ical_uid", ical_uid.to_s if ical_uid.presence
+ form.add "setup_time", setup_time.to_s if setup_time
+ form.add "breakdown_time", breakdown_time.to_s if breakdown_time
+ form.add "setup_event_id", setup_event_id.to_s if setup_event_id
+ form.add "breakdown_event_id", breakdown_event_id.to_s if breakdown_event_id
+ end
+ response = put("/api/staff/v1/events/#{event_id}/metadata/#{system_id}?#{params}", headers: authentication, body: metadata.to_json)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ JSON::Any.from_json(response.body)
+ end
+
+ # Search for metadata that exists on events to obtain the event information.
+ # For response details see `EventMetadata__Assigner` in the OpenAPI docs
+ # https://editor.swagger.io/?url=https://raw.githubusercontent.com/PlaceOS/staff-api/master/OPENAPI_DOC.yml
+ def query_metadata(
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ field_name : String? = nil,
+ value : String? = nil,
+ system_id : String? = nil,
+ event_ref : Array(String)? = nil
+ )
+ params = URI::Params.build do |form|
+ form.add "period_start", period_start.to_s if period_start
+ form.add "period_end", period_end.to_s if period_end
+ form.add "field_name", field_name if field_name.presence
+ form.add "value", value if value.presence
+ form.add "event_ref", event_ref.join(",") if event_ref && !event_ref.empty?
+ end
+
+ response = get("/api/staff/v1/events/extension_metadata/#{system_id}?#{params}", headers: authentication)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ begin
+ JSON.parse(response.body)
+ rescue error
+ logger.debug { "issue parsing:\n#{response.body.inspect}" }
+ raise error
+ end
+ end
+
+ # ===================================
+ # ZONE METADATA
+ # ===================================
+ def metadata(id : String, key : String? = nil)
+ placeos_client.metadata.fetch(id, key)
+ end
+
+ def metadata_children(id : String, key : String? = nil)
+ placeos_client.metadata.children(id, key)
+ end
+
+ @[Security(Level::Support)]
+ def write_metadata(id : String, key : String, payload : JSON::Any, description : String = "")
+ placeos_client.metadata.update(id, key, payload, description)
+ end
+
+ @[Security(Level::Support)]
+ def merge_metadata(id : String, key : String, payload : JSON::Any, description : String = "")
+ placeos_client.metadata.merge(id, key, payload, description)
+ end
+
+ # ===================================
+ # ZONE INFORMATION
+ # ===================================
+ def zone(zone_id : String)
+ placeos_client.zones.fetch zone_id
+ end
+
+ def zones(q : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0,
+ parent : String? = nil,
+ tags : Array(String) | String? = nil)
+ placeos_client.zones.search(
+ q: q,
+ limit: limit,
+ offset: offset,
+ parent_id: parent,
+ tags: tags
+ )
+ end
+
+ # ===================================
+ # BOOKINGS ACTIONS
+ # ===================================
+ @[Security(Level::Support)]
+ def create_booking(
+ booking_type : String,
+ asset_id : String,
+ user_id : String,
+ user_email : String,
+ user_name : String,
+ zones : Array(String),
+ booking_start : Int64? = nil,
+ booking_end : Int64? = nil,
+ checked_in : Bool = false,
+ approved : Bool? = nil,
+ title : String? = nil,
+ description : String? = nil,
+ time_zone : String? = nil,
+ extension_data : JSON::Any? = nil,
+ utm_source : String? = nil,
+ limit_override : Int64? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ attendees : Array(PlaceCalendar::Event::Attendee)? = nil,
+ process_state : String? = nil,
+ recurrence_type : String? = nil,
+ recurrence_days : Int32? = nil,
+ recurrence_nth_of_month : Int32? = nil,
+ recurrence_interval : Int32? = nil,
+ recurrence_end : Int64? = nil
+ )
+ now = time_zone ? Time.local(Time::Location.load(time_zone)) : Time.local
+ booking_start ||= now.at_beginning_of_day.to_unix
+ booking_end ||= now.at_end_of_day.to_unix
+
+ checked_in_at = now.to_unix if checked_in
+
+ logger.debug { "creating a #{booking_type} booking, starting #{booking_start}, asset #{asset_id}" }
+
+ params = URI::Params.build do |form|
+ form.add "utm_source", utm_source.to_s unless utm_source.nil?
+ form.add "limit_override", limit_override.to_s unless limit_override.nil?
+ form.add "event_id", event_id.to_s unless event_id.nil?
+ form.add "ical_uid", ical_uid.to_s unless ical_uid.nil?
+ end
+
+ response = post("/api/staff/v1/bookings?#{params}", headers: authentication, body: {
+ "booking_start" => booking_start,
+ "booking_end" => booking_end,
+ "booking_type" => booking_type,
+ "asset_id" => asset_id,
+ "user_id" => user_id,
+ "user_email" => user_email,
+ "user_name" => user_name,
+ "zones" => zones,
+ "checked_in" => checked_in,
+ "checked_in_at" => checked_in_at,
+ "approved" => approved,
+ "title" => title,
+ "description" => description,
+ "timezone" => time_zone,
+ "extension_data" => extension_data || JSON.parse("{}"),
+ "attendees" => attendees,
+ "process_state" => process_state,
+ "recurrence_type" => recurrence_type,
+ "recurrence_days" => recurrence_days,
+ "recurrence_nth_of_month" => recurrence_nth_of_month,
+ "recurrence_interval" => recurrence_interval,
+ "recurrence_end" => recurrence_end,
+ }.compact.to_json)
+ raise "issue creating #{booking_type} booking, starting #{booking_start}, asset #{asset_id}: #{response.status_code}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ @[Security(Level::Support)]
+ def update_booking(
+ booking_id : String | Int64,
+ booking_start : Int64? = nil,
+ booking_end : Int64? = nil,
+ asset_id : String? = nil,
+ title : String? = nil,
+ description : String? = nil,
+ timezone : String? = nil,
+ extension_data : JSON::Any? = nil,
+ approved : Bool? = nil,
+ checked_in : Bool? = nil,
+ limit_override : Int64? = nil,
+ instance : Int64? = nil,
+ recurrence_end : Int64? = nil,
+ )
+ logger.debug { "updating booking #{booking_id}" }
+
+ case checked_in
+ in true
+ checked_in_at = Time.utc.to_unix
+ in false
+ checked_out_at = Time.utc.to_unix
+ in nil
+ end
+
+ params = URI::Params.build do |form|
+ form.add "limit_override", limit_override.to_s unless limit_override.nil?
+ end
+
+ response = patch("/api/staff/v1/bookings/#{booking_id}?#{params}", headers: authentication, body: {
+ "booking_start" => booking_start,
+ "booking_end" => booking_end,
+ "checked_in" => checked_in,
+ "checked_in_at" => checked_in_at,
+ "checked_out_at" => checked_out_at,
+ "asset_id" => asset_id,
+ "title" => title,
+ "description" => description,
+ "timezone" => timezone,
+ "extension_data" => extension_data,
+ "instance" => instance,
+ "recurrence_end" => recurrence_end,
+ }.compact.to_json)
+ raise "issue updating booking #{booking_id}: #{response.status_code}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ @[Security(Level::Support)]
+ def reject(booking_id : String | Int64, utm_source : String? = nil, instance : Int64? = nil)
+ logger.debug { "rejecting booking #{booking_id}" }
+
+ params = URI::Params.build do |form|
+ form.add "instance", instance.to_s unless instance.nil?
+ form.add "utm_source", utm_source.to_s unless utm_source.nil?
+ end
+
+ response = post("/api/staff/v1/bookings/#{booking_id}/reject?#{params}", headers: authentication)
+ raise "issue rejecting booking #{booking_id}: #{response.status_code}" unless response.success?
+ true
+ end
+
+ @[Security(Level::Support)]
+ def approve(booking_id : String | Int64, instance : Int64? = nil)
+ logger.debug { "approving booking #{booking_id}" }
+ inst = "/instance/#{instance}" if instance
+ response = post("/api/staff/v1/bookings/#{booking_id}/approve#{inst}", headers: authentication)
+ raise "issue approving booking #{booking_id}: #{response.status_code}" unless response.success?
+ true
+ end
+
+ @[Security(Level::Support)]
+ def booking_state(booking_id : String | Int64, state : String, instance : Int64? = nil)
+ logger.debug { "updating booking #{booking_id}.#{instance} state to: #{state}" }
+ inst = "&instance=#{instance}" if instance
+ response = post("/api/staff/v1/bookings/#{booking_id}/update_state?state=#{state}#{inst}", headers: authentication)
+ raise "issue updating booking state #{booking_id}: #{response.status_code}" unless response.success?
+ true
+ end
+
+ @[Security(Level::Support)]
+ def booking_check_in(booking_id : String | Int64, state : Bool = true, utm_source : String? = nil, instance : Int64? = nil)
+ logger.debug { "checking in booking #{booking_id}.#{instance} to: #{state}" }
+
+ params = URI::Params.build do |form|
+ form.add "instance", instance.to_s unless instance.nil?
+ form.add "utm_source", utm_source.to_s unless utm_source.nil?
+ form.add "state", state.to_s
+ end
+ response = post("/api/staff/v1/bookings/#{booking_id}/check_in?#{params}", headers: authentication)
+ raise "issue checking in booking #{booking_id}: #{response.status_code}" unless response.success?
+ true
+ end
+
+ @[Security(Level::Support)]
+ def booking_delete(booking_id : String | Int64, utm_source : String? = nil, instance : Int64? = nil)
+ logger.debug { "deleting booking #{booking_id}" }
+ params = URI::Params.build do |form|
+ form.add "instance", instance.to_s unless instance.nil?
+ form.add "utm_source", utm_source.to_s unless utm_source.nil?
+ end
+ response = delete("/api/staff/v1/bookings/#{booking_id}?#{params}", headers: authentication)
+ raise "issue updating booking state #{booking_id}: #{response.status_code}" unless response.success?
+ true
+ end
+
+ @[Security(Level::Support)]
+ def booking_extension_data(booking_id : String | Int64, extension_data : Hash(String, JSON::Any), instance : Int64? = nil, signal_changes : Bool = false)
+ logger.debug { "updating booking ext data #{booking_id}.#{instance} with: #{extension_data}" }
+
+ params = URI::Params.build do |form|
+ form.add "signal_changes", signal_changes.to_s
+ end
+
+ response = patch("/api/staff/v1/bookings/#{booking_id}/ext_data/#{instance}?#{params}", headers: authentication, body: extension_data.to_json)
+ raise "issue updating booking #{booking_id}.#{instance}: #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ # ===================================
+ # BOOKINGS QUERY
+ # ===================================
+ def query_bookings(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ rejected : Bool? = nil,
+ checked_in : Bool? = nil,
+ include_checked_out : Bool? = nil,
+ extension_data : JSON::Any? = nil,
+ deleted : Bool? = nil,
+ asset_id : String? = nil,
+ limit : Int32? = nil
+ )
+ default_end = @period_end_default_in_min || 30
+
+ # Assumes occuring now
+ period_start ||= Time.utc.to_unix
+ period_end ||= default_end.minutes.from_now.to_unix
+ limit ||= @query_limit
+
+ params = URI::Params.build do |form|
+ form.add "period_start", period_start.to_s if period_start
+ form.add "period_end", period_end.to_s if period_end
+ form.add "type", type.to_s if type.presence
+
+ form.add "zones", zones.join(",") unless zones.empty?
+ form.add "user", user.to_s if user.presence
+ form.add "email", email.to_s if email.presence
+ form.add "state", state.to_s if state.presence
+ form.add "created_before", created_before.to_s if created_before
+ form.add "created_after", created_after.to_s if created_after
+ form.add "approved", approved.to_s unless approved.nil?
+ form.add "rejected", rejected.to_s unless rejected.nil?
+ form.add "checked_in", checked_in.to_s unless checked_in.nil?
+ form.add "deleted", deleted.to_s unless deleted.nil?
+ form.add "event_id", event_id.to_s if event_id.presence
+ form.add "ical_uid", ical_uid.to_s if ical_uid.presence
+ form.add "include_checked_out", include_checked_out.to_s unless include_checked_out.nil?
+
+ form.add "asset_id", asset_id.to_s unless asset_id.nil?
+ form.add "limit", limit.to_s
+
+ if extension_data
+ value = extension_data.as_h.map { |k, v| "#{k}:#{v}" }.join(",")
+ form.add "extension_data", "{#{value}}"
+ end
+ end
+
+ logger.debug { "requesting staff/v1/bookings: #{params}" }
+
+ # Get the existing bookings from the API to check if there is space
+ bookings = [] of JSON::Any
+ next_request = "/api/staff/v1/bookings?#{params}"
+
+ loop do
+ response = get(next_request, headers: authentication)
+ raise "issue loading list of bookings (zones #{zones}): #{response.status_code}" unless response.success?
+ links = LinkHeader.new(response)
+
+ # Just parse it here instead of using the Bookings object
+ # it will be parsed into an object on the far end
+ new_bookings = JSON.parse(response.body).as_a
+ bookings.concat new_bookings
+
+ last_req = next_request
+ next_request = links["next"]?
+ break if next_request.nil? || new_bookings.empty? || last_req == next_request
+ end
+
+ logger.debug { "bookings count: #{bookings.size}" }
+
+ bookings
+ end
+
+ def get_booking(booking_id : String | Int64, instance : Int64? = nil)
+ logger.debug { "getting booking #{booking_id}" }
+ params = "?instance=#{instance}" if instance
+ response = get("/api/staff/v1/bookings/#{booking_id}#{params}", headers: authentication)
+ raise "issue getting booking #{booking_id}: #{response.status_code}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ # lists asset IDs based on the parameters provided
+ #
+ # booking_type is required unless event_id or ical_uid is present
+ def booked(
+ type : String? = nil,
+ period_start : Int64? = nil,
+ period_end : Int64? = nil,
+ zones : Array(String) = [] of String,
+ user : String? = nil,
+ email : String? = nil,
+ state : String? = nil,
+ event_id : String? = nil,
+ ical_uid : String? = nil,
+ created_before : Int64? = nil,
+ created_after : Int64? = nil,
+ approved : Bool? = nil,
+ checked_in : Bool? = nil,
+ include_checked_out : Bool? = nil,
+ include_booked_by : Bool? = nil,
+ department : String? = nil,
+ limit : Int32? = nil,
+ offset : Int32? = nil,
+ permission : String? = nil,
+ extension_data : JSON::Any? = nil,
+ )
+ params = URI::Params.build do |form|
+ form.add "period_start", period_start.to_s if period_start
+ form.add "period_end", period_end.to_s if period_end
+ form.add "type", type.to_s if type.presence
+
+ form.add "zones", zones.join(",") unless zones.empty?
+ form.add "user", user.to_s if user.presence
+ form.add "email", email.to_s if email.presence
+ form.add "state", state.to_s if state.presence
+ form.add "created_before", created_before.to_s if created_before
+ form.add "created_after", created_after.to_s if created_after
+ form.add "approved", approved.to_s unless approved.nil?
+ form.add "checked_in", checked_in.to_s unless checked_in.nil?
+ form.add "event_id", event_id.to_s if event_id.presence
+ form.add "ical_uid", ical_uid.to_s if ical_uid.presence
+ form.add "include_checked_out", include_checked_out.to_s unless include_checked_out.nil?
+ form.add "include_booked_by", include_booked_by.to_s unless include_booked_by.nil?
+ form.add "department", department.to_s if department.presence
+ form.add "limit", limit.to_s if limit
+ form.add "offset", offset.to_s if offset
+ form.add "permission", permission.to_s if permission.presence
+
+ if extension_data
+ value = extension_data.as_h.map { |k, v| "#{k}:#{v}" }.join(",")
+ form.add "extension_data", "{#{value}}"
+ end
+ end
+
+ logger.debug { "requesting staff/v1/bookings/booked: #{params}" }
+ response = get("/api/staff/v1/bookings/booked?#{params}", headers: authentication)
+ raise "issue getting booked assets: #{response.status_code}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ # ===================================
+ # Driver debugging
+ # ===================================
+
+ def driver_introspection
+ {
+ protocol: PlaceOS::Driver::Stats.protocol_tracking,
+ memory: PlaceOS::Driver::Stats.memory_usage,
+ queue: @__queue__.@queue.size,
+ }
+ end
+
+ # ===================================
+ # SURVEYS
+ # ===================================
+
+ def get_survey_invites(survey_id : Int64? = nil, sent : Bool? = nil)
+ logger.debug { "getting survey_invites (survey #{survey_id}, sent #{sent})" }
+ params = URI::Params.new
+ params["survey_id"] = survey_id.to_s if survey_id
+ params["sent"] = sent.to_s unless sent.nil?
+ response = get("/api/staff/v1/surveys/invitations", params, headers: authentication)
+ raise "issue getting survey invitations (survey #{survey_id}, sent #{sent}): #{response.status_code}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ @[Security(Level::Support)]
+ def update_survey_invite(
+ token : String,
+ email : String? = nil,
+ sent : Bool? = nil
+ )
+ logger.debug { "updating survey invite #{token}" }
+ response = patch("/api/staff/v1/surveys/invitations/#{token}", headers: authentication, body: {
+ "email" => email,
+ "sent" => sent,
+ }.compact.to_json)
+ raise "issue updating survey invite #{token}: #{response.status_code}" unless response.success?
+ true
+ end
+
+ # ===================================
+
+ @[Security(Level::Support)]
+ def signal(channel : String, payload : JSON::Any? = nil)
+ placeos_client.root.signal(channel, payload)
+ end
+
+ # For accessing PlaceOS APIs
+ protected getter placeos_client : PlaceOS::Client do
+ PlaceOS::Client.new(
+ @place_domain,
+ host_header: @host_header,
+ x_api_key: @api_key
+ )
+ end
+
+ # ===================================
+ # PLACEOS AUTHENTICATION:
+ # ===================================
+ protected def authentication(headers : HTTP::Headers = HTTP::Headers.new) : HTTP::Headers
+ headers["Accept"] = "application/json"
+ headers["X-API-Key"] = @api_key.presence || "spec-test"
+ headers
+ end
+end
diff --git a/drivers/place/staff_api_spec.cr b/drivers/place/staff_api_spec.cr
new file mode 100644
index 00000000000..76ede5046fc
--- /dev/null
+++ b/drivers/place/staff_api_spec.cr
@@ -0,0 +1,92 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::StaffAPI" do
+ resp = exec(:query_bookings, "desk")
+
+ expect_http_request do |request, response|
+ headers = request.headers
+ if headers["X-API-Key"]? == "spec-test"
+ response.status_code = 200
+ response << %([{
+ "id": 1234,
+ "user_id": "user-12345",
+ "user_email": "steve@place.tech",
+ "user_name": "Steve T",
+ "asset_id": "desk-2-12",
+ "zones": ["zone-build1", "zone-level2"],
+ "booking_type": "Steve T",
+ "booking_start": 123456,
+ "booking_end": 12345678,
+ "timezone": "Australia/Sydney",
+ "checked_in": true,
+ "rejected": false,
+ "approved": false
+ }])
+ else
+ response.status_code = 401
+ end
+ end
+
+ resp.get.should eq(JSON.parse(%([{
+ "id": 1234,
+ "user_id": "user-12345",
+ "user_email": "steve@place.tech",
+ "user_name": "Steve T",
+ "asset_id": "desk-2-12",
+ "zones": ["zone-build1", "zone-level2"],
+ "booking_type": "Steve T",
+ "booking_start": 123456,
+ "booking_end": 12345678,
+ "timezone": "Australia/Sydney",
+ "checked_in": true,
+ "rejected": false,
+ "approved": false
+ }])))
+
+ sleep 1
+ invites_resp = exec(:get_survey_invites, sent: false)
+
+ expect_http_request do |request, response|
+ headers = request.headers
+ if headers["X-API-Key"]? == "spec-test"
+ response.status_code = 200
+
+ params = request.query_params
+ survey_id = params["survey_id"]? || 1234
+ sent = params["sent"]?
+
+ sent_invite = {
+ id: 123,
+ survey_id: survey_id,
+ token: "QWERTY",
+ email: "user@spec.test",
+ sent: true,
+ }
+ unsent_invite = {
+ id: 123,
+ survey_id: survey_id,
+ token: "QWERTY",
+ email: "user@spec.test",
+ sent: false,
+ }
+
+ if sent == "true"
+ response << [sent_invite].to_json
+ elsif sent == "false"
+ response << [unsent_invite].to_json
+ else
+ response << [sent_invite, unsent_invite].to_json
+ end
+ else
+ response.status_code = 401
+ end
+ end
+
+ invites_resp.get.should eq(JSON.parse(%([{
+ "id": 123,
+ "survey_id": 1234,
+ "token": "QWERTY",
+ "email": "user@spec.test",
+ "sent": false
+ }])))
+end
diff --git a/drivers/place/survey_mailer.cr b/drivers/place/survey_mailer.cr
new file mode 100644
index 00000000000..4f65d195b91
--- /dev/null
+++ b/drivers/place/survey_mailer.cr
@@ -0,0 +1,98 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+
+class Place::SurveyMailer < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "PlaceOS Survey Mailer"
+ generic_name :SurveyMailer
+ description %(emails survey invites)
+
+ default_settings({
+ timezone: "GMT",
+ send_invites: "*/3 * * * *",
+ email_template: "survey",
+ })
+
+ accessor staff_api : StaffAPI_1
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ @time_zone : Time::Location = Time::Location.load("GMT")
+
+ @visitor_emails_sent : UInt64 = 0_u64
+ @visitor_email_errors : UInt64 = 0_u64
+
+ @email_template : String = "survey"
+ @send_invites : String? = nil
+
+ def on_update
+ @send_invites = setting?(String, :send_invites).presence
+ @email_template = setting?(String, :email_template) || "survey"
+
+ time_zone = setting?(String, :timezone).presence || "GMT"
+ @time_zone = Time::Location.load(time_zone)
+
+ schedule.clear
+ if invites = @send_invites
+ schedule.cron(invites, @time_zone) { send_survey_emails }
+ end
+ end
+
+ def template_fields : Array(TemplateFields)
+ [
+ TemplateFields.new(
+ trigger: {@email_template, "invite"},
+ name: "Survey invite",
+ description: "Email invitation sent to participants to complete a survey",
+ fields: [
+ {name: "email", description: "Email address of the survey recipient"},
+ {name: "token", description: "Unique authentication token for accessing the survey"},
+ {name: "survey_id", description: "Unique identifier of the survey to be completed"},
+ ]
+ ),
+ ]
+ end
+
+ @[Security(Level::Support)]
+ def send_survey_emails
+ invites = Array(SurveyInvite).from_json staff_api.get_survey_invites(sent: false).get.to_json
+ sent_invites : Hash(String, Array(Int64)) = {} of String => Array(Int64)
+
+ invites.each do |invite|
+ next if invite.sent
+ begin
+ if !(sent_surveys = sent_invites[invite.email]?) || !sent_surveys.includes?(invite.survey_id)
+ sent_invites[invite.email] ||= [] of Int64
+ sent_invites[invite.email] << invite.survey_id
+
+ mailer.send_template(
+ to: invite.email,
+ template: {@email_template, "invite"},
+ args: {
+ email: invite.email,
+ token: invite.token,
+ survey_id: invite.survey_id,
+ })
+ end
+
+ staff_api.update_survey_invite(invite.token, sent: true)
+ rescue error
+ logger.warn(exception: error) { "failed to send survey email to #{invite.email}" }
+ end
+ end
+ end
+
+ struct SurveyInvite
+ include JSON::Serializable
+
+ property id : Int64
+ property survey_id : Int64
+ property token : String
+ property email : String
+ property sent : Bool?
+ end
+end
diff --git a/drivers/place/survey_mailer_spec.cr b/drivers/place/survey_mailer_spec.cr
new file mode 100644
index 00000000000..aca455dde19
--- /dev/null
+++ b/drivers/place/survey_mailer_spec.cr
@@ -0,0 +1,99 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/mailer"
+
+class StaffAPI < DriverSpecs::MockDriver
+ def get_survey_invites(survey_id : Int64? = nil, sent : Bool? = nil)
+ survey_id ||= 1
+
+ invites = [
+ {
+ id: 1,
+ survey_id: survey_id,
+ token: "QWERTY",
+ email: "user1@spec.test",
+ sent: false,
+ },
+ {
+ id: 2,
+ survey_id: survey_id,
+ token: "QWERTY",
+ email: "user1@spec.test",
+ sent: false,
+ },
+ {
+ id: 3,
+ survey_id: survey_id,
+ token: "QWERTY",
+ email: "user2@spec.test",
+ sent: false,
+ },
+ {
+ id: 4,
+ survey_id: survey_id,
+ token: "QWERTY",
+ email: "user3@spec.test",
+ sent: nil,
+ },
+ {
+ id: 5,
+ survey_id: survey_id,
+ token: "QWERTY",
+ email: "user4@spec.test",
+ sent: true,
+ },
+ ]
+
+ JSON.parse(invites.to_json)
+ end
+
+ def update_survey_invite(token : String, email : String? = nil, sent : Bool? = nil)
+ true
+ end
+end
+
+class Mailer < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Mailer
+
+ def on_load
+ self[:sent] = 0
+ end
+
+ def send_template(
+ to : String | Array(String),
+ template : Tuple(String, String),
+ args : TemplateItems,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ self[:sent] = self[:sent].as_i + 1
+ end
+
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ ) : Bool
+ true
+ end
+end
+
+DriverSpecs.mock_driver "Place::StaffAPI" do
+ system({
+ StaffAPI: {StaffAPI},
+ Mailer: {Mailer},
+ })
+
+ _resp = exec(:send_survey_emails).get
+ system(:Mailer_1)[:sent].should eq 3
+end
diff --git a/drivers/place/template_mailer.cr b/drivers/place/template_mailer.cr
new file mode 100644
index 00000000000..abb827cd83c
--- /dev/null
+++ b/drivers/place/template_mailer.cr
@@ -0,0 +1,298 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+
+# This driver uses metadata templates to send emails via the SMTP mailer.
+# It should be configured as Mailer_1 with the next mailer in the chain as Mailer_2.
+#
+# It also updates metadata in the staff API with available fields for use in email templates.
+class Place::TemplateMailer < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::Mailer
+
+ alias TemplateFields = PlaceOS::Driver::Interface::MailerTemplates::TemplateFields
+
+ descriptive_name "Template Mailer"
+ generic_name :Mailer
+ description %(uses metadata templates to send emails via the SMTP mailer)
+
+ default_settings({
+ cache_timeout: 300, # timeout for the template cache
+ keep_if_not_seen: 6, # keep fields for x updates if not seen, -1 to keep forever, 0 to never keep
+ timezone: "Australia/Sydney",
+ update_schedule: "*/20 * * * *", # cron schedule for updating template fields
+ })
+
+ accessor staff_api : StaffAPI_1
+
+ getter org_zone_ids : Array(String) { get_zone_ids?("org").not_nil! }
+ getter region_zone_ids : Array(String) { get_zone_ids?("region").not_nil! }
+ getter building_zone_ids : Array(String) { get_zone_ids?("building").not_nil! }
+ getter level_zone_ids : Array(String) { get_zone_ids?("level").not_nil! }
+
+ getter org_zone_id : String { get_local_zone_id(org_zone_ids).not_nil! }
+ getter building_zone_id : String { get_local_zone_id(building_zone_ids).not_nil! }
+
+ def mailer
+ system.implementing(Interface::Mailer)[1]
+ end
+
+ SEPERATOR = "."
+
+ @template_cache : TemplateCache = TemplateCache.new
+ @cache_timeout : Int64 = 300
+
+ # keep fields for x updates if not seen
+ # -1 to keep forever
+ # 0 to never keep
+ # 1 to keep for 1 update
+ @keep_if_not_seen : Int64 = 6
+ @not_seen_times : Hash(String, Int64) = Hash(String, Int64).new
+
+ @timezone : Time::Location = Time::Location.load("Australia/Sydney")
+ @update_schedule : String? = nil
+
+ def on_update
+ @org_zone_ids = nil
+ @region_zone_ids = nil
+ @building_zone_ids = nil
+ @level_zone_ids = nil
+
+ @org_zone_id = nil
+ @building_zone_id = nil
+
+ @cache_timeout = setting?(Int64, :cache_timeout) || 300_i64
+ @keep_if_not_seen = setting?(Int64, :keep_if_not_seen) || 6_i64
+
+ timezone = setting?(String, :timezone).presence || "Australia/Sydney"
+ @timezone = Time::Location.load(timezone)
+ @update_schedule = setting?(String, :update_schedule).presence
+
+ schedule.clear
+
+ if update_schedule = @update_schedule
+ schedule.cron(update_schedule, @timezone) do
+ update_template_fields(org_zone_id)
+ end
+ end
+
+ update_template_fields(org_zone_id)
+ end
+
+ def get_zone_ids?(tag : String) : Array(String)?
+ staff_api.zones(tags: tag).get.as_a.map(&.[]("id").as_s)
+ rescue error
+ logger.warn(exception: error) { "unable to determine #{tag} zone ids" }
+ nil
+ end
+
+ def get_local_zone_id(zone_ids : Array(String)) : String?
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine local zone id" }
+ nil
+ end
+
+ def get_template_fields?(zone_id : String) : Hash(String, MetadataTemplateFields)?
+ metadata = Metadata.from_json staff_api.metadata(zone_id, "email_template_fields").get["email_template_fields"].to_json
+ Hash(String, MetadataTemplateFields).from_json metadata.details.to_json
+ rescue error
+ logger.warn(exception: error) { "unable to get email template fields from zone #{zone_id} metadata" }
+ nil
+ end
+
+ def sticky_template_fields(zone_id : String) : Hash(String, MetadataTemplateFields)
+ # keep nothing
+ return Hash(String, MetadataTemplateFields).new if @keep_if_not_seen == 0
+
+ current_fields = get_template_fields?(zone_id) || Hash(String, MetadataTemplateFields).new
+ return current_fields if current_fields.empty?
+
+ # keep forever
+ return current_fields if @keep_if_not_seen == -1
+
+ sticky_fields = Hash(String, MetadataTemplateFields).new
+
+ current_fields.keys.each do |key|
+ @not_seen_times[key] = @not_seen_times[key]? ? @not_seen_times[key] + 1 : 1_i64
+
+ if @not_seen_times[key] <= @keep_if_not_seen
+ sticky_fields[key] = current_fields[key]
+ end
+ end
+
+ sticky_fields
+ end
+
+ def update_template_fields(zone_id : String)
+ template_fields : Hash(String, MetadataTemplateFields) = sticky_template_fields(zone_id)
+
+ system.implementing(Interface::MailerTemplates).each do |driver|
+ # next if the driver is turned off, or anything else goes wrong
+ begin
+ driver_template_fields = Array(TemplateFields).from_json driver.template_fields.get.to_json
+ rescue error
+ logger.warn(exception: error) { "unable to get template fields from module #{driver.module_id}" }
+ next
+ end
+
+ driver_template_fields = Array(TemplateFields).from_json driver.template_fields.get.to_json
+ driver_template_fields.each do |field_list|
+ template_fields["#{field_list[:trigger].join(SEPERATOR)}"] = MetadataTemplateFields.new(
+ module_name: driver.module_name,
+ name: field_list[:name],
+ description: field_list[:description],
+ fields: field_list[:fields],
+ )
+ end
+ end
+
+ template_fields.keys.each do |key|
+ @not_seen_times[key] = 0_i64
+ end
+
+ self[:template_fields] = template_fields
+
+ unless template_fields.empty?
+ staff_api.write_metadata(id: zone_id, key: "email_template_fields", payload: template_fields, description: "Available fields for use in email templates").get
+ end
+ end
+
+ # fetch templates from cache or metadata
+ def fetch_templates(zone_id : String?) : Array(Template)
+ return [] of Template unless zone_id
+
+ if (cache = @template_cache[zone_id]?) && cache[0] > Time.utc.to_unix
+ cache[1]
+ else
+ templates = get_templates?(zone_id) || [] of Template
+ @template_cache[zone_id] = {Time.utc.to_unix + @cache_timeout, templates}
+ templates
+ end
+ end
+
+ def template_cache
+ @template_cache
+ end
+
+ def clear_template_cache(zone_id : String? = nil)
+ if zone_id && !zone_id.blank?
+ @template_cache.delete(zone_id)
+ else
+ @template_cache = TemplateCache.new
+ end
+ end
+
+ # get templates from metadata
+ def get_templates?(zone_id : String) : Array(Template)?
+ metadata = Metadata.from_json staff_api.metadata(zone_id, "email_templates").get["email_templates"].to_json
+ metadata.details.as_a.map { |template| Template.from_json template.to_json }
+ rescue
+ logger.debug { "unable to get email templates from zone #{zone_id} metadata" }
+ nil
+ end
+
+ def find_template?(template : String, zone_ids : Array(String)) : Template?
+ org_id = (zone_ids & org_zone_ids)[0]?
+ region_id = (zone_ids & region_zone_ids)[0]?
+ building_id = (zone_ids & building_zone_ids)[0]?
+ level_id = (zone_ids & level_zone_ids)[0]?
+
+ # find the requested template
+ # order of precedence: level, building, region, org
+ fetch_templates(level_id).find { |t| t["trigger"] == template } ||
+ fetch_templates(building_id).find { |t| t["trigger"] == template } ||
+ fetch_templates(region_id).find { |t| t["trigger"] == template } ||
+ fetch_templates(org_id).find { |t| t["trigger"] == template } ||
+ nil
+ end
+
+ def generate_svg_qrcode(text : String) : String
+ mailer.generate_svg_qrcode(text).get.as_s
+ end
+
+ def generate_png_qrcode(text : String, size : Int32 = 128) : String
+ mailer.generate_png_qrcode(text, size).get.as_s
+ end
+
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ mailer.send_mail(to, subject, message_plaintext, message_html, resource_attachments, attachments, cc, bcc, from, reply_to)
+ end
+
+ def send_template(
+ to : String | Array(String),
+ template : Tuple(String, String),
+ args : TemplateItems,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ zone_ids = if (zones = args["zone_ids"]?) && zones.is_a?(Array(String))
+ zones
+ else
+ [org_zone_id, building_zone_id]
+ end
+
+ metadata_template = find_template?(template.join(SEPERATOR), zone_ids)
+
+ if metadata_template
+ subject = build_template(metadata_template["subject"].to_s, args)
+ text = build_template(metadata_template["text"]?.try &.to_s, args) || ""
+ html = build_template(metadata_template["html"]?.try &.to_s, args) || ""
+ from = metadata_template["from"].to_s if (from_template = metadata_template["from"]?) && from_template.to_s.presence
+ reply_to = metadata_template["reply_to"].to_s if (reply_to_template = metadata_template["reply_to"]?) && reply_to_template.to_s.presence
+
+ mailer.send_mail(to, subject, text, html, resource_attachments, attachments, cc, bcc, from, reply_to)
+ else
+ logger.info { "unable to find template #{template.join(SEPERATOR)} from zones #{zone_ids} metadata, forwarding to Mailer_2" }
+ mailer.send_template(to, template, args, resource_attachments, attachments, cc, bcc, from, reply_to)
+ end
+ end
+
+ alias Template = Hash(String, String | Int64)
+
+ # zone_id, timeout, templates
+ alias TemplateCache = Hash(String, Tuple(Int64, Array(Template)))
+
+ struct Metadata
+ include JSON::Serializable
+
+ property name : String
+ property description : String = ""
+ property details : JSON::Any
+ property parent_id : String
+ property schema_id : String? = nil
+ property editors : Set(String) = Set(String).new
+ property modified_by_id : String? = nil
+ end
+
+ struct MetadataTemplateFields
+ include JSON::Serializable
+
+ property module_name : String = ""
+ property name : String = ""
+ property description : String? = nil
+ property fields : Array(NamedTuple(name: String, description: String)) = [] of NamedTuple(name: String, description: String)
+
+ def initialize(
+ @module_name : String,
+ @name : String,
+ @description : String? = nil,
+ @fields : Array(NamedTuple(name: String, description: String)) = [] of NamedTuple(name: String, description: String)
+ )
+ end
+ end
+end
diff --git a/drivers/place/template_mailer_spec.cr b/drivers/place/template_mailer_spec.cr
new file mode 100644
index 00000000000..b1bd6e50c64
--- /dev/null
+++ b/drivers/place/template_mailer_spec.cr
@@ -0,0 +1,157 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+
+class StaffAPI < DriverSpecs::MockDriver
+ ZONES = [
+ {
+ created_at: 1660537814,
+ updated_at: 1681800971,
+ id: "zone-org-1234",
+ name: "Test Org Zone",
+ display_name: "Test Org Zone",
+ location: "",
+ description: "",
+ code: "",
+ type: "",
+ count: 0,
+ capacity: 0,
+ map_id: "",
+ tags: [
+ "org",
+ ],
+ triggers: [] of String,
+ parent_id: "zone-0000",
+ timezone: "Australia/Sydney",
+ },
+ {
+ created_at: 1660537814,
+ updated_at: 1681800971,
+ id: "zone-bld-1234",
+ name: "Test Building Zone",
+ display_name: "Test Building Zone",
+ location: "",
+ description: "",
+ code: "",
+ type: "",
+ count: 0,
+ capacity: 0,
+ map_id: "",
+ tags: [
+ "building",
+ ],
+ triggers: [] of String,
+ parent_id: "zone-0000",
+ timezone: "Australia/Sydney",
+ },
+ ]
+
+ # METADATA_TEMPLATES = {
+ # email_templates = {
+ # name: "email_templates",
+ # description: "Email Templates for Zone",
+ # details: [
+ # {
+ # id: "template-1",
+ # from: "support@example.com",
+ # html: "This is a test template
",
+ # text: "This is a test template",
+ # subject: "Test 1",
+ # trigger: "visitor_invited.visitor",
+ # zone_id: "zone-1234",
+ # category: "internal",
+ # reply_to: "noreply@example.com",
+ # created_at: 1725519680,
+ # updated_at: 1725519680,
+ # },
+ # {
+ # id: "template-2",
+ # from: "support@example.com",
+ # html: "This is a test template
",
+ # text: "This is a test template",
+ # subject: "Test 2",
+ # trigger: "visitor_invited.event",
+ # zone_id: "zone-1234",
+ # category: "internal",
+ # reply_to: "noreply@example.com",
+ # created_at: 1727745875,
+ # updated_at: 1727745875,
+ # },
+ # ],
+ # parent_id: "zone-1234",
+ # editors: [] of String,
+ # modified_by_id: "user-1234",
+ # },
+ # }
+
+ def zones(q : String? = nil,
+ limit : Int32 = 1000,
+ offset : Int32 = 0,
+ parent : String? = nil,
+ tags : Array(String) | String? = nil)
+ zones = ZONES
+ zones = zones.select { |zone| zone["tags"].includes?(tags) } if tags.is_a?(String)
+ JSON.parse(zones.to_json)
+ end
+
+ # def metadata(id : String, key : String? = nil)
+ # case key
+ # when "email_templates"
+ # JSON.parse(METADATA_TEMPLATES.to_json)
+ # when "email_template_fields"
+ # nil
+ # else
+ # nil
+ # end
+ # end
+end
+
+class Mailer < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::Mailer
+
+ def on_load
+ self[:sent] = 0
+ end
+
+ def send_template(
+ to : String | Array(String),
+ template : Tuple(String, String),
+ args : TemplateItems,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ )
+ self[:sent] = self[:sent].as_i + 1
+ true
+ end
+
+ def send_mail(
+ to : String | Array(String),
+ subject : String,
+ message_plaintext : String? = nil,
+ message_html : String? = nil,
+ resource_attachments : Array(ResourceAttachment) = [] of ResourceAttachment,
+ attachments : Array(Attachment) = [] of Attachment,
+ cc : String | Array(String) = [] of String,
+ bcc : String | Array(String) = [] of String,
+ from : String | Array(String) | Nil = nil,
+ reply_to : String | Array(String) | Nil = nil
+ ) : Bool
+ self[:sent] = self[:sent].as_i + 1
+ true
+ end
+end
+
+DriverSpecs.mock_driver "Place::TemplateMailer" do
+ # system({
+ # StaffAPI: {StaffAPI},
+ # Mailer_1: {Mailer},
+ # Mailer_2: {Mailer},
+ # })
+
+ # Missing hash key: "mod-Mailer/2" (KeyError)
+ # system(:Mailer_2)[:sent].should eq 0
+end
diff --git a/drivers/place/user_group_mappings.cr b/drivers/place/user_group_mappings.cr
new file mode 100644
index 00000000000..877f965ff77
--- /dev/null
+++ b/drivers/place/user_group_mappings.cr
@@ -0,0 +1,171 @@
+require "placeos-driver"
+
+class Place::UserGroupMappings < PlaceOS::Driver
+ descriptive_name "User Group Mappings"
+ generic_name :UserGroupMappings
+ description "monitors user logins and maps relevent groups to the local user profile"
+
+ accessor staff_api : StaffAPI_1
+ accessor calendar_api : Calendar_1
+
+ # NOTE:: user_sys_admin, user_support sets the users persmissions flags
+ default_settings({
+ # ID => place_name
+ group_mappings: {
+ "group_id" => {
+ place_id: "manager",
+ description: "managers of the level2 building",
+ },
+ "group2_id" => {
+ place_id: "boss",
+ description: "people that can access everything",
+ },
+ "ad_group3_id" => {
+ place_id: "user_sys_admin",
+ description: "this is a special group that sets place users as sys_admins",
+ },
+ },
+
+ # Group name prefix => group mappings
+ group_prefix: {
+ "group_name_prefix_" => {
+ strip_prefix: false,
+ place_id: "optional-place-id",
+ },
+ },
+
+ # authority id
+ authority_id: "authority-12345",
+ })
+
+ class UserLogin
+ include JSON::Serializable
+
+ property user_id : String
+ property provider : String
+ end
+
+ def on_load
+ monitor("auth/login") { |_subscription, payload| new_user_login(payload) }
+ on_update
+ end
+
+ alias Mapping = NamedTuple(place_id: String)
+ alias Prefix = NamedTuple(strip_prefix: Bool?, place_id: String?)
+
+ @authority_id : String = ""
+ @group_mappings : Hash(String, Mapping) = {} of String => Mapping
+ @group_prefixes : Hash(String, Prefix) = {} of String => Prefix
+ @users_checked : UInt64 = 0_u64
+ @error_count : UInt64 = 0_u64
+
+ def on_update
+ @group_mappings = setting?(Hash(String, Mapping), :group_mappings) || {} of String => Mapping
+ @group_prefixes = setting?(Hash(String, Prefix), :group_prefix) || {} of String => Prefix
+ @group_prefixes = @group_prefixes.transform_keys(&.downcase)
+
+ @authority_id = setting?(String, :authority_id) || "authority-12345"
+ end
+
+ protected def new_user_login(user_json)
+ user_details = UserLogin.from_json user_json
+ check_user(user_details.user_id)
+
+ @users_checked += 1
+ self[:users_checked] = @users_checked
+ rescue error
+ logger.error { error.inspect_with_backtrace }
+ self[:last_error] = {
+ error: error.message,
+ time: Time.local.to_s,
+ user: user_json,
+ }
+ @error_count += 1
+ self[:error_count] = @error_count
+ end
+
+ @[Security(Level::Support)]
+ def check_user(id : String) : Nil
+ logger.debug { "checking groups of: #{id}" }
+
+ # Loading the existing user info in PlaceOS (we need the users id)
+ user_json = staff_api.user(id).get
+ sync_user(user_json)
+ end
+
+ protected def sync_user(user_json)
+ # Loading the existing user info in PlaceOS (we need the users id)
+ user = NamedTuple(id: String, email: String, login_name: String?).from_json user_json.to_json
+ email = user[:login_name].presence || user[:email]
+ logger.debug { "found placeos user info: #{user[:email]}, id #{user[:email]}" }
+
+ # Request user details from GraphAPI or Google
+ users_groups = calendar_api.get_groups(email).get
+ logger.debug { "found user groups: #{users_groups.to_pretty_json}" }
+ users_groups = users_groups.as_a
+
+ users_group_ids = users_groups.map { |group| group["id"].as_s }
+ users_group_names = users_groups.map { |group| group["name"].as_s.downcase }
+
+ # Build the list of placeos groups based on the mappings and update the user model
+ groups = [] of String
+ @group_mappings.each { |group_id, place_group| groups << place_group[:place_id] if users_group_ids.includes? group_id }
+ @group_prefixes.each do |group_prefix, place_group|
+ users_group_names.each do |name|
+ if name.starts_with?(group_prefix)
+ if place_name = place_group[:place_id]
+ groups << place_name
+ elsif place_group[:strip_prefix]
+ groups << name.split(group_prefix, 2)[-1]
+ else
+ groups << name
+ end
+ end
+ end
+ end
+ staff_api.update_user(user[:id], {groups: groups}.to_json).get
+
+ logger.debug { "checked #{users_groups.size}, found #{groups.size} matching: #{groups}" }
+ end
+
+ @syncing : Bool = false
+
+ @[PlaceOS::Driver::Security(Level::Support)]
+ def sync_all_users
+ return "currently syncing" if @syncing
+ @syncing = true
+
+ limit = 100
+ offset = 0
+
+ issues_with = [] of String
+
+ loop do
+ users = staff_api.query_users(
+ limit: limit,
+ offset: offset,
+ authority_id: @authority_id
+ ).get.as_a
+
+ logger.debug { "syncing users #{offset}->#{offset + limit}..." }
+
+ # process 20 users a second
+ users.each do |user|
+ begin
+ sync_user(user)
+ sleep 50.milliseconds
+ rescue error
+ issues_with << user["email"].as_s
+ end
+ end
+
+ break if users.size < limit
+ offset += limit
+ end
+
+ logger.debug { "sync complete! issues with #{issues_with.size}:\n#{issues_with}" }
+ issues_with
+ ensure
+ @syncing = false
+ end
+end
diff --git a/drivers/place/user_group_mappings_spec.cr b/drivers/place/user_group_mappings_spec.cr
new file mode 100644
index 00000000000..09a8f056fbe
--- /dev/null
+++ b/drivers/place/user_group_mappings_spec.cr
@@ -0,0 +1,47 @@
+require "placeos-driver/spec"
+
+# :nodoc:
+class StaffAPIMock < DriverSpecs::MockDriver
+ def user(id : String)
+ {id: "user-1234", email: "steve@placeos.tech"}
+ end
+
+ def update_user(id : String, body_json : String) : Nil
+ self[id] = body_json
+ end
+end
+
+# :nodoc:
+class CalendarMock < DriverSpecs::MockDriver
+ def get_groups(user_id : String)
+ [
+ {
+ id: "5f4694-96f3-4209-a432-b04ac06ca7",
+ name: "Azure-Global-Microsoft Intune Users-Licensed",
+ description: "Azure-Global-Microsoft Intune Users-Licensed",
+ },
+ {
+ id: "bb8836-5942-402d-8d67-55b1a642",
+ name: "All Users",
+ description: "Auto generated group, do not change",
+ },
+ ]
+ end
+end
+
+DriverSpecs.mock_driver "Place::LogicExample" do
+ system({
+ StaffAPI: {StaffAPIMock},
+ Calendar: {CalendarMock},
+ })
+
+ settings({
+ group_mappings: {
+ "5f4694-96f3-4209-a432-b04ac06ca7" => {"place_id" => "intune"},
+ "admins" => {"place_id" => "im an admin"},
+ },
+ })
+
+ exec(:check_user, "user-1234").get
+ system(:StaffAPI_1)["user-1234"].should eq({"groups" => ["intune"]}.to_json)
+end
diff --git a/drivers/place/visitor_mailer.cr b/drivers/place/visitor_mailer.cr
new file mode 100644
index 00000000000..ccd8c97c98c
--- /dev/null
+++ b/drivers/place/visitor_mailer.cr
@@ -0,0 +1,654 @@
+require "placeos-driver"
+require "placeos-driver/interface/mailer"
+require "placeos-driver/interface/mailer_templates"
+require "placeos-models/placeos-models/base/jwt"
+
+require "./password_generator_helper"
+require "./visitor_models"
+
+require "uuid"
+require "oauth2"
+require "jwt"
+
+class Place::VisitorMailer < PlaceOS::Driver
+ include PlaceOS::Driver::Interface::MailerTemplates
+
+ descriptive_name "PlaceOS Visitor Mailer"
+ generic_name :VisitorMailer
+ description %(emails visitors when they are invited and notifies hosts when they check in)
+
+ default_settings({
+ timezone: "GMT",
+ date_time_format: "%c",
+ time_format: "%l:%M%p",
+ date_format: "%A, %-d %B",
+ booking_space_name: "Client Floor",
+ determine_host_name_using: "calendar-driver",
+
+ send_reminders: "0 7 * * *",
+ reminder_template: "visitor",
+ event_template: "event",
+ booking_template: "booking",
+ notify_checkin_template: "notify_checkin",
+ notify_induction_accepted_template: "induction_accepted",
+ notify_induction_declined_template: "induction_declined",
+ group_event_template: "group_event",
+ disable_qr_code: false,
+ send_network_credentials: false,
+ network_password_length: DEFAULT_PASSWORD_LENGTH,
+ network_password_exclude: DEFAULT_PASSWORD_EXCLUDE,
+ network_password_minimum_lowercase: DEFAULT_PASSWORD_MINIMUM_LOWERCASE,
+ network_password_minimum_uppercase: DEFAULT_PASSWORD_MINIMUM_UPPERCASE,
+ network_password_minimum_numbers: DEFAULT_PASSWORD_MINIMUM_NUMBERS,
+ network_password_minimum_symbols: DEFAULT_PASSWORD_MINIMUM_SYMBOLS,
+ network_group_ids: [] of String,
+ debug: false,
+ host_domain_filter: [] of String,
+
+ disable_event_visitors: true,
+ invite_zone_tag: "building",
+ is_campus: false,
+
+ domain_uri: "https://example.com/",
+ jwt_private_key: PlaceOS::Model::JWTBase.private_key,
+ })
+
+ accessor staff_api : StaffAPI_1
+ accessor calendar : Calendar_1
+ accessor network_provider : NetworkAccess_1 # Written for Cisco ISE Driver, but ideally compatible with others
+
+ def mailer
+ system.implementing(Interface::Mailer)[0]
+ end
+
+ def on_load
+ # Guest has been marked as attending a meeting in person
+ monitor("staff/guest/attending") { |_subscription, payload| guest_event(payload.gsub(/[^[:print:]]/, "")) }
+
+ # Guest has arrived in the lobby
+ monitor("staff/guest/checkin") { |_subscription, payload| guest_event(payload.gsub(/[^[:print:]]/, "")) }
+
+ # Booking induction status has been updated
+ monitor("staff/guest/induction_accepted") { |_subscription, payload| guest_event(payload.gsub(/[^[:print:]]/, "")) }
+ monitor("staff/guest/induction_declined") { |_subscription, payload| guest_event(payload.gsub(/[^[:print:]]/, "")) }
+
+ on_update
+ end
+
+ @time_zone : Time::Location = Time::Location.load("GMT")
+
+ @debug : Bool = false
+ @is_parent_zone : Bool = false
+ @users_checked_in : UInt64 = 0_u64
+ @users_accepted_induction : UInt64 = 0_u64
+ @users_declined_induction : UInt64 = 0_u64
+ @error_count : UInt64 = 0_u64
+
+ @visitor_emails_sent : UInt64 = 0_u64
+ @visitor_email_errors : UInt64 = 0_u64
+ @disable_qr_code : Bool = false
+ @host_domain_filter : Array(String) = [] of String
+
+ # See: https://crystal-lang.org/api/0.35.1/Time/Format.html
+ @date_time_format : String = "%c"
+ @time_format : String = "%l:%M%p"
+ @date_format : String = "%A, %-d %B"
+
+ getter building_zone : ZoneDetails do
+ find_building(control_system_zone_list)
+ end
+
+ getter parent_zone_ids : Array(String) = [] of String
+ @booking_space_name : String = "Client Floor"
+ @invite_zone_tag : String = "building"
+
+ @reminder_template : String = "visitor"
+ @send_reminders : String? = nil
+ @event_template : String = "event"
+ @booking_template : String = "booking"
+ @notify_checkin_template : String = "notify_checkin"
+ @notify_induction_accepted_template : String = "induction_accepted"
+ @notify_induction_declined_template : String = "induction_declined"
+ @group_event_template : String = "group_event"
+ @determine_host_name_using : String = "calendar-driver"
+ @send_network_credentials = false
+ @network_password_length : Int32 = DEFAULT_PASSWORD_LENGTH
+ @network_password_exclude : String = DEFAULT_PASSWORD_EXCLUDE
+ @network_password_minimum_lowercase : Int32 = DEFAULT_PASSWORD_MINIMUM_LOWERCASE
+ @network_password_minimum_uppercase : Int32 = DEFAULT_PASSWORD_MINIMUM_UPPERCASE
+ @network_password_minimum_numbers : Int32 = DEFAULT_PASSWORD_MINIMUM_NUMBERS
+ @network_password_minimum_symbols : Int32 = DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+ @network_group_ids = [] of String
+ @disable_event_visitors : Bool = true
+
+ @uri : URI = URI.new
+ @jwt_private_key : String = PlaceOS::Model::JWTBase.private_key
+
+ def on_update
+ @debug = setting?(Bool, :debug) || true
+ @date_time_format = setting?(String, :date_time_format) || "%c"
+ @time_format = setting?(String, :time_format) || "%l:%M%p"
+ @date_format = setting?(String, :date_format) || "%A, %-d %B"
+ @send_reminders = setting?(String, :send_reminders).presence
+ @reminder_template = setting?(String, :reminder_template) || "visitor"
+ @event_template = setting?(String, :event_template) || "event"
+ @booking_template = setting?(String, :booking_template) || "booking"
+ @notify_checkin_template = setting?(String, :notify_checkin_template) || "notify_checkin"
+ @notify_induction_accepted_template = setting?(String, :induction_accepted) || "induction_accepted"
+ @notify_induction_declined_template = setting?(String, :induction_declined) || "induction_declined"
+ @group_event_template = setting?(String, :group_event_template) || "group_event"
+ @disable_qr_code = setting?(Bool, :disable_qr_code) || false
+ @determine_host_name_using = setting?(String, :determine_host_name_using) || "calendar-driver"
+ @send_network_credentials = setting?(Bool, :send_network_credentials) || false
+ @network_password_length = setting?(Int32, :password_length) || DEFAULT_PASSWORD_LENGTH
+ @network_password_exclude = setting?(String, :password_exclude) || DEFAULT_PASSWORD_EXCLUDE
+ @network_password_minimum_lowercase = setting?(Int32, :password_minimum_lowercase) || DEFAULT_PASSWORD_MINIMUM_LOWERCASE
+ @network_password_minimum_uppercase = setting?(Int32, :password_minimum_uppercase) || DEFAULT_PASSWORD_MINIMUM_UPPERCASE
+ @network_password_minimum_numbers = setting?(Int32, :password_minimum_numbers) || DEFAULT_PASSWORD_MINIMUM_NUMBERS
+ @network_password_minimum_symbols = setting?(Int32, :password_minimum_symbols) || DEFAULT_PASSWORD_MINIMUM_SYMBOLS
+ @network_group_ids = setting?(Array(String), :network_group_ids) || [] of String
+ @host_domain_filter = setting?(Array(String), :host_domain_filter) || [] of String
+ @disable_event_visitors = setting?(Bool, :disable_event_visitors) || false
+ @invite_zone_tag = setting?(String, :invite_zone_tag) || "building"
+ @is_parent_zone = setting?(Bool, :is_campus) || false
+
+ time_zone = setting?(String, :timezone).presence || "GMT"
+ @time_zone = Time::Location.load(time_zone)
+
+ @booking_space_name = setting?(String, :booking_space_name).presence || "Client Floor"
+
+
+ @uri = URI.parse(setting?(String, :domain_uri) || "")
+ @jwt_private_key = setting?(String, :jwt_private_key) || PlaceOS::Model::JWTBase.private_key
+
+ zones = config.control_system.not_nil!.zones
+ schedule.clear
+ if reminders = @send_reminders
+ schedule.cron(reminders, @time_zone) { send_reminder_emails }
+ end
+ spawn { ensure_building_zone(zones) }
+ end
+
+ def control_system_zone_list
+ config.control_system.not_nil!.zones
+ end
+
+ protected def ensure_building_zone(zones) : Nil
+ find_building(zones)
+ rescue error
+ logger.warn(exception: error) { "error looking up building zone" }
+ schedule.in(5.seconds) { ensure_building_zone(zones) }
+ end
+
+ protected def find_building(zones : Array(String)) : ZoneDetails
+ zones.each do |zone_id|
+ zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json
+ if zone.tags.includes?(@invite_zone_tag)
+ @building_zone = zone
+ if @is_parent_zone && (child_zones = Array(ZoneDetails).from_json(staff_api.zones(parent: zone_id).get.to_json))
+ @parent_zone_ids = child_zones.map(&.id)
+ else
+ @parent_zone_ids = [] of String
+ end
+ break
+ end
+ end
+ raise "no building zone found in System" unless @building_zone
+ @building_zone.as(ZoneDetails)
+ end
+
+ protected def guest_event(payload)
+ logger.debug { "received guest event payload: #{payload}" }
+ guest_details = GuestNotification.from_json payload
+
+ # ensure the event is for this building
+ if zones = guest_details.zones
+ check = [building_zone.id] + @parent_zone_ids
+
+ if (check & zones).empty?
+ logger.debug { "ignoring event as does not match any zones: #{check}" }
+ return
+ end
+ end
+
+ # don't email staff members
+ if !@host_domain_filter.empty? && guest_details.attendee_email.split('@', 2)[1].downcase.in?(@host_domain_filter)
+ logger.debug { "ignoring event matches host domain filter" }
+ return
+ end
+
+ case guest_details
+ in GuestCheckin
+ send_checkedin_email(
+ @notify_checkin_template,
+ guest_details.attendee_email,
+ guest_details.attendee_name,
+ guest_details.host,
+ guest_details.event_summary,
+ guest_details.event_starting
+ )
+ self[:users_checked_in] = @users_checked_in += 1
+ return
+ in BookingInduction
+ if guest_details.induction.accepted?
+ send_induction_email(
+ @notify_induction_accepted_template,
+ guest_details.attendee_email,
+ guest_details.attendee_name,
+ guest_details.host,
+ guest_details.event_summary,
+ guest_details.event_starting,
+ guest_details.induction
+ )
+ self[:users_accepted_induction] = @users_accepted_induction += 1
+ elsif guest_details.induction.declined?
+ send_induction_email(
+ @notify_induction_declined_template,
+ guest_details.attendee_email,
+ guest_details.attendee_name,
+ guest_details.host,
+ guest_details.event_summary,
+ guest_details.event_starting,
+ guest_details.induction
+ )
+ self[:users_declined_induction] = @users_declined_induction += 1
+ end
+
+ return
+ in EventGuest
+ return if @disable_event_visitors
+
+ room = get_room_details(guest_details.system_id)
+ area_name = room.display_name.presence || room.name
+ template = @event_template
+ in BookingGuest
+ area_name = @booking_space_name
+ template = @booking_template
+ booking_type = staff_api.get_booking(guest_details.booking_id).get["booking_type"].as_s
+ template = @group_event_template if booking_type == "group-event"
+ in GuestNotification
+ # should never get here
+ return
+ end
+
+ send_visitor_qr_email(
+ template,
+ guest_details.attendee_email,
+ guest_details.attendee_name,
+ guest_details.host,
+ guest_details.event_summary,
+ guest_details.event_starting,
+ guest_details.resource_id,
+ guest_details.event_id,
+ area_name,
+ system_id: guest_details.responds_to?(:system_id) ? guest_details.system_id : nil,
+ )
+ rescue error
+ logger.error { error.inspect_with_backtrace }
+ self[:error_count] = @error_count += 1
+ self[:last_error] = {
+ error: error.message,
+ time: Time.local.to_s,
+ user: payload,
+ }
+ end
+
+ @[Security(Level::Support)]
+ def send_checkedin_email(
+ template : String,
+ visitor_email : String,
+ visitor_name : String?,
+ host_email : String?,
+ event_title : String?,
+ event_start : Int64
+ )
+ local_start_time = Time.unix(event_start).in(@time_zone)
+
+ mailer.send_template(
+ host_email,
+ {"visitor_invited", template}, # Template selection: "visitor_invited" "notify_checkin"
+ {
+ visitor_email: visitor_email,
+ visitor_name: visitor_name,
+ host_name: get_host_name(host_email),
+ host_email: host_email,
+ building_name: building_zone.display_name.presence || building_zone.name,
+ event_title: event_title,
+ event_start: local_start_time.to_s(@time_format),
+ event_date: local_start_time.to_s(@date_format),
+ event_time: local_start_time.to_s(@time_format),
+ }
+ )
+ end
+
+ @[Security(Level::Support)]
+ def send_induction_email(
+ template : String,
+ visitor_email : String,
+ visitor_name : String?,
+ host_email : String?,
+ event_title : String?,
+ event_start : Int64,
+ induction_status : Induction
+ )
+ local_start_time = Time.unix(event_start).in(@time_zone)
+
+ mailer.send_template(
+ host_email,
+ {"visitor_invited", template}, # Template selection: "visitor_invited" "induction_accepted"
+ {
+ visitor_email: visitor_email,
+ visitor_name: visitor_name,
+ host_name: get_host_name(host_email),
+ host_email: host_email,
+ building_name: building_zone.display_name.presence || building_zone.name,
+ event_title: event_title,
+ event_start: local_start_time.to_s(@time_format),
+ event_date: local_start_time.to_s(@date_format),
+ event_time: local_start_time.to_s(@time_format),
+ induction_status: induction_status.to_s,
+ }
+ )
+ end
+
+ def template_fields : Array(TemplateFields)
+ time_now = Time.utc.in(@time_zone)
+ common_fields = [
+ {name: "visitor_email", description: "Email address of the visiting guest"},
+ {name: "visitor_name", description: "Full name of the visiting guest"},
+ {name: "host_name", description: "Name of the person hosting the visitor"},
+ {name: "host_email", description: "Email address of the host"},
+ {name: "building_name", description: "Name of the building where the visit occurs"},
+ {name: "event_title", description: "Title or purpose of the visit"},
+ {name: "event_start", description: "Start time (e.g., #{time_now.to_s(@time_format)})"},
+ {name: "event_date", description: "Date of the visit (e.g., #{time_now.to_s(@date_format)})"},
+ {name: "event_time", description: "Time of the visit (or 'all day' for 24-hour events)"},
+ ]
+
+ invitation_fields = common_fields + [
+ {name: "room_name", description: "Name of the room or area being visited"},
+ {name: "network_username", description: "Network access username (if network credentials enabled)"},
+ {name: "network_password", description: "Generated network access password (if network credentials enabled)"},
+ ]
+
+ induction_fields = common_fields + [
+ {name: "induction_status", description: "Status of the induction (e.g., accepted or declined)"},
+ ]
+
+ jwt_fields = [
+ {name: "guest_jwt", description: "JWT token for the guest"},
+ {name: "kiosk_url", description: "URL for the visitor kiosk"},
+ ]
+
+ [
+ TemplateFields.new(
+ trigger: {"visitor_invited", @reminder_template},
+ name: "Visitor invited",
+ description: "Reminder email for upcoming visitor appointments",
+ fields: invitation_fields
+ ),
+ TemplateFields.new(
+ trigger: {"visitor_invited", @event_template},
+ name: "Visitor invited to event",
+ description: "Initial invitation for a visitor attending a calendar event",
+ fields: invitation_fields + jwt_fields
+ ),
+ TemplateFields.new(
+ trigger: {"visitor_invited", @booking_template},
+ name: "Visitor invited to booking",
+ description: "Initial invitation for a visitor with a space booking",
+ fields: invitation_fields + jwt_fields
+ ),
+ TemplateFields.new(
+ trigger: {"visitor_invited", @group_event_template},
+ name: "Visitor invited to group event booking",
+ description: "Initial invitation for a visitor attending a group event",
+ fields: invitation_fields
+ ),
+ TemplateFields.new(
+ trigger: {"visitor_invited", @notify_checkin_template},
+ name: "Visitor check in notification",
+ description: "Notification to host when their visitor checks in",
+ fields: common_fields
+ ),
+ TemplateFields.new(
+ trigger: {"visitor_invited", @notify_induction_accepted_template},
+ name: "Visitor induction accepted notification",
+ description: "Notification to host when their visitor accepts the induction",
+ fields: induction_fields
+ ),
+ TemplateFields.new(
+ trigger: {"visitor_invited", @notify_induction_declined_template},
+ name: "Visitor induction declined notification",
+ description: "Notification to host when their visitor declines the induction",
+ fields: induction_fields
+ ),
+ ]
+ end
+
+ @[Security(Level::Support)]
+ def send_visitor_qr_email(
+ template : String,
+ visitor_email : String,
+ visitor_name : String?,
+ host_email : String?,
+ event_title : String?,
+ event_start : Int64,
+
+ resource_id : String,
+ event_id : String,
+ area_name : String,
+
+ event_end : Int64? = nil,
+ system_id : String? = nil,
+ )
+ local_start_time = Time.unix(event_start).in(@time_zone)
+
+ attach = if @disable_qr_code
+ [] of NamedTuple(file_name: String, content: String, content_id: String)
+ else
+ qr_png = mailer.generate_png_qrcode(text: "VISIT:#{visitor_email},#{resource_id},#{event_id},#{host_email}", size: 256).get.as_s
+ [
+ {
+ file_name: "qr.png",
+ content: qr_png,
+ content_id: visitor_email,
+ },
+ ]
+ end
+
+ network_username = network_password = ""
+ network_username, network_password = update_network_user_password(
+ visitor_email,
+ generate_password(
+ length: @network_password_length,
+ exclude: @network_password_exclude,
+ minimum_lowercase: @network_password_minimum_lowercase,
+ minimum_uppercase: @network_password_minimum_uppercase,
+ minimum_numbers: @network_password_minimum_numbers,
+ minimum_symbols: @network_password_minimum_symbols
+ ),
+ @network_group_ids
+ ) if @send_network_credentials
+
+ event_time = if (end_timestamp = event_end) && (Time.unix(end_timestamp) - Time.unix(event_start)) == 24.hours
+ "all day"
+ else
+ local_start_time.to_s(@time_format)
+ end
+
+ guest_jwt = generate_guest_jwt(visitor_name || visitor_email, visitor_email, visitor_email, event_id, system_id || resource_id)
+ kiosk_url = "/visitor-kiosk/#/checkin/preferences?email=#{visitor_email}&jwt=#{guest_jwt}&event_id=#{event_id}"
+
+ mailer.send_template(
+ visitor_email,
+ {"visitor_invited", template}, # Template selection: "visitor_invited" action, "visitor" email
+ {
+ visitor_email: visitor_email,
+ visitor_name: visitor_name,
+ host_name: get_host_name(host_email),
+ host_email: host_email,
+ room_name: area_name,
+ building_name: building_zone.display_name.presence || building_zone.name,
+ event_title: event_title,
+ event_start: local_start_time.to_s(@time_format),
+ event_date: local_start_time.to_s(@date_format),
+ event_time: event_time,
+ network_username: network_username,
+ network_password: network_password,
+ guest_jwt: guest_jwt,
+ kiosk_url: kiosk_url,
+ },
+ attach
+ )
+ end
+
+ @[Security(Level::Support)]
+ def send_reminder_emails
+ now = 1.hour.ago.to_unix
+ later = 12.hours.from_now.to_unix
+
+ guests = staff_api.query_guests(
+ period_start: now,
+ period_end: later,
+ zones: {building_zone.id}
+ ).get.as_a
+
+ guests.uniq! { |g| g["email"].as_s.downcase }
+ guests.each do |guest|
+ begin
+ if event = guest["event"]?
+ send_visitor_qr_email(
+ @reminder_template,
+ guest["email"].as_s,
+ guest["name"].as_s?,
+ event["host"].as_s,
+ event["title"].as_s,
+ event["event_start"].as_i64,
+ event.dig("system", "id").as_s,
+ event["id"].as_s,
+ (event.dig?("system", "display_name") || event.dig("system", "name")).as_s,
+ event_end: event["event_end"].as_i64
+ )
+ elsif booking = guest["booking"]?
+ send_visitor_qr_email(
+ @reminder_template,
+ guest["email"].as_s,
+ guest["name"].as_s?,
+ booking["user_email"].as_s,
+ booking["title"].as_s?,
+ booking["booking_start"].as_i64,
+ booking["asset_id"].as_s,
+ booking["id"].as_i64.to_s,
+ @booking_space_name,
+ event_end: booking["booking_end"].as_i64
+ )
+ end
+ rescue error
+ logger.warn(exception: error) { "failed to send reminder email to #{guest["email"]}" }
+ end
+ end
+ end
+
+ # ===================================
+ # Guest JWT Generation:
+ # ===================================
+
+ @[Security(Level::Administrator)]
+ def generate_guest_jwt(name : String, email : String, guest_id : String, event_id : String, system_id : String)
+ now = Time.local(@time_zone)
+ tonight = now.at_end_of_day
+ tomorrow_night = tonight + 24.hours
+
+ payload = {
+ iss: "POS",
+ iat: now.to_unix,
+ exp: tomorrow_night.to_unix,
+ jti: UUID.random.to_s,
+ aud: @uri.try &.host,
+ scope: ["guest"],
+ sub: guest_id,
+ u: {
+ n: name,
+ e: email,
+ p: 0,
+ r: [event_id, system_id],
+ },
+ }
+
+ JWT.encode(payload, @jwt_private_key, JWT::Algorithm::RS256)
+ end
+
+ # ===================================
+ # PlaceOS API requests
+ # ===================================
+
+ class ZoneDetails
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property display_name : String?
+ property location : String?
+ property tags : Array(String)
+ property parent_id : String?
+ end
+
+ class SystemDetails
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property display_name : String?
+ property map_id : String?
+ end
+
+ protected def get_room_details(system_id : String, retries = 0)
+ SystemDetails.from_json staff_api.get_system(system_id).get.to_json
+ rescue error
+ raise "issue loading system details #{system_id}" if retries > 3
+ sleep 1.second
+ get_room_details(system_id, retries + 1)
+ end
+
+ protected def get_host_name(host_email)
+ @determine_host_name_using == "staff-api-driver" ? get_host_name_from_staff_api_driver(host_email) : get_host_name_from_calendar_driver(host_email)
+ end
+
+ protected def get_host_name_from_calendar_driver(host_email)
+ calendar.get_user(host_email).get["name"]
+ rescue error
+ logger.error { "issue loading host details #{host_email}" }
+ return "your host"
+ end
+
+ protected def get_host_name_from_staff_api_driver(host_email, retries = 0)
+ staff_api.staff_details(host_email).get["name"].as_s.split('(')[0]
+ rescue error
+ if retries > 3
+ logger.error { "issue loading host details #{host_email}" }
+ return "your host"
+ end
+ sleep 1.second
+ get_host_name_from_staff_api_driver(host_email, retries + 1)
+ end
+
+ # For Cisco ISE network credentials
+
+ def update_network_user_password(user_email : String, password : String, network_group_ids : Array(String) = [] of String)
+ # Check if they already exist
+ response = network_provider.update_internal_user_password_by_name(user_email, password).get
+ logger.debug { "Response from Network Identity provider for lookup of #{user_email} was:\n#{response}" } if @debug
+ rescue # todo: catch the specific error where the user already exists, instead of any error. Catch other errors in seperate rescue
+ # Create them if they don't already exist
+ create_network_user(user_email, password, network_group_ids)
+ else
+ {user_email, password}
+ end
+
+ def create_network_user(user_email : String, password : String, group_ids : Array(String) = [] of String)
+ response = network_provider.create_internal_user(email: user_email, name: user_email, password: password, identity_groups: group_ids).get
+ logger.debug { "Response from Network Identity provider for creating user #{user_email} was:\n #{response}\n\nDetails:\n#{response.inspect}" } if @debug
+ {response["name"], password}
+ end
+end
diff --git a/drivers/place/visitor_mailer_spec.cr b/drivers/place/visitor_mailer_spec.cr
new file mode 100644
index 00000000000..1dbd48e16f4
--- /dev/null
+++ b/drivers/place/visitor_mailer_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Place::VisitorMailer" do
+end
diff --git a/drivers/place/visitor_models.cr b/drivers/place/visitor_models.cr
new file mode 100644
index 00000000000..97cec39c664
--- /dev/null
+++ b/drivers/place/visitor_models.cr
@@ -0,0 +1,82 @@
+require "json"
+
+module Place
+ enum Induction
+ TENTATIVE
+ ACCEPTED
+ DECLINED
+ end
+
+ abstract class GuestNotification
+ include JSON::Serializable
+
+ use_json_discriminator "action", {
+ "booking_created" => BookingGuest,
+ "booking_updated" => BookingGuest,
+ "meeting_created" => EventGuest,
+ "meeting_update" => EventGuest,
+ "checkin" => GuestCheckin,
+ "induction_accepted" => BookingInduction,
+ "induction_declined" => BookingInduction,
+ }
+
+ property action : String
+
+ property checkin : Bool?
+ property event_summary : String
+ property event_starting : Int64
+ property attendee_name : String?
+ property attendee_email : String
+ property host : String
+
+ # This is optional for backwards compatibility
+ property zones : Array(String)?
+
+ property ext_data : Hash(String, JSON::Any)?
+ end
+
+ class EventGuest < GuestNotification
+ include JSON::Serializable
+
+ property system_id : String
+ property event_id : String
+ property resource : String
+
+ def resource_id
+ system_id
+ end
+ end
+
+ class BookingGuest < GuestNotification
+ include JSON::Serializable
+
+ property booking_id : Int64
+ property resource_id : String
+
+ def event_id
+ booking_id.to_s
+ end
+ end
+
+ class GuestCheckin < GuestNotification
+ include JSON::Serializable
+
+ property system_id : String = ""
+ property event_id : String = ""
+ property resource : String = ""
+ property resource_id : String = ""
+ end
+
+ class BookingInduction < GuestNotification
+ include JSON::Serializable
+
+ property induction : Induction = Induction::TENTATIVE
+ property booking_id : Int64
+ property resource_id : String
+ property resource_ids : Array(String)
+
+ def event_id
+ booking_id.to_s
+ end
+ end
+end
diff --git a/drivers/planar/clarity_matrix.cr b/drivers/planar/clarity_matrix.cr
new file mode 100644
index 00000000000..bd8e7039d2e
--- /dev/null
+++ b/drivers/planar/clarity_matrix.cr
@@ -0,0 +1,94 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+
+# Documentation: https://aca.im/driver_docs/Planar/020-1028-00%20RS232%20for%20Matrix.pdf
+# also https://aca.im/driver_docs/Planar/020-0567-05_WallNet_guide.pdf
+
+class Planar::ClarityMatrix < PlaceOS::Driver
+ include Interface::Powerable
+
+ # Discovery Information
+ descriptive_name "Planar Clarity Matrix Video Wall"
+ generic_name :VideoWall
+
+ # Global Cache Port
+ tcp_port 4999
+
+ def on_load
+ # Communication settings
+ queue.wait = false
+ transport.tokenizer = Tokenizer.new("\r")
+ end
+
+ def connected
+ do_poll
+ schedule.every(60.seconds) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ @power : Bool = false
+
+ def power(state : Bool)
+ power?.get
+ if state && @power == false
+ send("op ** display.power = on \r", name: "power", delay: 3.seconds)
+ result = power?
+ schedule.in(20.seconds) { recall(0) }
+ result
+ elsif !state && @power == true
+ send("op ** display.power = off \r", name: "power", delay: 3.seconds)
+ power?
+ end
+ end
+
+ def power?
+ send("op A1 display.power ? \r", wait: true, priority: 0)
+ end
+
+ def recall(preset : UInt32, **options)
+ send("op ** slot.recall (#{preset}) \r", **options, name: "recall")
+ end
+
+ def input_status?(**options)
+ send("op A1 slot.current ? \r", wait: true)
+ end
+
+ def do_poll
+ power?
+ input_status?(priority: 0) if @power
+ end
+
+ def build_date?
+ send("ST A1 BUILD.DATE ? \r", wait: true)
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "sent: #{data}" }
+
+ data = data.split('.') # OPA1DISPLAY.POWER=ON || OPA1SLOT.CURRENT=0
+ component = data[0] # OPA1DISPLAY || OPA1SLOT
+ data = data[1].split('=')
+
+ status = data[0].downcase.strip # POWER || CURRENT
+ value = data[1].strip # ON || 0
+
+ case status
+ when "power"
+ self[:power] = @power = value == "ON"
+ task.try &.success(@power)
+ when "current"
+ input = value.to_i
+ self[:input] = input
+ task.try &.success(input)
+ when "date"
+ # remove the inverted commas
+ task.try &.success(value[1..-2])
+ else
+ task.try &.success
+ end
+ end
+end
diff --git a/drivers/planar/clarity_matrix_spec.cr b/drivers/planar/clarity_matrix_spec.cr
new file mode 100644
index 00000000000..8703a16b324
--- /dev/null
+++ b/drivers/planar/clarity_matrix_spec.cr
@@ -0,0 +1,21 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Planar::ClarityMatrix" do
+ # On connect it queries the state of the device
+ should_send("op A1 display.power ? \r")
+ responds("OPA1DISPLAY.POWER=OFF\r")
+
+ resp = exec(:build_date?)
+ should_send("ST A1 BUILD.DATE ? \r")
+ responds(%(ST A1 BUILD.DATE= "JUN 15 2009 08:48:24"\r))
+ resp.get.should eq "JUN 15 2009 08:48:24"
+
+ resp = exec(:power, true)
+ should_send("op A1 display.power ? \r")
+ responds("OPA1DISPLAY.POWER=OFF\r")
+ should_send("op ** display.power = on \r")
+ sleep 3
+ should_send("op A1 display.power ? \r")
+ responds("OPA1DISPLAY.POWER=ON\r")
+ resp.get.should eq true
+end
diff --git a/drivers/point_grab/cogni_point.cr b/drivers/point_grab/cogni_point.cr
new file mode 100644
index 00000000000..742dea01415
--- /dev/null
+++ b/drivers/point_grab/cogni_point.cr
@@ -0,0 +1,380 @@
+require "uri"
+require "uuid"
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/PointGrab/CogniPointAPI2-1.pdf
+
+class PointGrab::CogniPoint < PlaceOS::Driver
+ # Discovery Information
+ generic_name :CogniPoint
+ descriptive_name "PointGrab CogniPoint REST API"
+
+ default_settings({
+ user_id: "10000000",
+ app_key: "c5a6adc6-UUID-46e8-b72d-91395bce9565",
+ })
+
+ @user_id : String = ""
+ @app_key : String = ""
+ @auth_token : String = ""
+ @auth_expiry : Time = 1.minute.ago
+
+ def on_update
+ @user_id = setting(String, :user_id)
+ @app_key = setting(String, :app_key)
+ end
+
+ class TokenResponse
+ include JSON::Serializable
+
+ property token : String
+ property expires_in : Int32
+ end
+
+ def expire_token!
+ @auth_expiry = 1.minute.ago
+ end
+
+ def token_expired?
+ @auth_expiry < Time.utc
+ end
+
+ def get_token
+ return @auth_token unless token_expired?
+
+ response = post("/be/cp/oauth2/token", body: "grant_type=client_credentials", headers: {
+ "Content-Type" => "application/x-www-form-urlencoded",
+ "Accept" => "application/json",
+ "Authorization" => "Basic #{Base64.strict_encode("#{@user_id}:#{@app_key}")}",
+ })
+
+ body = response.body
+ logger.debug { "received login response: #{body}" }
+
+ if response.success?
+ resp = TokenResponse.from_json(body.not_nil!)
+ token = resp.token
+ @auth_expiry = Time.utc + (resp.expires_in - 5).seconds
+ @auth_token = "Bearer #{resp.token}"
+ else
+ logger.error { "authentication failed with HTTP #{response.status_code}" }
+ raise "failed to obtain access token"
+ end
+ end
+
+ macro get_request(path, result_type)
+ begin
+ %token = get_token
+ %response = get({{path}}, headers: {
+ "Accept" => "application/json",
+ "Authorization" => %token
+ })
+
+ if %response.success?
+ {{result_type}}.from_json(%response.body.not_nil!)
+ else
+ expire_token! if %response.status_code == 401
+ raise "unexpected response #{%response.status_code}\n#{%response.body}"
+ end
+ end
+ end
+
+ class Customer
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ end
+
+ def customers
+ customers = get_request("/be/cp/v2/customers", NamedTuple(endCustomers: Array(Customer)))
+ customers[:endCustomers]
+ end
+
+ class GeoPosition
+ include JSON::Serializable
+
+ property latitude : Float64
+ property longitude : Float64
+ end
+
+ class MetricPositions
+ include JSON::Serializable
+
+ @[JSON::Field(key: "posX")]
+ property pos_x : Float64
+
+ @[JSON::Field(key: "posY")]
+ property pos_y : Float64
+ end
+
+ class Site
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+
+ class Location
+ include JSON::Serializable
+
+ @[JSON::Field(key: "houseNo")]
+ property house_number : String
+ property street : String
+ property city : String
+ property county : String
+ property state : String
+ property country : String
+ property zip : String
+
+ @[JSON::Field(key: "geoPosition")]
+ property geo_position : GeoPosition
+ end
+
+ @[JSON::Field(key: "customerId")]
+ property customer_id : String
+ property location : Location
+ end
+
+ def sites
+ sites = get_request("/be/cp/v2/sites", NamedTuple(sites: Array(Site)))
+ sites[:sites]
+ end
+
+ def site(site_id : String)
+ get_request("/be/cp/v2/sites/#{site_id}", Site)
+ end
+
+ class Building
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+
+ @[JSON::Field(key: "siteId")]
+ property site_id : String
+
+ property location : Site::Location
+ end
+
+ def buildings(site_id : String)
+ buildings = get_request("/be/cp/v2/sites/#{site_id}/buildings", NamedTuple(buildings: Array(Building)))
+ buildings[:buildings]
+ end
+
+ def building(site_id : String, building_id : String)
+ get_request("/be/cp/v2/sites/#{site_id}/buildings/#{building_id}", Building)
+ end
+
+ class Floor
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+
+ @[JSON::Field(key: "floorNumber")]
+ property floor_number : String
+
+ @[JSON::Field(key: "floorPlanURL")]
+ property floor_plan_url : String
+
+ @[JSON::Field(key: "widthDistance")]
+ property width_distance : Float64
+
+ @[JSON::Field(key: "lengthDistance")]
+ property length_distance : Float64
+
+ # NOTE:: unknown format for referencePoints => Array(?)
+ end
+
+ def floors(site_id : String, building_id : String)
+ floors = get_request("/be/cp/v2/sites/#{site_id}/buildings/#{building_id}/floors", NamedTuple(floors: Array(Building)))
+ floors[:floors]
+ end
+
+ def floor(site_id : String, building_id : String, floor_id : String)
+ get_request("/be/cp/v2/sites/#{site_id}/buildings/#{building_id}/floors/#{floor_id}", Floor)
+ end
+
+ class Area
+ include JSON::Serializable
+
+ property id : String
+ property name : String
+ property length : Float64
+ property width : Float64
+
+ @[JSON::Field(key: "centerX")]
+ property center_x : Float64
+
+ @[JSON::Field(key: "centerY")]
+ property center_y : Float64
+
+ property rotation : Int32
+ property frequency : Int32
+
+ @[JSON::Field(key: "deviceIDs")]
+ property device_ids : Array(String)
+
+ class Application
+ include JSON::Serializable
+
+ @[JSON::Field(key: "areaType")]
+ property area_type : String
+
+ @[JSON::Field(key: "applicationType")]
+ property application_type : String
+ end
+
+ property applications : Array(Application)
+
+ # Area Polygon positions in meters
+ @[JSON::Field(key: "metricPositions")]
+ property metric_positions : Array(MetricPositions)
+
+ # Area Polygon Coordinates positions
+ @[JSON::Field(key: "geoPositions")]
+ property geo_positions : Array(GeoPosition)?
+ end
+
+ class FloorAreas
+ include JSON::Serializable
+
+ @[JSON::Field(key: "floorId")]
+ property floor_id : String
+ property areas : Array(Area)
+ end
+
+ def building_areas(site_id : String, building_id : String)
+ floors = get_request("/be/cp/v2/sites/#{site_id}/buildings/#{building_id}/areas", NamedTuple(
+ floorsAreas: FloorAreas))
+ floors[:floorsAreas]
+ end
+
+ def areas(site_id : String, building_id : String, floor_id : String)
+ areas = get_request("/be/cp/v2/sites/#{site_id}/buildings/#{building_id}/floors/#{floor_id}/areas", NamedTuple(
+ areas: Array(Area)))
+ areas[:areas]
+ end
+
+ def area(site_id : String, building_id : String, floor_id : String, area_id : String)
+ get_request("/be/cp/v2/sites/#{site_id}/buildings/#{building_id}/floors/#{floor_id}/areas/#{area_id}", Area)
+ end
+
+ class Handler
+ include JSON::Serializable
+
+ property id : String
+ property token : String
+
+ @[JSON::Field(key: "thirdPartyAppID")]
+ property app_id : UInt32
+
+ @[JSON::Field(key: "endPoint")]
+ property end_point : String
+ end
+
+ def handlers
+ handlers = get_request("/be/cp/v2/resources/handlers", NamedTuple(
+ handlers: Array(Handler)))
+ handlers[:handlers]
+ end
+
+ class Subscription
+ include JSON::Serializable
+
+ property id : String
+ property token : String
+ property started : Bool
+ property endpoint : String
+ property uri : String
+
+ @[JSON::Field(key: "notificationType")]
+ property notification_type : String
+
+ @[JSON::Field(key: "subscriptionType")]
+ property subscription_type : String
+ end
+
+ enum NotificationType
+ Counting
+ Traffic
+ end
+
+ def subscribe(handler_uri : String, auth_token : String = UUID.random.to_s, events : NotificationType = NotificationType::Counting)
+ # Ensure the handler is a valid URI
+ URI.parse handler_uri
+
+ token = get_token
+ response = post(
+ "/be/cp/v2/telemetry/subscriptions",
+ body: {
+ subscriptionType: "PUSH",
+ notificationType: events.to_s.upcase,
+ endpoint: handler_uri,
+ token: auth_token,
+ }.to_json,
+ headers: {
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ "Authorization" => token,
+ }
+ )
+
+ body = response.body
+ logger.debug { "received login response: #{body}" }
+
+ if response.success?
+ Subscription.from_json(body.not_nil!)
+ else
+ logger.error { "authentication failed with HTTP #{response.status_code}" }
+ raise "failed to obtain access token"
+ end
+ end
+
+ def subscriptions
+ get_request("/be/cp/v2/telemetry/subscriptions", Array(Subscription))
+ end
+
+ def delete_subscription(id : String)
+ token = get_token
+ delete("/be/cp/v2/telemetry/subscriptions/#{id}",
+ headers: {
+ "Accept" => "application/json",
+ "Authorization" => token,
+ }
+ ).success?
+ end
+
+ def update_subscription(id : String, started : Bool = true)
+ token = get_token
+ patch(
+ "/be/cp/v2/telemetry/subscriptions/#{id}",
+ body: {started: started}.to_json,
+ headers: {
+ "Content-Type" => "application/json",
+ "Accept" => "application/json",
+ "Authorization" => token,
+ }
+ ).success?
+ end
+
+ # TODO:: this data is posted to the subscription endpoint
+ # we need to implement webhooks for this to work properly
+ class CountUpdate
+ include JSON::Serializable
+
+ @[JSON::Field(key: "areaId")]
+ property area_id : String
+ property devices : Array(String)
+
+ @[JSON::Field(key: "type")]
+ property event_type : String
+ property timestamp : UInt64
+ property count : Int32
+ end
+
+ def update_count(count_json : String)
+ count = CountUpdate.from_json(count_json)
+ self["area_#{count.area_id}"] = count.count
+ end
+end
diff --git a/drivers/point_grab/cogni_point_spec.cr b/drivers/point_grab/cogni_point_spec.cr
new file mode 100644
index 00000000000..adb4ed1c2fe
--- /dev/null
+++ b/drivers/point_grab/cogni_point_spec.cr
@@ -0,0 +1,33 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "PointGrab::CogniPoint" do
+ # Send the request
+ retval = exec(:get_token)
+ token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJSRUFEIiwiV1JJVEUiXSwiZXhwIjoxNTc0MjMzNjEyLCJhdXRob3JpdGllcyI6WyJST0xFX1RSVVNURURfQ0xJRU5UIl0sImp0aSI6IjM1ZjkxYjlkLTVmZmMtNDJkYy05YWZkLTJiZTE0YjI1MmE1NCIsImNsaWVudF9pZCI6IjEwMDAwMjEzIn0.Wzrsaey5z3ShAFYKOaWmgfoRZNsk-PclSK9IRtYf4b8"
+
+ # We should request a new token from Floorsense
+ expect_http_request do |request, response|
+ if io = request.body
+ data = io.gets_to_end
+
+ # The request is param encoded
+ if data == "grant_type=client_credentials" && request.headers["Authorization"] == "Basic #{Base64.strict_encode("10000000:c5a6adc6-UUID-46e8-b72d-91395bce9565")}"
+ response.status_code = 200
+ response.output.puts %({
+ "token": "#{token}",
+ "access_token": "#{token}",
+ "token_type": "bearer",
+ "expires_in": 3599
+ })
+ else
+ response.status_code = 401
+ response.output.puts ""
+ end
+ else
+ raise "expected request to include token type"
+ end
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq("Bearer #{token}")
+end
diff --git a/drivers/qbic/touch_panel.cr b/drivers/qbic/touch_panel.cr
new file mode 100644
index 00000000000..e8425db9445
--- /dev/null
+++ b/drivers/qbic/touch_panel.cr
@@ -0,0 +1,268 @@
+require "placeos-driver"
+require "uri"
+
+# docs: https://drive.google.com/file/d/1ytAML83qloy9o0WN6C1GWjJ1P-Hcq-WY/view
+
+class Qbic::TouchPanel < PlaceOS::Driver
+ descriptive_name "Qbic Touch Panel"
+ generic_name :Panel
+
+ default_settings({
+ password: "12345678",
+ })
+
+ uri_base "https://192.168.12.0"
+
+ USERNAME = "admin"
+ @password : String = ""
+ @auth_token : String = ""
+ @refresh_token : String? = nil
+ @expired : Bool = true
+
+ def on_update
+ @password = URI.encode_www_form setting(String, :password)
+
+ transport.before_request do |request|
+ request.headers["Content-Type"] = "application/json"
+ request.headers["Authorization"] = @auth_token unless token_expired?
+ end
+
+ schedule.clear
+ schedule.every(1.minute) do
+ logger.debug { "polling to check connectivity" }
+ resp = get("/v1/public/info/")
+ if resp.success?
+ logger.debug { resp.body }
+ get_all_leds
+ end
+ end
+ end
+
+ class FailureResponse
+ include JSON::Serializable
+
+ property detail : String
+ end
+
+ class AuthResponse
+ include JSON::Serializable
+
+ # Returned on success
+ property access_token : String
+ property refresh_token : String
+ property token_type : String
+ end
+
+ def token_expired?
+ @expired
+ end
+
+ def get_token
+ return @auth_token unless token_expired?
+
+ # attempt to use refresh token if one is available
+ if refresh_token = @refresh_token
+ response = post("/v1/oauth2/token",
+ body: {
+ grant_type: "refresh_token",
+ refresh_token: refresh_token,
+ }.to_json
+ )
+
+ if response.success?
+ resp = AuthResponse.from_json(response.body.not_nil!)
+ @expired = false
+ @auth_token = "#{resp.token_type} #{resp.access_token}"
+ @refresh_token = resp.refresh_token
+ return @auth_token
+ else
+ logger.debug { "refresh token request failed" }
+ end
+ end
+
+ # Fall back to using the username and password
+ response = post("/v1/oauth2/token",
+ body: {
+ grant_type: "password",
+ username: USERNAME,
+ password: @password,
+ }.to_json
+ )
+
+ data = response.body.not_nil!
+
+ if response.success?
+ resp = AuthResponse.from_json(data)
+ @expired = false
+ @refresh_token = resp.refresh_token
+ @auth_token = "#{resp.token_type} #{resp.access_token}"
+ else
+ resp = FailureResponse.from_json(data)
+ raise "failed to obtain access token: #{resp.detail} (#{response.status})"
+ end
+ end
+
+ @[Security(Level::Administrator)]
+ def update_password(new_password : String)
+ raise "password must be between 4 and 16 characters" unless new_password.size >= 4 && new_password.size <= 16
+ query("POST", "/v1/user/password") do
+ define_setting(:password, new_password)
+ end
+ end
+
+ @[Security(Level::Administrator)]
+ def wifi_scan
+ query("GET", "/v1/wifi/scan_results") { |data| JSON.parse(data.not_nil!) }
+ end
+
+ enum AdvertiseMode
+ LowLatency
+ Balanced
+ LowPower
+ end
+
+ @[Security(Level::Administrator)]
+ def set_ibeacon(
+ enabled : Bool,
+ major : UInt16? = nil,
+ minor : UInt16? = nil,
+ uuid : String? = nil,
+ advertise_mode : AdvertiseMode? = nil,
+ power : Int8? = nil
+ )
+ query("POST", "/v1/net/beacon/ibeacon", {
+ enabled: enabled ? "enabled" : "disabled",
+ major: major,
+ minor: minor,
+ uuid: uuid,
+ advertise_mode: advertise_mode.to_s.underscore,
+ power: power,
+ }.to_json) { true }
+ end
+
+ def get_ibeacon
+ query("GET", "/v1/net/beacon/ibeacon") { |data| JSON.parse(data.not_nil!) }
+ end
+
+ # https://github.com/google/eddystone/tree/master/eddystone-uid
+ @[Security(Level::Administrator)]
+ def set_eddystone_uid(
+ enabled : Bool,
+ namespace : String? = nil,
+ instance : String? = nil,
+ advertise_mode : AdvertiseMode? = nil,
+ power : Int8? = nil
+ )
+ query("POST", "/v1/net/beacon/eddystone_uid", {
+ enabled: enabled ? "enabled" : "disabled",
+ namespace: namespace,
+ instance: instance,
+ advertise_mode: advertise_mode.to_s.underscore,
+ power: power,
+ }.to_json) { true }
+ end
+
+ def get_eddystone_uid
+ query("GET", "/v1/net/beacon/eddystone_uid") { |data| JSON.parse(data.not_nil!) }
+ end
+
+ @[Security(Level::Administrator)]
+ def set_eddystone_url(
+ enabled : Bool,
+ url : String? = nil,
+ advertise_mode : AdvertiseMode? = nil,
+ power : Int8? = nil
+ )
+ query("POST", "/v1/net/beacon/eddystone_url", {
+ enabled: enabled ? "enabled" : "disabled",
+ url: url,
+ advertise_mode: advertise_mode.to_s.underscore,
+ power: power,
+ }.to_json) { true }
+ end
+
+ def get_eddystone_url
+ query("GET", "/v1/net/beacon/eddystone_url") { |data| JSON.parse(data.not_nil!) }
+ end
+
+ def device_info
+ query("GET", "/v1/info/") { |data| JSON.parse(data.not_nil!) }
+ end
+
+ def settings
+ query("GET", "/v1/settings") { |data| JSON.parse(data.not_nil!) }
+ end
+
+ @[Security(Level::Administrator)]
+ def set_setting(key : String, value : String | JSON::Any)
+ query("POST", "/v1/settings/#{key}", {
+ value: value,
+ }.to_json) { true }
+ end
+
+ @[Security(Level::Support)]
+ def set_url(value : String)
+ set_setting "content_url", value
+ end
+
+ def leds
+ query("GET", "/v1/led") { |data| self[:leds] = NamedTuple(results: Array(String)).from_json(data.not_nil!)[:results] }
+ end
+
+ def get_led_state(name : String)
+ query("GET", "/v1/led/#{name}") { |data| self[name] = JSON.parse(data.not_nil!) }
+ end
+
+ def get_all_leds
+ query("GET", "/v1/led") do |data|
+ leds = NamedTuple(results: Array(String)).from_json(data.not_nil!)[:results]
+ self[:light_names] = leds
+ leds.each { |name| get_led_state(name) }
+ true
+ end
+ end
+
+ @[Security(Level::Support)]
+ def set_led_state(name : String, red : UInt8, green : UInt8, blue : UInt8)
+ value = {
+ red: red,
+ green: green,
+ blue: blue,
+ }
+ query("POST", "/v1/led/#{name}", value.to_json) { self[name] = value }
+ end
+
+ def set_all_leds(red : UInt8, green : UInt8, blue : UInt8)
+ query("GET", "/v1/led") do |data|
+ leds = NamedTuple(results: Array(String)).from_json(data.not_nil!)[:results]
+ leds.each { |name| set_led_state(name, red, green, blue) }
+ true
+ end
+ end
+
+ private def query(
+ method, path, body : ::HTTP::Client::BodyType = nil,
+ params : Hash(String, String?) | URI::Params = URI::Params.new,
+ headers : Hash(String, String) | HTTP::Headers = HTTP::Headers.new,
+ **opts, &block : String -> _
+ )
+ queue(**opts) do |task|
+ response = http(method, path, body, params, headers)
+
+ if response.status.unauthorized?
+ @expired = true
+ get_token
+ task.retry
+ elsif response.success?
+ task.success block.call(response.body)
+ else
+ begin
+ resp = FailureResponse.from_json(response.body.not_nil!)
+ task.abort "#{resp.detail} - #{response.status} (#{response.status_code})"
+ rescue
+ task.abort "unexpected response #{response.status} (#{response.status_code})\n#{response.body}"
+ end
+ end
+ end
+ end
+end
diff --git a/drivers/qbic/touch_panel_spec.cr b/drivers/qbic/touch_panel_spec.cr
new file mode 100644
index 00000000000..1bcba72473d
--- /dev/null
+++ b/drivers/qbic/touch_panel_spec.cr
@@ -0,0 +1,44 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Qbic::TouchPanel" do
+ # Send the request
+ retval = exec(:get_token)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |request, response|
+ if io = request.body
+ data = io.gets_to_end
+ request = JSON.parse(data)
+
+ if request["grant_type"] == "password" && request["username"] == "admin" && request["password"] == "12345678"
+ response.status_code = 200
+ response.output.puts %({"access_token":"t6pm11le6m6pvae18ar9jqdap4","refresh_token":"gq5c6p1lps3m24nf1th0cmda32","token_type":"Bearer"})
+ else
+ response.status_code = 400
+ response.output.puts %({"detail":"Invalid_client, make sure username and password is correct."})
+ end
+ else
+ raise "expected request to include username and password"
+ end
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq("Bearer t6pm11le6m6pvae18ar9jqdap4")
+
+ # Get the list of LEDs on the device
+ retval = exec(:leds)
+
+ expect_http_request do |request, response|
+ if request.headers["Authorization"]? == "Bearer t6pm11le6m6pvae18ar9jqdap4"
+ response.status_code = 200
+ response.output.puts %({"results":["side_led", "front_led"]})
+ else
+ response.status_code = 401
+ response.output.puts %({"detail":"Invalid Authorization"})
+ end
+ end
+
+ retval.get.should eq ["side_led", "front_led"]
+
+ status[:leds].should eq ["side_led", "front_led"]
+end
diff --git a/drivers/qsc/q_sys_control.cr b/drivers/qsc/q_sys_control.cr
new file mode 100644
index 00000000000..eb10d434b1c
--- /dev/null
+++ b/drivers/qsc/q_sys_control.cr
@@ -0,0 +1,409 @@
+require "placeos-driver"
+
+# Documentation https://q-syshelp.qsc.com/Content/External_Control_APIs/ECP/ECP_Commands.htm
+
+class Qsc::QSysControl < PlaceOS::Driver
+ # Discovery Information
+ tcp_port 1702
+ descriptive_name "QSC Audio DSP External Control"
+ generic_name :Mixer
+
+ default_settings({
+ _change_groups: {
+ "room123_phone" => {
+ id: 1,
+ controls: ["VoIPCallStatusProgress", "VoIPCallStatusRinging", "VoIPCallStatusOffHook"],
+ },
+ },
+ })
+
+ alias Group = NamedTuple(id: Int32, controls: Set(String))
+ alias Ids = String | Array(String)
+ alias Val = Int32 | Float64
+
+ @username : String? = nil
+ @password : String? = nil
+
+ @connected : Bool = false
+ @change_group_id : Int32 = 30
+ @em_id : String? = nil
+ @emergency_subscribe : PlaceOS::Driver::Subscriptions::Subscription? = nil
+ getter history : Hash(String, Symbol) = {} of String => Symbol
+
+ @static_change_groups = {} of String => Group
+ @dynamic_change_groups = {} of String => Group
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("\r\n")
+ queue.retries = 1
+ on_update
+ end
+
+ def on_update
+ @username = setting?(String, :username)
+ @password = setting?(String, :password)
+
+ @static_change_groups = setting?(Hash(String, Group), :change_groups) || {} of String => Group
+
+ if @connected
+ login if @username
+ recreate_change_groups
+ end
+ end
+
+ def connected
+ @connected = true
+ login if @username
+ recreate_change_groups
+ schedule.every(40.seconds) do
+ logger.debug { "Maintaining Connection" }
+ about
+ end
+ end
+
+ def disconnected
+ @connected = false
+ schedule.clear
+ end
+
+ protected def recreate_change_groups
+ return unless @connected
+ change_groups = @static_change_groups.merge @dynamic_change_groups
+ change_groups.each do |name, group|
+ logger.debug { "configuring change group #{name}" }
+ group_id = group[:id]
+ controls = group[:controls]
+
+ # Re-create change groups and poll every 2 seconds
+ do_send("cgc #{group_id}\n") # , wait: false)
+ do_send("cgsna #{group_id} 2000\n") # , wait: false)
+ controls.each do |id|
+ do_send("cga #{group_id} #{id}\n") # , wait: false)
+ end
+ end
+
+ em_id = setting?(String, :emergency)
+
+ # Emergency ID changed
+ if (e = @emergency_subscribe) && @em_id != em_id
+ subscriptions.unsubscribe(e)
+ end
+
+ # Emergency ID exists
+ if em_id
+ group = create_change_group(:emergency)
+ group_id = group[:id]
+ controls = group[:controls]
+
+ # Add id to change group as required
+ unless controls.includes?(em_id)
+ # subscribe to changes
+ @em_id = em_id
+ @emergency_subscribe = subscribe(em_id) do |_, value|
+ self[:emergency] = value
+ end
+
+ update_change_group(:emergency, group_id, Set.new([em_id]))
+ do_send("cga #{group_id} #{em_id}\n") # , wait: false)
+ end
+ end
+ end
+
+ def get_status(control_id : String, **options)
+ fader_type = options[:fader_type]?
+ @history[control_id] = fader_type if fader_type
+ do_send("cg #{control_id}\n", **options)
+ end
+
+ def set_position(control_id : String, position : Int32, ramp_time : Val? = nil)
+ if ramp_time
+ do_send("cspr \"#{control_id}\" #{position} #{ramp_time}\n") # , wait: false)
+ schedule.in(ramp_time.seconds + 200.milliseconds) { get_status(control_id) }
+ else
+ do_send("csp \"#{control_id}\" #{position}\n")
+ end
+ end
+
+ def set_value(control_id : String, value : Val, ramp_time : Val? = nil, **options)
+ fader_type = options[:fader_type]?
+ @history[control_id] = fader_type if fader_type
+ if ramp_time
+ do_send("csvr \"#{control_id}\" #{value} #{ramp_time}\n", **options) # , wait: false)
+ schedule.in(ramp_time.seconds + 200.milliseconds) { get_status(control_id) }
+ else
+ do_send("csv \"#{control_id}\" #{value}\n", **options)
+ end
+ end
+
+ def about
+ do_send("sg\n", name: :status, priority: 0)
+ end
+
+ def login(username : String? = nil, password : String? = nil)
+ username ||= @username
+ password ||= @password
+ do_send("login #{username} #{password}\n", name: :login, priority: 99)
+ end
+
+ # Used to set a dial number/string
+ def set_string(control_ids : Ids, text : String)
+ ensure_array(control_ids).each do |id|
+ do_send("css \"#{id}\" \"#{text}\"\n").get
+ self[id] = text
+ end
+ end
+
+ # Used to trigger dialing etc
+ def trigger(control_id : String)
+ logger.debug { "Sending trigger to Qsys: ct #{control_id}" }
+ do_send("ct \"#{control_id}\"\n") # , wait: false)
+ end
+
+ # Compatibility Methods
+ def fader(fader_ids : Ids, level : Val)
+ level = level.to_f.clamp(0.0, 100.0)
+ percentage = level / 100.0
+ range = -100..20
+
+ # adjust into range
+ level_actual = percentage * (range.size - 1).to_f
+ level_actual = (level_actual + range.begin.to_f).round(1)
+
+ ensure_array(fader_ids).each do |f_id|
+ if @history[f_id]? == :percentage_fader
+ set_value(f_id, level, name: "fader#{f_id}")
+ else
+ set_value(f_id, level_actual, name: "fader#{f_id}", fader_type: :fader)
+ end
+ end
+ end
+
+ def faders(fader_ids : Ids, level : Val)
+ fader(fader_ids, level)
+ end
+
+ def mute(mute_ids : Ids, state : Bool = true)
+ level = state ? 1 : 0
+ ensure_array(mute_ids).each { |m_id| set_value(m_id, level, fader_type: :mute) }
+ end
+
+ def mutes(mute_ids : Ids, state : Bool)
+ mute(mute_ids, state)
+ end
+
+ def unmute(mute_ids : Ids)
+ mute(mute_ids, false)
+ end
+
+ def mute_toggle(mute_id : Ids)
+ mute(mute_id, !self["fader#{mute_id}_mute"]?.try(&.as_bool))
+ end
+
+ def snapshot(name : String, index : Int32, ramp_time : Val = 1.5)
+ do_send("ssl \"#{name}\" #{index} #{ramp_time}\n") # , wait: false)
+ end
+
+ def save_snapshot(name : String, index : Int32)
+ do_send("sss \"#{name}\" #{index}\n") # , wait: false)
+ end
+
+ # For inter-module compatibility
+ def query_fader(fader_ids : Ids)
+ fad = ensure_array(fader_ids)[0]
+ get_status(fad, fader_type: (@history[fad]? || :fader))
+ end
+
+ def query_faders(fader_ids : Ids)
+ ensure_array(fader_ids).each { |f_id| get_status(f_id, fader_type: (@history[f_id]? || :fader)) }
+ end
+
+ def query_mute(fader_ids : Ids)
+ fad = ensure_array(fader_ids)[0]
+ get_status(fad, fader_type: :mute)
+ end
+
+ def query_mutes(fader_ids : Ids)
+ ensure_array(fader_ids).each { |fad| get_status(fad, fader_type: :mute) }
+ end
+
+ def phone_number(number : String, control_id : String)
+ set_string(control_id, number)
+ end
+
+ def phone_dial(control_id : String)
+ trigger(control_id)
+ schedule.in(200.milliseconds) { poll_change_group(:phone) }
+ end
+
+ def phone_hangup(control_id : String)
+ phone_dial(control_id)
+ end
+
+ private def create_change_group(name) : Group
+ name = name.to_s
+
+ if group = @dynamic_change_groups[name]?
+ return group
+ end
+
+ # Provide a unique group id
+ next_id = @change_group_id
+ @change_group_id += 1
+
+ @dynamic_change_groups[name] = {
+ id: next_id,
+ controls: Set(String).new,
+ }
+
+ # create change group and poll every 2 seconds
+ do_send("cgc #{next_id}\n") # , wait: false)
+ do_send("cgsna #{next_id} 2000\n") # , wait: false)
+ @dynamic_change_groups[name]
+ end
+
+ private def update_change_group(name, id, controls) : Group
+ @dynamic_change_groups[name.to_s] = {
+ id: id,
+ controls: controls,
+ }
+ end
+
+ private def poll_change_group(name)
+ if group = @dynamic_change_groups[name]
+ do_send("cgpna #{group[:id]}\n") # , wait: false)
+ end
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ puts "GOT: #{data}"
+ return task.try(&.success) if data == "none\r\n"
+ logger.debug { "QSys sent: #{data}" }
+ resp = shellsplit(data)
+
+ case resp[0]
+ when "cv"
+ control_id = resp[1]
+ string_rep = resp[2]
+ value = resp[-2]
+ position = resp[-1].to_f
+
+ self["pos_#{control_id}"] = position
+ @history[control_id] = :percentage_fader if string_rep.ends_with?('%')
+
+ if type = @history[control_id]?
+ case type
+ when :fader
+ range = -100..20
+ vol_percent = ((value.to_f - range.begin.to_f) / (range.size - 1).to_f) * 100.0
+ self["fader#{control_id}"] = vol_percent.round(2)
+ when :percentage_fader
+ self["fader#{control_id}"] = value.to_f
+ when :mute
+ self["fader#{control_id}_mute"] = value.to_i == 1
+ end
+ else
+ value = resp[2]
+ if value == "false" || value == "true"
+ self[control_id] = value == "true"
+ else
+ self[control_id] = value.gsub('_', ' ')
+ end
+ logger.debug { "Received response from unknown ID type: #{control_id} == #{value}" }
+ end
+ when "cvv" # Control status, Array of control status
+ control_id = resp[1]
+ count = resp[2].to_i
+
+ if type = @history[control_id]?
+ # Skip strings and extract the values
+ next_count = count + 3
+ count = resp[next_count].to_i
+ 1.upto(count) do |index|
+ value = resp[next_count + index]
+
+ case type
+ when :fader
+ range = -100..20
+ vol_percent = ((value.to_f - range.begin.to_f) / (range.size - 1).to_f) * 100.0
+ self["fader#{control_id}"] = vol_percent.round(2)
+ when :mute
+ self["fader#{control_id}_mute"] = value == 1
+ end
+ end
+ else
+ # Don't skip strings here
+ next_count = 2
+ 1.upto(count) do |index|
+ value = resp[next_count + index]
+
+ if value == "false" || value == "true"
+ self[control_id] = value == "true"
+ else
+ self[control_id] = value.gsub('_', ' ')
+ end
+ end
+ logger.debug { "Received response from unknown ID type: #{control_id}" }
+
+ # Jump to the position values
+ next_count = count + 3
+ count = resp[next_count].to_i
+ end
+
+ # Grab the positions
+ next_count = next_count + count + 1
+ count = resp[next_count].to_i
+ 1.upto(count) do |index|
+ value = resp[next_count + index]
+ self["pos_#{control_id}"] = value
+ end
+ when "sr" # About response
+ self[:design_name] = resp[1]
+ self[:is_primary] = resp[3] == "1"
+ self[:is_active] = resp[4] == "1"
+ when "core_not_active", "bad_change_group_handle", "bad_command", "bad_id", "control_read_only", "too_many_change_groups"
+ return task.try(&.abort("Error response received: #{data}"))
+ when "login_required"
+ login if @username
+ return task.try(&.abort("Login is required!"))
+ when "login_success"
+ logger.debug { "Login success!" }
+ when "login_failed"
+ return task.try(&.abort("Invalid login details provided"))
+ when "rc"
+ logger.warn { "System is notifying us of a disconnect!" }
+ when "cmvv"
+ logger.debug { "received cmvv response" }
+ else
+ logger.warn { "Unknown response received #{data}" }
+ end
+
+ task.try(&.success)
+ end
+
+ private def do_send(req, fader_type : Symbol? = nil, **options)
+ logger.debug { "sending #{req}" }
+ send(req, **options)
+ end
+
+ private def ensure_array(object)
+ object.is_a?(Array) ? object : [object]
+ end
+
+ # Quick dirty port of https://github.com/ruby/ruby/blob/master/lib/shellwords.rb
+ private def shellsplit(line : String) : Array(String)
+ words = [] of String
+ field = ""
+ pattern = /\G\s*(?>([^\s\\\'\"]+)|'([^\']*)'|"((?:[^\"\\]|\\.)*)"|(\\.?)|(\S))(\s|\z)?/m
+ line.scan(pattern) do |match|
+ _, word, sq, dq, esc, garbage, sep = match.to_a
+ raise ArgumentError.new("Unmatched quote: #{line.inspect}") if garbage
+ field += (word || sq || dq.try(&.gsub(/\\([$`"\\\n])/, "\\1")) || esc.not_nil!.gsub(/\\(.)/, "\\1"))
+ if sep
+ words << field
+ field = ""
+ end
+ end
+ words
+ end
+end
diff --git a/drivers/qsc/q_sys_control_spec.cr b/drivers/qsc/q_sys_control_spec.cr
new file mode 100644
index 00000000000..326f5d9b1f7
--- /dev/null
+++ b/drivers/qsc/q_sys_control_spec.cr
@@ -0,0 +1,79 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Qsc::QSysControl" do
+ sleep 1
+
+ settings({
+ username: "user",
+ password: "pass",
+ emergency: "6",
+ })
+
+ should_send("login user pass\n")
+ responds("login_success\r\n")
+ should_send("cgc 30\n")
+ responds("none\r\n")
+ should_send("cgsna 30 2000\n")
+ responds("none\r\n")
+ should_send("cga 30 6\n")
+ responds("none\r\n")
+
+ exec(:about)
+ should_send("sg\n")
+ responds("sr \"MyDesign\" \"NIEC2bxnVZ6a\" 1 1\r\n")
+ status[:design_name].should eq("MyDesign")
+ status[:is_primary].should eq(true)
+ status[:is_active].should eq(true)
+
+ exec(:mute, ["1", "2", "3"], true)
+ should_send("csv \"1\" 1\n")
+ responds("cv \"1\" \"control string\" 1 8\r\n")
+ status[:pos_1].should eq(8)
+ status[:fader1_mute].should eq(true)
+ should_send("csv \"2\" 1\n")
+ responds("cv \"2\" \"control string\" 1 5\r\n")
+ status[:pos_2].should eq(5)
+ status[:fader2_mute].should eq(true)
+ should_send("csv \"3\" 1\n")
+ responds("cv \"3\" \"control string\" 1 4\r\n")
+ status[:pos_3].should eq(4)
+ status[:fader3_mute].should eq(true)
+
+ exec(:faders, ["1", "2", "3"], 90)
+ should_send("csv \"1\" 8.0\n")
+ responds("cv \"1\" \"control string\" 9 6\r\n")
+ status[:pos_1].should eq(6)
+ status[:fader1].should eq(90.83)
+ status[:fader1_mute].should eq(true)
+ should_send("csv \"2\" 8.0\n")
+ responds("cv \"2\" \"control string\" 8 7\r\n")
+ status[:pos_2].should eq(7)
+ status[:fader2].should eq(90)
+ should_send("csv \"3\" 8.0\n")
+ responds("cv \"3\" \"control string\" 8 8\r\n")
+ status[:pos_3].should eq(8)
+ status[:fader3].should eq(90)
+
+ exec(:fader, "HH2:Level", 90)
+ should_send(%(csv "HH2:Level" 8.0\n))
+ responds %(cv "HH2:Level" "-53.2dB" -53.2 8.0\r\n)
+ status["faderHH2:Level"].should eq(39.0)
+
+ exec(:phone_number, "0123456789", "1")
+ should_send("css \"1\" \"0123456789\"\n")
+ responds("cv \"1\" \"0123456789\" 9 8\r\n")
+ status[:"1"].should eq("0123456789")
+
+ # Test percentage faders
+ exec(:query_fader, ["3"])
+ should_send %(cg 3\n)
+ responds %(cv 3 "0%" 0 0\r\n)
+ status[:pos_3].should eq(0.0)
+ status[:fader3].should eq(0.0)
+
+ exec(:query_fader, ["2"])
+ should_send %(cg 2\n)
+ responds %(cv 2 "20%" 20 20\r\n)
+ status[:pos_2].should eq(20.0)
+ status[:fader2].should eq(20.0)
+end
diff --git a/drivers/qsc/q_sys_remote.cr b/drivers/qsc/q_sys_remote.cr
new file mode 100644
index 00000000000..b5706faa822
--- /dev/null
+++ b/drivers/qsc/q_sys_remote.cr
@@ -0,0 +1,433 @@
+require "json"
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/QSC/QRCDocumentation.pdf
+# https://q-syshelp.qsc.com/Content/External_Control_APIs/QRC/QRC_Commands.htm
+
+class Qsc::QSysRemote < PlaceOS::Driver
+ tcp_port 1710
+ descriptive_name "QSC Audio DSP"
+ generic_name :Mixer
+
+ @id : Int32 = 0
+ @db_based_faders : Bool? = nil
+ @username : String? = nil
+ @password : String? = nil
+
+ Delimiter = "\0"
+ JsonRpcVer = "2.0"
+ Errors = {
+ -32700 => "Parse error. Invalid JSON was received by the server.",
+ -32600 => "Invalid request. The JSON sent is not a valid Request object.",
+ -32601 => "Method not found.",
+ -32602 => "Invalid params.",
+ -32603 => "Server error.",
+ 2 => "Invalid Page Request ID",
+ 3 => "Bad Page Request - could not create the requested Page Request",
+ 4 => "Missing file",
+ 5 => "Change Groups exhausted",
+ 6 => "Unknown change croup",
+ 7 => "Unknown component name",
+ 8 => "Unknown control",
+ 9 => "Illegal mixer channel index",
+ 10 => "Logon required",
+ }
+ DB_RANGE = -100..20
+
+ alias Num = Int32 | Float64
+ alias ValTup = NamedTuple(Name: String, Value: Num)
+ alias PosTup = NamedTuple(Name: String, Position: Num)
+ alias Values = ValTup | PosTup | Array(ValTup) | Array(PosTup)
+ alias Ids = String | Array(String)
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(Delimiter)
+ on_update
+ end
+
+ def on_update
+ @db_based_faders = setting?(Bool, :db_based_faders)
+ @username = setting?(String, :username)
+ @password = setting?(String, :password)
+ logon if @username && @password
+ end
+
+ def connected
+ schedule.every(20.seconds) do
+ logger.debug { "Maintaining Connection" }
+ no_op
+ end
+ @id = 0
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ # This command does nothing but is useful for making sure the socket is left open
+ def no_op
+ do_send(cmd: :NoOp, priority: 0, wait: false)
+ end
+
+ def get_status
+ do_send(next_id, cmd: :StatusGet, params: 0, priority: 0)
+ end
+
+ def logon
+ do_send(
+ cmd: :Logon,
+ params: {
+ :User => @username,
+ :Password => @password,
+ },
+ priority: 99
+ )
+ end
+
+ def control_set(name : String, value : Num | Bool, ramp : Num? = nil, **options)
+ if ramp
+ params = {
+ :Name => name,
+ :Value => value,
+ :Ramp => ramp,
+ }
+ else
+ params = {
+ :Name => name,
+ :Value => value,
+ }
+ end
+
+ do_send(next_id, "Control.Set", params, **options)
+ end
+
+ def control_get(names : Array(String), **options)
+ do_send(next_id, "Control.Get", names, **options)
+ end
+
+ def component_get(c_name : String, controls : Array(String), **options)
+ do_send(next_id, "Component.Get", {
+ :Name => c_name,
+ :Controls => controls.map { |ctrl| {:Name => ctrl} },
+ }, **options)
+ end
+
+ def component_set(c_name : String, values : Values, **options)
+ values = ensure_array(values)
+
+ do_send(next_id, "Component.Set", {
+ :Name => c_name,
+ :Controls => values,
+ }, **options)
+ end
+
+ def component_trigger(component : String, trigger : String, **options)
+ do_send(next_id, "Component.Trigger", {
+ :Name => component,
+ :Controls => [{:Name => trigger}],
+ }, **options)
+ end
+
+ def get_components(**options)
+ do_send(next_id, "Component.GetComponents", **options)
+ end
+
+ def change_group_add_controls(group_id : String, controls : Array(String), **options)
+ do_send(next_id, "ChangeGroup.AddControl", {
+ :Id => group_id,
+ :Controls => controls,
+ }, **options)
+ end
+
+ def change_group_remove_controls(group_id : String, controls : Array(String), **options)
+ do_send(next_id, "ChangeGroup.Remove", {
+ :Id => group_id,
+ :Controls => controls,
+ }, **options)
+ end
+
+ def change_group_add_component(group_id : String, component_name : String, controls : Array(String), **options)
+ do_send(next_id, "ChangeGroup.AddComponentControl", {
+ :Id => group_id,
+ :Component => {
+ :Name => component_name,
+ :Controls => controls.map { |ctrl| {:Name => ctrl} },
+ },
+ }, **options)
+ end
+
+ # Returns values for all the controls
+ def poll_change_group(group_id : String, **options)
+ do_send(next_id, "ChangeGroup.Poll", {:Id => group_id}, **options)
+ end
+
+ # Removes the change group
+ def destroy_change_group(group_id : String, **options)
+ do_send(next_id, "ChangeGroup.Destroy", {:Id => group_id}, **options)
+ end
+
+ # Removes all controls from change group
+ def clear_change_group(group_id : String, **options)
+ do_send(next_id, "ChangeGroup.Clear", {:Id => group_id}, **options)
+ end
+
+ # Where every is the number of seconds between polls
+ def auto_poll_change_group(group_id : String, every : Num, **options)
+ do_send(next_id, "ChangeGroup.AutoPoll", {
+ :Id => group_id,
+ :Rate => every,
+ }, **options) # , wait: false)
+ end
+
+ # Example usage:
+ # mixer 'Parade', {1 => [2,3,4], 3 => 6}, true
+ def mixer(name : String, inouts : Hash(Int32, Int32 | Array(Int32)), mute : Bool = false, **options)
+ inouts.each do |input, outputs|
+ outputs = ensure_array(outputs)
+
+ do_send(next_id, "Mixer.SetCrossPointMute", {
+ :Name => name,
+ :Inputs => input.to_s,
+ :Outputs => outputs.join(' '),
+ :Value => mute,
+ }, **options)
+ end
+ end
+
+ Faders = {
+ matrix_in: {
+ type: :"Mixer.SetInputGain",
+ pri: :Inputs,
+ },
+ matrix_out: {
+ type: :"Mixer.SetOutputGain",
+ pri: :Outputs,
+ },
+ matrix_crosspoint: {
+ type: :"Mixer.SetCrossPointGain",
+ pri: :Inputs,
+ sec: :Outputs,
+ },
+ }
+
+ def matrix_fader(name : String, level : Num, index : Array(Int32), type : String = "matrix_out", **options)
+ info = Faders[type]
+
+ level = level.to_f.clamp(0.0, 100.0)
+ percentage = level / 100.0
+
+ # adjust into range
+ level_actual = percentage * (DB_RANGE.size - 1).to_f
+ level_actual = (level_actual + DB_RANGE.begin.to_f).round(1)
+
+ if sec = info[:sec]?
+ params = {
+ :Name => name,
+ info[:pri] => index[0],
+ sec => index[1],
+ :Value => level_actual,
+ }
+ else
+ params = {
+ :Name => name,
+ info[:pri] => index,
+ :Value => level_actual,
+ }
+ end
+
+ do_send(next_id, info[:type], params, **options)
+ end
+
+ Mutes = {
+ matrix_in: {
+ type: :"Mixer.SetInputMute",
+ pri: :Inputs,
+ },
+ matrix_out: {
+ type: :"Mixer.SetOutputMute",
+ pri: :Outputs,
+ },
+ }
+
+ def matrix_mute(name : String, value : Num, index : Array(Int32), type : String = "matrix_out", **options)
+ info = Mutes[type]
+
+ do_send(next_id, info[:type], {
+ :Name => name,
+ info[:pri] => index,
+ :Value => value,
+ }, **options)
+ end
+
+ # value can either be a number to set actual numeric values like decibels
+ # or Bool to deal with mute state
+ def fader(fader_ids : Ids, value : Num | Bool, component : String? = nil, type : String = "fader", use_value : Bool = false, **options)
+ faders = ensure_array(fader_ids)
+ if component && (val = value.as?(Num))
+ val = val.to_f.clamp(0.0, 100.0)
+
+ if @db_based_faders || use_value
+ percentage = val / 100.0
+
+ # adjust into range
+ level_actual = percentage * (DB_RANGE.size - 1).to_f
+ level_actual = (level_actual + DB_RANGE.begin.to_f).round(1)
+
+ fads = faders.map { |fad| {Name: fad, Value: level_actual} }
+ else
+ # I think this means 0-100%
+ fads = faders.map { |fad| {Name: fad, Position: val} }
+ end
+ component_set(component, fads, name: "level_#{faders[0]}").get
+ component_get(component, faders)
+ else
+ reqs = faders.map { |fad| control_set(fad, value) }
+ reqs.last.get
+ control_get(faders)
+ end
+ end
+
+ def faders(ids : Ids, value : Num | Bool, component : String? = nil, type : String = "fader", **options)
+ fader(ids, value, component, type, **options)
+ end
+
+ def mute(fader_id : Ids, state : Bool = true, component : String? = nil, type : String = "fader", **options)
+ fader(fader_id, state, component, type, state, **options)
+ end
+
+ def mutes(ids : Ids, state : Bool = true, component : String? = nil, type : String = "fader", **options)
+ mute(ids, state, component, type, **options)
+ end
+
+ def unmute(fader_id : Ids, component : String? = nil, type : String = "fader", **options)
+ mute(fader_id, false, component, type, **options)
+ end
+
+ def query_fader(fader_id : Ids, component : String? = nil, type : String = "fader")
+ faders = ensure_array(fader_id)
+ component ? component_get(component, faders) : control_get(faders)
+ end
+
+ def query_faders(ids : Ids, component : String? = nil, type : String = "fader", **options)
+ query_fader(ids, component, type, **options)
+ end
+
+ def query_mute(fader_id : Ids, component : String? = nil, type : String = "fader")
+ query_fader(fader_id, component, type)
+ end
+
+ def query_mutes(ids : Ids, component : String? = nil, type : String = "fader", **options)
+ query_fader(ids, component, type, **options)
+ end
+
+ def received(data, task)
+ data = String.new(data[0..-2])
+ response = JSON.parse(data)
+
+ logger.debug { "QSys sent:" }
+ logger.debug { response }
+
+ if err = response["error"]?
+ code = err["code"]
+ logger.warn { "Error code #{code} - #{Errors[code]}" }
+
+ if code == 10
+ if @username && @password
+ logon.get
+ return task.try(&.retry("Logged on and retrying command"))
+ else
+ return task.try(&.abort("Login required but no username and/or password in settings"))
+ end
+ end
+
+ return task.try(&.abort(err["message"]))
+ end
+
+ return task.try(&.success("Unknown response")) unless result = response["result"]?
+
+ case result
+ when .as_h?
+ if result["Controls"]? # Probably Component.Get
+ process(result["Controls"].as_a, result["Name"]?)
+ elsif result["Platform"]? # StatusGet
+ result.as_h.each { |k, v| self[k.underscore] = v }
+ end
+ when .as_a? # Control.Get
+ process(result.as_a)
+ end
+
+ task.try(&.success)
+ end
+
+ BoolVals = {"true", "false", "muted", "unmuted"}
+ BoolTrue = {"true", "muted"}
+
+ private def process(values : Array, name : JSON::Any? = nil)
+ component = name.try(&.as_s?) ? "_#{name}" : ""
+ values.each do |value|
+ name = value["Name"]
+
+ next unless val = value["Value"]?
+
+ pos = value["Position"]?
+ str = value["String"]?.try(&.as_s)
+
+ if BoolVals.includes?(str)
+ self["fader#{name}#{component}_mute"] = str.in?(BoolTrue)
+ else
+ # Seems like string values can be independent of the other values
+ # This should mostly work to detect a string value
+ if val == 0 && pos == 0 && str && str[0] != '0'
+ self["#{name}#{component}"] = str
+ next
+ end
+
+ if pos && (pos = pos.as_f?)
+ self["fader#{name}#{component}_pos"] = pos
+ self["fader#{name}#{component}"] = pos * 100.0
+ end
+
+ if val.as_s?
+ self["#{name}#{component}"] = val
+ elsif val = (val.as_i? || val.as_f?)
+ self["fader#{name}#{component}_val"] = val
+ end
+ end
+ end
+ end
+
+ def next_id
+ @id += 1
+ @id
+ end
+
+ private def do_send(id : Int32? = nil, cmd = nil, params = {} of String => String, **options)
+ if id
+ req = {
+ jsonrpc: JsonRpcVer,
+ id: id,
+ method: cmd,
+ params: params,
+ }
+ else
+ req = {
+ jsonrpc: JsonRpcVer,
+ method: cmd,
+ params: params,
+ }
+ end
+
+ logger.debug { "sending: #{req}" }
+
+ cmd = req.to_json + Delimiter
+
+ logger.debug { "sending json" }
+ logger.debug { cmd.inspect }
+
+ send(cmd, **options)
+ end
+
+ private def ensure_array(object)
+ object.is_a?(Array) ? object : [object]
+ end
+end
diff --git a/drivers/qsc/q_sys_remote_spec.cr b/drivers/qsc/q_sys_remote_spec.cr
new file mode 100644
index 00000000000..fb98549c46c
--- /dev/null
+++ b/drivers/qsc/q_sys_remote_spec.cr
@@ -0,0 +1,151 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Qsc::QSysRemote" do
+ settings({
+ username: "user",
+ password: "pass",
+ })
+
+ # logon
+ should_send({
+ jsonrpc: "2.0",
+ method: "Logon",
+ params: {
+ "User" => "user",
+ "Password" => "pass",
+ },
+ }.to_json + "\0")
+ responds({"TODO" => "response not defined in docs"}.to_json + "\0")
+
+ exec(:no_op)
+ should_send({
+ jsonrpc: "2.0",
+ method: "NoOp",
+ params: {} of String => String,
+ }.to_json + "\0")
+ responds({"TODO" => "response not defined in docs"}.to_json + "\0")
+
+ exec(:get_status)
+ should_send({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "StatusGet",
+ params: 0,
+ }.to_json + "\0")
+ responds({
+ "jsonrpc" => "2.0",
+ "id" => 1,
+ "result" => {
+ "Platform" => "Core 500i",
+ "State" => "Active",
+ "DesignName" => "SAF‐MainPA",
+ "DesignCode" => "qALFilm6IcAz",
+ "IsRedundant" => false,
+ "IsEmulator" => true,
+ "Status" => {
+ "Code" => 0,
+ "String" => "OK",
+ },
+ },
+ }.to_json + "\0")
+ status[:platform].should eq("Core 500i")
+ status[:state].should eq("Active")
+ status[:design_name].should eq("SAF‐MainPA")
+ status[:design_code].should eq("qALFilm6IcAz")
+ status[:is_redundant].should eq(false)
+ status[:is_emulator].should eq(true)
+ status[:status].should eq({
+ "Code" => 0,
+ "String" => "OK",
+ })
+
+ exec(:control_set, "MainGain", 8)
+ should_send({
+ "jsonrpc" => "2.0",
+ "id" => 2,
+ "method" => "Control.Set",
+ "params" => {
+ "Name" => "MainGain",
+ "Value" => 8,
+ },
+ }.to_json + "\0")
+ responds({
+ "jsonrpc" => "2.0",
+ "id" => 1234,
+ "result" => [
+ {
+ "Name" => "MainGain",
+ "Value" => 8,
+ },
+ ],
+ }.to_json + "\0")
+ status[:faderMainGain_val].should eq(8)
+
+ exec(:component_get, "My APM", ["ent.xfade.gain", "ent.xfade.gain2"])
+ should_send({
+ "jsonrpc" => "2.0",
+ "id" => 3,
+ "method" => "Component.Get",
+ "params" => {
+ "Name" => "My APM",
+ "Controls" => [
+ {"Name" => "ent.xfade.gain"},
+ {"Name" => "ent.xfade.gain2"},
+ ],
+ },
+ }.to_json + "\0")
+ responds({
+ "jsonrpc" => "2.0",
+ "result" => {
+ "Name" => "My APM",
+ "Controls" => [
+ {
+ "Name" => "ent.xfade.gain",
+ "Value" => -100.0,
+ "String" => "‐100.0dB",
+ "Position" => 0,
+ },
+ {
+ "Name" => "ent.xfade.gain2",
+ "Value" => 8.0,
+ "String" => "8.0dB",
+ "Position" => 0.9,
+ },
+ ],
+ },
+ }.to_json + "\0")
+ status["faderent.xfade.gain_My APM_pos"].should eq(0)
+ status["faderent.xfade.gain_My APM"].should eq(0)
+ status["faderent.xfade.gain2_My APM_pos"].should eq(0.9)
+ status["faderent.xfade.gain2_My APM"].should eq(90.0)
+
+ exec(:change_group_add_controls, "my change group", ["some control", "another control"])
+ should_send({
+ "jsonrpc" => "2.0",
+ "id" => 4,
+ "method" => "ChangeGroup.AddControl",
+ "params" => {
+ "Id" => "my change group",
+ "Controls" => ["some control", "another control"],
+ },
+ }.to_json + "\0")
+ responds({
+ "jsonrpc" => "2.0",
+ "id" => 4,
+ "result" => {
+ "Id" => "my change group",
+ "Changes" => [
+ {
+ "Name" => "some control",
+ "Value" => -12,
+ "String" => "‐12dB",
+ },
+ {
+ "Name" => "another control",
+ "Value" => -6,
+ "String" => "‐6dB",
+ },
+ ],
+ },
+ }.to_json + "\0")
+end
diff --git a/drivers/rhb_access/axiom_room_logic.cr b/drivers/rhb_access/axiom_room_logic.cr
new file mode 100644
index 00000000000..7070ccf7aad
--- /dev/null
+++ b/drivers/rhb_access/axiom_room_logic.cr
@@ -0,0 +1,55 @@
+require "placeos-driver"
+
+class RHBAccess::AxiomRoomLogic < PlaceOS::Driver
+ descriptive_name "Room Access Logic for Axiom rooms"
+ generic_name :RoomAccess
+ description "Abstracts room access for Axiom"
+
+ default_settings({
+ axiom_door_ids: [] of String,
+ axiom_status_poll_cron: "*/5 * * * *",
+ })
+
+ accessor axiom : AxiomXa
+
+ @door_ids = [] of String
+ @cron_string : String = "*/5 * * * *"
+
+ def on_update
+ @door_ids = setting(Array(String), :axiom_door_ids)
+ @cron_string = setting(String, :axiom_status_poll_cron)
+ schedule.clear
+ schedule.cron(@cron_string) { status? }
+ end
+
+ def lock
+ @door_ids.map { |d| axiom.lock(d).get }
+ rescue
+ logger.error { "AxiomXa: ERROR while LOCKING #{@door_ids}" }
+ else
+ self["locked_by_placeos_at"] = Time.local
+ status?
+ end
+
+ def unlock
+ @door_ids.map { |d| axiom.unlock(d).get }
+ rescue
+ logger.error { "AxiomXa: ERROR while UNLOCKING #{@door_ids}" }
+ else
+ self["unlocked_by_placeos_at"] = Time.local
+ status?
+ end
+
+ def status?
+ result = @door_ids.map { |id| {id, axiom.status?(id).get} }
+ rescue
+ logger.error { "AxiomXa: ERROR requesting STATUS of #{@door_ids}" }
+ else
+ doors_locked = 0
+ result.each do |id, status|
+ self[id] = status["Status"]
+ doors_locked += 1 if status["Status"].to_s.starts_with? "Locked"
+ end
+ self["doors_locked"] = doors_locked
+ end
+end
diff --git a/drivers/rhb_access/axiomxa.cr b/drivers/rhb_access/axiomxa.cr
new file mode 100644
index 00000000000..5b3eb05a5c9
--- /dev/null
+++ b/drivers/rhb_access/axiomxa.cr
@@ -0,0 +1,37 @@
+require "placeos-driver"
+require "axio"
+
+class RHBAccess::Axiomxa < PlaceOS::Driver
+ descriptive_name "RHB Access AxiomXA"
+ generic_name :AxiomXa
+ uri_base "http://127.0.0.1:60001"
+
+ alias Client = Axio::Client
+
+ default_settings({
+ username: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
+ password: "ABCDEF123456",
+ })
+
+ protected getter! client : Client
+
+ def on_update
+ host_name = config.uri.not_nil!.to_s
+ @client = Client.new(base_url: host_name.to_s, username: setting(String, :username), password: setting(String, :password))
+ end
+
+ def lock(id : String, permanent : Bool = false)
+ @client.try(&.access_points.lock(id: id, permanent: permanent.to_s))
+ self["access_point_#{id}"] = {"Status" => "locked", "permanent" => permanent.to_s}
+ end
+
+ def unlock(id : String, permanent : Bool = false)
+ @client.try(&.access_points.unlock(id: id, permanent: permanent.to_s))
+ self["access_point_#{id}"] = {"Status" => "unlocked", "permanent" => permanent.to_s}
+ end
+
+ def status?(id : String)
+ response = @client.try(&.access_points.status(id: id))
+ self["access_point_#{id}_status"] = JSON.parse(response.not_nil!.body)
+ end
+end
diff --git a/drivers/rhombus/integration_details.md b/drivers/rhombus/integration_details.md
new file mode 100644
index 00000000000..b51dd0ac517
--- /dev/null
+++ b/drivers/rhombus/integration_details.md
@@ -0,0 +1,102 @@
+# Webhook interaction details
+
+PlaceOS implements the following webhook methods for Rhombus initiated interactions.
+
+A webhook will be create in backoffice on PlaceOS that is unique for each client that will look something like this: https://instance.placeos.com/api/engine/v2/webhook/trig-DHgkU1~p/notify?secret=kfwu5WYc3a1suZ&exec=true&mod=RhombusSecurity&method=request
+
+or
+
+https://instance.placeos.com/api/engine/v2/webhook/trig-DHgkU1~p/notify/{secret}/{mod}/{index}/{request}
+
+## Creating the Webhook
+
+1. Add a new webhook
+ * enable webhook
+ * add supported methods: GET, POST, PUT, PATCH, DELETE
+2. Add the trigger to the system
+ * edit it to enable execute
+3. Copy the webhook link and add `exec=true&mod=RhombusSecurity&method=request` to the end of the URL
+
+## Supported methods
+
+These indicate the desired action to be performed, where `Place Webhook` is the unique webhook generated for the client and defined in Rhombus
+
+### POST Place Webhook
+
+Creates a new Rhombus subscription when POSTED with the following body:
+
+```yaml
+{
+ "webhook": "https://webhooks.rhombussystems.com/external/placeOsWebhook/AAAAAAAAAAAAAA",
+ # Random data if signed requests are desirable (optional)
+ "secret": "123456"
+}
+```
+
+responds 201 on success
+
+### DELETE Place Webhook
+
+Removes a Rhombus subscription when DELETED with the following body:
+
+```yaml
+{
+ "webhook": "https://webhooks.rhombussystems.com/external/placeOsWebhook/AAAAAAAAAAAAAA"
+}
+```
+
+expects the webhook URL to match an existing subscription
+responds 202 on success
+
+### GET Place Webhook
+
+Responds 200 and returns the list of doors in the security system:
+
+```yaml
+[
+ {
+ "door_id": "1234-2342",
+ "description": "Lobby Entrance"
+ },
+ {
+ "door_id": "5678-9012"
+ }
+]
+```
+
+### PUT Place Webhook
+
+Attempts to unlock a door when PUT with the following body:
+
+```yaml
+{
+ "door_id": "5678-9012"
+}
+```
+
+responds:
+
+* 200 for success
+* 403 when failed to unlock for any reason
+* 501 if the security system doesn't support remote door unlock
+
+## PlaceOS -> Rhombus
+
+When a door event is detected, the following is sent to each of the Rombus subscriptions
+
+### POST webhooks.rhombussystems
+
+with body
+
+```yaml
+{
+ "door_id": "123456",
+ "timestamp": "2022-04-03T23:59:25Z",
+ # signature set if a secret was provided with the initail subscription
+ "signature": "HMAC hex digest sha256 signature of timestamp",
+ "action": "granted", # or `"denied"` `"tamper"` `"request_to_exit"`
+ "card_id": "123456", # optional
+ "user_name": "Steve Place", # optional
+ "user_email": "steve@place.org", # optional
+}
+```
diff --git a/drivers/rhombus/security_interop.cr b/drivers/rhombus/security_interop.cr
new file mode 100644
index 00000000000..151163b6e6e
--- /dev/null
+++ b/drivers/rhombus/security_interop.cr
@@ -0,0 +1,98 @@
+require "http"
+require "placeos-driver"
+require "./security_interop_models"
+
+class Rhombus::SecurityInterop < PlaceOS::Driver
+ descriptive_name "Rhombus Security Interop"
+ generic_name :RhombusSecurity
+ description %(provides an interface for rhombus and local security platforms)
+
+ default_settings({
+ debug_webhook: false,
+ organization_id: "event",
+ })
+
+ @debug_webhook : Bool = false
+ @subscriptions : Array(Subscription) = [] of Subscription
+ @event_count : UInt64 = 0_u64
+
+ def on_update
+ subscriptions.clear
+ org_id = setting?(String, :organization_id) || "event"
+ monitor("security/#{org_id}/door") { |_subscription, payload| door_event(payload) }
+ @subscriptions = setting?(Array(Subscription), :subscriptions) || [] of Subscription
+ @debug_webhook = setting?(Bool, :debug_webhook) || false
+ end
+
+ protected def security
+ system.implementing(Interface::DoorSecurity)
+ end
+
+ def request(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "webhook received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+ logger.debug { body } if @debug_webhook
+
+ case method.downcase
+ when "post"
+ # new subscription
+ @subscriptions << Subscription.from_json(body)
+ define_setting(:subscriptions, @subscriptions)
+ {HTTP::Status::CREATED.to_i, {} of String => String, ""}
+ when "delete"
+ # delete subscription
+ sub_webhook = Subscription.from_json(body).webhook
+ @subscriptions.reject! { |sub| sub.webhook == sub_webhook }
+ define_setting(:subscriptions, @subscriptions)
+ {HTTP::Status::ACCEPTED.to_i, {} of String => String, ""}
+ when "get"
+ # return the list of doors
+ all_doors = [] of JSON::Any
+ security.door_list.get.each do |doors|
+ all_doors.concat doors.as_a
+ end
+ {HTTP::Status::OK.to_i, {"Content-Type" => "application/json"}, {
+ doors: all_doors,
+ }.to_json}
+ when "put"
+ # unlock a door
+ door = Interface::DoorSecurity::Door.from_json(body).door_id
+ case security.unlock(door).get.first.as_bool?
+ in true
+ {HTTP::Status::OK.to_i, {} of String => String, ""}
+ in false
+ {HTTP::Status::FORBIDDEN.to_i, {} of String => String, ""}
+ in nil
+ {HTTP::Status::NOT_IMPLEMENTED.to_i, {} of String => String, ""}
+ end
+ else
+ {HTTP::Status::BAD_REQUEST.to_i, {"Content-Type" => "application/json"}, {error: "unexpected HTTP request method: #{method}"}.to_json}
+ end
+ rescue error
+ logger.warn(exception: error) { "processing webhook request" }
+ {HTTP::Status::INTERNAL_SERVER_ERROR.to_i, {"Content-Type" => "application/json"}, error.message.to_s}
+ end
+
+ @[Security(Level::Administrator)]
+ def door_event(json : String)
+ logger.debug { "new door event detected: #{json}" }
+ webhook = Webhook.new Interface::DoorSecurity::DoorEvent.from_json(json)
+ @event_count += 1_u64
+
+ @subscriptions.each do |sub|
+ begin
+ logger.debug { "notifying webhook of new door event: #{sub.webhook}" }
+ webhook.sign(sub.secret)
+ response = HTTP::Client.post(
+ sub.webhook,
+ HTTP::Headers{"Content-Type" => "application/json"},
+ webhook.to_json
+ )
+ logger.warn { "request #{sub.webhook} failed with status: #{response.status_code}\n#{response.body}" } unless response.success?
+ rescue error
+ logger.error(exception: error) { "failed to notify subscription: #{sub.webhook}" }
+ end
+ end
+
+ self[:event_count] = @event_count
+ end
+end
diff --git a/drivers/rhombus/security_interop_models.cr b/drivers/rhombus/security_interop_models.cr
new file mode 100644
index 00000000000..18d31bb1363
--- /dev/null
+++ b/drivers/rhombus/security_interop_models.cr
@@ -0,0 +1,46 @@
+require "json"
+require "openssl/hmac"
+require "placeos-driver/interface/door_security"
+
+module Rhombus
+ class Subscription
+ include JSON::Serializable
+
+ getter webhook : String
+ getter secret : String?
+
+ def initialize(@webhook, @secret = nil)
+ end
+ end
+
+ class Webhook
+ include JSON::Serializable
+
+ getter door_id : String
+ getter timestamp : Time
+ getter signature : String? = nil
+ getter action : PlaceOS::Driver::Interface::DoorSecurity::Action
+ getter card_id : String?
+ getter user_name : String?
+ getter user_email : String?
+
+ def initialize(event : PlaceOS::Driver::Interface::DoorSecurity::DoorEvent)
+ @action = event.action
+ @door_id = event.door_id
+ @timestamp = Time.unix event.timestamp
+
+ @card_id = event.card_id
+ @user_name = event.user_name
+ @user_email = event.user_email
+ end
+
+ def sign(secret : String?)
+ if key = secret.presence
+ @signature = OpenSSL::HMAC.hexdigest(:sha256, key, timestamp.to_rfc3339)
+ else
+ @signature = nil
+ end
+ self
+ end
+ end
+end
diff --git a/drivers/rhombus/security_interop_spec.cr b/drivers/rhombus/security_interop_spec.cr
new file mode 100644
index 00000000000..b237a8f1d9c
--- /dev/null
+++ b/drivers/rhombus/security_interop_spec.cr
@@ -0,0 +1,68 @@
+require "placeos-driver/spec"
+require "placeos-driver/interface/door_security"
+
+DriverSpecs.mock_driver "Rhombus::SecurityInterop" do
+ system({
+ SecuritySystem: {DoorSecurityMock},
+ })
+
+ settings({
+ debug_webhook: true,
+ subscriptions: [{
+ # spec ports
+ webhook: URI.new("http", "127.0.0.1", __get_ports__[1]).to_s,
+ }],
+ })
+
+ # test notifying of a door event
+ timestamp = Time.utc
+ exec(:door_event, {
+ module_id: "testing",
+ security_system: "testing",
+ door_id: "the-door",
+ timestamp: timestamp.to_unix,
+ action: "RequestToExit",
+ }.to_json)
+
+ webhook = nil
+ expect_http_request do |request, response|
+ webhook = request.body.not_nil!.gets_to_end
+ response.status_code = 200
+ end
+
+ raise "no webhook payload" unless webhook
+
+ JSON.parse(webhook.not_nil!).should eq({
+ "door_id" => "the-door",
+ "timestamp" => timestamp.to_rfc3339,
+ "action" => "request_to_exit",
+ })
+
+ # test listing of doors
+ resp = exec(:request, "GET", {} of String => Array(String), "").get.not_nil!
+ JSON.parse(resp[2].as_s).should eq({
+ "doors" => [{
+ "door_id" => "testing",
+ }],
+ })
+
+ # test unlocking a door
+ resp = exec(:request, "PUT", {} of String => Array(String), %({"door_id": "some-door"})).get.not_nil!
+ resp[0].should eq(200)
+
+ system(:SecuritySystem_1)[:last_unlocked].should eq "some-door"
+end
+
+# :nodoc:
+class DoorSecurityMock < DriverSpecs::MockDriver
+ include PlaceOS::Driver::Interface::DoorSecurity
+
+ def door_list : Array(Door)
+ [Door.new("testing")]
+ end
+
+ def unlock(door_id : String) : Bool?
+ self[:last_unlocked] = door_id
+ true
+ end
+end
diff --git a/drivers/rhombus/security_mock.cr b/drivers/rhombus/security_mock.cr
new file mode 100644
index 00000000000..633d8b20bab
--- /dev/null
+++ b/drivers/rhombus/security_mock.cr
@@ -0,0 +1,85 @@
+require "json"
+require "faker"
+require "placeos-driver"
+require "placeos-driver/interface/door_security"
+
+class Rhombus::SecurityMock < PlaceOS::Driver
+ descriptive_name "Rhombus Mock Security System"
+ generic_name :SecurityMock
+ description %(a mock security system for interface testing)
+
+ include PlaceOS::Driver::Interface::DoorSecurity
+
+ default_settings({
+ door_list_size: 30,
+ swipe_event_every: 30,
+ })
+
+ record CardUser, card_id : String, user_name : String, user_email : String do
+ include JSON::Serializable
+ end
+
+ getter door_list : Array(Door) = [] of Door
+ getter card_holders : Array(CardUser) = [] of CardUser
+
+ def on_update
+ # ensure door names and IDs don't change between reloads
+ door_list_size = setting?(Int32, :door_list_size) || 30
+ Faker.seed door_list_size
+ door_id = 1000
+
+ doors = Array(Door).new(door_list_size)
+ door_list_size.times do
+ doors << Door.new(
+ door_id.to_s,
+ Faker::Commerce.department
+ )
+ door_id += 1
+ end
+ @door_list = doors
+
+ # generate some card holders
+ @card_holders = (0..10).map do
+ CardUser.new(
+ Faker::Business.credit_card_number,
+ Faker::Name.name,
+ Faker::Internet.safe_email
+ )
+ end
+
+ # Trigger regular swipe events
+ swipe_event_period = setting?(Int32, :swipe_event_every) || 30
+ schedule.clear
+ schedule.every(swipe_event_period.seconds) do
+ door = doors.sample
+ action = Action::Granted
+
+ case rand(6)
+ when 0, 1, 2
+ user = card_holders.sample
+ when 3
+ action = Action::Denied
+ user = card_holders.sample
+ when 4
+ action = Action::Tamper
+ when 5
+ action = Action::RequestToExit
+ end
+
+ publish("security/event/door", DoorEvent.new(
+ module_id: module_id,
+ security_system: "mock",
+ door_id: door.door_id,
+ action: action,
+ card_id: user.try &.card_id,
+ user_name: user.try &.user_name,
+ user_email: user.try &.user_email
+ ).to_json)
+ end
+ end
+
+ def unlock(door_id : String) : Bool?
+ self[:last_unlocked] = door_id
+ true
+ end
+end
diff --git a/drivers/samsung/displays/mdc_protocol.cr b/drivers/samsung/displays/mdc_protocol.cr
new file mode 100644
index 00000000000..43d22d9505b
--- /dev/null
+++ b/drivers/samsung/displays/mdc_protocol.cr
@@ -0,0 +1,365 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+class Samsung::Displays::MDCProtocol < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ INDICATOR = 0xAA_u8
+
+ enum Input
+ Vga = 0x14 # pc in manual
+ Dvi = 0x18
+ DviVideo = 0x1F
+ Hdmi = 0x21
+ HdmiPc = 0x22
+ Hdmi2 = 0x23
+ Hdmi2Pc = 0x24
+ Hdmi3 = 0x31
+ Hdmi3Pc = 0x32
+ Hdmi4 = 0x33
+ Hdmi4Pc = 0x34
+ DisplayPort = 0x25
+ Dtv = 0x40
+ Media = 0x60
+ Widi = 0x61
+ MagicInfo = 0x20
+ Whiteboard = 0x64
+ end
+
+ include Interface::InputSelection(Input)
+
+ # Discovery Information
+ tcp_port 1515
+ descriptive_name "Samsung MD, DM & QM Series LCD"
+ generic_name :Display
+
+ # Markdown description
+ description <<-DESC
+ For DM displays configure the following 1:
+
+ 1. Network Standby = ON
+ 2. Set Auto Standby = OFF
+ 3. Set Eco Solution, Auto Off = OFF
+
+ Hard Power off displays each night and hard power ON in the morning.
+ DESC
+
+ default_settings({
+ display_id: 0,
+ rs232_control: false,
+ })
+
+ @id : UInt8 = 0
+ @rs232 : Bool = false
+ @blank : Input?
+ @previous_volume : Int32 = 50
+ @input_target : Input? = nil
+ @power_target : Bool? = nil
+
+ def on_load
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.peek
+ logger.debug { "Received: #{bytes.hexstring}" }
+
+ # Ensure message indicator is well-formed
+ if bytes.first == INDICATOR
+ # [header, command, id, data.size, [data], checksum]
+ # return 0 if the message is incomplete
+ bytes.size < 4 ? 0 : bytes[3].to_i + 5
+ else
+ logger.debug { "Ignoring unexpected message" }
+ io.clear
+ 0
+ end
+ end
+
+ on_update
+ end
+
+ def on_update
+ @id = setting(UInt8, :display_id)
+ @rs232 = setting(Bool, :rs232_control)
+ @blank = setting?(String, :blanking_input).try &->Input.parse(String)
+ end
+
+ def connected
+ do_device_config unless self[:hard_off]?.try &.as_bool
+
+ schedule.every(30.seconds, true) do
+ do_poll
+ end
+ end
+
+ def disconnected
+ self[:power] = false unless @rs232
+ schedule.clear
+ end
+
+ # As true power off disconnects the server we only want to power off the panel
+ def power(state : Bool)
+ @power_target = state
+
+ if state
+ # Power on
+ do_send(Command::HardOff, 1)
+ do_send(Command::PanelMute, 0)
+ else
+ # Blank the screen before turning off panel if required
+ # required by some video walls where screens are chained
+ if (blanking_input = @blank) && self[:power]?
+ switch_to(blanking_input)
+ end
+ do_send(Command::PanelMute, 1)
+ end
+ end
+
+ def hard_off
+ do_send(Command::PanelMute, 0) if self[:power]?.try &.as_bool
+ do_send(Command::HardOff, 0)
+ end
+
+ def power?(**options) : Bool
+ do_send(Command::PanelMute, Bytes.empty, **options).get
+ !!self[:power]?.try(&.as_bool)
+ end
+
+ # Mutes both audio/video
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ mute_video(state) if layer.video? || layer.audio_video?
+ mute_audio(state) if layer.audio? || layer.audio_video?
+ end
+
+ # Adds video mute state compatible with projectors
+ def mute_video(state : Bool = true)
+ state = state ? 1 : 0
+ do_send(Command::PanelMute, state)
+ end
+
+ # Emulate audio mute
+ def mute_audio(state : Bool = true)
+ # Do nothing if already in desired state
+ return if self[:audio_mute]?.try(&.as_bool) == state
+ self[:audio_mute] = state
+ if state
+ @previous_volume = self[:volume]?.try(&.as_i) || 0
+ volume(0)
+ else
+ volume(@previous_volume)
+ end
+ end
+
+ # check software version
+ def software_version
+ do_send(Command::SoftwareVersion)
+ end
+
+ def serial_number
+ do_send(Command::SerialNumber)
+ end
+
+ def switch_to(input : Input, **options)
+ @input_target = input
+ do_send(Command::Input, input.value, **options)
+ end
+
+ enum SpeakerMode
+ Internal = 0
+ External = 1
+ end
+
+ def speaker_select(mode : SpeakerMode, **options)
+ do_send(Command::Speaker, mode.value, **options)
+ end
+
+ def do_poll
+ do_send(Command::Status, Bytes.empty, priority: 0)
+ power? unless self[:hard_off]?.try &.as_bool
+ end
+
+ alias Num = Int32 | Float64
+
+ DEVICE_SETTINGS = {
+ network_standby: Bool,
+ auto_off_timer: Bool,
+ auto_power: Bool,
+ volume: Num,
+ contrast: Num,
+ brightness: Num,
+ sharpness: Num,
+ colour: Num,
+ tint: Num,
+ red_gain: Num,
+ green_gain: Num,
+ blue_gain: Num,
+ }
+ {% for name, kind in DEVICE_SETTINGS %}
+ @[Security(Level::Administrator)]
+ def {{name.id}}(value : {{kind}}, **options)
+ {% if kind.resolve == Bool %}
+ state = value ? 1 : 0
+ data = {{name.id.stringify}} == "auto_off_timer" ? Bytes[0x81, state] : state
+ {% else %}
+ data = value.to_f.clamp(0.0, 100.0).round_away.to_i
+ {% end %}
+ do_send(Command.parse({{name.id.stringify}}), data, **options)
+ end
+ {% end %}
+
+ def do_device_config
+ {% for name, kind in DEVICE_SETTINGS %}
+ %value = setting?({{kind}}, {{name.id.stringify}})
+ {{name.id}}(%value) if %value
+ {% end %}
+ end
+
+ def volume_up
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume + 5.0)
+ end
+
+ def volume_down
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume - 5.0)
+ end
+
+ enum ResponseStatus
+ Ack = 0x41 # A
+ Nak = 0x4e # N
+ end
+
+ def received(data, task)
+ hex = data.hexstring
+ logger.debug { "Samsung sent: #{hex}" }
+
+ # Verify the checksum of the response
+ if data[-1] != (checksum = data[1..-2].sum(0) & 0xFF)
+ logger.error { "Invalid response, checksum should be: #{checksum.to_s(16)}" }
+ return task.try &.retry
+ end
+
+ status = ResponseStatus.from_value(data[4])
+ command = Command.from_value(data[5])
+ values = data[6..-2]
+ value = values.first
+
+ case status
+ when .ack?
+ case command
+ when .status?
+ self[:hard_off] = hard_off = values[0] == 0
+ self[:power] = false if hard_off
+ self[:volume] = values[1]
+ self[:audio_mute] = values[2] == 1
+ self[:input] = Input.from_value(values[3])
+ check_power_state
+ when .panel_mute?
+ self[:power] = value == 0
+ check_power_state
+ when .volume?
+ self[:volume] = value
+ self[:audio_mute] = false if value > 0
+ when .brightness?
+ self[:brightness] = value
+ when .input?
+ current_input = Input.from_value(value)
+ self[:input] = current_input
+ # The input feedback behaviour seems to go a little odd when
+ # screen split is active. Ignore any input forcing when on.
+ unless self[:screen_split]?.try &.as_bool
+ if current_input == @input_target
+ @input_target = nil
+ elsif input_target = @input_target
+ switch_to(input_target)
+ end
+ end
+ when .speaker?
+ self[:speaker] = SpeakerMode.from_value(value)
+ when .hard_off?
+ unless self[:hard_off]?.try &.as_bool
+ self[:hard_off] = hard_off = value == 0
+ self[:power] = false if hard_off
+ end
+ when .screen_split?
+ self[:screen_split] = value >= 0
+ when .software_version?
+ self[:software_version] = values.join
+ when .serial_number?
+ self[:serial_number] = values.join
+ else
+ logger.debug { "Samsung responded with ACK: #{value}" }
+ end
+
+ task.try &.success
+ when .nak?
+ task.try &.abort("Samsung responded with NAK: #{hex}")
+ else
+ task.try &.retry
+ end
+ end
+
+ private def check_power_state
+ if self[:power]? == @power_target
+ @power_target = nil
+ elsif power_target = @power_target
+ power(power_target)
+ end
+ end
+
+ enum Command : UInt8
+ Status = 0x00
+ HardOff = 0x11 # Completely powers off
+ PanelMute = 0xF9 # Screen blanking / visual mute
+ Volume = 0x12
+ Contrast = 0x24
+ Brightness = 0x25
+ Sharpness = 0x26
+ Colour = 0x27
+ Tint = 0x28
+ RedGain = 0x29
+ GreenGain = 0x2A
+ BlueGain = 0x2B
+ Input = 0x14
+ Mode = 0x18
+ Size = 0x19
+ Pip = 0x3C # picture in picture
+ AutoAdjust = 0x3D
+ WallMode = 0x5C # Video wall mode
+ Safety = 0x5D
+ WallOn = 0x84 # Video wall enabled
+ WallUser = 0x89 # Video wall user control
+ Speaker = 0x68
+ NetworkStandby = 0xB5 # Keep NIC active in standby, enable power on (without WOL)
+ AutoOffTimer = 0xE6 # Eco options (auto power off)
+ AutoPower = 0x33 # Device auto power control (presumably signal based?)
+ ScreenSplit = 0xB2 # Tri / quad split (larger panels only)
+ SoftwareVersion = 0x0E
+ SerialNumber = 0x0B
+ Time = 0xA7
+ Timer = 0xA4
+
+ def build(id : UInt8, data : Bytes) : Bytes
+ Bytes.new(data.size + 5).tap do |bytes|
+ bytes[0] = INDICATOR # Header
+ bytes[1] = self.value # Command
+ bytes[2] = id # Display ID
+ bytes[3] = data.size.to_u8 # Data size
+ data.each_with_index(4) { |b, i| bytes[i] = b } # Data
+ bytes[-1] = (bytes[1..-2].sum(0) & 0xFF).to_u8 # Checksum
+ end
+ end
+ end
+
+ private def do_send(command : Command, data : Int | Bytes = Bytes.empty, **options)
+ data = Bytes[data] if data.is_a?(Int)
+ bytes = command.build(@id, data)
+ logger.debug { "Sending to Samsung: #{bytes.hexstring}" }
+ send(bytes, **options)
+ end
+end
diff --git a/drivers/samsung/displays/mdc_protocol_spec.cr b/drivers/samsung/displays/mdc_protocol_spec.cr
new file mode 100644
index 00000000000..8f1e8761e94
--- /dev/null
+++ b/drivers/samsung/displays/mdc_protocol_spec.cr
@@ -0,0 +1,67 @@
+require "placeos-driver/spec"
+
+# [header, command, id, data.size, [data], checksum]
+
+DriverSpecs.mock_driver "Samsung::Displays::MDCProtocol" do
+ id = "\x00"
+
+ # connected -> do_poll
+ # power? will take priority over status as status has priority = 0
+ # power? -> panel_mute
+ should_send("\xAA\xF9#{id}\x00\xF9")
+ responds("\xAA\xFF#{id}\x03A\xF9\x00\x3C")
+ status[:power].should eq(true)
+ # status
+ should_send("\xAA\x00#{id}\x00\x00")
+ responds("\xAA\xFF#{id}\x09A\x00\x01\x06\x00\x14\x00\x00\x00\x64")
+ status[:hard_off].should eq(false)
+ status[:power].should eq(true)
+ status[:volume].should eq(6)
+ status[:audio_mute].should eq(false)
+ status[:input].should eq("Vga")
+
+ exec(:volume, 24)
+ should_send("\xAA\x12#{id}\x01\x18\x2B")
+ responds("\xAA\xFF#{id}\x03A\x12\x18\x6D")
+ status[:volume].should eq(24)
+ status[:audio_mute].should eq(false)
+
+ exec(:volume, 6)
+ should_send("\xAA\x12#{id}\x01\x06\x19")
+ responds("\xAA\xFF#{id}\x03A\x12\x06\x5B")
+ status[:volume].should eq(6)
+ status[:audio_mute].should eq(false)
+
+ exec(:mute)
+ # Video mute
+ should_send("\xAA\xF9#{id}\x01\x01\xFB")
+ responds("\xAA\xFF#{id}\x03A\xF9\x01\x3D")
+ status[:power].should eq(false)
+ # Audio mute
+ should_send("\xAA\x12#{id}\x01\x00\x13")
+ responds("\xAA\xFF\x00\x03A\x12\x00\x55")
+ status[:audio_mute].should eq(true)
+ status[:volume].should eq(0)
+
+ exec(:unmute)
+ # Video unmute
+ should_send("\xAA\xF9#{id}\x01\x00\xFA")
+ responds("\xAA\xFF#{id}\x03A\xF9\x00\x3C")
+ status[:power].should eq(true)
+ # Audio unmute
+ should_send("\xAA\x12#{id}\x01\x06\x19")
+ responds("\xAA\xFF#{id}\x03A\x12\x06\x5B")
+ status[:audio_mute].should eq(false)
+ status[:volume].should eq(6)
+
+ exec(:switch_to, "hdmi")
+ should_send("\xAA\x14#{id}\x01\x21\x36")
+ responds("\xAA\xFF#{id}\x03A\x14\x21\x78")
+ status[:input].should eq("Hdmi")
+
+ # power(false) == video_mute(true)
+ exec(:power, false)
+ should_send("\xAA\xF9#{id}\x01\x01\xFB")
+ responds("\xAA\xFF#{id}\x03A\xF9\x01\x3D")
+ status[:power].should eq(false)
+end
diff --git a/drivers/samsung/displays/reduced_mdc_protocol.cr b/drivers/samsung/displays/reduced_mdc_protocol.cr
new file mode 100644
index 00000000000..45a65dbeb16
--- /dev/null
+++ b/drivers/samsung/displays/reduced_mdc_protocol.cr
@@ -0,0 +1,290 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+class Samsung::Displays::ReducedMDCProtocol < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ INDICATOR = 0xAA_u8
+
+ enum Input
+ Vga = 0x14 # pc in manual
+ Dvi = 0x18
+ DviVideo = 0x1F
+ Hdmi = 0x21
+ HdmiPc = 0x22
+ Hdmi2 = 0x23
+ Hdmi2Pc = 0x24
+ Hdmi3 = 0x31
+ Hdmi3Pc = 0x32
+ Hdmi4 = 0x33
+ Hdmi4Pc = 0x34
+ DisplayPort = 0x25
+ Dtv = 0x40
+ Media = 0x60
+ Widi = 0x61
+ MagicInfo = 0x20
+ Whiteboard = 0x64
+ end
+
+ include Interface::InputSelection(Input)
+
+ # Discovery Information
+ tcp_port 1515
+ descriptive_name "Samsung Simplified Control Set"
+ generic_name :Display
+
+ # Markdown description
+ description <<-DESC
+ For DM displays configure the following 1:
+
+ 1. Network Standby = ON
+ 2. Set Auto Standby = OFF
+ 3. Set Eco Solution, Auto Off = OFF
+
+ Hard Power off displays each night and hard power ON in the morning.
+ DESC
+
+ default_settings({
+ display_id: 0,
+ rs232_control: false,
+ })
+
+ @id : UInt8 = 0
+ @rs232 : Bool = false
+ @blank : Input?
+ @previous_volume : Int32 = 50
+ @input_target : Input? = nil
+ @whiteboard_clear_input : Input? = nil
+ @power_target : Bool? = nil
+
+ def on_load
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.peek
+ logger.debug { "Received: #{bytes.hexstring}" }
+
+ # Ensure message indicator is well-formed
+ if bytes.first == INDICATOR
+ # [header, command, id, data.size, [data], checksum]
+ # return 0 if the message is incomplete
+ bytes.size < 4 ? 0 : bytes[3].to_i + 5
+ else
+ logger.debug { "Ignoring unexpected message" }
+ io.clear
+ 0
+ end
+ end
+
+ on_update
+ end
+
+ def on_update
+ @id = setting(UInt8, :display_id)
+ @rs232 = setting(Bool, :rs232_control)
+ if clear_input = setting?(String, :whiteboard_clear_input)
+ @whiteboard_clear_input = Input.parse(clear_input)
+ else
+ @whiteboard_clear_input = nil
+ end
+ end
+
+ def connected
+ schedule.every(30.seconds, true) do
+ do_poll
+ end
+ end
+
+ def disconnected
+ self[:power] = false unless @rs232
+ schedule.clear
+ end
+
+ # As true power off disconnects the server we only want to power off the panel
+ def power(state : Bool)
+ @power_target = state
+ do_send(Command::HardOff, state ? 1 : 0)
+ end
+
+ def hard_off
+ do_send(Command::HardOff, 0)
+ end
+
+ def power?(**options) : Bool
+ do_send(Command::HardOff, Bytes.empty, **options).get
+ !!self[:power]?.try(&.as_bool)
+ end
+
+ # Mutes both audio/video
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ mute_audio(state) if layer.audio? || layer.audio_video?
+ end
+
+ # Emulate audio mute
+ def mute_audio(state : Bool = true)
+ # Do nothing if already in desired state
+ return if self[:audio_mute]?.try(&.as_bool) == state
+ self[:audio_mute] = state
+ if state
+ @previous_volume = self[:volume]?.try(&.as_i) || 0
+ volume(0)
+ else
+ volume(@previous_volume)
+ end
+ end
+
+ def switch_to(input : Input, **options)
+ @input_target = input
+ do_send(Command::Input, input.value, **options)
+ end
+
+ # if the user has been using the display as a whiteboard
+ # then the display needs to switch input
+ def clear_whiteboard
+ target_input = @input_target
+ clear_input = @whiteboard_clear_input
+ if target_input && clear_input
+ switch_to(clear_input).get
+ switch_to target_input
+ end
+ end
+
+ def do_poll
+ if power?
+ # not even the input query is supported
+ # do_send(Command::Input, Bytes.empty, priority: 0)
+ do_send(Command::Volume, Bytes.empty, priority: 0)
+ end
+ end
+
+ def do_device_config
+ value = setting?(Int32 | Float64, :volume)
+ volume(value) if value
+ end
+
+ def volume(value : Int32 | Float64, **options)
+ data = value.to_f.clamp(0.0, 100.0).round_away.to_i
+ do_send(Command::Volume, data, **options)
+ end
+
+ def volume_up
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume + 5.0)
+ end
+
+ def volume_down
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume - 5.0)
+ end
+
+ enum ResponseStatus
+ Ack = 0x41 # A
+ Nak = 0x4e # N
+ end
+
+ def received(data, task)
+ hex = data.hexstring
+ logger.debug { "Samsung sent: #{hex}" }
+
+ # Verify the checksum of the response
+ if data[-1] != (checksum = data[1..-2].sum(0) & 0xFF)
+ logger.error { "Invalid response, checksum should be: #{checksum.to_s(16)}" }
+ return task.try &.retry
+ end
+
+ status = ResponseStatus.from_value(data[4])
+ command = Command.from_value(data[5])
+ values = data[6..-2]
+ value = values.first
+
+ case status
+ when .ack?
+ case command
+ when .volume?
+ self[:volume] = value
+ self[:audio_mute] = false if value > 0
+ when .input?
+ current_input = Input.from_value(value)
+ self[:input] = current_input
+ when .hard_off?
+ self[:power] = value != 0
+ check_power_state
+ else
+ logger.debug { "Samsung responded with ACK: #{value}" }
+ end
+
+ task.try &.success
+ when .nak?
+ task.try &.abort("Samsung responded with NAK: #{hex}")
+ else
+ task.try &.retry
+ end
+ end
+
+ private def check_power_state
+ power_target = @power_target
+ return if power_target.nil?
+
+ if self[:power]? == power_target
+ @power_target = nil
+ else
+ power(power_target)
+ end
+ end
+
+ enum Command : UInt8
+ Status = 0x00
+ HardOff = 0x11 # Completely powers off
+ PanelMute = 0xF9 # Screen blanking / visual mute
+ Volume = 0x12
+ Contrast = 0x24
+ Brightness = 0x25
+ Sharpness = 0x26
+ Colour = 0x27
+ Tint = 0x28
+ RedGain = 0x29
+ GreenGain = 0x2A
+ BlueGain = 0x2B
+ Input = 0x14
+ Mode = 0x18
+ Size = 0x19
+ Pip = 0x3C # picture in picture
+ AutoAdjust = 0x3D
+ WallMode = 0x5C # Video wall mode
+ Safety = 0x5D
+ WallOn = 0x84 # Video wall enabled
+ WallUser = 0x89 # Video wall user control
+ Speaker = 0x68
+ NetworkStandby = 0xB5 # Keep NIC active in standby, enable power on (without WOL)
+ AutoOffTimer = 0xE6 # Eco options (auto power off)
+ AutoPower = 0x33 # Device auto power control (presumably signal based?)
+ ScreenSplit = 0xB2 # Tri / quad split (larger panels only)
+ SoftwareVersion = 0x0E
+ SerialNumber = 0x0B
+ Time = 0xA7
+ Timer = 0xA4
+
+ def build(id : UInt8, data : Bytes) : Bytes
+ Bytes.new(data.size + 5).tap do |bytes|
+ bytes[0] = INDICATOR # Header
+ bytes[1] = self.value # Command
+ bytes[2] = id # Display ID
+ bytes[3] = data.size.to_u8 # Data size
+ data.each_with_index(4) { |b, i| bytes[i] = b } # Data
+ bytes[-1] = (bytes[1..-2].sum(0) & 0xFF).to_u8 # Checksum
+ end
+ end
+ end
+
+ private def do_send(command : Command, data : Int | Bytes = Bytes.empty, **options)
+ data = Bytes[data] if data.is_a?(Int)
+ bytes = command.build(@id, data)
+ logger.debug { "Sending to Samsung: #{bytes.hexstring}" }
+ send(bytes, **options)
+ end
+end
diff --git a/drivers/samsung/displays/reduced_mdc_protocol_spec.cr b/drivers/samsung/displays/reduced_mdc_protocol_spec.cr
new file mode 100644
index 00000000000..98c94158ad7
--- /dev/null
+++ b/drivers/samsung/displays/reduced_mdc_protocol_spec.cr
@@ -0,0 +1,50 @@
+require "placeos-driver/spec"
+
+# [header, command, id, data.size, [data], checksum]
+
+DriverSpecs.mock_driver "Samsung::Displays::ReducedMDCProtocol" do
+ id = "\x00"
+
+ # connected -> do_poll
+ # power? will take priority over status as status has priority = 0
+ # power? -> panel_mute
+ should_send("\xAA\x11#{id}\x00\x11")
+ responds("\xAA\xFF#{id}\x03A\x11\x00\x54")
+ status[:power].should eq(false)
+
+ exec(:power, true)
+ should_send("\xAA\x11#{id}\x01\x01\x13")
+ responds("\xAA\xFF#{id}\x03A\x11\x01\x55")
+ status[:power].should eq(true)
+
+ exec(:volume, 24)
+ should_send("\xAA\x12#{id}\x01\x18\x2B")
+ responds("\xAA\xFF#{id}\x03A\x12\x18\x6D")
+ status[:volume].should eq(24)
+ status[:audio_mute].should eq(false)
+
+ exec(:volume, 6)
+ should_send("\xAA\x12#{id}\x01\x06\x19")
+ responds("\xAA\xFF#{id}\x03A\x12\x06\x5B")
+ status[:volume].should eq(6)
+ status[:audio_mute].should eq(false)
+
+ exec(:mute)
+ # Audio mute
+ should_send("\xAA\x12#{id}\x01\x00\x13")
+ responds("\xAA\xFF\x00\x03A\x12\x00\x55")
+ status[:audio_mute].should eq(true)
+ status[:volume].should eq(0)
+
+ exec(:unmute)
+ # Audio unmute
+ should_send("\xAA\x12#{id}\x01\x06\x19")
+ responds("\xAA\xFF#{id}\x03A\x12\x06\x5B")
+ status[:audio_mute].should eq(false)
+ status[:volume].should eq(6)
+
+ exec(:switch_to, "hdmi")
+ should_send("\xAA\x14#{id}\x01\x21\x36")
+ responds("\xAA\xFF#{id}\x03A\x14\x21\x78")
+ status[:input].should eq("Hdmi")
+end
diff --git a/drivers/screen_technics/connect.cr b/drivers/screen_technics/connect.cr
new file mode 100644
index 00000000000..c0eabcf27ce
--- /dev/null
+++ b/drivers/screen_technics/connect.cr
@@ -0,0 +1,181 @@
+require "placeos-driver"
+require "placeos-driver/interface/moveable"
+require "placeos-driver/interface/stoppable"
+
+# Documentation: https://aca.im/driver_docs/Screen%20Technics/Screen%20Technics%20IP%20Connect%20module.pdf
+# Default user: Admin
+# Default pass: Connect
+
+class ScreenTechnics::Connect < PlaceOS::Driver
+ include Interface::Moveable
+ include Interface::Stoppable
+
+ # Discovery Information
+ descriptive_name "Screen Technics Projector Screen Control"
+ generic_name :Screen
+ tcp_port 3001
+
+ COMMANDS = {
+ up: 30,
+ down: 33,
+ status: 1, # this differs from the doc, but appears to work
+ stop: 36,
+ }
+
+ CMD_LOOKUP = {
+ 30 => :up,
+ 33 => :down,
+ 1 => :status,
+ 36 => :stop,
+ }
+
+ def on_load
+ # Communication settings
+ queue.delay = 500.milliseconds
+ transport.tokenizer = Tokenizer.new("\r\n")
+
+ on_update
+ end
+
+ def on_update
+ @count = setting?(Int32, :screen_count) || 1
+ end
+
+ def connected
+ schedule.every(15.seconds, immediate: true) {
+ (0...@count).each { |index| query_state(index) }
+ }
+ end
+
+ def disconnected
+ queue.clear
+ schedule.clear
+ end
+
+ def move(position : MoveablePosition, index : Int32 | String = 0)
+ index = index.to_i
+
+ case position
+ when MoveablePosition::Up
+ up(index)
+ when MoveablePosition::Down
+ down(index)
+ else
+ raise "invalid position requested"
+ end
+ end
+
+ def down(index : Int32 = 0)
+ return if down?(index)
+ stop(index)
+ do_send :down, index, name: "direction#{index}"
+ query_state(index)
+ end
+
+ def down?(index : Int32 = 0)
+ {"moving_bottom", "at_bottom"}.includes?(self["screen#{index}"]?)
+ end
+
+ def up(index : Int32 = 0)
+ return if up?(index)
+ stop(index)
+ do_send :up, index, name: "direction#{index}"
+ query_state(index)
+ end
+
+ def up?(index : Int32 = 0)
+ {"moving_top", "at_top"}.includes?(self["screen#{index}"]?)
+ end
+
+ def stop(index : Int32 | String = 0, emergency : Bool = false)
+ index = index.to_i
+
+ do_send(
+ :stop, index,
+ name: "stop#{index}",
+ clear_queue: emergency,
+ priority: emergency ? (queue.priority + 50) : queue.priority
+ )
+ end
+
+ def query_state(index : Int32 = 0)
+ do_send :status, index, 0x20
+ end
+
+ STATUS = {
+ 0 => :moving_top,
+ 1 => :moving_bottom,
+ 2 => :moving_preset_1,
+ 3 => :moving_preset_2,
+ 4 => :moving_top, # preset top
+ 5 => :moving_bottom, # preset bottom
+ 6 => :at_top,
+ 7 => :at_bottom,
+ 8 => :at_preset_1,
+ 9 => :at_preset_2,
+ 10 => :stopped,
+ 11 => :error,
+ # 12 => undefined
+ 13 => :error_timeout,
+ 14 => :error_current,
+ 15 => :error_rattle,
+ 16 => :at_bottom, # preset bottom
+ }
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Screen sent #{data}" }
+
+ # Builds an array of numbers from the returned string
+ parts = data.split(/,/).map &.strip.to_i
+ cmd = CMD_LOOKUP[parts[0] - 100]?
+
+ if cmd
+ index = parts[2] - 17
+
+ case cmd
+ when :up
+ logger.debug { "Screen#{index} moving up" }
+ self["position#{index}"] = MoveablePosition::Up
+ self["moving#{index}"] = true
+ when :down
+ logger.debug { "Screen#{index} moving down" }
+ self["position#{index}"] = MoveablePosition::Down
+ self["moving#{index}"] = true
+ when :stop
+ logger.debug { "Screen#{index} stopped" }
+ self["moving#{index}"] = false
+ screen = "screen#{index}"
+ self[screen] = :stopped unless {"at_top", "at_bottom"}.includes?(self[screen]?)
+ when :status
+ self["screen#{index}"] = status = STATUS[parts[-1]]
+
+ case status
+ when :moving_top, :at_top
+ self["position#{index}"] = MoveablePosition::Up
+ self["moving#{index}"] = status == :moving_top
+ when :moving_bottom, :at_bottom
+ self["position#{index}"] = MoveablePosition::Down
+ self["moving#{index}"] = status == :moving_bottom
+ when :stopped
+ self["moving#{index}"] = false
+ when :error, :error_timeout, :error_current, :error_rattle
+ self["moving#{index}"] = false
+ end
+ end
+
+ task.try &.success
+ else
+ error = "Unknown command #{parts[0]}"
+ logger.debug { error }
+ task.try &.abort(error)
+ end
+ end
+
+ protected def do_send(cmd, index = 0, *args, **options)
+ address = index + 17
+ parts = {COMMANDS[cmd], address} + args
+ request = "#{parts.join(", ")}\r\n"
+ send request, **options
+ end
+end
diff --git a/drivers/screen_technics/connect_spec.cr b/drivers/screen_technics/connect_spec.cr
new file mode 100644
index 00000000000..d57166adab6
--- /dev/null
+++ b/drivers/screen_technics/connect_spec.cr
@@ -0,0 +1,68 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "ScreenTechnics::Connect" do
+ # On connect it queries the state of all screens
+ should_send("1, 17, 32\r\n")
+ responds("101, 1, 17, 1\r\n")
+
+ status[:position0].should eq("Down")
+ status[:moving0].should be_true
+ status[:screen0].should eq("moving_bottom")
+
+ # Screen Technics requires a large delay between requests
+ # The timeout is because this execute won't occur until after a delay
+ exec(:query_state, index: 1) do |ret_val|
+ should_send("1, 18, 32\r\n", timeout: 1.second)
+ responds("101, 1, 18, 6\r\n")
+
+ # Wait for the execute return value
+ ret_val.get
+
+ status[:position1].should eq("Up")
+ status[:moving1].should eq(false)
+ status[:screen1].should eq("at_top")
+ end
+
+ # ===================
+ # Test emergency stop
+ # ===================
+ exec(:move, "Down", 2) do |ret_val|
+ # A call to down involves a
+ # * stop command
+ # * down command
+ # * status request
+ sleep 1.second
+ should_send("36, 19\r\n", timeout: 1.second)
+ responds("136, 1, 19\r\n")
+
+ # --> Wait for the down command
+ should_send("33, 19\r\n", timeout: 1.second)
+
+ # Execute the emergency stop and request another down request
+ exec(:stop, index: 2, emergency: true) do |response|
+ exec(:move, "Down", 2)
+ sleep 500.milliseconds
+
+ # --> respond to the down command
+ responds("133, 1, 19, 1\r\n")
+
+ # Should receive emergency stop command
+ should_send("36, 19\r\n", timeout: 1.second)
+ responds("136, 1, 19\r\n")
+ status[:moving2].should eq(false)
+ response.get
+ end
+
+ # Original down command should have failed
+ expect_raises(PlaceOS::Driver::RemoteException, "queue cleared (Abort)") do
+ ret_val.get
+ end
+
+ puts "(timeout below expected)"
+
+ # Ensure second down command is not sent
+ expect_raises(Channel::ClosedError) do
+ should_send("33, 17\r\n", timeout: 1.second)
+ end
+ end
+end
diff --git a/drivers/secure_os/ws_api.cr b/drivers/secure_os/ws_api.cr
new file mode 100644
index 00000000000..68e29475da0
--- /dev/null
+++ b/drivers/secure_os/ws_api.cr
@@ -0,0 +1,190 @@
+require "placeos-driver"
+require "./ws_api_models"
+
+# docs: https://drive.google.com/file/d/1moo9NnFWukSf6fegxaZnSP5ShqSr_A03/view?usp=sharing
+
+class SecureOS::WsApi < PlaceOS::Driver
+ generic_name :SecureOS
+ descriptive_name "SecureOS WebSocket API"
+
+ uri_base "ws://secureos.server:8888/"
+ default_settings({
+ shared_host: true,
+ rest_api_host: "http://172.16.1.120:8888",
+ basic_auth: {
+ username: "srvc_acct",
+ password: "password!",
+ },
+ camera_types: [] of String,
+ camera_states: [StateType::Attached, StateType::Armed, StateType::Alarmed],
+ camera_events: [] of String,
+ })
+
+ @rest_api_host : String = ""
+ @camera_list : Array(Camera) = [] of Camera
+ @camera_types : Array(String) = [] of String
+ @camera_states : Array(StateType) = [] of StateType
+ @camera_events : Array(String) = [] of String
+ @watchlist_list : Array(Watchlist) = [] of Watchlist
+
+ getter! basic_auth : NamedTuple(username: String, password: String)
+
+ def on_update
+ @rest_api_host = setting String, :rest_api_host
+ @basic_auth = setting NamedTuple(username: String, password: String), :basic_auth
+ @camera_types = setting Array(String), :camera_types
+ @camera_states = setting Array(StateType), :camera_states
+ @camera_events = setting Array(String), :camera_events
+ end
+
+ def connected
+ response = http_client.get "#{@rest_api_host}/api/v1/ws_auth"
+ if response.success?
+ auth = AuthResponse.from_json response.body
+ send({type: :auth, token: auth.data.token}.to_json, wait: false)
+ else
+ raise "Authentication failed"
+ end
+
+ schedule.every(30.seconds) { send({type: :get_server_time}.to_json, name: :server_time) }
+ schedule.every(5.minutes, immediate: true) do
+ camera_list
+ subscribe_all
+ watchlist_list
+ end
+ rescue error
+ logger.warn(exception: error) { "Authentication failed" }
+ disconnect
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ protected def http_client
+ host = setting?(Bool, :shared_host) ? config.uri.not_nil! : @rest_api_host
+ client = HTTP::Client.new URI.parse(host)
+ client.basic_auth **basic_auth
+ client
+ end
+
+ private def subscribe_all
+ states = @camera_states.empty? ? nil : @camera_states
+ events = @camera_events.empty? ? nil : @camera_events
+ rules = [] of SubscribeRule
+
+ @camera_list.each do |camera|
+ next unless @camera_types.empty? || @camera_types.includes? camera.type
+
+ rules << SubscribeRule.new(
+ type: camera.type,
+ id: camera.id,
+ action: :STATE_CHANGED,
+ states: states,
+ )
+ rules << SubscribeRule.new(
+ type: camera.type,
+ id: camera.id,
+ action: :EVENT,
+ events: events,
+ )
+ end
+
+ return if rules.empty?
+
+ send({
+ type: :subscribe,
+ # id: 1234, # optional id used in error responses
+ data: {
+ add_rules: rules,
+ },
+ }.to_json, wait: false)
+ end
+
+ private def camera_list
+ response = http_client.get "#{@rest_api_host}/api/v1/cameras"
+ if response
+ json_response = CameraResponse.from_json response.body
+ self["camera_list"] = @camera_list = json_response.data
+ else
+ logger.warn { "Failed to get camera list" }
+ end
+ rescue error
+ logger.warn(exception: error) { "Failed to get camera list" }
+ end
+
+ def watchlist_add_lp(watchlist : String, license_plate : String, comment : String = "")
+ if wl = @watchlist_list.find { |l| l.name == watchlist }
+ response = http_client.post(
+ "#{@rest_api_host}/api/v1/watchlists/#{wl.id}/set",
+ headers: HTTP::Headers{"Content-Type" => "application/json"},
+ body: {
+ number: license_plate,
+ comment: comment,
+ }.to_json
+ )
+ unless response
+ logger.warn { "Failed to add license plate to watchlist" }
+ end
+ else
+ logger.warn { "Failed to find a watchlist named: #{watchlist}" }
+ end
+ rescue error
+ logger.warn(exception: error) { "Failed to add license plate to watchlist" }
+ end
+
+ def watchlist_remove_lp(watchlist : String, license_plate : String)
+ if wl = @watchlist_list.find { |l| l.name == watchlist }
+ response = http_client.post(
+ "#{@rest_api_host}/api/v1/watchlists/#{wl.id}/delete",
+ headers: HTTP::Headers{"Content-Type" => "application/json"},
+ body: {number: license_plate}.to_json
+ )
+ unless response
+ logger.warn { "Failed to remove license plate from watchlist" }
+ end
+ else
+ logger.warn { "Failed to find a watchlist named: #{watchlist}" }
+ end
+ rescue error
+ logger.warn(exception: error) { "Failed to remove license plate from watchlist" }
+ end
+
+ private def watchlist_list
+ response = http_client.get "#{@rest_api_host}/api/v1/watchlists"
+ if response
+ json_response = WatchlistResponse.from_json response.body
+ self["watchlist_list"] = @watchlist_list = json_response.data
+ else
+ logger.warn { "Failed to get watchlist list" }
+ end
+ rescue error
+ logger.warn(exception: error) { "Failed to get watchlist list" }
+ end
+
+ def received(data, task)
+ raw_json = String.new data
+ logger.debug { "SecureOS sent: #{raw_json}" }
+
+ type_check = JSON.parse(raw_json)["type"]?
+ if type_check
+ response = Response.from_json raw_json
+ case response
+ in StateWrapper
+ self["camera_#{response.data.id}_states"] = response.data
+ in EventWrapper
+ self["camera_#{response.data.id}"] = response.data
+ in ErrorWrapper
+ logger.warn { "SecureOS error: #{response.data}" }
+ if response.data.error.in?({"INVALID_AUTH_TOKEN", "UNAUTHORIZED"})
+ disconnect
+ else
+ self["last_error"] = response.data
+ end
+ in Response
+ end
+ end
+
+ task.try &.success
+ end
+end
diff --git a/drivers/secure_os/ws_api_models.cr b/drivers/secure_os/ws_api_models.cr
new file mode 100644
index 00000000000..457da680f23
--- /dev/null
+++ b/drivers/secure_os/ws_api_models.cr
@@ -0,0 +1,133 @@
+require "json"
+
+module SecureOS
+ enum StateType
+ Attached
+ Armed
+ Alarmed
+ end
+
+ struct SubscribeRule
+ include JSON::Serializable
+
+ getter type : String
+ getter id : String
+ getter states : Array(StateType)? = nil
+ getter events : Array(String)? = nil
+ getter action : Symbol
+
+ def initialize(
+ @type : String,
+ @id : String,
+ @action : Symbol,
+ @states : Array(StateType)? = nil,
+ @events : Array(String)? = nil
+ )
+ end
+ end
+
+ abstract class Response
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ use_json_discriminator "type", {
+ "state" => StateWrapper,
+ "event" => EventWrapper,
+ "error" => ErrorWrapper,
+ }
+ end
+
+ class StateWrapper < Response
+ getter type : String = "state"
+ getter data : State
+ end
+
+ class EventWrapper < Response
+ getter type : String = "event"
+ getter data : Event
+ end
+
+ class ErrorWrapper < Response
+ getter type : String = "error"
+ getter data : Error
+ end
+
+ struct State
+ include JSON::Serializable
+
+ getter type : String
+ getter id : String | Int64?
+ getter ticks : Int64
+
+ @[JSON::Field(converter: Time::Format.new("%FT%T.%L", Time::Location::UTC))]
+ getter time : Time # "2017-02-02T16:10:07.241"
+
+ getter states : Hash(String, Bool)
+ end
+
+ struct Event
+ include JSON::Serializable
+
+ getter type : String
+ getter id : String | Int64?
+ getter action : String
+ getter ticks : Int64?
+
+ @[JSON::Field(converter: Time::Format.new("%FT%T.%L", Time::Location::UTC))]
+ getter time : Time # "2017-02-02T16:10:07.241"
+
+ getter parameters : JSON::Any?
+ end
+
+ struct Error
+ include JSON::Serializable
+
+ getter request_id : String | Int64?
+ getter message : String
+ getter error : String
+ end
+
+ struct AuthResponse
+ include JSON::Serializable
+
+ getter data : AuthToken
+ getter status : String
+ end
+
+ struct AuthToken
+ include JSON::Serializable
+
+ getter token : String
+ end
+
+ struct CameraResponse
+ include JSON::Serializable
+
+ getter data : Array(Camera)
+ getter status : String
+ end
+
+ struct Camera
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String
+ getter settings : JSON::Any
+ getter status : JSON::Any
+ getter type : String
+ end
+
+ struct WatchlistResponse
+ include JSON::Serializable
+
+ getter data : Array(Watchlist)
+ getter status : String
+ end
+
+ struct Watchlist
+ include JSON::Serializable
+
+ getter id : String
+ getter name : String
+ end
+end
diff --git a/drivers/secure_os/ws_api_spec.cr b/drivers/secure_os/ws_api_spec.cr
new file mode 100644
index 00000000000..81631701320
--- /dev/null
+++ b/drivers/secure_os/ws_api_spec.cr
@@ -0,0 +1,150 @@
+require "placeos-driver/spec"
+require "./ws_api_models"
+
+private macro respond_with(code, body)
+ res.headers["Content-Type"] = "application/json"
+ res.status_code = {{code}}
+ res.output << {{body}}
+end
+
+DriverSpecs.mock_driver "SecureOS::WsApi" do
+ # Getting WebSocket auth token
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/ws_auth")
+ req.headers["Authorization"]?.should eq("Basic #{Base64.strict_encode("srvc_acct:password!")}")
+ respond_with 200, {data: {token: "qwertyuio"}, status: :success}.to_json
+ end
+ should_send({type: :auth, token: "qwertyuio"}.to_json)
+
+ # Getting a list of cameras
+ camera = {
+ "id" => "1",
+ "name" => "Camera 1",
+ "settings" => {"telemetry_id" => "native"},
+ "status" => {
+ "enabled" => true,
+ "state" => "DISARMED",
+ },
+ "type" => "CAM",
+ }
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/cameras")
+ req.headers["Authorization"]?.should eq("Basic #{Base64.strict_encode("srvc_acct:password!")}")
+ respond_with 200, {
+ data: [camera],
+ status: "success",
+ }.to_json
+ end
+ status[:camera_list].should eq([camera])
+
+ # Subscribing to states/events
+ should_send({
+ type: :subscribe,
+ data: {
+ add_rules: [
+ {
+ type: "CAM",
+ id: "1",
+ states: [
+ SecureOS::StateType::Attached,
+ SecureOS::StateType::Armed,
+ SecureOS::StateType::Alarmed,
+ ],
+ action: :STATE_CHANGED,
+ },
+ {
+ type: "CAM",
+ id: "1",
+ action: :EVENT,
+ },
+ ],
+ },
+ }.to_json)
+
+ # Getting a list of watchlists
+ expect_http_request do |req, res|
+ req.method.should eq("GET")
+ req.path.should eq("/api/v1/watchlists")
+ req.headers["Authorization"]?.should eq("Basic #{Base64.strict_encode("srvc_acct:password!")}")
+ respond_with 200, {
+ data: [
+ {id: "1", name: "some list"},
+ ],
+ status: "success",
+ }.to_json
+ end
+ status[:camera_list].should eq([camera])
+
+ # Adding a license plate to a watchlist
+ exec(:watchlist_add_lp, watchlist: "some list", license_plate: "ABC", comment: "Test plate")
+ expect_http_request do |req, res|
+ req.method.should eq("POST")
+ req.path.should eq("/api/v1/watchlists/1/set")
+ req.headers["Authorization"]?.should eq("Basic #{Base64.strict_encode("srvc_acct:password!")}")
+ req.headers["Content-Type"]?.should eq("application/json")
+ respond_with 200, "Comment for number ABC has been set."
+ end
+
+ # Removing a license plate from a watchlist
+ exec(:watchlist_remove_lp, watchlist: "some list", license_plate: "ABC")
+ expect_http_request do |req, res|
+ req.method.should eq("POST")
+ req.path.should eq("/api/v1/watchlists/1/delete")
+ req.headers["Authorization"]?.should eq("Basic #{Base64.strict_encode("srvc_acct:password!")}")
+ req.headers["Content-Type"]?.should eq("application/json")
+ respond_with 200, "Number ABC has been deleted from the watchlist."
+ end
+
+ # Recieving states
+ states = {
+ "type" => "CAM",
+ "id" => "1",
+ "ticks" => 501234,
+ "time" => "2017-02-02T16:10:07.241",
+ "states" => {
+ "attached" => true,
+ "armed" => true,
+ "alarmed" => false,
+ },
+ }
+ transmit({
+ type: :state,
+ data: states,
+ }.to_json)
+ status[:camera_1_states].should eq(states)
+
+ # Recieving events
+ event = {
+ "type" => "CAM",
+ "id" => "1",
+ "action" => "CAR_LP_RECOGNIZED",
+ "ticks" => 501234,
+ "time" => "2017-02-02T16:10:07.241",
+ "parameters" => {
+ "camera_id" => "6",
+ "direction_name" => "approaching",
+ "number" => "T345 LYW",
+ "recognizer_id" => "1",
+ "recognizer_type" => "LPR_CAM",
+ },
+ }
+ transmit({
+ type: :event,
+ data: event,
+ }.to_json)
+ status[:camera_1].should eq(event)
+
+ # A delete event is sent if a subscribed camera is removed
+ transmit({
+ type: :event,
+ data: {
+ type: :CAM,
+ id: "1",
+ action: :DELETED,
+ time: "2017-02-02T16:10:07.241",
+ },
+ }.to_json)
+ status[:camera_1]["parameters"]?.should be_nil
+end
diff --git a/drivers/sharp/pn_series.cr b/drivers/sharp/pn_series.cr
new file mode 100644
index 00000000000..b8cb3b30f2c
--- /dev/null
+++ b/drivers/sharp/pn_series.cr
@@ -0,0 +1,292 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+# Documentation: https://aca.im/driver_docs/Sharp/pnl601b.pdf
+# also https://aca.im/driver_docs/Sharp/PN_L802B_operation_guide.pdf
+
+class Sharp::PnSeries < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Input
+ DVI = 1
+ HDMI = 10
+ HDMI2 = 13
+ HDMI3 = 18
+ DisplayPort = 14
+ VGA = 2
+ VGA2 = 16
+ Component = 3
+
+ def data
+ "INPS" + self.value.to_s.rjust(4, '0')
+ end
+ end
+
+ include Interface::InputSelection(Input)
+
+ tcp_port 10008
+ descriptive_name "Sharp Monitor"
+ generic_name :Display
+
+ @volume_min : Int32 = 0
+ @volume_max : Int32 = 31
+ @brightness_min : Int32 = 0
+ @brightness_max : Int32 = 31
+ @contrast_min : Int32 = 0
+ @contrast_max : Int32 = 60 # multiply by two when VGA selected
+ @dbl_contrast : Bool = true
+ @model_number : Bool = false
+
+ @vol_status : PlaceOS::Driver::Proxy::Scheduler::TaskWrapper? = nil
+
+ DELIMITER = "\x0D\x0A"
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(DELIMITER)
+ end
+
+ def connected
+ # Will be sent after login is requested (config - wait ready)
+ send_credentials
+
+ schedule.every(60.seconds) do
+ logger.debug { "-- Polling Display" }
+ do_poll
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ delay = self[:power_on_delay]?.try(&.as_i) || 5
+
+ # If the requested state is different from the current state
+ if state != !!self[:power]?.try(&.as_bool)
+ if state
+ logger.debug { "-- Sharp LCD, requested to power on" }
+ do_send("POWR 1", name: :POWR, timeout: delay.seconds + 15.seconds)
+ self[:warming] = true
+ self[:power] = true
+ do_send("POWR????", name: :POWR, timeout: 10.seconds) # clears warming
+ else
+ logger.debug { "-- Sharp LCD, requested to power off" }
+ do_send("POWR 0", name: :POWR, timeout: 15.seconds)
+ self[:power] = false
+ end
+ end
+
+ power?
+ mute_status(0)
+ volume_status(0)
+ end
+
+ def power?(**options)
+ do_send("POWR????", **options, name: :POWR, timeout: 10.seconds).get
+ self[:power].as_bool
+ end
+
+ # Resets the brightness and contrast settings
+ def reset
+ do_send("ARST 2")
+ end
+
+ def switch_to(input : Input)
+ logger.debug { "-- Sharp LCD, requested to switch to: #{input}" }
+ do_send(input.data, name: :input, delay: 2.seconds, timeout: 20.seconds).get # does an auto adjust on switch to vga
+ video_input(40)
+ brightness_status(40) # higher status than polling commands - lower than input switching (vid then audio is common)
+ contrast_status(40)
+ end
+
+ AUDIO = {
+ audio1: "ASDP 2",
+ audio2: "ASDP 3",
+ dvi: "ASDP 1",
+ dvi_alt: "ASDA 1",
+ hdmi: "ASHP 0",
+ hdmi_3mm: "ASHP 1",
+ hdmi_rca: "ASHP 2",
+ vga: "ASAP 1",
+ component: "ASCA 1",
+ }
+ AUDIO_RESPONSE = AUDIO.to_h.invert
+
+ def switch_audio(input : String)
+ logger.debug { "-- Sharp LCD, requested to switch audio to: #{input}" }
+
+ do_send(AUDIO[input], name: "audio")
+ mute_status(40) # higher status than polling commands - lower than input switching
+ volume_status(40) # Mute response requests volume
+ end
+
+ def auto_adjust
+ do_send("AGIN 1", timeout: 20.seconds)
+ end
+
+ def brightness(val : Int32 | Float64)
+ val = val.to_f.clamp(0.0, 100.0)
+ percentage = val / 100.0
+ brightness = (percentage * @brightness_max.to_f).round_away.to_i
+
+ do_send("VLMP#{brightness.to_s.rjust(4, ' ')}")
+ end
+
+ def contrast(val : Int32 | Float64)
+ val = val.to_f.clamp(0.0, 100.0)
+ percentage = val / 100.0
+ contrast = (percentage * @contrast_max.to_f).round_away.to_i
+
+ # See Sharp manual
+ multiplier = self[:input]? == "VGA" && @dbl_contrast ? 2 : 1
+ contrast = contrast * multiplier
+ do_send("CONT#{contrast.to_s.rjust(4, ' ')}")
+ end
+
+ def volume(val : Int32 | Float64)
+ @vol_status.try(&.cancel)
+ @vol_status = schedule.in(2.seconds) do
+ @vol_status = nil
+ volume_status
+ end
+
+ val = val.to_f.clamp(0.0, 100.0)
+ percentage = val / 100.0
+ vol_actual = (percentage * @volume_max.to_f).round_away.to_i
+
+ do_send("VOLM#{vol_actual.to_s.rjust(4, ' ')}")
+ end
+
+ def volume_up
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume + 5.0)
+ end
+
+ def volume_down
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume - 5.0)
+ end
+
+ # There seems to only be audio mute available
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ if layer == MuteLayer::Video
+ logger.warn { "Sharp LCD requested to mute video which is unsupported" }
+ else
+ logger.debug { "Sharp LCD, requested to mute #{state}" }
+ do_send("MUTE #{state ? '1' : '0'}")
+ mute_status(50) # High priority mute status
+ end
+ end
+
+ OPERATION_CODE = {
+ video_input: "INPS",
+ volume_status: "VOLM",
+ mute_status: "MUTE",
+ power_on_delay: "PWOD",
+ contrast_status: "CONT",
+ brightness_status: "VLMP",
+ model_number: "INF1",
+ }
+ {% for name, cmd in OPERATION_CODE %}
+ @[Security(Level::Administrator)]
+ def {{name.id}}(priority : Int32 = 0, **options)
+ data = {{cmd.id.stringify}} + "????"
+ logger.debug { "Sharp sending: #{data}" }
+ do_send(data, **options, priority: priority) # Status polling is a low priority
+ end
+ {% end %}
+
+ def do_poll
+ if power?
+ model_number unless self[:model_number]? # only query the model number if we don't already have it
+ power_on_delay
+ mute_status
+ end
+ end
+
+ private def determine_contrast_mode
+ # As of 09/2015 only the PN-L802B does not have double contrast on RGB input.
+ # All prior models do double the contrast and don't have an L so let's assume it's the L in the model number that determines this for now
+ # (we can confirm the logic as more models are released)
+ @dbl_contrast = false if self[:model_number].as_s.includes?('L')
+ logger.debug { "dbl_contrast is #{@dbl_contrast}" }
+ end
+
+ private def send_credentials
+ do_send(setting?(String?, :username) || "", priority: 100, delay: 500.milliseconds) # , wait: false)
+ # TODO: figure out equivalent in crystal for delay_on_receive
+ do_send(setting?(String?, :password) || "", priority: 100) # , delay_on_receive: 1000)
+ end
+
+ def received(data, task)
+ data = String.new(data[0..-3])
+ logger.debug { "-- Sharp LCD, received: #{data}" }
+
+ if data == "Password:OK"
+ return task.try(&.success("Login successful"))
+ elsif data == "Password:Login incorrect"
+ schedule.in(5.seconds) { send_credentials }
+ return task.try(&.success("Sharp LCD, bad login or logged off. Attempting login.."))
+ elsif data == "OK"
+ return task.try(&.success)
+ elsif data == "WAIT"
+ logger.debug { "-- Sharp LCD, wait" }
+ return
+ elsif data == "ERR"
+ return task.try(&.abort("-- Sharp LCD, error"))
+ elsif data.size < 8 # Out of order send?
+ return task.try(&.abort("Sharp sent out of order response: #{data}"))
+ end
+
+ command, value = data.split
+
+ case command
+ when "POWR" # Power status
+ self[:warming] = false
+ self[:power] = value.to_i > 0
+ when "INPS" # Input status
+ input = Input.from_value?(value.to_i)
+ self[:input] = input || "unknown"
+ logger.debug { "-- Sharp LCD, input #{self[:input]} == #{value}" }
+ when "VOLM" # Volume status
+ vol_percent = (value.to_i.to_f / @volume_max.to_f) * 100.0
+ self[:volume] = vol_percent.round(2) unless self[:audio_mute]?.try(&.as_bool)
+ when "MUTE" # Mute status
+ self[:audio_mute] = (mute = value.to_i == 1)
+ if mute
+ self[:volume] = 0
+ else
+ volume_status(90) # high priority
+ end
+ when "CONT" # Contrast status
+ val = value.to_i / (self[:input]? == "VGA" && @dbl_contrast ? 2 : 1)
+ contrast = (val.to_f / @contrast_max.to_f) * 100.0
+ self[:contrast] = contrast.round(2)
+ when "VLMP" # brightness status
+ brightness = (value.to_i.to_f / @brightness_max.to_f) * 100.0
+ self[:brightness] = brightness.round(2)
+ when "PWOD"
+ self[:power_on_delay] = value.to_i
+ when "INF1"
+ self[:model_number] = value
+ logger.debug { "-- Sharp LCD, model number #{self[:model_number]}" }
+ determine_contrast_mode
+ when "ASDP", "ASDA", "ASHP", "ASAP", "ASCA" # audio switching commands
+ self[:audio_input] = AUDIO_RESPONSE[data] || "unknown"
+ end
+
+ task.try(&.success)
+ end
+
+ private def do_send(data, delay = 100.milliseconds, **options)
+ send("#{data}#{DELIMITER}", **options, delay: delay)
+ end
+end
diff --git a/drivers/sharp/pn_series_spec.cr b/drivers/sharp/pn_series_spec.cr
new file mode 100644
index 00000000000..052afd71955
--- /dev/null
+++ b/drivers/sharp/pn_series_spec.cr
@@ -0,0 +1,76 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Sharp::PnSeries" do
+ # connected
+ # send_credentials
+ should_send("\x0D\x0A")
+ responds("OK\x0D\x0A")
+ should_send("\x0D\x0A")
+ responds("Password:Login incorrect\x0D\x0A")
+
+ # Settings can only be accessed after on_load and connected
+ settings({
+ username: "user",
+ password: "pass",
+ })
+
+ # Retrying send_credentials
+ sleep 5
+ should_send("user\x0D\x0A")
+ responds("OK\x0D\x0A")
+ should_send("pass\x0D\x0A")
+ responds("Password:OK\x0D\x0A")
+
+ exec(:do_poll)
+ should_send("POWR????\x0D\x0A")
+ responds("POWR 001\x0D\x0A")
+ status[:warming].should eq(false)
+ status[:power].should eq(true)
+ should_send("INF1????\x0D\x0A")
+ responds("INF1 P802B\x0D\x0A")
+ status[:model_number].should eq("P802B")
+ should_send("PWOD????\x0D\x0A")
+ responds("PWOD 002\x0D\x0A")
+ status[:power_on_delay].should eq(2)
+ should_send("MUTE????\x0D\x0A")
+ responds("MUTE 000\x0D\x0A")
+ status[:audio_mute].should eq(false)
+ should_send("VOLM????\x0D\x0A")
+ responds("VOLM 010\x0D\x0A")
+ status[:volume].should eq(32.26)
+
+ exec(:switch_to, "hdmi")
+ should_send("INPS0010\x0D\x0A")
+ responds("WAIT\x0D\x0A")
+ responds("OK\x0D\x0A")
+ sleep 2
+ should_send("INPS????\x0D\x0A")
+ responds("INPS 10\x0D\x0A")
+ status[:input].should eq("HDMI")
+ should_send("VLMP????\x0D\x0A")
+ responds("VLMP 15\x0D\x0A")
+ status[:brightness].should eq(48.39)
+ should_send("CONT????\x0D\x0A")
+ responds("CONT 15\x0D\x0A")
+ status[:contrast].should eq(25.0)
+
+ exec(:switch_audio, "component")
+ should_send("ASCA 1\x0D\x0A")
+ responds("ASCA 1\x0D\x0A")
+ status[:audio_input].should eq("component")
+
+ should_send("MUTE????\x0D\x0A")
+ responds("MUTE 000\x0D\x0A")
+
+ should_send("VOLM????\x0D\x0A")
+ responds("VOLM 015\x0D\x0A")
+ status[:volume].should eq(48.39)
+
+ exec(:volume, 100)
+ should_send("VOLM????\x0D\x0A")
+ responds("VOLM 015\x0D\x0A")
+ status[:volume].should eq(48.39)
+
+ should_send("VOLM 31\x0D\x0A")
+ responds("ASCA 1\x0D\x0A")
+end
diff --git a/drivers/shure/microphone/mxa.cr b/drivers/shure/microphone/mxa.cr
new file mode 100644
index 00000000000..b2066089c5e
--- /dev/null
+++ b/drivers/shure/microphone/mxa.cr
@@ -0,0 +1,218 @@
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+
+# Documentation: https://aca.im/driver_docs/Shure/MXA910%20command%20strings.pdf
+
+class Shure::Microphone::MXA < PlaceOS::Driver
+ include Interface::AudioMuteable
+
+ tcp_port 2202
+ descriptive_name "Shure Ceiling Array Microphone"
+ generic_name :CeilingMic
+
+ default_settings({
+ send_meter_levels: false,
+ })
+
+ def connected
+ transport.tokenizer = Tokenizer.new(" >")
+
+ schedule.every(60.seconds) do
+ logger.debug { "-- Polling Mics" }
+ do_poll
+ end
+
+ query_all
+ set_meter_rate(0) if setting?(Bool, :send_meter_levels) != true
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def query_all
+ do_send "GET 0 ALL"
+ end
+
+ def query_device_id
+ do_send "GET DEVICE_ID", name: :device_id
+ end
+
+ def query_firmware
+ do_send "GET FW_VER", name: :firmware
+ end
+
+ # rate in milliseconds
+ def set_meter_rate(rate : Int32)
+ raise "rate must be a number greater than 100, was #{rate}" unless rate == 0 || rate >= 100
+ do_send "SET METER_RATE", rate.to_s, name: :meter_rate
+ end
+
+ # Mute commands
+ def query_mute
+ do_send "GET DEVICE_AUDIO_MUTE"
+ end
+
+ def mute(state : Bool = true)
+ val = state ? "ON" : "OFF"
+ do_send "SET DEVICE_AUDIO_MUTE", val, name: :mute
+ end
+
+ def unmute
+ mute false
+ end
+
+ # part of the mutable interface
+ def mute_audio(state : Bool = true, index : Int32 | String = 0)
+ mute(state)
+ end
+
+ # Preset commands
+ def query_preset
+ do_send "GET PRESET"
+ end
+
+ def preset(number : Int32)
+ raise "must be a number between 1-10, was #{number}" unless number.in?(1..10)
+ do_send "SET PRESET", number.to_s, name: :preset
+ end
+
+ # flash the LED for 30 seconds
+ def flash
+ do_send "SET FLASH ON"
+ end
+
+ enum Colour
+ RED
+ GREEN
+ BLUE
+ PINK
+ PURPLE
+ YELLOW
+ ORANGE
+ WHITE
+ end
+
+ # LED Setup
+ def query_led_state
+ do_send "GET DEV_LED_IN_STATE"
+ end
+
+ def led(on : Bool = true)
+ led_state_muted on
+ led_state_unmuted on
+ end
+
+ def query_led_colour_muted
+ do_send "GET LED_COLOR_MUTED"
+ end
+
+ # Supported colours: :RED, :GREEN, :BLUE, :PINK, :PURPLE, :YELLOW, :ORANGE, :WHITE
+ def led_colour_muted(colour : Colour)
+ do_send "SET LED_COLOR_MUTED", colour.to_s.upcase, name: :muted_color
+ end
+
+ def query_led_colour_unmuted
+ do_send "GET LED_COLOR_UNMUTED"
+ end
+
+ def led_colour_unmuted(colour : Colour)
+ do_send "SET LED_COLOR_UNMUTED", colour.to_s.upcase, name: :unmuted_color
+ end
+
+ def query_led_state_unmuted
+ do_send "GET LED_STATE_UNMUTED"
+ end
+
+ def led_state_unmuted(on : Bool = true)
+ state = on ? "ON" : "OFF"
+ do_send "SET LED_STATE_UNMUTED", state
+ end
+
+ def query_led_state_muted
+ do_send "GET LED_STATE_MUTED"
+ end
+
+ def led_state_muted(on : Bool = true)
+ state = on ? "ON" : "OFF"
+ do_send "SET LED_STATE_MUTED", state
+ end
+
+ def received(bytes, task)
+ data = String.new(bytes)
+ logger.debug { "-- received: #{data}" }
+
+ # Convert { some data here } to " some data here " and remove control chars
+ data = data.split("< ", 2)[1].gsub(/[\{\}]/, '"').rchop(" >")
+
+ # Then use shellsplit to capture the parts and remove whitespace
+ resp = shellsplit(data).map(&.strip)
+
+ # We want to ignore sample responses
+ if resp[0] == "SAMPLE"
+ resp[1..-1].each_with_index do |level, index|
+ self["output#{index + 1}"] = level.to_i
+ end
+ return
+ end
+
+ return task.try &.abort if resp[1] == "ERR"
+
+ # Check if the first value is a number - channel level details
+ if resp[1] =~ /^[0-9]+$/
+ chann = resp[1]
+ param = resp[2].try &.downcase
+ value = resp[3].try &.downcase
+
+ self["#{param}_#{chann}"] = value
+ return task.try &.success
+ end
+
+ # Global value details
+ param = resp[1].downcase
+ value = resp[2]
+
+ case param
+ when "device_audio_mute" then self[:muted] = value == "ON"
+ when "dev_led_state_muted" then self[:led_muted] = value == "ON"
+ when "dev_led_state_unmuted" then self[:led_unmuted] = value == "ON"
+ else
+ self[param] = case value
+ when "ON" then true
+ when "OFF" then false
+ when .to_i? then value.to_i
+ else
+ value
+ end
+ end
+
+ task.try &.success
+ end
+
+ def do_poll
+ query_device_id
+ end
+
+ protected def do_send(*command, **options)
+ cmd = "< #{command.join(' ')} >"
+ logger.debug { "-- sending: #{cmd}" }
+ send(cmd, **options)
+ end
+
+ # Quick dirty port of https://github.com/ruby/ruby/blob/master/lib/shellwords.rb
+ protected def shellsplit(line : String) : Array(String)
+ words = [] of String
+ field = ""
+ pattern = /\G\s*(?>([^\s\\\'\"]+)|'([^\']*)'|"((?:[^\"\\]|\\.)*)"|(\\.?)|(\S))(\s|\z)?/m
+ line.scan(pattern) do |match|
+ _, word, sq, dq, esc, garbage, sep = match.to_a
+ raise ArgumentError.new("Unmatched quote: #{line.inspect}") if garbage
+ field += (word || sq || dq.try(&.gsub(/\\([$`"\\\n])/, "\\1")) || esc.not_nil!.gsub(/\\(.)/, "\\1"))
+ if sep
+ words << field
+ field = ""
+ end
+ end
+ words
+ end
+end
diff --git a/drivers/shure/microphone/mxa_spec.cr b/drivers/shure/microphone/mxa_spec.cr
new file mode 100644
index 00000000000..5c1e2866df9
--- /dev/null
+++ b/drivers/shure/microphone/mxa_spec.cr
@@ -0,0 +1,18 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Shure::Microphone::MXA" do
+ should_send("< GET 0 ALL >").responds "< REP PRESET 10 >"
+ status[:preset].should eq(10)
+
+ should_send("< SET METER_RATE 0 >").responds "< REP METER_RATE 0 >"
+ status[:meter_rate].should eq(0)
+
+ exec(:query_device_id)
+ should_send("< GET DEVICE_ID >").responds "< REP DEVICE_ID { steves dev } >"
+ status[:device_id].should eq("steves dev")
+
+ responds "< SAMPLE 002 003 002 000 000 001 002 003 000 >"
+ sleep 1
+ status[:output1].should eq 2
+ status[:output2].should eq 3
+end
diff --git a/drivers/siemens/desigo.cr b/drivers/siemens/desigo.cr
new file mode 100644
index 00000000000..f317657fe8c
--- /dev/null
+++ b/drivers/siemens/desigo.cr
@@ -0,0 +1,55 @@
+require "placeos-driver"
+require "desigo"
+
+class Siemens::Desigo < PlaceOS::Driver
+ descriptive_name "Siemens Desigo Gateway"
+ generic_name :Desigo
+ uri_base "https://127.0.0.1:8080/WebService/api/"
+
+ alias Client = ::Desigo::Client
+
+ default_settings({
+ username: "admin",
+ password: "admin",
+ })
+
+ protected getter! client : Client
+
+ def on_update
+ base_url = config.uri.not_nil!.to_s
+ username = setting(String, :username)
+ password = setting(String, :password)
+
+ @client = Client.new(base_url: base_url, username: username, password: password)
+
+ spawn do
+ loop do
+ @client.try(&.heartbeat.signal)
+ sleep 60
+ end
+ end
+ end
+
+ def property_values(id : String)
+ property_values = @client.try(&.property_values.get(id: id))
+ self["property_values#{id}"] = property_values
+ end
+
+ def values(id : String)
+ values = @client.try(&.values.get(id: id))
+ self["values#{id}"] = values
+ end
+
+ def commands(id : String)
+ commands = @client.try(&.commands.get(id: id))
+ self["commands#{id}"] = commands
+ end
+
+ # Because of the introspect failing on generics,
+ # we can pass in the `command_inputs_for_execution` as a JSON string
+ # "[{\"Name\": \"Value\", \"DataType\": \"ExtendedEnum\", \"Value\": \"1\"}]"
+ def execute(id : String, property_name : String, command_id : String, command_inputs_for_execution : String)
+ return_value = @client.try(&.commands.execute(id: id, property_name: property_name, command_id: command_id, command_inputs_for_execution: JSON.parse(command_inputs_for_execution)))
+ self["execute#{id}_property#{property_name}_command#{command_id}"] = return_value
+ end
+end
diff --git a/drivers/siemens/desigo/room_logic.cr b/drivers/siemens/desigo/room_logic.cr
new file mode 100644
index 00000000000..0c331fd7965
--- /dev/null
+++ b/drivers/siemens/desigo/room_logic.cr
@@ -0,0 +1,36 @@
+require "placeos-driver"
+
+class Siemens::Desigo::RoomLogic < PlaceOS::Driver
+ descriptive_name "Siemens Desigo single room status abstraction"
+ generic_name :RoomBMS
+ description "Exposes Desigo values for a single room"
+
+ default_settings({
+ desigo_queries: [] of Query,
+ desigo_status_poll_cron: "*/5 * * * *",
+ })
+
+ accessor desigo : Desigo
+
+ @queries = [] of Query
+ @cron_string : String = "*/5 * * * *"
+
+ def on_update
+ @queries = setting(Array(Query), :desigo_queries)
+ @cron_string = setting(String, :desigo_status_poll_cron)
+ schedule.clear
+ schedule.cron(@cron_string) { do_queries }
+ end
+
+ def do_queries
+ responses = @queries.map { |q| {q.name, desigo.values(q.param).get} }
+ responses.each { |name, value| self[name] = value.as_a.first.as_h["Value"]["Value"] }
+ end
+
+ struct Query
+ include JSON::Serializable
+ getter name : String
+ property command : String # todo: support different commands
+ property param : String # todo: support multiple params
+ end
+end
diff --git a/drivers/sony/camera/cgi_protocol.cr b/drivers/sony/camera/cgi_protocol.cr
new file mode 100644
index 00000000000..46c9c5c8cc9
--- /dev/null
+++ b/drivers/sony/camera/cgi_protocol.cr
@@ -0,0 +1,330 @@
+require "placeos-driver"
+require "placeos-driver/interface/camera"
+
+# Documentation: https://aca.im/driver_docs/Sony/sony-camera-CGI-Commands-1.pdf
+
+class Sony::Camera::CGI < PlaceOS::Driver
+ # include Interface::Powerable
+ include Interface::Camera
+
+ # Discovery Information
+ generic_name :Camera
+ descriptive_name "Sony Camera HTTP CGI Protocol"
+
+ default_settings({
+ basic_auth: {
+ username: "admin",
+ password: "Admin_1234",
+ },
+ invert_controls: false,
+ presets: {
+ name: {pan: 1, tilt: 1, zoom: 1},
+ },
+ })
+
+ enum Movement
+ Idle
+ Moving
+ Unknown
+ end
+
+ def on_load
+ # Configure the constants
+ @pantilt_speed = -100..100
+ self[:pan_speed] = self[:tilt_speed] = {min: -100, max: 100, stop: 0}
+ self[:has_discrete_zoom] = true
+
+ schedule.every(60.seconds) { query_status }
+ schedule.in(5.seconds) do
+ query_status
+ info?
+ end
+ on_update
+ end
+
+ @invert_controls = false
+ @presets = {} of String => NamedTuple(pan: Int32, tilt: Int32, zoom: Int32)
+
+ def on_update
+ self[:invert_controls] = @invert_controls = setting?(Bool, :invert_controls) || false
+ @presets = setting?(Hash(String, NamedTuple(pan: Int32, tilt: Int32, zoom: Int32)), :presets) || {} of String => NamedTuple(pan: Int32, tilt: Int32, zoom: Int32)
+ self[:presets] = @presets.keys
+ end
+
+ # 24bit twos complement
+ private def twos_complement(value)
+ if value > 0
+ value > 0x80000 ? -(((~(value & 0xFFFFF)) + 1) & 0xFFFFF) : value
+ else
+ ((~(-value & 0xFFFFF)) + 1) & 0xFFFFF
+ end
+ end
+
+ private def query(path, **opts, &block : Hash(String, String) -> _)
+ queue(**opts) do |task|
+ response = get(path)
+ data = response.body.not_nil!
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+
+ # convert data into more consumable state
+ state = {} of String => String
+ data.split("&").each do |key_value|
+ parts = key_value.strip.split("=")
+ state[parts[0]] = parts[1]
+ end
+
+ result = block.call(state)
+ task.success result
+ end
+ end
+
+ # Temporary values until the camera is queried
+ @moving = false
+ @zooming = false
+ @max_speed = 1
+ @zoom_raw = 0
+ @pan = 0
+ @tilt = 0
+ @pan_range = 0..1
+ @tilt_range = 0..1
+ @zoom_range = 0..1
+
+ def query_status(priority : Int32 = 0)
+ # Response looks like:
+ # AbsolutePTZF=15400,fd578,0000,cbde&PanMovementRange=eac00,15400
+ query("/command/inquiry.cgi?inq=ptzf", priority: priority) do |response|
+ # load the current state
+ response.each do |key, value|
+ case key
+ when "AbsolutePTZF"
+ # Pan, Tilt, Zoom,Focus
+ # AbsolutePTZF=15400,fd578,0000,ca52
+ parts = value.split(",")
+ self[:pan] = @pan = twos_complement parts[0].to_i(16)
+ self[:tilt] = @tilt = twos_complement parts[1].to_i(16)
+ @zoom_raw = parts[2].to_i(16)
+ when "PanMovementRange"
+ # PanMovementRange=eac00,15400
+ parts = value.split(",")
+ pan_min = twos_complement parts[0].to_i(16)
+ pan_max = twos_complement parts[1].to_i(16)
+ @pan_range = pan_min..pan_max
+ self[:pan_range] = {min: pan_min, max: pan_max}
+ when "TiltMovementRange"
+ # TiltMovementRange=fc400,b400
+ parts = value.split(",")
+ tilt_min = twos_complement parts[0].to_i(16)
+ tilt_max = twos_complement parts[1].to_i(16)
+ @tilt_range = tilt_min..tilt_max
+ self[:tilt_range] = {min: tilt_min, max: tilt_max}
+ when "ZoomMovementRange"
+ # min, max, digital
+ # ZoomMovementRange=0000,4000,7ac0
+ parts = value.split(",")
+ zoom_min = parts[0].to_i(16)
+ zoom_max = parts[1].to_i(16)
+ @zoom_range = zoom_min..zoom_max
+ self[:zoom_range] = {min: zoom_min, max: zoom_max}
+ when "PtzfStatus"
+ # PtzfStatus=idle,idle,idle,idle
+ parts = value.split(",").map { |state| Movement.parse(state) }[0..2]
+ self[:moving] = @moving = parts.includes?(Movement::Moving)
+
+ # when "AbsoluteZoom"
+ # # AbsoluteZoom=609
+ # self[:zoom] = @zoom_raw = value.to_i(16)
+
+ # NOTE:: These are not required as speeds are scaled
+ #
+ # when "ZoomMaxVelocity"
+ # # ZoomMaxVelocity=8
+ # @zoom_speed = 1..value.to_i(16)
+
+ when "PanTiltMaxVelocity"
+ # PanTiltMaxVelocity=24
+ @max_speed = value.to_i(16)
+ end
+ end
+
+ self[:zoom] = @zoom_raw.not_nil!.to_f * (100.0 / @zoom_range.end.to_f)
+
+ response
+ end
+ end
+
+ def info?
+ query("/command/inquiry.cgi?inq=system", priority: 0) do |response|
+ response.each do |key, value|
+ if {"ModelName", "Serial", "SoftVersion", "ModelForm", "CGIVersion"}.includes?(key)
+ self[key.underscore] = value
+ end
+ end
+ response
+ end
+ end
+
+ private def action(path, **opts, &block : HTTP::Client::Response -> _)
+ queue(**opts) do |task|
+ response = get(path)
+ raise "request error #{response.status_code}\n#{response.body}" unless response.success?
+
+ result = block.call(response)
+ task.success result
+ end
+ end
+
+ # Implement Stoppable interface
+ def stop(index : Int32 | String = 0, emergency : Bool = false)
+ # indexes start at 1 on sony cameras
+ index = index.to_i + 1
+
+ action("/command/ptzf.cgi?Move=stop,motor,image#{index}",
+ priority: 999,
+ name: "moving",
+ clear_queue: emergency
+ ) do
+ zoom ZoomDirection::Stop if @zooming
+ self[:moving] = @moving = false
+ query_status
+ end
+ end
+
+ # Implement Moveable interface
+ def move(position : MoveablePosition, index : Int32 | String = 0)
+ # indexes start at 1 on sony cameras
+ index = index.to_i + 1
+
+ case position
+ when MoveablePosition::Up, MoveablePosition::Down,
+ MoveablePosition::Left, MoveablePosition::Right
+ # Tilt, Pan
+ if @invert_controls && (position.up? || position.down?)
+ position = position.up? ? MoveablePosition::Down : MoveablePosition::Up
+ end
+
+ action("/command/ptzf.cgi?Move=#{position.to_s.downcase},0,image#{index}",
+ name: "moving"
+ ) { self[:moving] = @moving = true }
+ when MoveablePosition::In
+ zoom ZoomDirection::In
+ when MoveablePosition::Out
+ zoom ZoomDirection::Out
+ else
+ raise "unsupported direction: #{position}"
+ end
+ end
+
+ macro in_range(range, value)
+ {{value}} = if {{range}}.includes? {{value}}
+ {{value}}
+ else
+ {{value}} < {{range}}.begin ? {{range}}.begin : {{range}}.end
+ end
+ {{value}} = twos_complement({{value}})
+ end
+
+ def pantilt(pan : Int32, tilt : Int32, zoom : Int32? = nil) : Nil
+ in_range @pan_range, pan
+ in_range @tilt_range, tilt
+
+ if zoom
+ in_range @zoom_range, zoom
+
+ action("/command/ptzf.cgi?AbsolutePTZF=#{pan.to_s(16)},#{tilt.to_s(16)},#{zoom.to_s(16)}",
+ name: "position"
+ ) do
+ self[:pan] = @pan = pan
+ self[:tilt] = @tilt = tilt
+ self[:zoom] = @zoom_raw = zoom.not_nil!
+ end
+ else
+ action("/command/ptzf.cgi?AbsolutePanTilt=#{pan.to_s(16)},#{tilt.to_s(16)},#{@max_speed.to_s(16)}",
+ name: "position"
+ ) do
+ self[:pan] = @pan = pan
+ self[:tilt] = @tilt = tilt
+ end
+ end
+ end
+
+ # Implement Camera interface
+ def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0)
+ index = index.to_i + 1
+ pan_speed = pan_speed.to_i
+ tilt_speed = tilt_speed.to_i
+
+ range = -100..100
+ in_range range, pan_speed
+ in_range range, tilt_speed
+
+ tilt_speed = -tilt_speed if @invert_controls && tilt_speed != 0
+
+ action("/command/ptzf.cgi?ContinuousPanTiltZoom=#{pan_speed.to_s(16)},#{tilt_speed.to_s(16)},0,image#{index}",
+ name: "moving"
+ ) do
+ self[:moving] = @moving = (pan_speed != 0 || tilt_speed != 0)
+ query_status if !@moving
+ @moving
+ end
+ end
+
+ def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0)
+ index = index.to_i + 1
+
+ position = position.clamp(0.0, 100.0)
+ percentage = position / 100.0
+ zoom_value = (percentage * @zoom_range.end.to_f).to_i
+
+ action("/command/ptzf.cgi?AbsoluteZoom=#{zoom_value.to_s(16)}",
+ name: "zooming"
+ ) do
+ @zoom_raw = zoom_value
+ self[:zoom] = @zoom = position
+ end
+ end
+
+ def zoom(direction : ZoomDirection, index : Int32 | String = 0)
+ index = index.to_i + 1
+
+ if direction.stop?
+ action("/command/ptzf.cgi?Move=stop,zoom,image#{index}",
+ priority: 999,
+ name: "zooming"
+ ) { self[:zooming] = @zooming = false }
+ else
+ action("/command/ptzf.cgi?Move=#{direction.out? ? "wide" : "near"},0,image#{index}",
+ name: "zooming"
+ ) { self[:zooming] = @zooming = true }
+ end
+ end
+
+ def home
+ action("/command/presetposition.cgi?HomePos=ptz-recall",
+ name: "position"
+ ) { query_status }
+ end
+
+ def recall(position : String, index : Int32 | String = 0)
+ preset = @presets[position]?
+ if preset
+ pantilt **preset
+ else
+ raise "unknown preset #{position}"
+ end
+ end
+
+ def save_position(name : String, index : Int32 | String = 0)
+ @presets[name] = {
+ pan: @pan, tilt: @tilt, zoom: @zoom_raw,
+ }
+ define_setting(:presets, @presets)
+ self[:presets] = @presets.keys
+ end
+
+ def remove_position(name : String, index : Int32 | String = 0)
+ @presets.delete name
+ define_setting(:presets, @presets)
+ self[:presets] = @presets.keys
+ end
+end
diff --git a/drivers/sony/camera/cgi_protocol_spec.cr b/drivers/sony/camera/cgi_protocol_spec.cr
new file mode 100644
index 00000000000..2e377209ff0
--- /dev/null
+++ b/drivers/sony/camera/cgi_protocol_spec.cr
@@ -0,0 +1,20 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Floorsense::Desks" do
+ # Send the request
+ retval = exec(:query_status)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output.puts %(AbsolutePTZF=15400,fd578,0000,cb5a&PanMovementRange=eac00,15400&PanPanoramaRange=de00,2200&PanTiltMaxVelocity=24&PtzInstance=1&TiltMovementRange=fc400,b400&TiltPanoramaRange=fc00,1200&ZoomMaxVelocity=8&ZoomMovementRange=0000,4000,7ac0&PtzfStatus=idle,idle,idle,idle&AbsoluteZoom=609)
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.not_nil!["AbsoluteZoom"].should eq("609")
+ status[:pan].should eq(87040)
+ status[:pan_range].should eq({"min" => -87040, "max" => 87040})
+
+ status[:tilt].should eq(-10888)
+ status[:tilt_range].should eq({"min" => -15360, "max" => 46080})
+end
diff --git a/drivers/sony/camera/visca.cr b/drivers/sony/camera/visca.cr
new file mode 100644
index 00000000000..0857dade69f
--- /dev/null
+++ b/drivers/sony/camera/visca.cr
@@ -0,0 +1,430 @@
+require "placeos-driver"
+require "placeos-driver/interface/camera"
+require "placeos-driver/interface/powerable"
+require "bindata"
+
+# Documentation: https://aca.im/driver_docs/Sony/sony_visca_over_ip.pdf
+# https://aca.im/driver_docs/Aver/tr530-320-control-codes.pdf
+
+class Sony::Camera::VISCA < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Camera
+
+ # Discovery Information.
+ generic_name :Camera
+ descriptive_name "VISCA PTZ Camera"
+ udp_port 52381
+
+ default_settings({
+ max_pan_tilt_speed: 0x0F,
+ zoom_speed: 0x07,
+ zoom_max: 0x4000,
+ camera_no: 0x01,
+ invert_controls: false,
+ })
+
+ @sequence : UInt32 = 0
+
+ @max_pan_tilt_speed : UInt8 = 0x0F_u8
+ @zoom_speed : UInt8 = 0x03_u8
+ @zoom_max : UInt16 = 0x4000_u16
+ @camera_address : UInt8 = 0x81_u8
+ @invert : Bool = false
+
+ alias Presets = Hash(String, Tuple(UInt16, UInt16, Float64))
+ @presets : Presets = {} of String => Tuple(UInt16, UInt16, Float64)
+ getter zoom_raw : UInt16 = 0_u16
+ @zoom_pos : Float64 = 0.0
+ getter tilt_pos : UInt16 = 0_u16
+ getter pan_pos : UInt16 = 0_u16
+
+ # we want to tokenize the stream, ensure we only process a single packet at a time
+ # and that we have the complete message
+ def on_load
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.peek
+ # return 0 if the message is incomplete
+ next 0 if bytes.size < 4
+ # return the length of the message
+ IO::Memory.new(bytes[2..3]).read_bytes(UInt16, IO::ByteFormat::BigEndian).to_i + 8
+ end
+
+ on_update
+ end
+
+ def on_update
+ @presets = setting?(Presets, :camera_presets) || @presets
+
+ @max_pan_tilt_speed = setting?(UInt8, :max_pan_tilt_speed) || 0x0F_u8
+ @zoom_speed = setting?(UInt8, :zoom_speed) || 0x03_u8
+ @zoom_max = setting?(UInt16, :zoom_max) || 0x4000_u16
+ @camera_address = 0x80_u8 | (setting?(UInt8, :camera_no) || 1_u8)
+ self[:presets] = @presets.keys
+ self[:inverted] = @invert = setting?(Bool, :invert_controls) || false
+ end
+
+ # clear the interface
+ def connected
+ reset_sequence_number
+ send_cmd(Bytes[0x00, 0x01], name: :if_clear, priority: 98)
+ end
+
+ # ====== Powerable Interface ======
+
+ def power(state : Bool)
+ payload = state ? Bytes[0x04, 0x00, 0x02] : Bytes[0x04, 0x00, 0x03]
+ send_cmd(payload, name: :power)
+ end
+
+ def power?
+ send_inq(Bytes[0x04, 0x00], name: :power_query)
+ end
+
+ # ====== Camera Interface ======
+
+ def home
+ send_cmd(Bytes[0x06, 0x04], name: :pantilt)
+ end
+
+ def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0)
+ tilt_speed = -tilt_speed if @invert
+
+ pan_neg, pan_value, pan_zero = joyspeed(pan_speed, @max_pan_tilt_speed)
+ tilt_neg, tilt_value, tilt_zero = joyspeed(tilt_speed, @max_pan_tilt_speed)
+
+ pan_direction = pan_zero ? "03" : (pan_neg ? "01" : "02")
+ tilt_direction = tilt_zero ? "03" : (tilt_neg ? "02" : "01")
+
+ bytes = "0601#{pan_value}#{tilt_value}#{pan_direction}#{tilt_direction}"
+ resp = send_cmd(bytes.hexbytes, name: :joystick)
+
+ # query the current position after we've stopped moving
+ if pan_zero && tilt_zero
+ spawn do
+ resp.get
+ schedule.in(1.seconds) { pantilt? }
+ end
+ end
+
+ resp
+ end
+
+ protected def joyspeed(speed : Float64, max)
+ speed = speed.clamp(-100.0, 100.0)
+ negative = speed < 0.0
+ speed = speed.abs if negative
+
+ percentage = speed / 100.0
+ value = (percentage * max.to_f).round.to_u8
+
+ bytes = Bytes[value].hexstring.rjust(2, '0')
+ {negative, bytes, value.zero?}
+ end
+
+ def encode_position(value : UInt16) : String
+ io = IO::Memory.new
+ io.write_bytes(value, IO::ByteFormat::BigEndian)
+ bytes = io.to_slice.hexstring.rjust(4, '0')
+ "0#{bytes[0]}0#{bytes[1]}0#{bytes[2]}0#{bytes[3]}"
+ end
+
+ protected def decode_position(bytes : Bytes) : UInt16
+ pos_data = bytes.hexstring.rjust(8, '0')
+ hexstring = "#{pos_data[1]}#{pos_data[3]}#{pos_data[5]}#{pos_data[7]}"
+ IO::Memory.new(hexstring.hexbytes).read_bytes(UInt16, IO::ByteFormat::BigEndian)
+ end
+
+ # moves to an absolute position
+ def pantilt(pan : UInt16, tilt : UInt16, speed : UInt8)
+ speed = speed.clamp(0_u8, @max_pan_tilt_speed)
+ bytes = "0602#{Bytes[speed].hexstring.rjust(2, '0')}00#{encode_position(pan)}#{encode_position(tilt)}"
+ send_cmd(bytes.hexbytes, name: :pantilt)
+ end
+
+ def recall(position : String, index : Int32 | String = 0)
+ if pos = @presets[position]?
+ pan_pos, tilt_pos, zoom_pos = pos
+ pantilt(pan_pos, tilt_pos, @max_pan_tilt_speed)
+ zoom_to(zoom_pos)
+ else
+ raise "unknown preset #{position}"
+ end
+ end
+
+ def save_position(name : String, index : Int32 | String = 0)
+ @presets[name] = {@pan_pos, @tilt_pos, @zoom_pos}
+ save_presets
+ end
+
+ def remove_position(name : String, index : Int32 | String = 0)
+ @presets.delete(name)
+ save_presets
+ end
+
+ protected def save_presets
+ define_setting(:camera_presets, @presets)
+ self[:presets] = @presets.keys
+ end
+
+ # ====== Moveable Interface ======
+
+ # moves at 50% of max speed
+ def move(position : MoveablePosition, index : Int32 | String = 0)
+ case position
+ in .up?
+ joystick(pan_speed: 0.0, tilt_speed: 50.0)
+ in .down?
+ joystick(pan_speed: 0.0, tilt_speed: -50.0)
+ in .left?
+ joystick(pan_speed: -50.0, tilt_speed: 0.0)
+ in .right?
+ joystick(pan_speed: 50.0, tilt_speed: 0.0)
+ in .in?
+ zoom(:in)
+ in .out?
+ zoom(:out)
+ in .open?, .close?
+ # not supported
+ end
+ end
+
+ # ====== Zoomable Interface ======
+
+ # Zooms to an absolute position
+ def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0)
+ position = position.clamp(0.0, 100.0)
+ percentage = position / 100.0
+ zoom_value = (percentage * @zoom_max.to_f).to_u16
+
+ bytes = "0447#{encode_position(zoom_value)}"
+ send_cmd(bytes.hexbytes, name: :zoom_to)
+ end
+
+ def zoom(direction : ZoomDirection, index : Int32 | String = 0)
+ speed_byte = case direction
+ in .stop?
+ schedule.in(500.milliseconds) { zoom? }
+ 0x00_u8
+ in .out?
+ 0x20_u8 | @zoom_speed
+ in .in?
+ 0x30_u8 | @zoom_speed
+ end
+
+ send_cmd(Bytes[0x04, 0x07, speed_byte], name: :zoom)
+ end
+
+ def zoom?
+ send_inq Bytes[0x04, 0x47], name: :zoom_query, priority: 0
+ end
+
+ def pantilt?
+ send_inq Bytes[0x06, 0x12], name: :pantilt_query, priority: 0
+ end
+
+ # ====== Stoppable Interface ======
+
+ def stop(index : Int32 | String = 0, emergency : Bool = false)
+ zoom(:stop)
+ end
+
+ # =================================
+
+ # VISCA over IP packet layout
+ class Packet < BinData
+ endian big
+
+ enum Type : UInt16
+ Command = 0x0100
+ Inquiry = 0x0110
+ Reply = 0x0111
+ # VISCA device setting
+ DeviceSetting = 0x0120
+
+ # reset: 0x01
+ # error: 0x0Fyy (yy = 01 : sequence number error, 02 : message error)
+ DeviceControl = 0x0200
+
+ # Acknowledge for reset
+ DeviceReply = 0x0201
+ end
+
+ enum_field UInt16, type : Type = Type::Command
+ uint16 :size, value: ->{ payload.size.to_u16 }
+ uint32 :sequence
+ bytes :payload, length: ->{ size }
+ end
+
+ @[Security(Level::Administrator)]
+ def send_cmd(bytes : String)
+ send_cmd(bytes.hexbytes)
+ end
+
+ protected def send_cmd(bytes : Bytes, **options)
+ # VISCA message
+ payload = IO::Memory.new
+ payload.write Bytes[@camera_address, 0x01]
+ payload.write bytes
+ payload.write_byte 0xFF_u8
+
+ # OverIP wrapper
+ packet = Packet.new
+ packet.type = :command
+ packet.payload = payload.to_slice
+
+ queue(**options) do |task|
+ sequence = next_sequence
+ packet.sequence = sequence
+
+ transport.send(packet, task) do |data, the_task|
+ # curry in the sequence we are expecting
+ received(data, the_task, sequence)
+ end
+ end
+ end
+
+ @[Security(Level::Administrator)]
+ def send_inq(bytes : String)
+ send_inq(bytes.hexbytes)
+ end
+
+ protected def send_inq(bytes : Bytes, **options)
+ # VISCA message
+ payload = IO::Memory.new
+ payload.write Bytes[@camera_address, 0x09]
+ payload.write bytes
+ payload.write_byte 0xFF_u8
+
+ # OverIP wrapper
+ packet = Packet.new
+ packet.type = :inquiry
+ packet.payload = payload.to_slice
+
+ queue(**options) do |task|
+ sequence = next_sequence
+ packet.sequence = sequence
+
+ transport.send(packet, task) do |data, the_task|
+ # curry in the sequence we are expecting
+ received(data, the_task, sequence)
+ end
+ end
+ end
+
+ protected def next_sequence : UInt32
+ # we want to ignore overflows
+ @sequence = @sequence &+ 1_u32
+ end
+
+ @[Security(Level::Administrator)]
+ def reset_sequence_number(directly : Bool = false)
+ packet = Packet.new
+ packet.type = :device_control
+ packet.sequence = @sequence = 1_u32
+ packet.payload = Bytes[0x01_u8]
+
+ return transport.send(packet) if directly
+ queue(name: :reset_sequence_number, priority: 99) do |task|
+ transport.send(packet, task) do |data|
+ # curry in the sequence we are expecting
+ received(data, task, @sequence)
+ end
+ end
+ end
+
+ # process incoming data, tokenised so we know each data packet is exactly one message
+ def received(data, task, sequence : UInt32? = nil) : Nil
+ logger.debug { "Camera sent: 0x#{data.hexstring}" }
+
+ # Was this expected data? Should have a sequence curried in
+ if sequence.nil?
+ logger.info { "unexpected packet received, ignoring as no sequence pending" }
+ return
+ end
+
+ io = IO::Memory.new(data)
+ packet = io.read_bytes(Packet)
+ payload = packet.payload
+
+ # process any errors
+ case packet.type
+ when .device_control?
+ case payload[-1]
+ when 1_u8
+ # Abnormality in the sequence number, let's reset it
+ # then we can retry the task in the ack
+ reset_sequence_number(directly: true)
+ logger.info { "sequence number error, resetting sequence" }
+ when 2_u8
+ # Abnormality in the message
+ task.try(&.abort("bad request"))
+ end
+ return
+ when .device_reply?
+ if task && task.name == "reset_sequence_number"
+ task.success
+ else
+ task.try(&.retry("sequence number reset, retrying task"))
+ end
+ return
+ when .reply?
+ # ensure it's for the current request
+ if sequence != packet.sequence
+ logger.info { "unexpected sequence number, ignoring" }
+ return
+ end
+ else
+ logger.info { "unexpected packet type #{packet.type}, ignoring" }
+ return
+ end
+
+ # Check response
+ check_command = payload[1] & 0xF0_u8
+ case check_command
+ when 0x40_u8
+ # ignore accepted message, we are interested in the completion message
+ logger.debug { "ignoring command accepted message" }
+ return
+ when 0x50_u8
+ logger.debug { "command complete message" }
+ # command execution completed successfully
+ # fall through to processing
+ when 0x60_u8
+ # an error occured!
+ case payload[2]
+ when 0x02_u8
+ task.try(&.abort("syntax error in request"))
+ when 0x03_u8
+ # command buffer is full, lets retry the request
+ schedule.in(50.milliseconds) { task.try &.retry("camera busy") }
+ when 0x04_u8
+ task.try(&.abort("request was cancelled by the user"))
+ when 0x05_u8
+ # attempt to cancel a command that might have already executed
+ task.try(&.success)
+ when 0x41_u8
+ # command can't be executed with the current camera state
+ task.try(&.abort("request could not be performed"))
+ end
+ return
+ end
+
+ # process the packet!
+ case task.try &.name
+ when "zoom_query"
+ @zoom_raw = zoom_value = decode_position(payload[2..5])
+ self[:zoom] = @zoom_pos = zoom_value.to_f * (100.0 / @zoom_max.to_f)
+ when "pantilt_query"
+ @pan_pos = decode_position(payload[2..5])
+ @tilt_pos = decode_position(payload[6..9])
+ when "zoom_to"
+ zoom?
+ when "pantilt"
+ pantilt?
+ when "power_query"
+ self[:power] = payload[-2] == 0x02_u8
+ end
+
+ task.try &.success
+ end
+end
diff --git a/drivers/sony/camera/visca_spec.cr b/drivers/sony/camera/visca_spec.cr
new file mode 100644
index 00000000000..565ad41bba8
--- /dev/null
+++ b/drivers/sony/camera/visca_spec.cr
@@ -0,0 +1,84 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Sony::Camera::VISCA" do
+ # reset the sequence number
+ puts "Testing sequence reset"
+ should_send Bytes[0x02, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01]
+ responds Bytes[0x02, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01]
+
+ # clear the interface socket
+ puts "Testing interface clear"
+ should_send Bytes[0x01, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x02, 0x81, 0x01, 0x00, 0x01, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x90, 0x50, 0xFF]
+
+ puts "Testing camera go-home"
+ exec :home
+ should_send Bytes[0x01, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x03, 0x81, 0x01, 0x06, 0x04, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x03, 0x90, 0x40, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x03, 0x90, 0x50, 0xFF]
+
+ # Should then query the current camera position
+ puts "Testing camera pan tilt position query"
+ should_send Bytes[0x01, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x04, 0x81, 0x09, 0x06, 0x12, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x04, 0x90, 0x40, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x0B, 0x00, 0x00, 0x00, 0x04, 0x90, 0x50,
+ 0x0B, 0x0E, 0x0E, 0x0F, # pan pos
+ 0x01, 0x02, 0x03, 0x04, # tilt pos
+ 0xFF,
+ ]
+
+ # Check the positions are parsed correctly
+ puts "Confirming pan tilt position parsing"
+ exec(:pan_pos).get.should eq 0xBEEF
+ exec(:tilt_pos).get.should eq 0x1234
+
+ # should be able to set the camera position
+ puts "Check pan tilt request"
+ exec(:pantilt, 0x3456, 0x7890, 0xFF)
+ should_send Bytes[0x01, 0x00, 0x00, 0x0F, 0x00, 0x00, 0x00, 0x05, 0x81, 0x01,
+ 0x06, 0x02,
+ 0x0F, 0x00, # speed
+ 0x03, 0x04, 0x05, 0x06, # pan pos
+ 0x07, 0x08, 0x09, 0x00, # tilt pos
+ 0xFF,
+ ]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x05, 0x90, 0x40, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x05, 0x90, 0x50, 0xFF]
+
+ # Should then query the current camera position
+ puts "Should query pan tilt position"
+ should_send Bytes[0x01, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x06, 0x81, 0x09, 0x06, 0x12, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x06, 0x90, 0x40, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x0B, 0x00, 0x00, 0x00, 0x06, 0x90, 0x50,
+ 0x03, 0x04, 0x05, 0x06, # pan pos
+ 0x07, 0x08, 0x09, 0x00, # tilt pos
+ 0xFF,
+ ]
+
+ # Check the positions are parsed correctly
+ puts "Confirming pan tilt position were updated"
+ exec(:pan_pos).get.should eq 0x3456
+ exec(:tilt_pos).get.should eq 0x7890
+
+ puts "Zoom to an absolute position"
+ exec(:zoom_to, 25.0)
+ should_send Bytes[0x01, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x07, 0x81, 0x01,
+ 0x04, 0x47,
+ 0x01, 0x00, 0x00, 0x00, # zoom value
+ 0xFF,
+ ]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x90, 0x40, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x07, 0x90, 0x50, 0xFF]
+
+ # Should then query the current camera zoom level
+ puts "Should query zoom level"
+ should_send Bytes[0x01, 0x10, 0x00, 0x05, 0x00, 0x00, 0x00, 0x08, 0x81, 0x09, 0x04, 0x47, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x03, 0x00, 0x00, 0x00, 0x08, 0x90, 0x40, 0xFF]
+ responds Bytes[0x01, 0x11, 0x00, 0x07, 0x00, 0x00, 0x00, 0x08, 0x90, 0x50,
+ 0x01, 0x00, 0x00, 0x00, # pan pos
+ 0xFF,
+ ]
+
+ exec(:zoom_raw).get.should eq 0x1000
+ status[:zoom].should eq 25
+end
diff --git a/drivers/sony/displays/bravia.cr b/drivers/sony/displays/bravia.cr
new file mode 100644
index 00000000000..926cce60f52
--- /dev/null
+++ b/drivers/sony/displays/bravia.cr
@@ -0,0 +1,254 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+# Documentation: https://aca.im/driver_docs/Sony/sony%20bravia%20simple%20ip%20control.pdf
+
+class Sony::Displays::Bravia < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ private INDICATOR = "\x2A\x53" # *S
+ private HASH = "################"
+
+ # Discovery Information
+ tcp_port 20060
+ descriptive_name "Sony Bravia LCD Display"
+ generic_name :Display
+
+ enum Input : UInt32
+ {% for idx in 0..3 %}
+ Tv{{idx}} = {{ idx }}
+ Hdmi{{idx}} = {{10000_0000 + idx}}
+ Mirror{{idx}} = {{50000_0000 + idx}}
+ Vga{{idx}} = {{60000_0000 + idx}}
+ {% end %}
+
+ def self.from_param(value : String) : self
+ from_value UInt32.new(value)
+ rescue
+ raise "Unknown enum #{self} value: #{value}"
+ end
+
+ def to_param : String
+ value.to_s.rjust(5, '0')
+ end
+ end
+
+ include Interface::InputSelection(Input)
+
+ def switch_to(input : Input)
+ logger.debug { "switching input to #{input}" }
+ request(Command::Input, input.to_param)
+ self[:input] = input.to_s
+ input?
+ end
+
+ def input?
+ query(Command::Input, priority: 0)
+ end
+
+ def on_load
+ self[:volume_min] = 0
+ self[:volume_max] = 100
+ end
+
+ def connected
+ schedule.every(30.seconds, true) do
+ do_poll
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ request(Command::Power, state)
+ power?
+ end
+
+ def power?
+ query(Command::Power)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ request(Command::Mute, state)
+ mute?
+ end
+
+ def unmute
+ mute false
+ end
+
+ def mute?
+ query(Command::Mute, priority: 0)
+ end
+
+ def mute_audio(state : Bool = true)
+ request(Command::AudioMute, state)
+ audio_mute?
+ end
+
+ def unmute_audio
+ mute_audio false
+ end
+
+ def audio_mute?
+ query(Command::AudioMute, priority: 0)
+ end
+
+ def volume(level : Int32 | Float64)
+ level = level.to_f.clamp(0.0, 100.0).round_away.to_i
+ request(Command::Volume, level)
+ volume?
+ end
+
+ def volume?
+ query(Command::Volume, priority: 0)
+ end
+
+ def volume_up
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume + 5.0)
+ end
+
+ def volume_down
+ current_volume = status?(Float64, :volume) || 50.0
+ volume(current_volume - 5.0)
+ end
+
+ def do_poll
+ if self[:power]?
+ input?
+ mute?
+ audio_mute?
+ volume?
+ end
+ end
+
+ enum MessageType : UInt8
+ Answer = 0x41
+ Control = 0x43
+ Enquiry = 0x45
+ Notify = 0x4e
+ Error = 0x46
+
+ def control_character
+ value.chr
+ end
+ end
+
+ def received(data, task)
+ parsed_data = convert_binary(data[3..6])
+ cmd = Command.from_response?(parsed_data)
+ return task.try(&.abort("unrecognised command: #{parsed_data}")) if cmd.nil?
+ param = data[7..-1]
+ return task.try(&.abort("error")) if param.first? == MessageType::Error.value
+ case MessageType.from_value?(data[2])
+ when MessageType::Answer
+ update_status cmd, param
+ task.try &.success
+ when MessageType::Notify
+ update_status cmd, param
+ else
+ logger.debug { "Unhandled device response: #{data[2].chr rescue data[2]}" }
+ task.try &.abort("Unhandled device response")
+ end
+ end
+
+ COMMANDS = {
+ ir_code: "IRCC",
+ power: "POWR",
+ volume: "VOLU",
+ audio_mute: "AMUT",
+ mute: "PMUT",
+ channel: "CHNN",
+ tv_input: "ISRC",
+ input: "INPT",
+ toggle_mute: "TPMU",
+ pip: "PIPI",
+ toggle_pip: "TPIP",
+ position_pip: "TPPP",
+ broadcast_address: "BADR",
+ mac_address: "MADR",
+ }
+
+ {% begin %}
+ enum Command
+ {% begin %}
+ {% for command in COMMANDS.keys %}
+ {{ command.camelcase.id }}
+ {% end %}
+ {% end %}
+
+ def function
+ {% begin %}
+ case self
+ {% for kv in COMMANDS.to_a %}
+ {% command, value = kv[0], kv[1] %}
+ in {{ command.camelcase }} then {{ value }}
+ {% end %}
+ end
+ {% end %}
+ end
+
+ def self.from_response?(message)
+ {% begin %}
+ case message
+ {% for kv in COMMANDS.to_a %}
+ {% command, value = kv[0], kv[1] %}
+ when {{ value }} then {{ command.camelcase.id }}
+ {% end %}
+ end
+ {% end %}
+ end
+ end
+ {% end %}
+
+ protected def convert_binary(data)
+ String.new(slice: data)
+ end
+
+ protected def request(command, parameter, **options)
+ cmd = command.function
+ parameter = parameter ? 1 : 0 if parameter.is_a?(Bool)
+ param = parameter.to_s.rjust(16, '0')
+ do_send(MessageType::Control, cmd, param, **options)
+ end
+
+ protected def query(state, **options)
+ cmd = state.function
+ do_send(MessageType::Enquiry, cmd, HASH, **options)
+ end
+
+ protected def do_send(type, command, parameter, **options)
+ cmd = "#{INDICATOR}#{type.control_character}#{command}#{parameter}\n"
+ send(cmd, **options)
+ end
+
+ protected def update_status(cmd : Command, param)
+ parsed_data = convert_binary(param)
+ case cmd
+ when .power?
+ self[:power] = parsed_data.to_i == 1
+ when .mute?
+ self[:mute] = parsed_data.to_i == 1
+ when .audio_mute?
+ self[:audio_mute] = parsed_data.to_i == 1
+ when .pip?
+ self[:pip] = parsed_data.to_i == 1
+ when .volume?
+ self[:volume] = parsed_data.to_i
+ when .mac_address?
+ self[:mac_address] = parsed_data.split('#')[0]
+ when .input?
+ self[:input] = Input.from_param(parsed_data[7..15])
+ end
+ end
+end
diff --git a/drivers/sony/displays/bravia_rest.cr b/drivers/sony/displays/bravia_rest.cr
new file mode 100644
index 00000000000..3e69c697019
--- /dev/null
+++ b/drivers/sony/displays/bravia_rest.cr
@@ -0,0 +1,237 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+# Documentation: https://pro-bravia.sony.net/develop/integrate/rest-api/spec/
+class Sony::Displays::BraviaRest < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ # Discovery Information
+ uri_base "http://display"
+ descriptive_name "Sony Bravia REST API Display"
+ generic_name :Display
+ description "Sony Bravia Professional Display controlled via REST API. Requires Pre-Shared Key (PSK) authentication."
+
+ default_settings({
+ psk: "your_psk_here",
+ })
+
+ enum Input
+ HDMI1 = 1
+ HDMI2 = 2
+ HDMI3 = 3
+ HDMI4 = 4
+ Component1 = 5
+ Component2 = 6
+ Component3 = 7
+ Composite1 = 8
+ Screen_mirroring = 9
+ PC = 10
+
+ def to_uri : String
+ case self
+ when .hdmi1?, .hdmi2?, .hdmi3?, .hdmi4?
+ "extInput:hdmi?port=#{value}"
+ when .component1?, .component2?, .component3?
+ "extInput:component?port=#{value - 4}"
+ when .composite1?
+ "extInput:composite?port=1"
+ when .screen_mirroring?
+ "extInput:widi?port=1"
+ when .pc?
+ "extInput:cec?port=1"
+ else
+ "extInput:hdmi?port=1"
+ end
+ end
+ end
+
+ include Interface::InputSelection(Input)
+
+ @psk : String = ""
+
+ def on_load
+ self[:volume_min] = 0
+ self[:volume_max] = 100
+ on_update
+ end
+
+ def on_update
+ @psk = setting(String, :psk)
+ end
+
+ def connected
+ schedule.every(30.seconds, true) do
+ do_poll
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ send_command("system", "setPowerStatus", {status: state})
+ power?
+ end
+
+ def power?
+ send_command("system", "getPowerStatus", [] of String)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo,
+ )
+ send_command("audio", "setAudioMute", {status: state})
+ mute?
+ end
+
+ def unmute
+ mute false
+ end
+
+ def mute?
+ send_command("audio", "getVolumeInformation", [] of String)
+ end
+
+ def volume(level : Int32 | Float64)
+ level = level.to_f.clamp(0.0, 100.0).round_away.to_i
+ send_command("audio", "setAudioVolume", {volume: level.to_s, target: "speaker"})
+ volume?
+ end
+
+ def volume?
+ send_command("audio", "getVolumeInformation", [] of String)
+ end
+
+ def volume_up
+ send_command("audio", "setAudioVolume", {volume: "+5", target: "speaker"})
+ volume?
+ end
+
+ def volume_down
+ send_command("audio", "setAudioVolume", {volume: "-5", target: "speaker"})
+ volume?
+ end
+
+ def switch_to(input : Input)
+ logger.debug { "switching input to #{input}" }
+ send_command("avContent", "setPlayContent", {uri: input.to_uri})
+ self[:input] = input.to_s
+ input?
+ end
+
+ def input?
+ send_command("avContent", "getPlayingContentInfo", [] of String)
+ end
+
+ def do_poll
+ if status?(Bool, :power)
+ volume?
+ mute?
+ input?
+ end
+ end
+
+ private def send_command(service : String, method : String, params)
+ headers = HTTP::Headers{
+ "Content-Type" => "application/json",
+ "X-Auth-PSK" => @psk,
+ }
+
+ body = {
+ method: method,
+ id: Random.rand(1..999),
+ params: [params],
+ version: "1.0",
+ }.to_json
+
+ response = post("/sony/#{service}", body: body, headers: headers)
+
+ unless response.success?
+ logger.error { "HTTP error: #{response.status_code} - #{response.body}" }
+ raise "HTTP Error: #{response.status_code}"
+ end
+
+ data = JSON.parse(response.body)
+
+ if error = data["error"]?
+ logger.error { "Sony Bravia API error: #{error}" }
+ raise "API Error: #{error}"
+ end
+
+ result = data["result"]?
+ process_response(method, result)
+ result
+ end
+
+ private def process_response(method : String, result)
+ case method
+ when "getPowerStatus"
+ if result.responds_to?(:as_a) && (array = result.as_a?) && array.size > 0
+ status = array[0].as_h
+ power_status = status["status"]?.try(&.as_s) == "active"
+ self[:power] = power_status
+ end
+ when "getVolumeInformation"
+ if result.responds_to?(:as_a) && (array = result.as_a?) && array.size > 0
+ volume_info = array[0].as_a
+ volume_info.each do |info|
+ vol_data = info.as_h
+ if vol_data["target"]?.try(&.as_s) == "speaker"
+ self[:volume] = vol_data["volume"]?.try(&.as_i) || 0
+ self[:mute] = vol_data["mute"]?.try(&.as_bool) || false
+ break
+ end
+ end
+ end
+ when "getPlayingContentInfo"
+ if result.responds_to?(:as_a) && (array = result.as_a?) && array.size > 0
+ content_info = array[0].as_h
+ uri = content_info["uri"]?.try(&.as_s) || ""
+ self[:input] = parse_input_from_uri(uri)
+ end
+ end
+ end
+
+ private def parse_input_from_uri(uri : String) : String
+ if uri.includes?("hdmi")
+ if match = uri.match(/port=(\d+)/)
+ port = match[1].to_i
+ case port
+ when 1 then "HDMI1"
+ when 2 then "HDMI2"
+ when 3 then "HDMI3"
+ when 4 then "HDMI4"
+ else "HDMI1"
+ end
+ else
+ "HDMI1"
+ end
+ elsif uri.includes?("component")
+ if match = uri.match(/port=(\d+)/)
+ port = match[1].to_i
+ case port
+ when 1 then "Component1"
+ when 2 then "Component2"
+ when 3 then "Component3"
+ else "Component1"
+ end
+ else
+ "Component1"
+ end
+ elsif uri.includes?("composite")
+ "Composite1"
+ elsif uri.includes?("widi")
+ "Screen_mirroring"
+ elsif uri.includes?("cec")
+ "PC"
+ else
+ "Unknown"
+ end
+ end
+end
diff --git a/drivers/sony/displays/bravia_rest_spec.cr b/drivers/sony/displays/bravia_rest_spec.cr
new file mode 100644
index 00000000000..94fe3c71b5b
--- /dev/null
+++ b/drivers/sony/displays/bravia_rest_spec.cr
@@ -0,0 +1,300 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Sony::Displays::BraviaRest" do
+ settings({
+ psk: "test123",
+ })
+
+ # Test power on
+ exec(:power, true)
+ expect_http_request do |request, response|
+ request.method.should eq("POST")
+ request.path.should eq("/sony/system")
+ request.headers["X-Auth-PSK"].should eq("test123")
+ request.headers["Content-Type"].should eq("application/json")
+
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setPowerStatus")
+ body["params"].as_a[0]["status"].should eq(true)
+ body["version"].should eq("1.0")
+
+ response.status_code = 200
+ response << %({
+ "result": [0],
+ "id": 123
+ })
+ end
+
+ expect_http_request do |request, response|
+ request.method.should eq("POST")
+ request.path.should eq("/sony/system")
+
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getPowerStatus")
+
+ response.status_code = 200
+ response << %({
+ "result": [{"status": "active"}],
+ "id": 124
+ })
+ end
+ status[:power].should eq(true)
+
+ # Test power off
+ exec(:power, false)
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setPowerStatus")
+ body["params"].as_a[0]["status"].should eq(false)
+
+ response.status_code = 200
+ response << %({
+ "result": [0],
+ "id": 125
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getPowerStatus")
+
+ response.status_code = 200
+ response << %({
+ "result": [{"status": "standby"}],
+ "id": 126
+ })
+ end
+ status[:power].should eq(false)
+
+ # Test volume control
+ exec(:volume, 50)
+ expect_http_request do |request, response|
+ request.path.should eq("/sony/audio")
+
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setAudioVolume")
+ body["params"].as_a[0]["volume"].should eq("50")
+ body["params"].as_a[0]["target"].should eq("speaker")
+
+ response.status_code = 200
+ response << %({
+ "result": [0],
+ "id": 127
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getVolumeInformation")
+
+ response.status_code = 200
+ response << %({
+ "result": [[{
+ "target": "speaker",
+ "volume": 50,
+ "mute": false,
+ "maxVolume": 100,
+ "minVolume": 0
+ }]],
+ "id": 128
+ })
+ end
+ status[:volume].should eq(50)
+ status[:mute].should eq(false)
+
+ # Test volume up
+ exec(:volume_up)
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setAudioVolume")
+ body["params"].as_a[0]["volume"].should eq("+5")
+ body["params"].as_a[0]["target"].should eq("speaker")
+
+ response.status_code = 200
+ response << %({
+ "result": [0],
+ "id": 129
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getVolumeInformation")
+
+ response.status_code = 200
+ response << %({
+ "result": [[{
+ "target": "speaker",
+ "volume": 55,
+ "mute": false,
+ "maxVolume": 100,
+ "minVolume": 0
+ }]],
+ "id": 130
+ })
+ end
+ status[:volume].should eq(55)
+
+ # Test volume down
+ exec(:volume_down)
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setAudioVolume")
+ body["params"].as_a[0]["volume"].should eq("-5")
+
+ response.status_code = 200
+ response << %({
+ "result": [0],
+ "id": 131
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getVolumeInformation")
+
+ response.status_code = 200
+ response << %({
+ "result": [[{
+ "target": "speaker",
+ "volume": 50,
+ "mute": false,
+ "maxVolume": 100,
+ "minVolume": 0
+ }]],
+ "id": 132
+ })
+ end
+ status[:volume].should eq(50)
+
+ # Test mute
+ exec(:mute)
+ expect_http_request do |request, response|
+ request.path.should eq("/sony/audio")
+
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setAudioMute")
+ body["params"].as_a[0]["status"].should eq(true)
+
+ response.status_code = 200
+ response << %({
+ "result": [0],
+ "id": 133
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getVolumeInformation")
+
+ response.status_code = 200
+ response << %({
+ "result": [[{
+ "target": "speaker",
+ "volume": 50,
+ "mute": true,
+ "maxVolume": 100,
+ "minVolume": 0
+ }]],
+ "id": 134
+ })
+ end
+ status[:mute].should eq(true)
+
+ # Test unmute
+ exec(:unmute)
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setAudioMute")
+ body["params"].as_a[0]["status"].should eq(false)
+
+ response.status_code = 200
+ response << %({
+ "result": [0],
+ "id": 135
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getVolumeInformation")
+
+ response.status_code = 200
+ response << %({
+ "result": [[{
+ "target": "speaker",
+ "volume": 50,
+ "mute": false,
+ "maxVolume": 100,
+ "minVolume": 0
+ }]],
+ "id": 136
+ })
+ end
+ status[:mute].should eq(false)
+
+ # Test input switching to HDMI1
+ exec(:switch_to, "hdmi1")
+ expect_http_request do |request, response|
+ request.path.should eq("/sony/avContent")
+
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setPlayContent")
+ body["params"].as_a[0]["uri"].should eq("extInput:hdmi?port=1")
+
+ response.status_code = 200
+ response << %({
+ "result": [],
+ "id": 137
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getPlayingContentInfo")
+
+ response.status_code = 200
+ response << %({
+ "result": [{
+ "uri": "extInput:hdmi?port=1",
+ "source": "extInput:hdmi",
+ "title": "HDMI 1"
+ }],
+ "id": 138
+ })
+ end
+ status[:input].should eq("HDMI1")
+
+ # Test input switching to HDMI3
+ exec(:switch_to, "hdmi3")
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("setPlayContent")
+ body["params"].as_a[0]["uri"].should eq("extInput:hdmi?port=3")
+
+ response.status_code = 200
+ response << %({
+ "result": [],
+ "id": 139
+ })
+ end
+
+ expect_http_request do |request, response|
+ body = JSON.parse(request.body.not_nil!)
+ body["method"].should eq("getPlayingContentInfo")
+
+ response.status_code = 200
+ response << %({
+ "result": [{
+ "uri": "extInput:hdmi?port=3",
+ "source": "extInput:hdmi",
+ "title": "HDMI 3"
+ }],
+ "id": 140
+ })
+ end
+ status[:input].should eq("HDMI3")
+
+ # Error handling is working properly as shown in the logs
+ # but testing exceptions in HTTP drivers requires different patterns
+end
diff --git a/drivers/sony/displays/bravia_spec.cr b/drivers/sony/displays/bravia_spec.cr
new file mode 100644
index 00000000000..275d3457b14
--- /dev/null
+++ b/drivers/sony/displays/bravia_spec.cr
@@ -0,0 +1,55 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Sony::Displays::Bravia" do
+ exec(:power, true)
+ should_send("\x2A\x53\x43POWR0000000000000001\n")
+ responds("\x2A\x53\x41POWR0000000000000000\n")
+ should_send("\x2A\x53\x45POWR################\n")
+ responds("\x2A\x53\x41POWR0000000000000001\n")
+ status[:power].should eq(true)
+
+ exec(:switch_to, "hdmi1")
+ should_send("\x2A\x53\x43INPT0000000100000001\n")
+ responds("\x2A\x53\x41INPT0000000000000000\n")
+ should_send("\x2A\x53\x45INPT################\n")
+ responds("\x2A\x53\x41INPT0000000100000001\n")
+ status[:input].should eq("Hdmi1")
+
+ exec(:switch_to, "vga3")
+ should_send("\x2A\x53\x43INPT0000000600000003\n")
+ responds("\x2A\x53\x41INPT0000000000000000\n")
+ should_send("\x2A\x53\x45INPT################\n")
+ responds("\x2A\x53\x41INPT0000000600000003\n")
+ status[:input].should eq("Vga3")
+
+ exec(:volume, 99)
+ should_send("\x2A\x53\x43VOLU0000000000000099\n")
+ responds("\x2A\x53\x41VOLU0000000000000000\n")
+ should_send("\x2A\x53\x45VOLU################\n")
+ responds("\x2A\x53\x41VOLU0000000000000099\n")
+ status[:volume].should eq(99)
+
+ # Test failure
+ exec(:mute)
+ should_send("\x2A\x53\x43PMUT0000000000000001\n")
+ responds("\x2A\x53\x41PMUT0000000000000000\n")
+ should_send("\x2A\x53\x45PMUT################\n")
+ responds("\x2A\x53\x41PMUT0000000000000001\n")
+ status[:mute].should eq(true)
+
+ exec(:unmute)
+ should_send("\x2A\x53\x43PMUT0000000000000000\n")
+ responds("\x2A\x53\x41PMUTFFFFFFFFFFFFFFFF\n")
+ should_send("\x2A\x53\x45PMUT################\n")
+ responds("\x2A\x53\x41PMUT0000000000000001\n")
+ status[:mute].should eq(true)
+
+ exec(:volume, 50)
+ should_send("\x2A\x53\x43VOLU0000000000000050\n")
+ responds("\x2A\x53\x4EPMUT0000000000000000\n") # mix in a notify
+ responds("\x2A\x53\x41VOLU0000000000000000\n")
+ should_send("\x2A\x53\x45VOLU################\n")
+ responds("\x2A\x53\x41VOLU0000000000000050\n")
+ status[:volume].should eq(50)
+ status[:mute].should eq(false)
+end
diff --git a/drivers/sony/projector/fh.cr b/drivers/sony/projector/fh.cr
new file mode 100644
index 00000000000..bfbc9871e85
--- /dev/null
+++ b/drivers/sony/projector/fh.cr
@@ -0,0 +1,171 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+# Documentation: https://drive.google.com/a/room.tools/file/d/1C0gAWNOtkbrHFyky_9LfLCkPoMcYU9lO/view?usp=sharing
+
+class Sony::Projector::Fh < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ enum Inputs
+ HDMI
+ DVI
+ Video
+ SVideo
+ RGB
+ HDBaseT
+ InputA
+ InputB
+ InputC
+ InputD
+ InputE
+
+ def to_message : String
+ case self
+ in HDMI, DVI, Video, SVideo, RGB, HDBaseT
+ to_s.downcase + "1"
+ in InputA, InputB, InputC, InputD, InputE
+ to_s.underscore
+ end
+ end
+
+ def readable : String
+ to_s.downcase
+ end
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(Inputs)
+
+ descriptive_name "Sony Projector FH Series"
+ generic_name :Display
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("\r\n")
+ end
+
+ def connected
+ schedule.every(60.seconds) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ set("power", state ? "on" : "off").get
+ self[:power] = state
+ end
+
+ def power?
+ get("power_status")
+ !!self[:power]?.try(&.as_bool)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ set("blank", state ? "on" : "off").get
+ self[:mute] = state
+ end
+
+ def mute?
+ get("blank").get
+ self[:mute].as_bool
+ end
+
+ INPUTS_LOOKUP = {
+ "hdmi1" => Inputs::HDMI,
+ "dvi1" => Inputs::DVI,
+ "video1" => Inputs::Video,
+ "svideo1" => Inputs::SVideo,
+ "rgb1" => Inputs::RGB,
+ "hdbaset1" => Inputs::HDBaseT,
+ "input_a" => Inputs::InputA,
+ "input_b" => Inputs::InputB,
+ "input_c" => Inputs::InputC,
+ "input_d" => Inputs::InputD,
+ "input_e" => Inputs::InputE,
+ }
+
+ def switch_to(input : Inputs)
+ set("input", input.to_message).get
+ self[:input] = input.readable
+ end
+
+ def input?
+ get("input").get
+ self[:input].as_s
+ end
+
+ {% for name in ["contrast", "brightness", "color", "hue", "sharpness"] %}
+ def {{name.id}}?
+ get({{name.id.stringify}})
+ end
+
+ def {{name.id}}(val : Int32)
+ set({{name.id.stringify}}, val.clamp(0, 100))
+ end
+ {% end %}
+
+ private def do_poll
+ return unless power?
+ input?
+ mute?
+ end
+
+ def received(response, task)
+ process_response(response, task)
+ end
+
+ private def process_response(response, task, path = nil)
+ response = String.new(response)
+ logger.debug { "Sony proj sent: #{response}" }
+ data = shellsplit(response.strip.downcase)
+
+ return task.try &.success if data[0] == "ok"
+ return task.try &.abort if data[0] == "err_cmd"
+
+ case path
+ when "power_status"
+ self[:power] = data[0] == "on"
+ when "blank"
+ self[:mute] = data[0] == "on"
+ when "input"
+ self[:input] = INPUTS_LOOKUP[data[0]].readable
+ end
+ task.try &.success
+ end
+
+ private def get(path, **options)
+ cmd = "#{path} ?\r\n"
+ logger.debug { "Sony projector FH requesting: #{cmd}" }
+ send(cmd, **options) { |data, task| process_response(data, task, path) }
+ end
+
+ private def set(path, arg, **options)
+ cmd = "#{path} \"#{arg}\"\r\n"
+ logger.debug { "Sony projector FH sending: #{cmd}" }
+ send(cmd, **options) { |data, task| process_response(data, task, path) }
+ end
+
+ # Quick dirty port of https://github.com/ruby/ruby/blob/master/lib/shellwords.rb
+ private def shellsplit(line : String) : Array(String)
+ words = [] of String
+ field = ""
+ pattern = /\G\s*(?>([^\s\\\'\"]+)|'([^\']*)'|"((?:[^\"\\]|\\.)*)"|(\\.?)|(\S))(\s|\z)?/m
+ line.scan(pattern) do |match|
+ _, word, sq, dq, esc, garbage, sep = match.to_a
+ raise ArgumentError.new("Unmatched quote: #{line.inspect}") if garbage
+ field += (word || sq || dq.try(&.gsub(/\\([$`"\\\n])/, "\\1")) || esc.not_nil!.gsub(/\\(.)/, "\\1"))
+ if sep
+ words << field
+ field = ""
+ end
+ end
+ words
+ end
+end
diff --git a/drivers/sony/projector/fh_spec.cr b/drivers/sony/projector/fh_spec.cr
new file mode 100644
index 00000000000..908f8fab38e
--- /dev/null
+++ b/drivers/sony/projector/fh_spec.cr
@@ -0,0 +1,33 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Sony::Projector::Fh" do
+ exec(:power?)
+ should_send("power_status ?\r\n")
+ responds("\"standby\"\r\n")
+ status[:power].should eq(false)
+
+ exec(:power, true)
+ should_send("power \"on\"\r\n")
+ responds("ok\r\n")
+ status[:power].should eq(true)
+
+ exec(:mute?)
+ should_send("blank ?\r\n")
+ responds("\"on\"\r\n")
+ status[:mute].should eq(true)
+
+ exec(:mute, false)
+ should_send("blank \"off\"\r\n")
+ responds("ok\r\n")
+ status[:mute].should eq(false)
+
+ exec(:input?)
+ should_send("input ?\r\n")
+ responds("\"hdmi1\"\r\n")
+ status[:input].should eq("hdmi")
+
+ exec(:switch_to, "rgb")
+ should_send("input \"rgb1\"\r\n")
+ responds("ok\r\n")
+ status[:input].should eq("rgb")
+end
diff --git a/drivers/sony/projector/pj_talk.cr b/drivers/sony/projector/pj_talk.cr
new file mode 100644
index 00000000000..a0c287bb336
--- /dev/null
+++ b/drivers/sony/projector/pj_talk.cr
@@ -0,0 +1,292 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+# Documentation: https://aca.im/driver_docs/Sony/Sony_Q004_R1_protocol.pdf
+# also https://aca.im/driver_docs/Sony/TCP_CMDs.pdf
+
+class Sony::Projector::PjTalk < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ descriptive_name "Sony Projector PjTalk"
+ generic_name :Display
+ tcp_port 53484
+
+ default_settings({
+ community: "SONY",
+ })
+
+ @community : String = ""
+
+ def on_load
+ # abstract tokenizer, expects us to return the message length
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.to_slice
+
+ # Min message length is 10 bytes, with the 10th byte being the payload size
+ bytes.size < 10 ? -1 : 10 + bytes[9]
+ end
+
+ on_update
+ end
+
+ def on_update
+ @community = setting?(String, :community) || "SONY"
+ end
+
+ def connected
+ schedule.every(60.seconds) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ if state
+ # Need to send twice in case of deep sleep
+ logger.debug { "requested to power on" }
+ do_send(:set, :power_on, name: :power)
+ else
+ logger.debug { "requested to power off" }
+ do_send(:set, :power_off, name: :power, delay: 3.seconds)
+ end
+
+ # Request status update
+ power?(priority: 50)
+ end
+
+ def power?(priority : Int32 = 0, **options)
+ do_send(:get, :power_status, **options, priority: priority).get
+ !!self[:power].try(&.as_bool)
+ end
+
+ enum Input
+ HDMI = 0x0003 # same as InputB
+ InputA = 0x0002
+ InputB = 0x0003
+ InputC = 0x0004
+ InputD = 0x0005
+ USB = 0x0006 # USB type B
+ Network = 0x0007 # network
+
+ def to_bytes : Bytes
+ Bytes[self.value >> 8, self.value & 0xFF]
+ end
+
+ def self.from_bytes(b : Bytes)
+ Input.from_value((b[0].to_u16 << 8) + b[1])
+ end
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(Input)
+
+ def switch_to(input : Input)
+ do_send(:set, :input, input.to_bytes) # , delay_on_receive: 500.milliseconds)
+ logger.debug { "requested to switch to: #{input}" }
+
+ input?
+ end
+
+ def input?
+ do_send(:get, :input, priority: 0)
+ end
+
+ def lamp_time?
+ do_send(:get, :lamp_timer, priority: 0)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ do_send(:set, :mute, Bytes[0, state ? 1 : 0]) # , delay_on_receive: 500)
+ mute?
+ end
+
+ def mute?
+ do_send(:get, :mute, priority: 0)
+ end
+
+ METHODS = [:contrast, :brightness, :color, :hue, :sharpness]
+
+ {% for name in METHODS %}
+ @[Security(Level::Administrator)]
+ def {{name.id}}?
+ do_send(:get, {{name}}, priority: 0)
+ end
+ {% end %}
+
+ {% for name in METHODS %}
+ @[Security(Level::Administrator)]
+ def {{name.id}}(value : UInt8)
+ do_send(:set, {{name}}, Bytes[0, value.clamp(0, 100)], priority: 0)
+ end
+ {% end %}
+
+ enum Command
+ PowerOn = 0x172E
+ PowerOff = 0x172F
+ Input = 0x0001
+ Mute = 0x0030
+ ErrorStatus = 0x0101
+ PowerStatus = 0x0102
+ Contrast = 0x0010
+ Brightness = 0x0011
+ Color = 0x0012
+ Hue = 0x0013
+ Sharpness = 0x0014
+ LampTimer = 0x0113
+
+ def to_bytes : Bytes
+ Bytes[self.value >> 8, self.value & 0xFF]
+ end
+
+ def self.from_bytes(b : Bytes)
+ Command.from_value((b[0].to_u16 << 8) + b[1])
+ end
+ end
+
+ enum CommandType : UInt8
+ Set = 0
+ Get
+ end
+
+ PJTALK_HEADER = Bytes[0x02, 0x0a]
+
+ protected def do_send(cmd_type : CommandType, command : Command, param : Bytes? = nil, name : String | Symbol? = nil, **options)
+ io = IO::Memory.new(14)
+ io.write PJTALK_HEADER
+ io << @community
+ io.write_byte(cmd_type.value)
+ io.write(command.to_bytes)
+
+ if param
+ io.write_byte param.size.to_u8
+ io.write param
+ else
+ io.write_byte 0_u8
+ end
+
+ send(io.to_slice, **options, name: name || (param ? "#{command}_req" : command))
+ end
+
+ def do_poll
+ if power?
+ input?
+ mute?
+ do_send(:get, :error_status, priority: 0)
+ lamp_time?
+ end
+ end
+
+ enum ResponseStatus : UInt8
+ NoGood = 0
+ Okay
+ end
+
+ def received(data, task)
+ logger.debug { "sony proj sent: 0x#{data.hexstring}" }
+
+ response_status = ResponseStatus.from_value data[6]
+ pjt_command = Command.from_bytes data[7..8]
+ pjt_length = data[9]
+ pjt_data = pjt_length > 0 ? data[10..-1] : Bytes.new(0)
+
+ # check for error response
+ if response_status.no_good?
+ category = ERROR_CATEGORY[pjt_data[0]]? || :unknown
+ message = ERRORS[category][pjt_data[1]]? || "unknown: category #{pjt_data[1].to_s(16)}, reason #{pjt_data[1].to_s(16)}"
+
+ self[:last_error] = "#{category}: #{message}"
+ logger.debug { "Command #{pjt_command} failed with #{category}: #{message}" }
+ return task.try &.abort
+ end
+
+ # process a successful response
+ case pjt_command
+ when .power_on?
+ self[:power] = true
+ when .power_off?
+ self[:power] = false
+ when .lamp_timer?
+ # Two bytes converted to a 16bit integer
+ # we use negative indexes as can be a 32bit response (only 16bits needed)
+ self[:lamp_usage] = (pjt_data[-2].to_u16 << 8) + pjt_data[-1]
+ when .power_status?
+ case pjt_data[-1]
+ when 0, 8
+ self[:warming] = self[:cooling] = self[:power] = false
+ when 1, 2
+ self[:cooling] = false
+ self[:warming] = self[:power] = true
+ when 3
+ self[:power] = true
+ self[:warming] = self[:cooling] = false
+ when 4, 5, 6, 7
+ self[:cooling] = true
+ self[:warming] = self[:power] = false
+ end
+ schedule.in(5.seconds) { power? } if self[:warming] || self[:cooling]
+ when .mute?
+ self[:mute] = pjt_data[-1] == 1
+ when .input?
+ self[:input] = Input.from_bytes(pjt_data)
+ when .contrast?, .brightness?, color?, .hue?, .sharpness?
+ self[pjt_command.to_s.downcase] = pjt_data[-1]
+ end
+
+ task.try &.success
+ end
+
+ ERROR_CATEGORY = {
+ 0x01_u8 => :item_error,
+ 0x02_u8 => :community_error,
+ 0x10_u8 => :request_error,
+ 0x20_u8 => :network_error,
+ 0xF0_u8 => :comms_error,
+ 0xF1_u8 => :ram_error,
+ }
+
+ ERRORS = {
+ item_error: {
+ 0x01_u8 => "Invalid Item",
+ 0x02_u8 => "Invalid Item Request",
+ 0x03_u8 => "Invalid Length",
+ 0x04_u8 => "Invalid Data",
+ 0x11_u8 => "Short Data",
+ 0x80_u8 => "Not Applicable Item",
+ },
+ community_error: {
+ 0x01_u8 => "Different Community",
+ },
+ request_error: {
+ 0x01_u8 => "Invalid Version",
+ 0x02_u8 => "Invalid Category",
+ 0x03_u8 => "Invalid Request",
+ 0x11_u8 => "Short Header",
+ 0x12_u8 => "Short Community",
+ 0x13_u8 => "Short Command",
+ },
+ network_error: {
+ 0x01_u8 => "Timeout",
+ },
+ comms_error: {
+ 0x01_u8 => "Timeout",
+ 0x10_u8 => "Check Sum Error",
+ 0x20_u8 => "Framing Error",
+ 0x30_u8 => "Parity Error",
+ 0x40_u8 => "Over Run Error",
+ 0x50_u8 => "Other Comm Error",
+ 0xF0_u8 => "Unknown Response",
+ },
+ ram_error: {
+ 0x10_u8 => "Read Error",
+ 0x20_u8 => "Write Error",
+ },
+ unknown: {} of UInt8 => String,
+ }
+end
diff --git a/drivers/sony/projector/pj_talk_spec.cr b/drivers/sony/projector/pj_talk_spec.cr
new file mode 100644
index 00000000000..9afd43d1431
--- /dev/null
+++ b/drivers/sony/projector/pj_talk_spec.cr
@@ -0,0 +1,17 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Sony::Projector::PjTalk" do
+ resp = exec(:power?)
+ should_send("020a534f4e5901010200".hexbytes)
+ responds("020a534f4e590101020100".hexbytes)
+ resp.get.should eq false
+ status[:power].should eq(false)
+
+ exec(:power, true)
+ should_send("020a534f4e5900172E00".hexbytes)
+ responds("020a534f4e5901172E00".hexbytes)
+
+ should_send("020a534f4e5901010200".hexbytes)
+ responds("020a534f4e590101020103".hexbytes)
+ status[:power].should eq(true)
+end
diff --git a/drivers/sony/projector/serial_control.cr b/drivers/sony/projector/serial_control.cr
new file mode 100644
index 00000000000..07375056974
--- /dev/null
+++ b/drivers/sony/projector/serial_control.cr
@@ -0,0 +1,229 @@
+require "placeos-driver"
+require "placeos-driver/interface/powerable"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+
+# Documentation: https://aca.im/driver_docs/Sony/Sony_Q004_R1_protocol.pdf
+
+class Sony::Projector::SerialControl < PlaceOS::Driver
+ include Interface::Powerable
+ include Interface::Muteable
+
+ descriptive_name "Sony Projector (RS232 Control)"
+ generic_name :Display
+
+ INDICATOR = 0xA9_u8
+ DELIMITER = 0x9A_u8
+
+ def on_load
+ transport.tokenizer = Tokenizer.new(Bytes[DELIMITER])
+ end
+
+ def connected
+ schedule.every(60.seconds) { do_poll }
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def power(state : Bool)
+ if state
+ # Need to send twice in case of deep sleep
+ logger.debug { "requested to power on" }
+ do_send(Type::Set, Command::PowerOn, name: :power)
+ do_send(Type::Set, Command::PowerOn, name: :power, delay: 3.seconds)
+ else
+ logger.debug { "requested to power off" }
+ do_send(Type::Set, Command::PowerOff, name: :power, delay: 3.seconds)
+ end
+
+ # Request status update
+ power?(priority: 50)
+ end
+
+ def power?(priority : Int32 = 0, **options)
+ do_send(Type::Get, Command::PowerStatus, **options, priority: priority).get
+ !!self[:power].try(&.as_bool)
+ end
+
+ enum Input
+ HDMI = 0x0003 # same as InputB
+ InputA = 0x0002
+ InputB = 0x0003
+ InputC = 0x0004
+ InputD = 0x0005
+ USB = 0x0006 # USB type B
+ Network = 0x0007 # network
+
+ def to_bytes : Bytes
+ Bytes[self.value >> 8, self.value & 0xFF]
+ end
+
+ def self.from_bytes(b : Bytes)
+ Input.from_value((b[0].to_u16 << 8) + b[1])
+ end
+ end
+
+ include PlaceOS::Driver::Interface::InputSelection(Input)
+
+ def switch_to(input : Input)
+ do_send(Type::Set, Command::Input, input.to_bytes) # , delay_on_receive: 500.milliseconds)
+ logger.debug { "requested to switch to: #{input}" }
+
+ input?
+ end
+
+ def input?
+ do_send(Type::Get, Command::Input, priority: 0)
+ end
+
+ def lamp_time?
+ do_send(Type::Get, Command::LampTimer, priority: 0)
+ end
+
+ def mute(
+ state : Bool = true,
+ index : Int32 | String = 0,
+ layer : MuteLayer = MuteLayer::AudioVideo
+ )
+ do_send(Type::Set, Command::Mute, Bytes[0, state ? 1 : 0]) # , delay_on_receive: 500)
+ mute?
+ end
+
+ def mute?
+ do_send(Type::Get, Command::Mute, priority: 0)
+ end
+
+ METHODS = ["Contrast", "Brightness", "Color", "Hue", "Sharpness"]
+
+ {% for name in METHODS %}
+ @[Security(Level::Administrator)]
+ def {{name.id.downcase}}?
+ do_send(Type::Get, Command::{{name.id}}, priority: 0)
+ end
+ {% end %}
+
+ {% for name in METHODS %}
+ @[Security(Level::Administrator)]
+ def {{name.id.downcase}}(value : UInt8)
+ do_send(Type::Set, Command::{{name.id}}, Bytes[0, value.clamp(0, 100)], priority: 0)
+ end
+ {% end %}
+
+ ERRORS = {
+ 0x00 => "No Error",
+ 0x01 => "Lamp Error",
+ 0x02 => "Fan Error",
+ 0x04 => "Cover Error",
+ 0x08 => "Temperature Error",
+ 0x10 => "D5V Error",
+ 0x20 => "Power Error",
+ 0x40 => "Warning Error",
+ 0x80 => "NVM Data ERROR",
+ }
+
+ private def do_poll
+ if power?(priority: 0)
+ input?
+ mute?
+ do_send(Type::Get, Command::ErrorStatus, priority: 0)
+ lamp_time?
+ end
+ end
+
+ enum Command
+ PowerOn = 0x172E
+ PowerOff = 0x172F
+ Input = 0x0001
+ Mute = 0x0030
+ ErrorStatus = 0x0101
+ PowerStatus = 0x0102
+ Contrast = 0x0010
+ Brightness = 0x0011
+ Color = 0x0012
+ Hue = 0x0013
+ Sharpness = 0x0014
+ LampTimer = 0x0113
+
+ def to_bytes : Bytes
+ Bytes[self.value >> 8, self.value & 0xFF]
+ end
+
+ def self.from_bytes(b : Bytes)
+ Command.from_value((b[0].to_u16 << 8) + b[1])
+ end
+ end
+
+ enum Type : UInt8
+ Set
+ Get
+ end
+
+ private def do_send(type : Type, command : Command, param : Bytes = Bytes.new(2), **options)
+ # indicator: 1, command: 2, type: 1, param: 2, checksum: 1, delimiter: 1
+ data = Bytes.new(8).tap do |bytes|
+ bytes[0] = INDICATOR
+ command.to_bytes.each_with_index(1) { |b, i| bytes[i] = b } # bytes[1..2]
+ bytes[3] = type.value
+ param.each_with_index(4) { |b, i| bytes[i] = b } # bytes[4..5]
+ bytes[7] = DELIMITER
+ end
+ data[6] = data[1..5].reduce { |a, b| a |= b } # checksum
+
+ send(data, **options)
+ end
+
+ def received(data, task)
+ logger.debug { "sony proj sent: 0x#{data.hexstring}" }
+
+ cmd = data[1..2]
+ type = data[3]
+ resp = data[4..5]
+
+ checksum = data[1..5].reduce { |a, b| a |= b }
+ return task.try &.abort("Checksum should be 0x#{checksum.to_s(base: 16, upcase: true)}") unless data[6] == checksum
+
+ # Check if an ACK/NAK
+ if type == 0x03
+ if cmd == Bytes[0, 0]
+ return task.try &.success
+ else # Command failed
+ return task.try &.abort("Command failed with 0x#{cmd.join(&.to_s(base: 16, upcase: true))}")
+ end
+ else
+ case command = Command.from_bytes(cmd)
+ when .power_on?
+ self[:power] = true
+ when .power_off?
+ self[:power] = false
+ when .lamp_timer?
+ # Two bytes converted to a 16bit integer
+ self[:lamp_usage] = (resp[-2].to_u16 << 8) + resp[-1]
+ when .power_status?
+ case resp[-1]
+ when 0, 8
+ self[:warming] = self[:cooling] = self[:power] = false
+ when 1, 2
+ self[:cooling] = false
+ self[:warming] = self[:power] = true
+ when 3
+ self[:power] = true
+ self[:warming] = self[:cooling] = false
+ when 4, 5, 6, 7
+ self[:cooling] = true
+ self[:warming] = self[:power] = false
+ end
+ schedule.in(5.seconds) { power? } if self[:warming] || self[:cooling]
+ when .mute?
+ self[:mute] = resp[-1] == 1
+ when .input?
+ self[:input] = Input.from_bytes(resp)
+ when .contrast?, .brightness?, color?, .hue?, .sharpness?
+ self[command.to_s.downcase] = resp[-1]
+ end
+ end
+
+ task.try &.success
+ end
+end
diff --git a/drivers/sony/projector/serial_control_spec.cr b/drivers/sony/projector/serial_control_spec.cr
new file mode 100644
index 00000000000..20ba8289b32
--- /dev/null
+++ b/drivers/sony/projector/serial_control_spec.cr
@@ -0,0 +1,48 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Sony::Projector::SerialControl" do
+ exec(:power, true)
+ should_send("\xA9\x17\x2E\x00\x00\x00\x3F\x9A")
+ responds("\xA9\x00\x00\x03\x00\x00\x03\x9A")
+ should_send("\xA9\x17\x2E\x00\x00\x00\x3F\x9A")
+ responds("\xA9\x00\x00\x03\x00\x00\x03\x9A")
+ sleep 3
+ # power?
+ should_send("\xA9\x01\x02\x01\x00\x00\x03\x9A")
+ responds("\xA9\x01\x02\x02\x00\x03\x03\x9A")
+ status[:cooling].should eq(false)
+ status[:warming].should eq(false)
+ status[:power].should eq(true)
+
+ exec(:switch_to, "hdmi")
+ should_send("\xA9\x00\x01\x00\x00\x03\x03\x9A")
+ responds("\xA9\x00\x00\x03\x00\x00\x03\x9A")
+ # input?
+ should_send("\xA9\x00\x01\x01\x00\x00\x01\x9A")
+ responds("\xA9\x00\x01\x02\x00\x03\x03\x9A")
+ status[:input].should eq("HDMI")
+
+ exec(:mute)
+ should_send("\xA9\x00\x30\x00\x00\x01\x31\x9A")
+ responds("\xA9\x00\x00\x03\x00\x00\x03\x9A")
+ # mute?
+ should_send("\xA9\x00\x30\x01\x00\x00\x31\x9A")
+ responds("\xA9\x00\x30\x02\x00\x01\x33\x9A")
+ status[:mute].should eq(true)
+
+ exec(:lamp_time?)
+ should_send("\xA9\x01\x13\x01\x00\x00\x13\x9A")
+ responds("\xA9\x01\x13\x02\x03\xE8\xFB\x9A")
+ status[:lamp_usage].should eq(1000)
+
+ exec(:power, false)
+ should_send("\xA9\x17\x2F\x00\x00\x00\x3F\x9A")
+ responds("\xA9\x00\x00\x03\x00\x00\x03\x9A")
+ sleep 3
+ # power?
+ should_send("\xA9\x01\x02\x01\x00\x00\x03\x9A")
+ responds("\xA9\x01\x02\x02\x00\x04\x07\x9A")
+ status[:cooling].should eq(true)
+ status[:warming].should eq(false)
+ status[:power].should eq(false)
+end
diff --git a/drivers/steinel/hpd2.cr b/drivers/steinel/hpd2.cr
new file mode 100644
index 00000000000..e3ccc46b0f7
--- /dev/null
+++ b/drivers/steinel/hpd2.cr
@@ -0,0 +1,239 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+
+class Steinel::HPD2 < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ generic_name :PeopleCounter
+ descriptive_name "Steinel HPD-2"
+
+ # Local network
+ uri_base "https://192.168.0.20"
+
+ default_settings({
+ basic_auth: {
+ username: "admin",
+ password: "steinel",
+ },
+ })
+
+ @mac : String = ""
+ getter! state : NamedTuple(
+ illuminance: Interface::Sensor::Detail,
+ temperature: Interface::Sensor::Detail,
+ humidity: Interface::Sensor::Detail,
+ presence: Interface::Sensor::Detail,
+ people: Interface::Sensor::Detail,
+ illuminance_zones: Array(Interface::Sensor::Detail),
+ presence_zones: Array(Interface::Sensor::Detail),
+ people_zones: Array(Interface::Sensor::Detail),
+ )
+
+ def on_update
+ @mac = URI.parse(config.uri.not_nil!).hostname.not_nil!
+ schedule.every(5.seconds) { get_status }
+ end
+
+ {% begin %}
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless @mac == mac
+ return nil unless id
+
+ # https://crystal-lang.org/api/1.1.0/String.html#rpartition(search:Char%7CString):Tuple(String,String,String)-instance-method
+ sensor, _, index_str = id.rpartition('-')
+ if sensor.empty?
+ case id
+ {% for sensor in %w(humidity temperature presence people illuminance) %}
+ when {{sensor}}
+ state[{{sensor.id.symbolize}}]
+ {% end %}
+ end
+ elsif index = index_str.to_i?
+ case id
+ {% for sensor in %w(presence people illuminance) %}
+ when {{sensor}}
+ state[{{sensor.id.symbolize}}_zones][index]?
+ {% end %}
+ end
+ end
+ rescue error
+ logger.warn(exception: error) { "checking for sensor" }
+ nil
+ end
+ {% end %}
+
+ alias SensorType = Interface::Sensor::SensorType
+
+ TYPES = {
+ illuminance: SensorType::Illuminance,
+ temperature: SensorType::Temperature,
+ humidity: SensorType::Humidity,
+ presence: SensorType::Presence,
+ people: SensorType::PeopleCount,
+
+ illuminance_zones: SensorType::Illuminance,
+ presence_zones: SensorType::Presence,
+ people_zones: SensorType::PeopleCount,
+ }
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+ return NO_MATCH if mac && mac != @mac
+ return state.values.to_a.flatten unless type
+
+ sensor_type = SensorType.parse(type)
+ matches = [] of Interface::Sensor::Detail | Array(Interface::Sensor::Detail)
+ TYPES.each { |key, key_type| matches << state[key] if key_type == sensor_type }
+
+ matches.flatten
+ rescue error
+ logger.warn(exception: error) { "searching for sensors" }
+ NO_MATCH
+ end
+
+ def get_status
+ response = get("/api/sensorstatus.php")
+
+ logger.debug { "received #{response.body}" }
+
+ if response.success?
+ status = SensorStatus.from_json(response.body.not_nil!)
+
+ time = Time.utc.to_unix
+ mod_id = module_id
+ humidity = Interface::Sensor::Detail.new(SensorType::Humidity, status.humidity.to_f, time, @mac, "humidity", "Humidity", module_id: mod_id, binding: "humidity")
+ self[:humidity] = status.humidity.to_f
+ temperature = Interface::Sensor::Detail.new(SensorType::Temperature, status.temperature.to_f, time, @mac, "temperature", "Temperature", module_id: mod_id, binding: "temperature", unit: "Cel")
+ self[:temperature] = status.temperature.to_f
+ pres = status.person_presence.zero? ? 0.0 : 1.0
+ presence = Interface::Sensor::Detail.new(SensorType::Presence, pres, time, @mac, "presence", "Person Presence", module_id: mod_id, binding: "presence")
+ self[:presence] = pres
+ people = Interface::Sensor::Detail.new(SensorType::PeopleCount, status.detected_persons.to_f, time, @mac, "people", "Detected Persons", module_id: mod_id, binding: "people")
+ self[:people] = status.detected_persons
+ illuminance = Interface::Sensor::Detail.new(SensorType::Illuminance, status.global_illuminance_lux, time, @mac, "illuminance", "Illuminance", module_id: mod_id, binding: "illuminance", unit: "lx")
+ self[:illuminance] = status.global_illuminance_lux
+
+ self[:presence_zones] = status.person_presence_zone.map { |value| value.zero? ? 0.0 : 1.0 }
+ presence_zones = status.person_presence_zone.map_with_index do |value, index|
+ Interface::Sensor::Detail.new(SensorType::Presence, value.zero? ? 0.0 : 1.0, time, @mac, "presence-#{index}", "Person Presence in Zone#{index}")
+ end
+ self[:people_zones] = status.detected_persons_zone
+ people_zones = status.detected_persons_zone.map_with_index do |value, index|
+ Interface::Sensor::Detail.new(SensorType::PeopleCount, value.to_f, time, @mac, "people-#{index}", "Detected People in Zone#{index}")
+ end
+ self[:illuminance_zones] = status.lux_zone
+ illuminance_zones = status.lux_zone.map_with_index do |value, index|
+ Interface::Sensor::Detail.new(SensorType::Illuminance, value, time, @mac, "illuminance-#{index}", "Illuminance in Zone#{index}", unit: "lx")
+ end
+
+ @state = {
+ humidity: humidity,
+ temperature: temperature,
+ presence: presence,
+ people: people,
+ illuminance: illuminance,
+
+ presence_zones: presence_zones,
+ people_zones: people_zones,
+ illuminance_zones: illuminance_zones,
+ }
+
+ status
+ else
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+ end
+
+ class SensorStatus
+ include JSON::Serializable
+
+ @[JSON::Field(key: "AppVersion")]
+ property app_version : String
+
+ @[JSON::Field(key: "FpgaVersion")]
+ property fpga_version : String
+
+ @[JSON::Field(key: "KnxSapNumber")]
+ property knx_sap_number : String
+
+ @[JSON::Field(key: "KnxVersion")]
+ property knx_version : String
+
+ @[JSON::Field(key: "KnxAddr")]
+ property knx_address : String
+
+ @[JSON::Field(key: "GitRevision")]
+ property git_revision : String
+
+ @[JSON::Field(key: "ModelName")]
+ property model_name : String
+
+ @[JSON::Field(key: "FrameProcessingTimeMs")]
+ property frame_processing_time_ms : Int32
+
+ @[JSON::Field(key: "AverageFps5")]
+ property average_fps5 : Float64
+
+ @[JSON::Field(key: "AverageFps50")]
+ property average_fps50 : Float64
+
+ @[JSON::Field(key: "RunningTimeHHMMSS")]
+ property running_time : String
+
+ @[JSON::Field(key: "UptimeHHMMSS")]
+ property uptime : String
+
+ @[JSON::Field(key: "IrLedOn")]
+ property ir_led_on : Int32
+
+ @[JSON::Field(key: "DetectedPersons")]
+ property detected_persons : Int32
+
+ @[JSON::Field(key: "PersonPresence")]
+ property person_presence : Int32
+
+ @[JSON::Field(key: "DetectedPersonsZone")]
+ property detected_persons_zone : Array(Int32)
+
+ @[JSON::Field(key: "PersonPresenceZone")]
+ property person_presence_zone : Array(Int32)
+
+ @[JSON::Field(key: "DetectionZonesPresent")]
+ property detection_zones_present : Int32
+
+ @[JSON::Field(key: "GlobalIlluminanceLux")]
+ property global_illuminance_lux : Float64
+
+ @[JSON::Field(key: "LuxZone")]
+ property lux_zone : Array(Float64)
+
+ @[JSON::Field(key: "GlobalLightValue")]
+ property global_light_value : Int32
+
+ @[JSON::Field(key: "ArmsensorCpuUsage")]
+ property arm_sensor_cpu_usage : String
+
+ @[JSON::Field(key: "WebServerCpuUsage")]
+ property web_server_cpu_usage : String
+
+ @[JSON::Field(key: "Temperature")]
+ property temperature : String
+
+ @[JSON::Field(key: "Humidity")]
+ property humidity : String
+
+ @[JSON::Field(key: "KnxDetected")]
+ property knx_detected : String
+
+ @[JSON::Field(key: "KnxProgramMode")]
+ property knx_program_mode : String
+
+ @[JSON::Field(key: "KnxLedState")]
+ property knx_led_state : String
+ property final : String
+ end
+end
diff --git a/drivers/steinel/hpd2_spec.cr b/drivers/steinel/hpd2_spec.cr
new file mode 100644
index 00000000000..b4975736552
--- /dev/null
+++ b/drivers/steinel/hpd2_spec.cr
@@ -0,0 +1,29 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Xovis::SensorAPI" do
+ # Send the request
+ retval = exec(:get_status)
+ data = %({"AppVersion": "3.2.3", "FpgaVersion": "v300", "KnxSapNumber": "0", "KnxVersion": "0", "KnxAddr":
+ "", "GitRevision": "d45734c2", "ModelName": "15_2xroute_fix26", "FrameProcessingTimeMs": 1179,
+ "AverageFps5": 0.850314, "AverageFps50": 0.855873, "RunningTimeHHMMSS": "672:55:58",
+ "UptimeHHMMSS": "672:56:35", "IrLedOn": 0, "DetectedPersons": 0, "PersonPresence": 0,
+ "DetectedPersonsZone": [0, 0, 0, 0, 0], "PersonPresenceZone": [0, 0, 0, 0, 0],
+ "DetectionZonesPresent": 0, "GlobalIlluminanceLux": 39.0, "LuxZone": [0.0, 0.0, 0.0, 0.0, 0.0],
+ "GlobalLightValue": 72, "ArmsensorCpuUsage": "20", "WebServerCpuUsage": "2", "Temperature":
+ "27.745661", "Humidity": "25.286158", "KnxDetected": "0", "KnxProgramMode": "0", "KnxLedState":
+ "0", "final": "OK" })
+
+ # We should request a new token from Floorsense
+ expect_http_request do |request, response|
+ if request.headers["Authorization"]? == "Basic YWRtaW46c3RlaW5lbA=="
+ response.status_code = 200
+ response.output.puts data
+ else
+ puts request.headers.inspect
+ response.status_code = 401
+ end
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq(JSON.parse(data))
+end
diff --git a/drivers/stripe/api.cr b/drivers/stripe/api.cr
new file mode 100644
index 00000000000..f67c62a45e6
--- /dev/null
+++ b/drivers/stripe/api.cr
@@ -0,0 +1,105 @@
+require "placeos-driver"
+require "stripetease"
+
+class Stripe::API < PlaceOS::Driver
+ descriptive_name "Stripe API Gateway"
+ generic_name :Payment
+ uri_base "https://api.stripe.com"
+
+ alias Client = Stripetease::Client
+
+ default_settings({api_key: "ABCDEFGHIJKLMNOPQRSTUVWXYZ"})
+
+ protected getter! client : Client
+
+ def on_update
+ host_name = config.uri.not_nil!.to_s
+ api_key = setting(String, :api_key)
+
+ @client = Stripetease::Client.new(base_url: host_name, api_key: api_key)
+ end
+
+ def add_payment_method(
+ type : String,
+ billing_details : Hash(String, String)? = nil,
+ metadata : Hash(String, String)? = nil,
+ acss_debit : Hash(String, String)? = nil,
+ affirm : Hash(String, String)? = nil,
+ afterpay_clearpay : Hash(String, String)? = nil,
+ alipay : Hash(String, String)? = nil,
+ au_becs_debit : Hash(String, String)? = nil,
+ bacs_debit : Hash(String, String)? = nil,
+ bancontact : Hash(String, String)? = nil,
+ blik : Hash(String, String)? = nil,
+ boleto : Hash(String, String)? = nil,
+ card : Hash(String, String)? = nil,
+ customer_balance : Hash(String, String)? = nil,
+ eps : Hash(String, String)? = nil,
+ fpx : Hash(String, String)? = nil,
+ giropay : Hash(String, String)? = nil,
+ ideal : Hash(String, String)? = nil,
+ interac_present : Hash(String, String)? = nil,
+ klarna : Hash(String, String)? = nil,
+ konbini : Hash(String, String)? = nil,
+ link : Hash(String, String)? = nil,
+ oxxo : Hash(String, String)? = nil,
+ p24 : Hash(String, String)? = nil,
+ paynow : Hash(String, String)? = nil,
+ promptpay : Hash(String, String)? = nil,
+ radar_options : Hash(String, String)? = nil,
+ sepa_debit : Hash(String, String)? = nil,
+ sofort : Hash(String, String)? = nil,
+ us_bank_account : Hash(String, String)? = nil,
+ wechat_pay : Hash(String, String)? = nil
+ )
+ payment_method = @client.not_nil!.payment_methods.create(type, billing_details, metadata, acss_debit, affirm, afterpay_clearpay, alipay, au_becs_debit, bacs_debit, bancontact, blik, boleto, card, customer_balance, eps, fpx, giropay, ideal, interac_present, klarna, konbini, link, oxxo, p24, paynow, promptpay, radar_options, sepa_debit, sofort, us_bank_account, wechat_pay)
+ self["payment_method"] = payment_method
+ end
+
+ def list_payment_methods(type : String, customer : String? = nil, ending_before : String? = nil, limit : Int32? = nil, starting_after : String? = nil)
+ payment_methods = @client.not_nil!.payment_methods.list(type: type, customer: customer, ending_before: ending_before, limit: limit, starting_after: starting_after)
+ self["payment_methods"] = payment_methods
+ end
+
+ def get_product_prices(active : Bool? = nil, currency : String? = nil, product : String? = nil, type : String? = nil, created : Hash(String, String)? = nil, ending_before : String? = nil, limit : Int32? = nil, lookup_keys : Array(String)? = nil, recurring : Hash(String, String)? = nil, starting_after : String? = nil)
+ product_prices = @client.not_nil!.prices.list(active: active, currency: currency, product: product, type: type, created: created, ending_before: ending_before, limit: limit, lookup_keys: lookup_keys, recurring: recurring, starting_after: starting_after)
+ self["product_prices"] = product_prices
+ end
+
+ def create_payment_intent(amount : Int32, currency : String, automatic_payment_methods : Hash(String, String)? = nil, confirm : Bool? = nil, customer : String? = nil, description : String? = nil, metadata : Hash(String, String)? = nil, off_session : Bool? = nil, payment_method : String? = nil, receipt_email : String? = nil, setup_future_usage : String? = nil, shipping : Hash(String, String)? = nil, statement_descriptor : String? = nil, statement_descriptor_suffix : String? = nil, application_fee_amount : Int32? = nil, capture_method : String? = nil, confrimation_method : String? = nil, error_on_requires_action : Bool? = nil, mandate : String? = nil, mandate_data : Hash(String, String)? = nil, on_behalf_of : String? = nil, payment_method_data : Hash(String, String)? = nil, payment_method_types : Array(String)? = nil, payment_method_options : Hash(String, String)? = nil, radar_options : Hash(String, String)? = nil, return_url : String? = nil, transfer_data : Hash(String, String)? = nil, transfer_group : String? = nil, use_stripe_sdk : Bool? = nil)
+ payment_intent = @client.not_nil!.payment_intents.create(amount: amount, currency: currency, automatic_payment_methods: automatic_payment_methods, confirm: confirm, customer: customer, description: description, metadata: metadata, off_session: off_session, payment_method: payment_method, receipt_email: receipt_email, setup_future_usage: setup_future_usage, shipping: shipping, statement_descriptor: statement_descriptor, statement_descriptor_suffix: statement_descriptor_suffix, application_fee_amount: application_fee_amount, capture_method: capture_method, confrimation_method: confrimation_method, error_on_requires_action: error_on_requires_action, mandate_data: mandate_data, on_behalf_of: on_behalf_of, payment_method_data: payment_method_data, payment_method_types: payment_method_types, payment_method_options: payment_method_options, radar_options: radar_options, return_url: return_url, transfer_data: transfer_data, transfer_group: transfer_group, use_stripe_sdk: use_stripe_sdk)
+ self["payment_intent"] = payment_intent
+ end
+
+ def confirm_payment_intent(id : String, payment_method : String? = nil, receipt_email : String? = nil, setup_future_usage : String? = nil, shipping : Hash(String, String)? = nil, capture_method : String? = nil, error_on_requires_action : Bool? = nil, mandate : String? = nil, mandate_data : Hash(String, String)? = nil, off_session : Bool? = nil, payment_method_data : Hash(String, String)? = nil, payment_method_options : Hash(String, String)? = nil, payment_method_types : Array(String)? = nil, radar_options : Hash(String, String)? = nil, return_url : String? = nil, use_stripe_sdk : Bool? = nil)
+ payment_intent = @client.not_nil!.payment_intents.confirm(id: id, payment_method: payment_method, receipt_email: receipt_email, setup_future_usage: setup_future_usage, shipping: shipping, capture_method: capture_method, error_on_requires_action: error_on_requires_action, mandate: mandate, mandate_data: mandate_data, off_session: off_session, payment_method_data: payment_method_data, payment_method_options: payment_method_options, payment_method_types: payment_method_types, radar_options: radar_options, use_stripe_sdk: use_stripe_sdk)
+ self["payment_intent"] = payment_intent
+ end
+
+ def cancel_payment_intent(id : String, cancellation_reason : String? = nil)
+ @client.not_nil!.payment_intents.cancel(id: id, cancellation_reason: cancellation_reason)
+ self["payment_intent"] = nil
+ end
+
+ def get_customer(id : String)
+ self["customer"] = @client.not_nil!.customers.get(id)
+ end
+
+ def list_customers(email : String? = nil, created : Hash(String, String)? = nil, ending_before : String? = nil, limit : Int32? = nil, starting_after : String? = nil)
+ self["customers"] = @client.not_nil!.customers.list(email: email, created: created, ending_before: ending_before, limit: limit, starting_after: starting_after)
+ end
+
+ def search_customers(query : String, limit : Int32? = nil, page : Int32? = nil)
+ self["customers"] = @client.not_nil!.customers.search(query: query, limit: limit, page: page)
+ end
+
+ def create_customer(account_balance : Int32? = nil, coupon : String? = nil, default_source : String? = nil, description : String? = nil, email : String? = nil, invoice_prefix : String? = nil, metadata : Hash(String, String)? = nil, shipping : Hash(String, String)? = nil, source : String? = nil, tax_info : Hash(String, String)? = nil)
+ customer = @client.not_nil!.customers.create(account_balance: account_balance, coupon: coupon, default_source: default_source, description: description, email: email, invoice_prefix: invoice_prefix, metadata: metadata, shipping: shipping, source: source, tax_info: tax_info)
+ self["customer"] = customer
+ end
+
+ def update_customer(id : String, customer : String? = nil, account_balance : Int32? = nil, coupon : String? = nil, default_source : String? = nil, description : String? = nil, email : String? = nil, invoice_prefix : String? = nil, metadata : Hash(String, String)? = nil, shipping : Hash(String, String)? = nil, source : String? = nil, tax_info : Hash(String, String)? = nil)
+ customer = @client.not_nil!.customers.update(id: id, customer: customer, account_balance: account_balance, coupon: coupon, default_source: default_source, description: description, email: email, invoice_prefix: invoice_prefix, metadata: metadata, shipping: shipping, source: source, tax_info: tax_info)
+ self["customer"] = customer
+ end
+end
diff --git a/drivers/tv_one/corio_master.cr b/drivers/tv_one/corio_master.cr
new file mode 100644
index 00000000000..fee922aed88
--- /dev/null
+++ b/drivers/tv_one/corio_master.cr
@@ -0,0 +1,298 @@
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/TV%20One/CORIOmaster-Commands-v1.7.0.pdf
+
+class PlaceOS::Driver::Task
+ # allows us to access request data in the response
+
+ property request_payload : String? = nil
+end
+
+class TvOne::CorioMaster < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "tvOne CORIOmaster image processor"
+ generic_name :VideoWall
+ tcp_port 10001
+
+ default_settings({
+ username: "admin",
+ password: "adminpw",
+ })
+
+ @username : String = "admin"
+ @password : String = "adminpw"
+ @ready : Bool = false
+ @window_cache : Hash(UInt32, JSON::Any) = {} of UInt32 => JSON::Any
+
+ def on_update
+ @username = setting(String, :username)
+ @password = setting(String, :password)
+ end
+
+ def disconnected
+ schedule.clear
+ self[:ready] = @ready = false
+ end
+
+ def connected
+ # fallback if we don't get the ready signal
+ schedule.in(30.seconds) { disconnect unless @ready }
+
+ # maintain the connection
+ schedule.every(1.minute) { do_poll }
+
+ spawn { init_connection }
+ end
+
+ protected def init_connection
+ task = exec("login", @username, @password, priority: 99, name: "login").get
+ sync_state
+ end
+
+ def sync_state
+ query("Preset.Take", expose_as: :preset)
+ query_preset_list(expose_as: :presets)
+ deep_query("Windows", expose_as: :windows)
+ deep_query("Canvases", expose_as: :canvases)
+ deep_query("Layouts", expose_as: :layouts)
+ query "CORIOmax.Serial_Number", expose_as: :serial_number
+ query "CORIOmax.Software_Version", expose_as: :firmware
+ end
+
+ def do_poll
+ logger.debug { "polling device" }
+ query "Preset.Take", expose_as: :preset
+ end
+
+ def preset(id : UInt32)
+ set("Preset.Take", id).get
+ self[:preset] = id
+ if wins = @window_cache[id]?
+ logger.debug { "loading cached window state" }
+ self[:windows] = wins
+ end
+
+ # The full query of window params can take up to ~15 seconds. To
+ # speed things up a little for other modules that depend on this
+ # state, cache window info against preset ID's. These are then used
+ # to provide instant status updates.
+ #
+ # As the device only supports a single connection the only time the
+ # cache will contain stale data is following editing of presets, in
+ # which case window state will update silently in the background.
+ spawn do
+ windows = query_windows
+ logger.debug { "window cache for preset #{id} updated" }
+ self[:windows] = @window_cache[id] = windows
+ end
+ id
+ end
+
+ def switch(map : Hash(String, Array(UInt32)))
+ results = map.flat_map do |slot, windows|
+ windows.map { |id| window(id, "Input", slot) }
+ end
+
+ spawn do
+ # wait for operations to complete
+ results.each(&.get)
+
+ # patch state
+ if state = status?(Hash(String, Hash(String, JSON::Any)), :windows)
+ map.each do |slot, windows|
+ value = JSON::Any.new(slot)
+ windows.each do |id|
+ if win = state["window#{id}"]?
+ win["input"] = value
+ end
+ end
+ end
+
+ self["windows"] = state
+ end
+ end
+ nil
+ end
+
+ def window(id : UInt32, property : String, value : Int64 | Bool | Nil | String)
+ set("Window#{id}.#{property}", value)
+ end
+
+ def query_windows
+ deep_query("Windows")
+ end
+
+ def preset_list
+ query_preset_list
+ end
+
+ alias PresetList = Hash(Int32, NamedTuple(name: String, canvas: String, time: Int64))
+
+ # Get the presets available for recall - for some inexplicible reason this
+ # has a wildly different API to the rest of the system
+ protected def query_preset_list(expose_as = nil)
+ task = exec("Routing.Preset.PresetList").get(response_required: true)
+ raise "exec failed" unless task.state.success?
+
+ if preset_list = JSON.parse(task.payload).as_h?
+ presets = preset_list.each_with_object(PresetList.new) do |(key, val), h|
+ id = key[/\d+/].to_i
+ name, canvas, time = val.as_s.split ","
+ h[id] = {
+ name: name,
+ canvas: canvas,
+ time: time.to_i64,
+ }
+ end
+
+ self[expose_as] = presets unless expose_as.nil?
+ presets
+ end
+ end
+
+ protected def query(path, expose_as = nil, **opts)
+ logger.debug { "querying: #{path}" }
+
+ task = send("#{path}\r\n", **opts).get(response_required: true)
+ raise "query failed" unless task.state.success?
+
+ logger.debug { "query response: #{task.payload}" }
+
+ result = JSON.parse(task.payload)
+ self[expose_as] = result if expose_as
+ result
+ end
+
+ protected def deep_query(path, expose_as = nil, **opts)
+ logger.debug { "deep querying: #{path}" }
+
+ result = query(path, **opts)
+ logger.debug { "deep response: #{result.inspect}" }
+
+ if val = result.as_h?
+ val.each do |k, v|
+ val[k] = deep_query(k) if v == "<...>"
+ end
+ val
+
+ self[expose_as] = val if expose_as
+ JSON::Any.new(val)
+ else
+ self[expose_as] = result if expose_as
+ result
+ end
+ end
+
+ protected def set(path, val, **opts)
+ logger.debug { "setting #{path} to #{val}" }
+ send("#{path} = #{val}\r\n", **opts, name: path)
+ end
+
+ protected def exec(command, *params, **opts)
+ param_string = params.join ','
+ logger.debug { "executing: #{command}(#{param_string})" }
+ send "#{command}(#{param_string})\r\n", **opts
+ end
+
+ def received(data, task)
+ data = String.new(data)
+ logger.debug { "Received => #{data}" }
+
+ # wait for an indicator string that hints at the start of the protocol
+ if !@ready
+ if data =~ /Interface Ready/i
+ configure_tokenizer
+ self[:ready] = @ready = true
+ end
+ return
+ end
+
+ # split the result into lines
+ body = data.lines
+ captures = /!(\w+)\W*(.*)$/.match(body.pop).try &.captures
+ return task.try(&.abort("")) unless captures
+
+ type = captures[0].as(String)
+ message = captures[1].as(String).downcase
+
+ # extract the path in the original request
+ # can be "the.path" or "the.path = val" formats
+ request = task.try &.request_payload.try(&.strip.downcase.split(" ")[0])
+
+ # process the result
+ case type
+ when "Done"
+ if request && request == message
+ response = parse_response(body, request)
+ task.try &.success(response)
+ end
+ when "Info"
+ logger.info { "#{request} => #{message}" }
+ task.try &.success
+ when "Error"
+ logger.error { message }
+ task.try &.abort
+ when "Event"
+ logger.info { "unhandled event: #{message}" }
+ else
+ logger.error { "unhandled response: #{data}" }
+ task.try &.abort
+ end
+ end
+
+ protected def configure_tokenizer
+ transport.tokenizer = Tokenizer.new do |io|
+ buffer = String.new(io.peek)
+
+ # start of final line of the payload
+ final_start = buffer.index('!')
+ next 0 unless final_start
+
+ # end of the final line
+ final_line_end = buffer.index("\r\n", final_start)
+
+ if final_line_end
+ final_line_end + 2
+ else
+ 0
+ end
+ end
+ end
+
+ protected def parse_response(lines, command)
+ kv_pairs = lines.map do |line|
+ k, v = line.split("=")
+ {k.strip.downcase, v.strip}
+ end
+
+ updates = kv_pairs.to_h.transform_values do |val|
+ if resp = val.to_i64?
+ resp
+ else
+ case val
+ when "NULL" then nil
+ when /(Off)|(No)/ then false
+ when /(On)|(Yes)/ then true
+ else
+ val
+ end
+ end
+ end
+
+ updates.reject! { |k, _| k.ends_with? "()" }
+
+ return nil if updates.empty?
+
+ if updates.size == 1 && (single_value = updates[command]?)
+ # Single property query
+ single_value
+ elsif updates.values.all?(&.nil?)
+ # Property list
+ updates.keys
+ else
+ # Property set
+ remove = "#{command}."
+ updates.transform_keys { |x| x.sub(remove, "") }
+ end
+ end
+end
diff --git a/drivers/tv_one/corio_master_spec.cr b/drivers/tv_one/corio_master_spec.cr
new file mode 100644
index 00000000000..8d83848a060
--- /dev/null
+++ b/drivers/tv_one/corio_master_spec.cr
@@ -0,0 +1,181 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "TvOne::CorioMaster" do
+ transmit <<-INIT
+ // ===================\r
+ // CORIOmaster - CORIOmax\r
+ // ===================\r
+ // Command Interface Ready\r
+ Please login. Use 'login(username,password)'\r
+ INIT
+
+ should_send "login(admin,adminpw)\r\n"
+ responds "!Info : User admin Logged In\r\n"
+ status[:connected].should be_true
+ status[:ready].should be_true
+
+ should_send "Preset.Take\r\n"
+ responds <<-RX
+ Preset.Take = 1\r
+ !Done Preset.Take\r\n
+ RX
+
+ status[:preset]?.should eq 1
+
+ should_send "Routing.Preset.PresetList()\r\n"
+ responds <<-RX
+ !Done Routing.Preset.PresetList()\r\n
+ RX
+ status[:presets]?.should be_nil
+
+ should_send "Windows\r\n"
+ responds <<-RX
+ !Done Windows\r\n
+ RX
+ should_send "Canvases\r\n"
+ responds <<-RX
+ !Done Canvases\r\n
+ RX
+ should_send "Layouts\r\n"
+ responds <<-RX
+ !Done Layouts\r\n
+ RX
+ should_send "CORIOmax.Serial_Number\r\n"
+ responds <<-RX
+ CORIOmax.Serial_Number = 2218031005149\r
+ !Done CORIOmax.Serial_Number\r\n
+ RX
+ status[:serial_number].should eq 2218031005149_i64
+
+ should_send "CORIOmax.Software_Version\r\n"
+ responds <<-RX
+ CORIOmax.Software_Version = V1.30701.P4 Master\r
+ !Done CORIOmax.Software_Version\r\n
+ RX
+ status[:firmware].should eq "V1.30701.P4 Master"
+
+ result = exec(:query_windows)
+ should_send("Windows\r\n")
+ responds(
+ <<-RX
+ Windows.Window1 = <...>\r
+ Windows.Window2 = <...>\r
+ !Done Windows\r\n
+ RX
+ )
+ should_send("window1\r\n")
+ responds(
+ <<-RX
+ Window1.FullName = Window1\r
+ Window1.Alias = NULL\r
+ Window1.Input = Slot3.In1\r
+ Window1.Canvas = Canvas1\r
+ Window1.CanWidth = 1280\r
+ Window1.CanHeight = 720\r
+ !Done Window1\r\n
+ RX
+ )
+ should_send("window2\r\n")
+ responds(
+ <<-RX
+ Window2.FullName = Window2\r
+ Window2.Alias = NULL\r
+ Window2.Input = Slot3.In2\r
+ Window2.Canvas = Canvas1\r
+ Window2.CanWidth = 1280\r
+ Window2.CanHeight = 720\r
+ !Done Window2\r\n
+ RX
+ )
+
+ result.get.should eq({
+ "window1" => {
+ "fullname" => "Window1",
+ "alias" => nil,
+ "input" => "Slot3.In1",
+ "canvas" => "Canvas1",
+ "canwidth" => 1280,
+ "canheight" => 720,
+ },
+ "window2" => {
+ "fullname" => "Window2",
+ "alias" => nil,
+ "input" => "Slot3.In2",
+ "canvas" => "Canvas1",
+ "canwidth" => 1280,
+ "canheight" => 720,
+ },
+ })
+
+ result = exec(:preset_list)
+ should_send("Routing.Preset.PresetList()\r\n")
+ responds(
+ <<-RX
+ Routing.Preset.PresetList[1]=Sharing-Standard,Canvas1,0\r
+ Routing.Preset.PresetList[2]=Standard-4-Screen,Canvas1,0\r
+ Routing.Preset.PresetList[3]=Standard-10-Screen,Canvas1,0\r
+ Routing.Preset.PresetList[11]=Clear,Canvas1,0\r
+ !Done Routing.Preset.PresetList()\r\n
+ RX
+ )
+ result.get.should eq({
+ "1" => {"name" => "Sharing-Standard", "canvas" => "Canvas1", "time" => 0},
+ "2" => {"name" => "Standard-4-Screen", "canvas" => "Canvas1", "time" => 0},
+ "3" => {"name" => "Standard-10-Screen", "canvas" => "Canvas1", "time" => 0},
+ "11" => {"name" => "Clear", "canvas" => "Canvas1", "time" => 0},
+ })
+
+ result = exec(:preset, 1)
+ should_send("Preset.Take = 1\r\n")
+ responds(
+ <<-RX
+ Preset.Take = 1\r
+ !Done Preset.Take\r\n
+ RX
+ )
+
+ result.get.should eq 1
+ status[:preset]?.should eq 1
+
+ should_send("Windows\r\n")
+ responds(
+ <<-RX
+ Windows.Window1 = <...>\r
+ !Done Windows\r\n
+ RX
+ )
+ should_send("window1\r\n")
+ responds(
+ <<-RX
+ Window1.FullName = Window1\r
+ Window1.Alias = NULL\r
+ Window1.Input = Slot3.In1\r
+ Window1.Canvas = Canvas1\r
+ Window1.CanWidth = 1280\r
+ Window1.CanHeight = 720\r
+ !Done Window1\r\n
+ RX
+ )
+
+ status[:windows]?.should eq({
+ "window1" => {
+ "fullname" => "Window1",
+ "alias" => nil,
+ "input" => "Slot3.In1",
+ "canvas" => "Canvas1",
+ "canwidth" => 1280,
+ "canheight" => 720,
+ },
+ })
+
+ exec(:switch, {"Slot1.In1" => [1]})
+ should_send("Window1.Input = Slot1.In1\r\n")
+ responds(
+ <<-RX
+ Window1.Input = Slot1.In1\r
+ !Done Window1.Input\r\n
+ RX
+ )
+
+ status[:windows]["window1"]["input"].should eq("Slot1.In1")
+end
diff --git a/drivers/tv_one/window_stacker.cr b/drivers/tv_one/window_stacker.cr
new file mode 100644
index 00000000000..41243003395
--- /dev/null
+++ b/drivers/tv_one/window_stacker.cr
@@ -0,0 +1,88 @@
+require "placeos-driver"
+
+class TvOne::WindowStacker < PlaceOS::Driver
+ descriptive_name "Videowall Window Stacker Logic"
+ generic_name :WindowStacker
+
+ description <<-DESC
+ The CORIOmaster videowall processors does not provide the ability to hide
+ windows on signal loss. This logic module may be used to bind windows used
+ in a layout to displays defined within a system. When a display has no source
+ routed it's Z-index will be dropped to 0, revealing background content.
+
+ Use settings to define mapping between system outputs and window ID's:
+ ```
+ {
+ "windows": {
+ "Display_1": [1, 2, 3],
+ "Display_2": 7,
+ "Display_3": [9, 4]
+ }
+ }
+ ```
+ DESC
+
+ default_settings({
+ show_z_index: 15,
+ hide_z_index: 0,
+ videowall: "VideoWall_1",
+ windows: {
+ "Display_1" => [1, 2, 3],
+ "Display_2" => 7,
+ "Display_3" => [9, 4],
+ },
+ })
+
+ @subscriptions : Array(::PlaceOS::Driver::Subscriptions::IndirectSubscription) = [] of PlaceOS::Driver::Subscriptions::IndirectSubscription
+ @videowall : String = "VideoWall_1"
+ @show : Int32 = 15
+ @hide : Int32 = 0
+
+ def on_update
+ clear_subscriptions
+
+ bindings = setting?(Hash(String, Int32 | Array(Int32)), :windows) || {} of String => Int32 | Array(Int32)
+ @videowall = setting?(String, :videowall) || "VideoWall_1"
+ @show = setting?(Int32, :show_z_index) || 15 # visible z layer
+ @hide = setting?(Int32, :hide_z_index) || 0 # hidden z layer
+
+ # Subscribe to source updates and relayer on change
+ sys = system[:System_1]
+ @subscriptions = bindings.map do |display, window|
+ sys.subscribe("output/#{display}") do |_sub, value|
+ logger.debug { "Restacking #{display} linked windows due to source change" }
+ source = JSON.parse(value)["source"]?.try(&.as_s?)
+ restack window, source
+ end
+ end
+
+ # Also restack after a videowall preset recall
+ @subscriptions << system[@videowall].subscribe("preset") do |_sub, notice|
+ logger.debug { "Restacking all videowall windows due to preset change" }
+ bindings.each do |display, window|
+ begin
+ source = system[:System]["output/#{display}"]["source"]?.try(&.as_s?)
+ restack window, source
+ rescue
+ logger.warn { "could not find active source for #{display}" }
+ end
+ end
+ end
+ end
+
+ protected def clear_subscriptions
+ logger.debug { "clearing subscriptions!" }
+ @subscriptions.each { |sub| subscriptions.unsubscribe(sub) }
+ @subscriptions.clear
+ end
+
+ protected def restack(window : Int32 | Array(Int32), source : String?)
+ window = window.is_a?(Array) ? window : [window]
+ wall_controller = system[@videowall]
+
+ z_index = {nil, "MUTE"}.includes?(source) ? @hide : @show
+ window.each do |id|
+ wall_controller.window id, "Zorder", z_index
+ end
+ end
+end
diff --git a/drivers/tv_one/window_stacker_spec.cr b/drivers/tv_one/window_stacker_spec.cr
new file mode 100644
index 00000000000..d5c594ef58b
--- /dev/null
+++ b/drivers/tv_one/window_stacker_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "TvOne::WindowStacker" do
+end
diff --git a/drivers/twenty_five_live_pro/api.cr b/drivers/twenty_five_live_pro/api.cr
new file mode 100644
index 00000000000..806b2cf22da
--- /dev/null
+++ b/drivers/twenty_five_live_pro/api.cr
@@ -0,0 +1,323 @@
+require "placeos-driver"
+require "./models/*"
+
+module TwentyFiveLivePro
+ class API < PlaceOS::Driver
+ descriptive_name "25 Live Pro API Gateway"
+ generic_name :Bookings
+ uri_base "https://example.com/r25ws/wrd/partners/run"
+
+ default_settings({
+ username: "admin",
+ password: "admin",
+ user_agent: "PlaceOS",
+ })
+
+ def on_load
+ on_update
+ end
+
+ @username : String = "admin"
+ @password : String = "admin"
+
+ @user_agent : String = "PlaceOS"
+
+ def on_update
+ @username = setting(String, :username)
+ @password = setting(String, :password)
+
+ @user_agent = setting?(String, :user_agent) || "PlaceOS"
+ end
+
+ def get_space_details(id : Int32, included_elements : Array(String) = [] of String, expanded_elements : Array(String) = [] of String)
+ params = URI::Params.build do |form|
+ form.add "include", included_elements.join(",")
+ form.add "expand", expanded_elements.join(",")
+ end
+
+ response = get("/external/space/#{id}/detail.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ Models::SpaceDetail.from_json(response.body)
+ end
+
+ def list_spaces(page : Int32 = 1, items_per_page : Int32 = 100, paginate : String? = nil)
+ spaces = [] of Models::Space
+
+ loop do
+ params = URI::Params.build do |form|
+ form.add "page", page.to_s
+ form.add "itemsPerPage", items_per_page.to_s
+ form.add "paginate", paginate if paginate
+ end
+
+ response = get("/external/space/list.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ paginated_response = Models::PaginatedResponse.from_json(response.body)
+
+ if page < paginated_response.content.data.total_pages
+ begin
+ Array(Models::Space).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |space|
+ spaces.push(space)
+ end
+
+ page += 1
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ elsif page == paginated_response.content.data.total_pages
+ begin
+ Array(Models::Space).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |space|
+ spaces.push(space)
+ end
+
+ break
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ else
+ break
+ end
+ end
+
+ spaces
+ end
+
+ def availability(id : Int32, start_date : String, end_date : String, included_elements : Array(String) = [] of String, expanded_elements : Array(String) = [] of String)
+ params = URI::Params.build do |form|
+ form.add "include", included_elements.join(",")
+ form.add "expand", expanded_elements.join(",")
+ end
+
+ body = {
+ "spaces" => [
+ {
+ "spaceId" => id,
+ "dates" => {
+ "startDt" => start_date,
+ "endDt" => end_date,
+ },
+ },
+ ],
+ }
+
+ response = post("/external/spaceAvailability.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"}, body: body.to_json)
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ Models::Availability.from_json(response.body)
+ end
+
+ def get_resource_details(id : Int32, included_elements : Array(String) = [] of String, expanded_elements : Array(String) = [] of String)
+ params = URI::Params.build do |form|
+ form.add "include", included_elements.join(",")
+ form.add "expand", expanded_elements.join(",")
+ end
+
+ response = get("/external/resource/#{id}/detail.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ Models::ResourceDetail.from_json(response.body)
+ end
+
+ def list_resources(page : Int32 = 1, items_per_page : Int32 = 100, paginate : String? = nil)
+ resources = [] of Models::Resource
+
+ loop do
+ params = URI::Params.build do |form|
+ form.add "page", page.to_s
+ form.add "itemsPerPage", items_per_page.to_s
+ form.add "paginate", paginate if paginate
+ end
+
+ response = get("/external/resource/list.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ paginated_response = Models::PaginatedResponse.from_json(response.body)
+
+ if page < paginated_response.content.data.total_pages
+ begin
+ Array(Models::Resource).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |resource|
+ resources.push(resource)
+ end
+
+ page += 1
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ elsif page == paginated_response.content.data.total_pages
+ begin
+ Array(Models::Resource).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |resource|
+ resources.push(resource)
+ end
+
+ break
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ else
+ break
+ end
+ end
+
+ resources
+ end
+
+ def get_organization_details(id : Int32, included_elements : Array(String) = [] of String, expanded_elements : Array(String) = [] of String)
+ params = URI::Params.build do |form|
+ form.add "include", included_elements.join(",")
+ form.add "expand", expanded_elements.join(",")
+ end
+
+ response = get("/external/organization/#{id}/detail.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ Models::OrganizationDetail.from_json(response.body)
+ end
+
+ def list_organizations(page : Int32 = 1, items_per_page : Int32 = 100, paginate : String? = nil)
+ organizations = [] of Models::Organization
+
+ loop do
+ params = URI::Params.build do |form|
+ form.add "page", page.to_s
+ form.add "itemsPerPage", items_per_page.to_s
+ form.add "paginate", paginate if paginate
+ end
+
+ response = get("/external/organization/list.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ paginated_response = Models::PaginatedResponse.from_json(response.body)
+
+ if page < paginated_response.content.data.total_pages
+ begin
+ Array(Models::Organization).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |organization|
+ organizations.push(organization)
+ end
+
+ page += 1
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ elsif page == paginated_response.content.data.total_pages
+ begin
+ Array(Models::Organization).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |organization|
+ organizations.push(organization)
+ end
+
+ break
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ else
+ break
+ end
+ end
+
+ organizations
+ end
+
+ def list_reservations(space_id : Int32, start_date : String, end_date : String)
+ params = URI::Params.build do |form|
+ form.add "space_id", space_id.to_s
+ form.add "start_dt", start_date
+ form.add "end_dt", end_date
+ end
+
+ response = get("/reservations.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ Models::Reservations.from_json(response.body)
+ end
+
+ def get_event_details(id : Int32, included_elements : Array(String) = [] of String, expanded_elements : Array(String) = [] of String)
+ params = URI::Params.build do |form|
+ form.add "include", included_elements.join(",")
+ form.add "expand", expanded_elements.join(",")
+ end
+
+ response = get("/external/event/#{id}/detail.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ Models::EventDetail.from_json(response.body)
+ end
+
+ def list_events(space_id : Int32 = 1, page : Int32 = 1, items_per_page : Int32 = 100, since : String? = nil, paginate : String? = nil)
+ events = [] of Models::Event
+
+ loop do
+ params = URI::Params.build do |form|
+ form.add "space_id", space_id.to_s
+ form.add "page", page.to_s
+ form.add "itemsPerPage", items_per_page.to_s
+ form.add "created_since", since if since
+ form.add "paginate", paginate if paginate
+ end
+
+ response = get("/external/event/list.json?#{params}", headers: HTTP::Headers{"Authorization" => get_basic_authorization, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ paginated_response = Models::PaginatedResponse.from_json(response.body)
+
+ if page < paginated_response.content.data.total_pages
+ begin
+ Array(Models::Event).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |event|
+ events.push(event)
+ end
+
+ page += 1
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ elsif page == paginated_response.content.data.total_pages
+ begin
+ Array(Models::Event).from_json(paginated_response.content.data.json_unmapped.["items"].to_json).each do |event|
+ events.push(event)
+ end
+
+ break
+ rescue exception
+ logger.warn { "failed to parse body:\n#{response.body}" }
+ raise exception
+ end
+ else
+ break
+ end
+ end
+
+ events
+ end
+
+ protected def get_basic_authorization
+ "Basic #{Base64.strict_encode("#{@username}:#{@password}")}"
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/api_spec.cr b/drivers/twenty_five_live_pro/api_spec.cr
new file mode 100644
index 00000000000..4778fa334be
--- /dev/null
+++ b/drivers/twenty_five_live_pro/api_spec.cr
@@ -0,0 +1,886 @@
+require "./models/**"
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "TwentyFiveLivePro::API" do
+ # Spaces
+
+ get_space_details = exec(:get_space_details, 1, ["all"], ["all"])
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/space/1/detail.json?include=all&expand=all"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": 365785,
+ "updated": "2023-04-05T00:37:53-07:00",
+ "data": {
+ "items": [
+ {
+ "kind": "space",
+ "id": 1,
+ "etag": "00000029",
+ "spaceName": "0104-232",
+ "spaceFormalName": "Cox Science Center, 232",
+ "maxCapacity": 24,
+ "updated": "2013-08-01T08:17:01-07:00",
+ "layouts": [],
+ "features": [],
+ "categories": [],
+ "attributes": [
+ {
+ "attributeId": -38,
+ "value": "Wet Lab - Research"
+ },
+ {
+ "attributeId": -37,
+ "value": "OPEN"
+ },
+ {
+ "attributeId": -36,
+ "value": "G"
+ },
+ {
+ "attributeId": -33,
+ "value": "Second Floor"
+ },
+ {
+ "attributeId": -32
+ },
+ {
+ "attributeId": -31,
+ "value": "2008-05-30"
+ },
+ {
+ "attributeId": -30,
+ "value": "T"
+ },
+ {
+ "attributeId": -12,
+ "value": "1350"
+ },
+ {
+ "attributeId": -10,
+ "value": "2"
+ },
+ {
+ "attributeId": -9,
+ "value": "250-04"
+ },
+ {
+ "attributeId": -7
+ },
+ {
+ "attributeId": -6,
+ "value": "Cox Science Center"
+ }
+ ],
+ "roles": []
+ }
+ ]
+ },
+ "expandedInfo": {
+ "attributes": [
+ {
+ "attributeId": -30,
+ "attributeName": "1",
+ "dataType": "B"
+ },
+ {
+ "attributeId": -31,
+ "attributeName": "1",
+ "dataType": "D"
+ },
+ {
+ "attributeId": -32,
+ "attributeName": "1",
+ "dataType": "D"
+ },
+ {
+ "attributeId": -33,
+ "attributeName": "1",
+ "dataType": "S"
+ },
+ {
+ "attributeId": -38,
+ "attributeName": "1",
+ "dataType": "S"
+ },
+ {
+ "attributeId": -36,
+ "attributeName": "1",
+ "dataType": "S"
+ },
+ {
+ "attributeId": -37,
+ "attributeName": "1",
+ "dataType": "S"
+ },
+ {
+ "attributeId": -12,
+ "attributeName": "1",
+ "dataType": "N"
+ },
+ {
+ "attributeId": -6,
+ "attributeName": "1",
+ "dataType": "S"
+ },
+ {
+ "attributeId": -10,
+ "attributeName": "1",
+ "dataType": "S"
+ },
+ {
+ "attributeId": -7,
+ "attributeName": "1",
+ "dataType": "2"
+ },
+ {
+ "attributeId": -9,
+ "attributeName": "1",
+ "dataType": "S"
+ }
+ ],
+ "roles": [],
+ "contacts": []
+ }
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected get space details request"
+ end
+ end
+
+ space_detail = TwentyFiveLivePro::Models::SpaceDetail.from_json(get_space_details.get.not_nil!.to_json)
+ space_detail.content.data.items.first.id.should eq 1
+
+ list_spaces = exec(:list_spaces, 1, 10, nil)
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/space/list.json?page=1&itemsPerPage=10"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": 365784,
+ "updated": "2023-04-04T22:19:51-07:00",
+ "data": {
+ "paginateKey": 16414,
+ "pageIndex": 1,
+ "totalPages": 1,
+ "totalItems": 10,
+ "currentItemCount": 10,
+ "itemsPerPage": 10,
+ "pagingLinkTemplate": "https://webservices.collegenet.com/r25ws/wrd/partners/run/external/space/list.json?current_cont_id=754&paginate=16414&page={index}&order=asc",
+ "items": [
+ {
+ "kind": "space",
+ "id": 37932,
+ "etag": "00000029",
+ "spaceName": "0104-232",
+ "spaceFormalName": "Cox Science Center, 232",
+ "maxCapacity": 24,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 37929,
+ "etag": "00000027",
+ "spaceName": "0509-111",
+ "spaceFormalName": "Handleman Building, 111",
+ "maxCapacity": 10,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 37671,
+ "etag": "00000021",
+ "spaceName": "1_R1",
+ "spaceFormalName": "First Floor Conference Room",
+ "maxCapacity": 12,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 13296,
+ "etag": "00000024",
+ "spaceName": "100*133",
+ "spaceFormalName": "Classroom - M",
+ "maxCapacity": 42,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 13301,
+ "etag": "00000024",
+ "spaceName": "100*142",
+ "spaceFormalName": "Classroom - M",
+ "maxCapacity": 49,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 40771,
+ "etag": "00000021",
+ "spaceName": "1028 DANA",
+ "maxCapacity": 30,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 40759,
+ "etag": "00000021",
+ "spaceName": "1040 DANA",
+ "maxCapacity": 30,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 40779,
+ "etag": "00000021",
+ "spaceName": "1045 GGBL",
+ "maxCapacity": 30,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 40793,
+ "etag": "00000021",
+ "spaceName": "1121 LBME",
+ "maxCapacity": 30,
+ "canRequest": true
+ },
+ {
+ "kind": "space",
+ "id": 40747,
+ "etag": "00000021",
+ "spaceName": "130 TAP",
+ "maxCapacity": 30,
+ "canRequest": true
+ }
+ ]
+ }
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected list spaces request"
+ end
+ end
+
+ spaces = Array(TwentyFiveLivePro::Models::Space).from_json(list_spaces.get.not_nil!.to_json)
+ spaces.size.should eq 10
+
+ get_availability = exec(:availability, 1, "2023-06-04T14:30:00-07:00", "2023-06-04T15:30:00-07:00", ["all"], ["all"])
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/spaceAvailability.json?include=all&expand=all"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": "365796",
+ "updated": "2023-04-05T01:32:44-07:00",
+ "data": {
+ "spaces": [
+ {
+ "spaceId": 1,
+ "dates": [
+ {
+ "startDt": "2023-06-04T14:30:00-07:00",
+ "endDt": "2023-06-04T15:30:00-07:00"
+ }
+ ],
+ "available": false,
+ "conflictType": 3
+ }
+ ]
+ },
+ "expandedInfo": {
+ "conflictTypes": [
+ {
+ "conflictTypeId": 1,
+ "conflictTypeName": "pendRes",
+ "conflictTypeDescription": "Conflicts with a pending space reservation for this space."
+ },
+ {
+ "conflictTypeId": 2,
+ "conflictTypeName": "res",
+ "conflictTypeDescription": "Conflicts with a space reservation for this space."
+ },
+ {
+ "conflictTypeId": 3,
+ "conflictTypeName": "hour",
+ "conflictTypeDescription": "Conflicts with the Open/Close hour setting for this space."
+ },
+ {
+ "conflictTypeId": 4,
+ "conflictTypeName": "blackout",
+ "conflictTypeDescription": "Conflicts with a blackout for this space."
+ }
+ ]
+ }
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected list spaces request"
+ end
+ end
+
+ availability = TwentyFiveLivePro::Models::Availability.from_json(get_availability.get.not_nil!.to_json)
+ availability.content.data.spaces.first.space_id.should eq 1
+
+ # Resources
+
+ get_resource_details = exec(:get_resource_details, 1, ["all"], ["all"])
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/resource/1/detail.json?include=all&expand=all"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": 365793,
+ "updated": "2023-04-05T01:03:14-07:00",
+ "data": {
+ "items": [
+ {
+ "kind": "resource",
+ "id": 1,
+ "etag": "00000021",
+ "resourceName": "1",
+ "updated": "2001-11-19T11:41:39-08:00",
+ "categories": [
+ {
+ "categoryId": 935
+ }
+ ],
+ "attributes": [],
+ "stock": [
+ {
+ "versionNumber": 2,
+ "startDate": "2001-11-19T00:00:00",
+ "endDate": "2100-12-31T00:00:00",
+ "stockLevel": 2
+ }
+ ]
+ }
+ ]
+ },
+ "expandedInfo": [
+ {
+ "categories": [
+ {
+ "categoryId": 935,
+ "categoryName": "1"
+ }
+ ]
+ }
+ ]
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected get resource details request"
+ end
+ end
+
+ resource_detail = TwentyFiveLivePro::Models::ResourceDetail.from_json(get_resource_details.get.not_nil!.to_json)
+ resource_detail.content.data.items.first.id.should eq 1
+
+ list_resources = exec(:list_resources, 1, 10, nil)
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/resource/list.json?page=1&itemsPerPage=10"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": 365792,
+ "updated": "2023-04-05T01:01:36-07:00",
+ "data": {
+ "paginateKey": 16419,
+ "pageIndex": 1,
+ "totalPages": 1,
+ "totalItems": 67,
+ "currentItemCount": 10,
+ "itemsPerPage": 10,
+ "pagingLinkTemplate": "https://webservices.collegenet.com/r25ws/wrd/partners/run/external/resource/list.json?current_cont_id=754&paginate=16419&page={index}&order=asc",
+ "items": [
+ {
+ "kind": "resource",
+ "id": 29,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 87,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 12,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 62,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 69,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 10,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 57,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 54,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 4,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ },
+ {
+ "kind": "resource",
+ "id": 75,
+ "etag": "00000021",
+ "resourceName": "1",
+ "canRequest": true
+ }
+ ]
+ }
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected list resources request"
+ end
+ end
+
+ resources = Array(TwentyFiveLivePro::Models::Resource).from_json(list_resources.get.not_nil!.to_json)
+ resources.size.should eq 10
+
+ # Organizations
+
+ get_organization_details = exec(:get_organization_details, 1, ["all"], ["all"])
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/organization/1/detail.json?include=all&expand=all"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": 1,
+ "updated": "2023-04-05T01:00:08-07:00",
+ "data": {
+ "items": [
+ {
+ "kind": "organization",
+ "id": 1,
+ "etag": "0000003A",
+ "organizationName": "1",
+ "organizationTitle": "1",
+ "updated": "2013-08-01T08:16:20.230-07:00",
+ "organizationTypeId": 16,
+ "categories": [
+ {
+ "categoryId": 945
+ }
+ ],
+ "attributes": [
+ {
+ "attributeId": -42,
+ "value": "F"
+ },
+ {
+ "attributeId": -41,
+ "value": "22505-00"
+ }
+ ]
+ }
+ ]
+ },
+ "expandedInfo": [
+ {
+ "categories": [
+ {
+ "categoryId": 945,
+ "categoryName": "1"
+ }
+ ],
+ "attributes": [
+ {
+ "attributeId": -41,
+ "attributeName": "1",
+ "dataType": "S"
+ },
+ {
+ "attributeId": -42,
+ "attributeName": "1",
+ "dataType": "B"
+ }
+ ],
+ "organization_types": [
+ {
+ "organizationTypeId": 16,
+ "orgTypeName": "1",
+ "rateGroupId": 11
+ }
+ ]
+ }
+ ]
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected get organization details request"
+ end
+ end
+
+ organization_detail = TwentyFiveLivePro::Models::OrganizationDetail.from_json(get_organization_details.get.not_nil!.to_json)
+ organization_detail.content.data.items.first.id.should eq 1
+
+ list_organizations = exec(:list_organizations, 1, 10, nil)
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/organization/list.json?page=1&itemsPerPage=10"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": 365790,
+ "updated": "2023-04-05T00:57:56-07:00",
+ "data": {
+ "paginateKey": 16418,
+ "pageIndex": 1,
+ "totalPages": 1,
+ "totalItems": 291,
+ "currentItemCount": 10,
+ "itemsPerPage": 10,
+ "pagingLinkTemplate": "https://webservices.collegenet.com/r25ws/wrd/partners/run/external/organization/list.json?current_cont_id=754&paginate=16418&page={index}&order=asc",
+ "items": [
+ {
+ "kind": "organization",
+ "id": 31888,
+ "etag": "0000003A",
+ "organizationName": "1",
+ "organizationTitle": "1",
+ "organizationTypeId": 16
+ },
+ {
+ "kind": "organization",
+ "id": 32247,
+ "etag": "00000021",
+ "organizationName": "1",
+ "organizationTypeId": 27
+ },
+ {
+ "kind": "organization",
+ "id": 269,
+ "etag": "0000002C",
+ "organizationName": "1",
+ "organizationTitle": "1",
+ "organizationTypeId": 23
+ },
+ {
+ "kind": "organization",
+ "id": 211,
+ "etag": "00000026",
+ "organizationName": "1",
+ "organizationTitle": "1",
+ "organizationTypeId": 23
+ },
+ {
+ "kind": "organization",
+ "id": 32103,
+ "etag": "00000033",
+ "organizationName": "1"
+ },
+ {
+ "kind": "organization",
+ "id": 32096,
+ "etag": "00000033",
+ "organizationName": "1"
+ },
+ {
+ "kind": "organization",
+ "id": 31383,
+ "etag": "00000021",
+ "organizationName": "1",
+ "organizationTypeId": 27
+ },
+ {
+ "kind": "organization",
+ "id": 31951,
+ "etag": "00000039",
+ "organizationName": "1",
+ "organizationTitle": "1",
+ "organizationTypeId": 16
+ },
+ {
+ "kind": "organization",
+ "id": 15988,
+ "etag": "00000024",
+ "organizationName": "1",
+ "organizationTitle": "1",
+ "organizationTypeId": 27
+ },
+ {
+ "kind": "organization",
+ "id": 247,
+ "etag": "00000025",
+ "organizationName": "1",
+ "organizationTitle": "1",
+ "organizationTypeId": 16
+ }
+ ]
+ }
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected list organizations request"
+ end
+ end
+
+ organizations = Array(TwentyFiveLivePro::Models::Organization).from_json(list_organizations.get.not_nil!.to_json)
+ organizations.size.should eq 10
+
+ # Events
+
+ get_event_details = exec(:get_event_details, 1, ["all"], ["all"])
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/event/1/detail.json?include=all&expand=all"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "id": 365795,
+ "updated": "2023-04-05T01:08:28-07:00",
+ "data": {
+ "items": [
+ {
+ "kind": "event",
+ "id": 1,
+ "etag": "00000021",
+ "name": "cccccccc",
+ "title": "RoomView Created Event",
+ "eventLocator": "2023-AASXCJ",
+ "priority": 0,
+ "updated": "2023-04-04T17:17:52-07:00",
+ "dates": {
+ "startDate": "2023-04-04T14:30:00-07:00",
+ "endDate": "2023-04-04T15:00:00-07:00"
+ },
+ "organizations": {},
+ "context": {
+ "state": 1,
+ "typeId": 231,
+ "parentId": 63070
+ },
+ "categories": [],
+ "attributes": [],
+ "requirements": [],
+ "roles": [
+ {
+ "roleId": -2,
+ "contactId": 718
+ }
+ ],
+ "text": [
+ {}
+ ],
+ "profiles": [
+ {
+ "profileId": 169006,
+ "name": "Rsrv_169006",
+ "expectedCount": null,
+ "registeredCount": null,
+ "occurrenceDefn": {
+ "recTypeId": 0,
+ "initStartDt": "2023-04-04T14:30:00-07:00",
+ "initEndDt": "2023-04-04T15:00:00-07:00"
+ },
+ "comments": "~tromeo@crestron.com~admin",
+ "reservations": [
+ {
+ "rsrvId": 1031353,
+ "state": 1,
+ "rsrvStartDt": "2023-04-04T14:30:00-07:00",
+ "evStartDt": "2023-04-04T14:30:00-07:00",
+ "evEndDt": "2023-04-04T15:00:00-07:00",
+ "rsrvEndDt": "2023-04-04T15:00:00-07:00",
+ "spaces": [
+ {
+ "reserved": [
+ {
+ "spaceId": 40722,
+ "share": false,
+ "instructions": "[rv]",
+ "rating": 0
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "expandedInfo": {
+ "organizations": [],
+ "roles": [
+ {
+ "roleId": -2,
+ "etag": "00000021",
+ "roleName": "Scheduler"
+ }
+ ],
+ "spaces": [
+ {
+ "spaceId": 40722,
+ "etag": "00000021",
+ "spaceName": "MSIMS_003",
+ "spaceFormalName": "Michael Sims Test Room 003",
+ "maxCapacity": 60
+ }
+ ],
+ "resources": [],
+ "states": [
+ {
+ "state": 1,
+ "stateName": "Tentative"
+ }
+ ],
+ "eventTypes": [
+ {
+ "typeId": 231,
+ "typeName": "Meeting"
+ }
+ ],
+ "parentNodes": [
+ {
+ "id": 63070,
+ "locator": "2019-AAHVAJ",
+ "name": "Standard Events",
+ "title": "New Folder 63070",
+ "nodeType": "folder",
+ "typeName": "Event Folder",
+ "startDt": "2019-01-01T00:00:00-08:00",
+ "endDt": "2050-12-31T23:59:00-08:00"
+ }
+ ],
+ "contacts": [
+ {
+ "contactId": 718,
+ "etag": "00000021",
+ "firstName": null,
+ "familyName": "Crestron",
+ "email": "rpollard@crestron.com",
+ "isFavorite": false
+ }
+ ]
+ }
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected get event details request"
+ end
+ end
+
+ event_detail = TwentyFiveLivePro::Models::EventDetail.from_json(get_event_details.get.not_nil!.to_json)
+ event_detail.content.data.items.first.id.should eq 1
+
+ list_events = exec(:list_events, 1, 10, nil)
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/event/list.json?page=1&itemsPerPage=10"
+ response.status_code = 200
+ response << %({
+ "content": {
+ "requestId": 365786,
+ "updated": "2023-04-05T00:51:51-07:00",
+ "data": {
+ "paginateKey": 16415,
+ "pageIndex": 1,
+ "totalPages": 1,
+ "totalItems": 2,
+ "currentItemCount": 2,
+ "itemsPerPage": 10,
+ "pagingLinkTemplate": "https://webservices.collegenet.com/r25ws/wrd/partners/run/external/event/list.json?current_cont_id=754&paginate=16415&page={index}&sort=event_name&order=asc",
+ "items": [
+ {
+ "kind": "event",
+ "id": 63065,
+ "etag": "00000022",
+ "eventName": "Academic Cabinet",
+ "eventLocator": "2019-AAHVAC",
+ "updated": "2019-06-13T10:05:20-07:00",
+ "dates": {
+ "startDate": "2019-01-01T00:00:00-08:00",
+ "endDate": "2050-12-31T23:59:00-08:00"
+ }
+ },
+ {
+ "kind": "event",
+ "id": 63066,
+ "etag": "00000023",
+ "eventName": "Event Cabinet",
+ "eventLocator": "2019-AAHVAD",
+ "updated": "2019-06-13T10:21:03-07:00",
+ "dates": {
+ "startDate": "2019-01-01T00:00:00-08:00",
+ "endDate": "2050-12-31T23:59:00-08:00"
+ }
+ }
+ ]
+ }
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected list events request"
+ end
+ end
+
+ events = Array(TwentyFiveLivePro::Models::Event).from_json(list_events.get.not_nil!.to_json)
+ events.size.should eq 2
+end
diff --git a/drivers/twenty_five_live_pro/models/attribute.cr b/drivers/twenty_five_live_pro/models/attribute.cr
new file mode 100644
index 00000000000..889d054c871
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/attribute.cr
@@ -0,0 +1,12 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Attribute
+ include JSON::Serializable
+
+ @[JSON::Field(key: "attributeId")]
+ property attribute_id : Int64
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/availability.cr b/drivers/twenty_five_live_pro/models/availability.cr
new file mode 100644
index 00000000000..21ffca88c11
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/availability.cr
@@ -0,0 +1,59 @@
+require "json"
+require "./expanded/conflict"
+
+module TwentyFiveLivePro
+ module Models
+ struct Availability
+ include JSON::Serializable
+
+ struct Content
+ include JSON::Serializable
+
+ @[JSON::Field(key: "requestId")]
+ property request_id : String
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ struct Data
+ include JSON::Serializable
+
+ struct Space
+ include JSON::Serializable
+
+ @[JSON::Field(key: "spaceId")]
+ property space_id : Int64
+
+ @[JSON::Field(key: "dates")]
+ property dates : Array(Hash(String, JSON::Any))
+
+ @[JSON::Field(key: "available")]
+ property available : Bool
+
+ @[JSON::Field(key: "conflictType")]
+ property conflict_type : Int64?
+ end
+
+ @[JSON::Field(key: "spaces")]
+ property spaces : Array(Space)
+ end
+
+ @[JSON::Field(key: "data")]
+ property data : Data
+
+ struct ExpandedInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "conflictTypes")]
+ property conflict_types : Array(Expanded::Conflict)?
+ end
+
+ @[JSON::Field(key: "expandedInfo")]
+ property expanded_info : ExpandedInfo?
+ end
+
+ @[JSON::Field(key: "content")]
+ property content : Content
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/category.cr b/drivers/twenty_five_live_pro/models/category.cr
new file mode 100644
index 00000000000..07d2673ba88
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/category.cr
@@ -0,0 +1,14 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Category
+ include JSON::Serializable
+
+ @[JSON::Field(key: "categoryId")]
+ property category_id : Int64
+ @[JSON::Field(key: "inheritCode")]
+ property inherit_code : Int64?
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/date.cr b/drivers/twenty_five_live_pro/models/date.cr
new file mode 100644
index 00000000000..636f1f4a7de
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/date.cr
@@ -0,0 +1,29 @@
+require "json"
+
+module TwentyFiveLivePro
+ struct Date
+ include JSON::Serializable
+
+ @[JSON::Field(key: "startDate", converter: TwentyFiveLivePro::Date::Converter)]
+ property start_date : Time
+
+ @[JSON::Field(key: "endDate", converter: TwentyFiveLivePro::Date::Converter)]
+ property end_date : Time
+
+ def duration
+ end_date - start_date
+ end
+
+ module Converter
+ extend self
+
+ def to_json(value, json : JSON::Builder)
+ json.string(value.to_rfc3339)
+ end
+
+ def from_json(value : JSON::PullParser)
+ Time.parse_rfc3339(value.read_string)
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/event.cr b/drivers/twenty_five_live_pro/models/event.cr
new file mode 100644
index 00000000000..7b96e249c0e
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/event.cr
@@ -0,0 +1,35 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Event
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "eventName")]
+ property name : String
+
+ @[JSON::Field(key: "eventTitle")]
+ property title : String?
+
+ @[JSON::Field(key: "eventLocator")]
+ property event_locator : String
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ @[JSON::Field(key: "dates")]
+ property date : Date
+
+ property container : Bool?
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/event_detail.cr b/drivers/twenty_five_live_pro/models/event_detail.cr
new file mode 100644
index 00000000000..565f9e72ba7
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/event_detail.cr
@@ -0,0 +1,95 @@
+require "json"
+require "./expanded/**"
+
+module TwentyFiveLivePro
+ module Models
+ struct EventDetail
+ include JSON::Serializable
+
+ struct Content
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : Int64?
+
+ @[JSON::Field(key: "updated")]
+ property updated : String?
+
+ struct Data
+ include JSON::Serializable
+
+ struct Event
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "name")]
+ property name : String
+
+ @[JSON::Field(key: "eventLocator")]
+ property event_locator : String
+
+ @[JSON::Field(key: "priority")]
+ property priority : Int64
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ @[JSON::Field(key: "dates")]
+ property date : Date
+ end
+
+ @[JSON::Field(key: "items")]
+ property items : Array(Event)
+ end
+
+ @[JSON::Field(key: "data")]
+ property data : Data
+
+ struct ExpandedInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "organizations")]
+ property organizations : Array(Expanded::Organization)?
+
+ @[JSON::Field(key: "attributes")]
+ property attributes : Array(Expanded::Attribute)?
+
+ @[JSON::Field(key: "roles")]
+ property roles : Array(Expanded::Role)?
+
+ @[JSON::Field(key: "spaces")]
+ property spaces : Array(Expanded::Space)?
+
+ @[JSON::Field(key: "resources")]
+ property resources : Array(Expanded::Resource)?
+
+ @[JSON::Field(key: "states")]
+ property states : Array(Expanded::State)?
+
+ @[JSON::Field(key: "eventTypes")]
+ property event_types : Array(Expanded::EventType)?
+
+ @[JSON::Field(key: "parentNodes")]
+ property parent_nodes : Array(Expanded::ParentNode)?
+
+ @[JSON::Field(key: "contacts")]
+ property contacts : Array(Expanded::Contact)?
+ end
+
+ @[JSON::Field(key: "expandedInfo")]
+ property expanded_info : ExpandedInfo?
+ end
+
+ @[JSON::Field(key: "content")]
+ property content : Content
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/attribute.cr b/drivers/twenty_five_live_pro/models/expanded/attribute.cr
new file mode 100644
index 00000000000..5587aed9f9f
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/attribute.cr
@@ -0,0 +1,18 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Attribute
+ include JSON::Serializable
+
+ @[JSON::Field(key: "attributeId")]
+ property attribute_id : Int64
+ @[JSON::Field(key: "attributeName")]
+ property attribute_name : String
+ @[JSON::Field(key: "attributeType")]
+ property attribute_type : String?
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/category.cr b/drivers/twenty_five_live_pro/models/expanded/category.cr
new file mode 100644
index 00000000000..8e151c04513
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/category.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Category
+ include JSON::Serializable
+
+ @[JSON::Field(key: "categoryId")]
+ property category_id : Int64
+ @[JSON::Field(key: "categoryName")]
+ property category_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/conflict.cr b/drivers/twenty_five_live_pro/models/expanded/conflict.cr
new file mode 100644
index 00000000000..141fbcb73e9
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/conflict.cr
@@ -0,0 +1,18 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Conflict
+ include JSON::Serializable
+
+ @[JSON::Field(key: "conflictTypeId")]
+ property conflict_type_id : Int64
+ @[JSON::Field(key: "conflictTypeName")]
+ property conflict_type_name : String
+ @[JSON::Field(key: "conflictTypeDescription")]
+ property conflict_type_description : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/contact.cr b/drivers/twenty_five_live_pro/models/expanded/contact.cr
new file mode 100644
index 00000000000..23b70c4ace2
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/contact.cr
@@ -0,0 +1,22 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Contact
+ include JSON::Serializable
+
+ @[JSON::Field(key: "contactId")]
+ property contact_id : Int64?
+ @[JSON::Field(key: "etag")]
+ property etag : String?
+ @[JSON::Field(key: "firstName")]
+ property first_name : String?
+ @[JSON::Field(key: "familyName")]
+ property family_name : String?
+ @[JSON::Field(key: "email")]
+ property email : String?
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/event_type.cr b/drivers/twenty_five_live_pro/models/expanded/event_type.cr
new file mode 100644
index 00000000000..c08d281df65
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/event_type.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct EventType
+ include JSON::Serializable
+
+ @[JSON::Field(key: "typeId")]
+ property type_id : Int64
+ @[JSON::Field(key: "typeName")]
+ property type_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/feature.cr b/drivers/twenty_five_live_pro/models/expanded/feature.cr
new file mode 100644
index 00000000000..a7c57fcfc16
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/feature.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Feature
+ include JSON::Serializable
+
+ @[JSON::Field(key: "featureId")]
+ property feature_id : Int64
+ @[JSON::Field(key: "featureName")]
+ property feature_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/layout.cr b/drivers/twenty_five_live_pro/models/expanded/layout.cr
new file mode 100644
index 00000000000..c6259a4780a
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/layout.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Layout
+ include JSON::Serializable
+
+ @[JSON::Field(key: "layoutId")]
+ property layout_id : Int64
+ @[JSON::Field(key: "layoutName")]
+ property layout_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/organization.cr b/drivers/twenty_five_live_pro/models/expanded/organization.cr
new file mode 100644
index 00000000000..79c6cdb5a84
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/organization.cr
@@ -0,0 +1,18 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Organization
+ include JSON::Serializable
+
+ @[JSON::Field(key: "organizationId")]
+ property organization_id : Int64
+ @[JSON::Field(key: "etag")]
+ property etag : String
+ @[JSON::Field(key: "organizationName")]
+ property organization_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/organization_type.cr b/drivers/twenty_five_live_pro/models/expanded/organization_type.cr
new file mode 100644
index 00000000000..3a0fd2d9053
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/organization_type.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct OrganizationType
+ include JSON::Serializable
+
+ @[JSON::Field(key: "organizationTypeId")]
+ property organization_type_id : Int64
+ @[JSON::Field(key: "organizationTypeName")]
+ property organization_type_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/parent_node.cr b/drivers/twenty_five_live_pro/models/expanded/parent_node.cr
new file mode 100644
index 00000000000..1ecb726c9f0
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/parent_node.cr
@@ -0,0 +1,28 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct ParentNode
+ include JSON::Serializable
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+ @[JSON::Field(key: "locator")]
+ property locator : String
+ @[JSON::Field(key: "name")]
+ property name : String
+ @[JSON::Field(key: "title")]
+ property title : String
+ @[JSON::Field(key: "nodeType")]
+ property node_type : String
+ @[JSON::Field(key: "typeName")]
+ property type_name : String
+ @[JSON::Field(key: "startDt")]
+ property start_dt : String
+ @[JSON::Field(key: "endDt")]
+ property end_dt : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/resource.cr b/drivers/twenty_five_live_pro/models/expanded/resource.cr
new file mode 100644
index 00000000000..3b8b937d5f5
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/resource.cr
@@ -0,0 +1,18 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Resource
+ include JSON::Serializable
+
+ @[JSON::Field(key: "resourceId")]
+ property resource_id : Int64
+ @[JSON::Field(key: "etag")]
+ property etag : String
+ @[JSON::Field(key: "resourceName")]
+ property resource_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/role.cr b/drivers/twenty_five_live_pro/models/expanded/role.cr
new file mode 100644
index 00000000000..bfcb073655b
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/role.cr
@@ -0,0 +1,18 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Role
+ include JSON::Serializable
+
+ @[JSON::Field(key: "roleId")]
+ property role_id : Int64
+ @[JSON::Field(key: "etag")]
+ property etag : String
+ @[JSON::Field(key: "roleName")]
+ property role_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/space.cr b/drivers/twenty_five_live_pro/models/expanded/space.cr
new file mode 100644
index 00000000000..3cc0a24a7d2
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/space.cr
@@ -0,0 +1,22 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct Space
+ include JSON::Serializable
+
+ @[JSON::Field(key: "spaceId")]
+ property space_id : Int64
+ @[JSON::Field(key: "etag")]
+ property etag : String
+ @[JSON::Field(key: "spaceName")]
+ property space_name : String
+ @[JSON::Field(key: "spaceFormalName")]
+ property space_formal_name : String
+ @[JSON::Field(key: "maxCapacity")]
+ property max_capacity : Int64
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/expanded/state.cr b/drivers/twenty_five_live_pro/models/expanded/state.cr
new file mode 100644
index 00000000000..a578604085c
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/expanded/state.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ module Expanded
+ struct State
+ include JSON::Serializable
+
+ @[JSON::Field(key: "state")]
+ property state : Int64
+ @[JSON::Field(key: "stateName")]
+ property state_name : String
+ end
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/feature.cr b/drivers/twenty_five_live_pro/models/feature.cr
new file mode 100644
index 00000000000..42f30dd6c55
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/feature.cr
@@ -0,0 +1,14 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Feature
+ include JSON::Serializable
+
+ @[JSON::Field(key: "featureId")]
+ property feature_id : Int64
+ @[JSON::Field(key: "quantity")]
+ property quantity : Int64
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/layout.cr b/drivers/twenty_five_live_pro/models/layout.cr
new file mode 100644
index 00000000000..8831af0f9ff
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/layout.cr
@@ -0,0 +1,20 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Layout
+ include JSON::Serializable
+
+ @[JSON::Field(key: "layoutId")]
+ property layout_id : Int64
+ @[JSON::Field(key: "defaultLayout")]
+ property default_layout : Bool
+ @[JSON::Field(key: "layoutPhotoId")]
+ property layout_photo_id : Int64?
+ @[JSON::Field(key: "layoutDiagramId")]
+ property layout_diagram_id : Int64?
+ @[JSON::Field(key: "layoutCapacity")]
+ property layout_capacity : Int64
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/organization.cr b/drivers/twenty_five_live_pro/models/organization.cr
new file mode 100644
index 00000000000..87986f208a9
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/organization.cr
@@ -0,0 +1,27 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Organization
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "organizationName")]
+ property organization_name : String
+
+ @[JSON::Field(key: "organizationTitle")]
+ property organization_title : String?
+
+ @[JSON::Field(key: "organizationTypeId")]
+ property organization_type_id : Int64?
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/organization_detail.cr b/drivers/twenty_five_live_pro/models/organization_detail.cr
new file mode 100644
index 00000000000..fbbbd34d122
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/organization_detail.cr
@@ -0,0 +1,73 @@
+require "json"
+
+require "./expanded/organization_type"
+require "./expanded/category"
+
+module TwentyFiveLivePro
+ module Models
+ struct OrganizationDetail
+ include JSON::Serializable
+
+ struct Content
+ include JSON::Serializable
+
+ @[JSON::Field(key: "requestId")]
+ property request_id : Int64
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ struct Data
+ include JSON::Serializable
+
+ struct Organization
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "organizationName")]
+ property organization_name : String
+
+ @[JSON::Field(key: "organizationTitle")]
+ property organization_title : String
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ @[JSON::Field(key: "organizationTypeId")]
+ property organization_type_id : Int64
+ end
+
+ @[JSON::Field(key: "items")]
+ property items : Array(Organization)
+ end
+
+ @[JSON::Field(key: "data")]
+ property data : Data
+
+ struct ExpandedInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "organizationTypes")]
+ property organization_types : Array(Expanded::OrganizationType)?
+
+ @[JSON::Field(key: "organizationCategories")]
+ property organization_categories : Array(Expanded::Category)?
+ end
+
+ @[JSON::Field(key: "expandedInfo")]
+ property expanded_info : Array(ExpandedInfo)?
+ end
+
+ @[JSON::Field(key: "content")]
+ property content : Content
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/paginated_response.cr b/drivers/twenty_five_live_pro/models/paginated_response.cr
new file mode 100644
index 00000000000..bda9ef696c5
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/paginated_response.cr
@@ -0,0 +1,45 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct PaginatedResponse
+ include JSON::Serializable
+
+ struct Content
+ include JSON::Serializable
+
+ struct Data
+ include JSON::Serializable
+ include JSON::Serializable::Unmapped
+
+ @[JSON::Field(key: "paginateKey")]
+ property paginate_key : Int64
+
+ @[JSON::Field(key: "pageIndex")]
+ property page_index : Int64
+
+ @[JSON::Field(key: "totalPages")]
+ property total_pages : Int64
+
+ @[JSON::Field(key: "totalItems")]
+ property total_items : Int64
+
+ @[JSON::Field(key: "currentItemCount")]
+ property current_item_count : Int64
+
+ @[JSON::Field(key: "itemsPerPage")]
+ property items_per_page : Int64
+
+ @[JSON::Field(key: "pagingLinkTemplate")]
+ property paging_link_template : String
+ end
+
+ @[JSON::Field(field: "data")]
+ property data : Data
+ end
+
+ @[JSON::Field(field: "content")]
+ property content : Content
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/reservations.cr b/drivers/twenty_five_live_pro/models/reservations.cr
new file mode 100644
index 00000000000..985e5c02acb
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/reservations.cr
@@ -0,0 +1,146 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Reservations
+ include JSON::Serializable
+
+ @[JSON::Field(key: "engine")]
+ property engine : String?
+
+ struct Data
+ include JSON::Serializable
+
+ @[JSON::Field(key: "post_event_dt")]
+ property post_event_dt : Date
+
+ @[JSON::Field(key: "registration_url")]
+ property registration_url : String
+
+ @[JSON::Field(key: "event_end_dt")]
+ property event_end_dt : Date
+
+ @[JSON::Field(key: "profile_description")]
+ property profile_description : String
+
+ @[JSON::Field(key: "profile_name")]
+ property profile_name : String?
+
+ @[JSON::Field(key: "reservation_comment_id")]
+ property reservation_comment_id : String?
+
+ @[JSON::Field(key: "expected_count")]
+ property expected_count : Int64
+
+ @[JSON::Field(key: "reservation_state_name")]
+ property reservation_state_name : String?
+
+ @[JSON::Field(key: "last_mod_dt")]
+ property last_mod_dt : Date
+
+ struct Space
+ include JSON::Serializable
+
+ @[JSON::Field(key: "default_layout_capacity")]
+ property default_layout_capacity : String?
+
+ @[JSON::Field(key: "shared")]
+ property shared : String?
+
+ @[JSON::Field(key: "layout_id")]
+ property layout_id : Int64
+
+ @[JSON::Field(key: "layout_name")]
+ property layout_name : String?
+
+ @[JSON::Field(key: "space_instructions")]
+ property space_instructions : String?
+
+ @[JSON::Field(key: "space_name")]
+ property space_name : String?
+
+ @[JSON::Field(key: "space_instruction_id")]
+ property space_instruction_id : String?
+
+ @[JSON::Field(key: "selected_layout_capacity")]
+ property selected_layout_capacity : Int64
+
+ @[JSON::Field(key: "actual_count")]
+ property actual_count : String?
+
+ @[JSON::Field(key: "space_id")]
+ property space_id : Int64
+
+ @[JSON::Field(key: "formal_name")]
+ property formal_name : String?
+ end
+
+ @[JSON::Field(key: "space_reservation")]
+ property space_reservation : Space
+
+ @[JSON::Field(key: "event_title")]
+ property event_title : String?
+
+ @[JSON::Field(key: "reservation_state")]
+ property reservation_state : Int64
+
+ @[JSON::Field(key: "event_locator")]
+ property event_locator : String?
+
+ @[JSON::Field(key: "organization_name")]
+ property organization_name : String?
+
+ @[JSON::Field(key: "event_type_class")]
+ property event_type_class : String?
+
+ @[JSON::Field(key: "event_type_name")]
+ property event_type_name : String?
+
+ @[JSON::Field(key: "reservation_start_dt")]
+ property reservation_start_dt : Date
+
+ @[JSON::Field(key: "reservation_comments")]
+ property reservation_comments : String?
+
+ @[JSON::Field(key: "reservation_id")]
+ property reservation_id : Int64
+
+ @[JSON::Field(key: "pre_event_dt")]
+ property pre_event_dt : Date
+
+ @[JSON::Field(key: "event_id")]
+ property event_id : Int64
+
+ @[JSON::Field(key: "profile_id")]
+ property profile_id : Int64
+
+ @[JSON::Field(key: "organization_id")]
+ property organization_id : Int64
+
+ @[JSON::Field(key: "reservation_end_dt")]
+ property reservation_end_dt : Date
+
+ @[JSON::Field(key: "registered_count")]
+ property registered_count : Int64
+
+ @[JSON::Field(key: "last_mod_user")]
+ property last_mod_user : String?
+
+ @[JSON::Field(key: "event_name")]
+ property event_name : String?
+
+ @[JSON::Field(key: "event_start_dt")]
+ property event_start_dt : Date
+
+ @[JSON::Field(key: "registration_label")]
+ property registration_label : String?
+ end
+
+ @[JSON::Field(key: "reservation")]
+ property reservation : Array(Data)
+ end
+
+ @[JSON::Field(key: "Reservations")]
+ property reservations : Reservations
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/resource.cr b/drivers/twenty_five_live_pro/models/resource.cr
new file mode 100644
index 00000000000..f82ba29050c
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/resource.cr
@@ -0,0 +1,24 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Resource
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "resourceName")]
+ property resource_name : String
+
+ @[JSON::Field(key: "canRequest")]
+ property can_request : Bool
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/resource_detail.cr b/drivers/twenty_five_live_pro/models/resource_detail.cr
new file mode 100644
index 00000000000..c6e0b53b46b
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/resource_detail.cr
@@ -0,0 +1,67 @@
+require "json"
+
+require "./expanded/category.cr"
+require "./expanded/attribute.cr"
+
+module TwentyFiveLivePro
+ module Models
+ struct ResourceDetail
+ include JSON::Serializable
+
+ struct Content
+ include JSON::Serializable
+
+ @[JSON::Field(key: "requestId")]
+ property request_id : Int64
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ struct Data
+ include JSON::Serializable
+
+ struct Resource
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "resourceName")]
+ property resource_name : String
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+ end
+
+ @[JSON::Field(key: "items")]
+ property items : Array(Resource)
+ end
+
+ @[JSON::Field(key: "data")]
+ property data : Data
+
+ struct ExpandedInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "categories")]
+ property categories : Array(Expanded::Category)?
+
+ @[JSON::Field(key: "attributes")]
+ property attributes : Array(Expanded::Attribute)?
+ end
+
+ @[JSON::Field(key: "expandedInfo")]
+ property expanded_info : Array(ExpandedInfo)?
+ end
+
+ @[JSON::Field(key: "content")]
+ property content : Content
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/role.cr b/drivers/twenty_five_live_pro/models/role.cr
new file mode 100644
index 00000000000..990612cfa30
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/role.cr
@@ -0,0 +1,15 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Role
+ include JSON::Serializable
+
+ @[JSON::Field(key: "roleId")]
+ property role_id : Int64
+
+ @[JSON::Field(key: "contactId")]
+ property contact_id : Int64
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/space.cr b/drivers/twenty_five_live_pro/models/space.cr
new file mode 100644
index 00000000000..82bde03fa7a
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/space.cr
@@ -0,0 +1,30 @@
+require "json"
+
+module TwentyFiveLivePro
+ module Models
+ struct Space
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "spaceName")]
+ property space_name : String
+
+ @[JSON::Field(key: "spaceFormalName")]
+ property space_formal_name : String?
+
+ @[JSON::Field(key: "maxCapacity")]
+ property max_capacity : Int64
+
+ @[JSON::Field(key: "canRequest")]
+ property can_request : Bool
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/models/space_detail.cr b/drivers/twenty_five_live_pro/models/space_detail.cr
new file mode 100644
index 00000000000..113d0ec5dc6
--- /dev/null
+++ b/drivers/twenty_five_live_pro/models/space_detail.cr
@@ -0,0 +1,99 @@
+require "json"
+
+require "./expanded/**"
+
+module TwentyFiveLivePro
+ module Models
+ struct SpaceDetail
+ include JSON::Serializable
+
+ struct Content
+ include JSON::Serializable
+
+ @[JSON::Field(key: "requestId")]
+ property request_id : Int64
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ struct Data
+ include JSON::Serializable
+
+ struct Space
+ include JSON::Serializable
+
+ @[JSON::Field(key: "kind")]
+ property kind : String
+
+ @[JSON::Field(key: "id")]
+ property id : Int64
+
+ @[JSON::Field(key: "etag")]
+ property etag : String
+
+ @[JSON::Field(key: "spaceName")]
+ property space_name : String
+
+ @[JSON::Field(key: "spaceFormalName")]
+ property space_formal_name : String?
+
+ @[JSON::Field(key: "maxCapacity")]
+ property max_capacity : Int64
+
+ @[JSON::Field(key: "updated")]
+ property updated : String
+
+ @[JSON::Field(key: "layouts")]
+ property layouts : Array(Layout)?
+
+ @[JSON::Field(key: "features")]
+ property features : Array(Feature)?
+
+ @[JSON::Field(key: "categories")]
+ property categories : Array(Category)?
+
+ @[JSON::Field(key: "attributes")]
+ property attributes : Array(Attribute)?
+
+ @[JSON::Field(key: "roles")]
+ property roles : Array(Role)?
+ end
+
+ @[JSON::Field(key: "items")]
+ property items : Array(Space)
+ end
+
+ @[JSON::Field(key: "data")]
+ property data : Data
+
+ struct ExpandedInfo
+ include JSON::Serializable
+
+ @[JSON::Field(key: "layouts")]
+ property layouts : Array(Expanded::Layout)?
+
+ @[JSON::Field(key: "features")]
+ property features : Array(Expanded::Feature)?
+
+ @[JSON::Field(key: "categories")]
+ property categories : Array(Expanded::Category)?
+
+ @[JSON::Field(key: "attributes")]
+ property attributes : Array(Expanded::Attribute)?
+
+ @[JSON::Field(key: "roles")]
+ property roles : Array(Expanded::Role)?
+
+ @[JSON::Field(key: "contacts")]
+ property contacts : Array(Expanded::Contact)?
+ end
+
+ @[JSON::Field(key: "expandedInfo")]
+ property expanded_info : ExpandedInfo?
+ end
+
+ @[JSON::Field(key: "content")]
+ property content : Content
+ end
+ end
+end
diff --git a/drivers/twenty_five_live_pro/room_schedule.cr b/drivers/twenty_five_live_pro/room_schedule.cr
new file mode 100644
index 00000000000..f01798f8a8b
--- /dev/null
+++ b/drivers/twenty_five_live_pro/room_schedule.cr
@@ -0,0 +1,181 @@
+require "placeos-driver"
+require "./models/**"
+
+class TwentyFiveLivePro::RoomSchedule < PlaceOS::Driver
+ descriptive_name "25Live Pro Room Schedule Logic"
+ generic_name :RoomSchedule
+ description %(Polls 25Live Pro API Module to expose bookings relevant for the selected System)
+
+ default_settings({
+ twenty_five_live_pro_space_id: "set 25Live Pro Space ID here",
+ polling_cron: "*/15 * * * *",
+ debug: false,
+ })
+
+ accessor twenty_five_live_pro : API_1
+
+ @space_id : String = "set 25Live Pro Space ID here"
+ @cron_string : String = "*/15 * * * *"
+ @debug : Bool = false
+ @next_countdown : PlaceOS::Driver::Proxy::Scheduler::TaskWrapper? = nil
+ @request_lock : Mutex = Mutex.new
+ @request_running : Bool = false
+
+ def on_update
+ @debug = setting(Bool, :debug) || false
+ @space_id = setting(String, :twenty_five_live_pro_space_id)
+ @cron_string = setting(String, :polling_cron)
+ schedule.clear
+ schedule.cron(@cron_string, immediate: true) { fetch_and_expose_todays_events }
+ end
+
+ def fetch_and_expose_todays_events
+ return if @request_running
+
+ @request_lock.synchronize do
+ begin
+ @request_running = true
+ @next_countdown.try &.cancel
+ @next_countdown = nil
+ today = Time.local
+ todays_events = fetch_events(today.to_s("%Y-%m-%d"), today.to_s("%Y-%m-%d"), today.to_s("%Y%m%d"))
+
+ # Determine which events contain other events
+ todays_events.sort_by(&.date.duration).reverse!
+
+ todays_events.each_with_index do |e, i|
+ if todays_events.skip(i + 1).find { |f| contains?(e, f) }
+ e.container = true
+ else
+ e.container = false
+ end
+ end
+
+ current_and_past_events, future_events = todays_events.partition { |e| Time.local > e.date.start_date }
+ current_events, past_events = current_and_past_events.partition { |e| in_progress?(e) }
+
+ if @debug
+ self[:todays_upcoming_events] = future_events
+ self[:todays_past_events] = past_events
+ end
+
+ next_event = future_events.min_by? &.date.start_date
+ previous_event = past_events.max_by? &.date.end_date
+ current_event = current_events.find { |e| !e.container }
+ current_container_event = current_events.find(&.container)
+
+ update_event_details(previous_event, current_event, next_event)
+ advance_countdowns(previous_event, current_event, next_event, current_container_event)
+ todays_events
+ ensure
+ @request_running = false
+ end
+ end
+ end
+
+ def fetch_events(start_date : String, end_date : String, since : String)
+ relevant_events = [] of Models::Event
+ events = Array(Models::Event).from_json(twenty_five_live_pro.list_events(1, 100, since, nil).get.not_nil!.to_json)
+
+ events.each do |event|
+ details = Models::EventDetail.from_json(twenty_five_live_pro.get_event_details(event.id, ["all"], ["all"]).get.not_nil!.to_json)
+
+ if expanded_info = details.content.expanded_info
+ if spaces = expanded_info.spaces
+ next if spaces.empty?
+
+ if @space_id == spaces.first.space_id
+ if event_data = details.content.data
+ if event_items = event_data.items
+ next if event_items.empty?
+
+ event_items.each do |event_item|
+ if date = event_item.date
+ if date.start_date.to_rfc3339.includes?(start_date) && date.end_date.to_rfc3339.includes?(start_date)
+ relevant_events.push(Models::Event.from_json(event_item.to_json))
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ relevant_events
+ end
+
+ private def update_event_details(previous_event : Models::Event | Nil = nil, current_event : Models::Event | Nil = nil, next_event : Models::Event | Nil = nil)
+ if previous_event
+ self[:previous_event_ends_at] = previous_event.date.end_date
+ self[:previous_event_was_container] = previous_event.container
+ self[:previous_event_id] = previous_event.id if @debug
+ end
+
+ if current_event
+ self[:current_event_starts_at] = current_event.date.start_date
+ self[:current_event_ends_at] = current_event.date.end_date
+ self[:current_event_id] = current_event.id if @debug
+ self[:current_event_description] = current_event.name if @debug
+ end
+
+ if next_event
+ self[:next_event_starts_at] = next_event.date.start_date
+ self[:next_event_is_container] = next_event.container
+ self[:next_event_id] = next_event.id if @debug
+ end
+ end
+
+ private def advance_countdowns(previous : Models::Event | Nil, current : Models::Event | Nil, next_event : Models::Event | Nil, container : Models::Event | Nil)
+ previous ? countup_previous_event(previous) : (self[:minutes_since_previous_event] = nil)
+ next_event_started = next_event ? countdown_next_event(next_event) : (self[:minutes_til_next_event] = nil)
+ current_event_ended = current ? countdown_current_event(current) : (self[:minutes_since_current_event_started] = self[:minutes_til_current_event_ends] = nil)
+
+ logger.debug { "Next event started? #{next_event_started}\nCurrent event ended? #{current_event_ended}" } if @debug
+ @next_countdown = if next_event_started || current_event_ended
+ schedule.in(1.minutes) { fetch_and_expose_todays_events.as(Array(Models::Event)) }
+ else
+ schedule.in(1.minutes) { advance_countdowns(previous, current, next_event, container).as(Bool) }
+ end
+
+ self[:event_in_progress] = current ? in_progress?(current) : false
+ self[:container_event_in_progess] = container ? in_progress?(container) : false
+ self[:no_upcoming_events] = next_event.nil?
+ end
+
+ private def countup_previous_event(previous : Models::Event)
+ time_since_previous = Time.local - previous.date.end_date
+ self[:minutes_since_previous_event] = time_since_previous.total_minutes.to_i
+ end
+
+ private def countdown_next_event(next_event : Models::Event)
+ time_til_next = next_event.date.start_date - Time.local
+ self[:minutes_til_next_event] = time_til_next.total_minutes.to_i
+ # return whether the next event has started
+ Time.local >= next_event.date.start_date
+ end
+
+ private def countdown_current_event(current : Models::Event)
+ time_since_start = Time.local - current.date.start_date
+ time_til_end = current.date.end_date - Time.local
+ self[:minutes_since_current_event_started] = time_since_start.total_minutes.to_i
+ self[:minutes_til_current_event_ends] = time_til_end.total_minutes.to_i
+ # return whether the current event has ended
+ Time.local > current.date.end_date
+ end
+
+ private def in_progress?(event : Models::Event)
+ now = Time.local
+ now >= event.date.start_date && now <= event.date.end_date
+ end
+
+ # Does a contain b?
+ private def contains?(a : Models::Event, b : Models::Event)
+ b.date.start_date >= a.date.start_date && b.date.end_date <= a.date.end_date
+ end
+
+ private def overlaps?(a : Models::Event, b : Models::Event)
+ b.date.start_date < a.date.end_date || b.date.end_date > a.date.start_date
+ end
+end
diff --git a/drivers/ubipark/api.cr b/drivers/ubipark/api.cr
new file mode 100644
index 00000000000..2a32bacbee3
--- /dev/null
+++ b/drivers/ubipark/api.cr
@@ -0,0 +1,152 @@
+require "placeos-driver"
+require "json"
+
+module UbiPark
+ class API < PlaceOS::Driver
+ descriptive_name "UbiPark API Gateway"
+ generic_name :Bookings
+ uri_base "https://api-data.ubipark.com/"
+
+ default_settings({
+ api_key: "WWvT7qvWd2ZcQmo67CutkyAvbGumG4b7",
+ tenant_id: 0,
+ api_version: "v1.0",
+ user_agent: "PlaceOS",
+ })
+
+ def on_load
+ on_update
+ end
+
+ @api_key : String = "WWvT7qvWd2ZcQmo67CutkyAvbGumG4b7"
+ @tenant_id : Int32 = 0
+
+ @api_version : String = "v1.0"
+
+ @user_agent : String = "PlaceOS"
+
+ def on_update
+ @api_key = setting(String, :api_key)
+ @tenant_id = setting(Int32, :tenant_id)
+
+ @api_version = setting(String, :api_version)
+
+ @user_agent = setting?(String, :user_agent) || "PlaceOS"
+ end
+
+ def list_users(max_records : Int32, offset : Int32, from_last_modified_time : String)
+ body = {
+ "maxRecords" => max_records,
+ "offset" => offset,
+ "fromLastModifiedTime" => from_last_modified_time,
+ }.to_json
+
+ response = http("GET", "/data/export/#{@api_version}/user/list", body: body, headers: HTTP::Headers{"X-ApiKey" => @api_key, "X-ApiTenantID" => @tenant_id.to_s, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ logger.debug { "response status code: #{response.status_code}" }
+ logger.debug { "response body:\n#{response.body}" }
+
+ unless response.success?
+ self[:error] = "The response returned by the server had a status code of #{response.status_code}, see the logs for the response body"
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ JSON.parse(response.body)
+ end
+
+ def list_userpermits(max_records : Int32, offset : Int32, from_last_modified_time : String, car_park_id : Int32, user_id : Int32)
+ body = {
+ "maxRecords" => max_records,
+ "offset" => offset,
+ "fromLastModifiedTime" => from_last_modified_time,
+ "carParkId" => car_park_id,
+ "userId" => user_id,
+ }.to_json
+
+ response = http("GET", "/data/export/#{@api_version}/userpermit/list", body: body, headers: HTTP::Headers{"X-ApiKey" => @api_key, "X-ApiTenantID" => @tenant_id.to_s, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ unless response.success?
+ self[:error] = "The response returned by the server had a status code of #{response.status_code}, see the logs for the response body"
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ logger.debug { "response status code: #{response.status_code}" }
+ logger.debug { "response body:\n#{response.body}" }
+
+ unless response.success?
+ self[:error] = "The response returned by the server had a status code of #{response.status_code}, see the logs for the response body"
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ JSON.parse(response.body)
+ end
+
+ def list_products(car_park_id : Int32?, tenant_id : Int32?)
+ query = [] of String
+
+ query.push("carParkID=#{car_park_id}") unless car_park_id.nil?
+ query.push("tenantID=#{tenant_id}") unless tenant_id.nil?
+
+ url = query.size > 0 ? "/api/payment/productList?#{query.join("&")}" : "/api/payment/productList"
+
+ response = http("GET", url, headers: HTTP::Headers{"X-ApiKey" => @api_key, "X-ApiTenantID" => @tenant_id.to_s, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ logger.debug { "response status code: #{response.status_code}" }
+ logger.debug { "response body:\n#{response.body}" }
+
+ unless response.success?
+ self[:error] = "The response returned by the server had a status code of #{response.status_code}, see the logs for the response body"
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ JSON.parse(response.body)
+ end
+
+ def list_reasons(tenant_id : Int32?)
+ query = [] of String
+
+ query.push("tenantID=#{tenant_id}") unless tenant_id.nil?
+
+ url = query.size > 0 ? "/api/payment/reasonList?#{query.join("&")}" : "/api/payment/productList"
+
+ response = http("GET", url, headers: HTTP::Headers{"X-ApiKey" => @api_key, "X-ApiTenantID" => @tenant_id.to_s, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ logger.debug { "response status code: #{response.status_code}" }
+ logger.debug { "response body:\n#{response.body}" }
+
+ unless response.success?
+ self[:error] = "The response returned by the server had a status code of #{response.status_code}, see the logs for the response body"
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ JSON.parse(response.body)
+ end
+
+ def make_payment(payment_id : String, promise_pay_card_name : String, user_id : String, tenant_id : Int32, product_id : String, from_date : String, to_date : String, amount : Float64)
+ raise "amount can't be less than zero" if amount < 0
+
+ body = {
+ "paymentID" => payment_id,
+ "promisePayCardName" => promise_pay_card_name,
+ "userID" => user_id,
+ "tenantID" => tenant_id,
+ "productID" => product_id,
+ "fromDate" => from_date,
+ "toDate" => to_date,
+ "amount" => ("%.2f" % amount).to_f64,
+ }.to_json
+
+ response = post("/api/payment/makepayment", body: body, headers: HTTP::Headers{"X-ApiKey" => @api_key, "X-ApiTenantID" => @tenant_id.to_s, "User-Agent" => @user_agent, "Content-Type" => "application/json"})
+
+ logger.debug { "response status code: #{response.status_code}" }
+ logger.debug { "response body:\n#{response.body}" }
+
+ unless response.success?
+ self[:error] = "The response returned by the server had a status code of #{response.status_code}, see the logs for the response body"
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+
+ JSON.parse(response.body)
+ end
+ end
+end
diff --git a/drivers/ubipark/api_spec.cr b/drivers/ubipark/api_spec.cr
new file mode 100644
index 00000000000..3453f95a1dd
--- /dev/null
+++ b/drivers/ubipark/api_spec.cr
@@ -0,0 +1,466 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "UbiPark::API" do
+ # Users
+
+ list_users = exec(:list_users, 10, 0, String.new)
+
+ expect_http_request do |request, response|
+ case "#{request.path}"
+ when "/data/export/v1.0/user/list"
+ response.status_code = 200
+ response << %([
+ {
+ "RecordCount": 453,
+ "MoreResultsAvailable": true,
+ "Users": [
+ {
+ "userId": 9717,
+ "firstName": "John",
+ "lastName": "Smith",
+ "phone": "Smith",
+ "email": "johnsmith@ubipark.com",
+ "hasCreditCard": true,
+ "marketing": true,
+ "updated": "true"
+ }
+ ]
+ }
+ ])
+ else
+ response.status_code = 500
+ response << "expected get space details request"
+ end
+ end
+
+ users = list_users.get.not_nil!
+ users.as_a.first.["RecordCount"].as_i.should eq 453
+
+ # User permits
+
+ list_userpermits = exec(:list_userpermits, 0, 0, String.new, 0, 0)
+
+ expect_http_request do |request, response|
+ case "#{request.path}"
+ when "/data/export/v1.0/userpermit/list"
+ response.status_code = 200
+ response << %([
+ {
+ "RecordCount": 453,
+ "MoreResultsAvailable": true,
+ "UserPermits": [
+ {
+ "userPermitId": 10917,
+ "userId": 9717,
+ "email": "string",
+ "emailConfirmed": true,
+ "firstName": "string",
+ "lastName": "string",
+ "phoneNo": "string",
+ "permitId": 0,
+ "permitName": "string",
+ "carParkId": 0,
+ "carParkName": "string",
+ "approvalStatus": 0,
+ "approvedBy": 0,
+ "approved": "2024-04-11T14:48:28.487Z",
+ "message": "string",
+ "notes": "string",
+ "effectiveFrom": "2024-04-11T14:48:28.487Z",
+ "effectiveTo": "2024-04-11T14:48:28.487Z",
+ "emailed": true,
+ "notified": true,
+ "userConfirmationRequired": true,
+ "acknowledged": true,
+ "paidTo": "2024-04-11T14:48:28.487Z",
+ "paidToUTC": "2024-04-11T14:48:28.487Z",
+ "address": "string",
+ "suburb": "string",
+ "stateId": 0,
+ "postcode": "string",
+ "ContactAddress": "string",
+ "licensePlate": "string",
+ "vehicleStateId": 0,
+ "vehicleMake": "string",
+ "vehicleModel": "string",
+ "vehicleColour": "string",
+ "courtesyPeriod": true,
+ "expiryEmailed": true,
+ "expiryNotified": true,
+ "active": true,
+ "inserted": "2024-04-11T14:48:28.487Z",
+ "insertedBy": 0,
+ "updated": "true",
+ "updatedBy": 0,
+ "tenantId": 0,
+ "permitNotes": "string",
+ "editVehicle": true,
+ "editVehicleHours": 0,
+ "editVehicleText": "string",
+ "editVehicleTooltip": "string",
+ "vehicleCountryId": 0,
+ "paymentPostcode": "string",
+ "paymentOccupants": 0,
+ "reminderEmailed": true,
+ "reminderNotified": true,
+ "vehicleStateCode": "string",
+ "permitCreated": "2024-04-11T14:48:28.488Z",
+ "permitModified": "2024-04-11T14:48:28.488Z",
+ "timezoneKey": "string",
+ "permitType": 0,
+ "partPayment": true,
+ "permitExternalRef": "string",
+ "airlineId": 0,
+ "permitReasonId": 0,
+ "externalRef": "string",
+ "externalUserId": "string",
+ "permitState": 0,
+ "stateCode": "string",
+ "permitGroupId": 0,
+ "permitGroupCode": "string",
+ "qRCode": true,
+ "reservedBays": true,
+ "userCancel": true,
+ "userCancelHours1": 0,
+ "userCancelFee1": 0,
+ "userCancelHours2": 0,
+ "userCancelFee2": 0,
+ "Overstay": true,
+ "OverstayEmailed": true,
+ "OverstayNotified": true,
+ "OverstayPayment": true,
+ "StaffId": "string",
+ "userChange": true,
+ "UserChangeHours": 0,
+ "userChangeTooltip": "string",
+ "userChangeText": "string",
+ "staffIdDisplay": true,
+ "staffIdRequired": true,
+ "permitSchedule": true,
+ "schedule": true,
+ "Mom": true,
+ "monFrom": "2024-04-11T14:48:28.488Z",
+ "monTo": "2024-04-11T14:48:28.488Z",
+ "tue": true,
+ "tueFrom": "2024-04-11T14:48:28.488Z",
+ "tueTo": "2024-04-11T14:48:28.488Z",
+ "wed": true,
+ "wedFrom": "2024-04-11T14:48:28.488Z",
+ "wedTo": "2024-04-11T14:48:28.488Z",
+ "thu": true,
+ "thuFrom": "2024-04-11T14:48:28.488Z",
+ "thuTo": "2024-04-11T14:48:28.488Z",
+ "fri": true,
+ "friFrom": "2024-04-11T14:48:28.488Z",
+ "friTo": "2024-04-11T14:48:28.488Z",
+ "sat": true,
+ "satFrom": "2024-04-11T14:48:28.488Z",
+ "satTo": "2024-04-11T14:48:28.488Z",
+ "sun": true,
+ "sunFrom": "2024-04-11T14:48:28.488Z",
+ "sunTo": "2024-04-11T14:48:28.488Z",
+ "versionNo": 0,
+ "reason": "string",
+ "attachment": "string",
+ "fileName": "string",
+ "contentType": "string",
+ "promoCodeId": 0,
+ "promoCodeOneTimeUseId": 0,
+ "promoCode": "string",
+ "captureNumberPlate": true,
+ "numberPlateRequired": true,
+ "confirmLicensePlate": true,
+ "groupPermit": true,
+ "organisationName": "string",
+ "organisationPrincipal": "string",
+ "organiserFirstName": "string",
+ "organiserLastName": "string",
+ "organiserMobileNo": "string",
+ "organiserEmail": "string",
+ "contactPhoneNo": "string",
+ "contactFaxNo": "string",
+ "agencyName": "string",
+ "agencyPhone": "string",
+ "agencyContactName": "string",
+ "agencyEmailAddress": "string",
+ "vehicleTypeId": 0,
+ "noChildren": 0,
+ "NoAdults": 0,
+ "noUnder5": 0,
+ "entryID": 0,
+ "noVehicles": 0,
+ "transportCompanyName": "string",
+ "groupPermitTypeId": 0,
+ "multiDay": true,
+ "customText": "string",
+ "bayLabel": "string",
+ "courtesyPeriodTooltip": "string",
+ "courtesyPeriodText": "string",
+ "courtesyPeriodNotValidText": "string",
+ "zoneCode": "string",
+ "permitAreaCode": "string",
+ "siteCode": "string",
+ "uniqueId": "string",
+ "userCancelDays": 0,
+ "userCancelHours": 0,
+ "vehicleCountryCode": "string",
+ "permitCourtesyPeriod": true,
+ "courtesyPeriodTime": "2024-04-11T14:48:28.488Z",
+ "startGraceDays": 0,
+ "startGraceMins": 0,
+ "endGraceDays": 0,
+ "endGraceMins": 0,
+ "userRefund": true,
+ "userRefundFee": 0,
+ "uploadDocument": true,
+ "uploadDocumentLabel": "string",
+ "documentUploadRequired": true,
+ "documentDeleteAfterApproval": true,
+ "overnight": true,
+ "noSeats": 0,
+ "leaderFirstName": "string",
+ "leaderLastName": "string",
+ "leaderMobileNo": "string",
+ "leaderEmail": "string",
+ "appID": 0,
+ "customAdminValue": "string"
+ }
+ ]
+ }
+ ])
+ else
+ response.status_code = 500
+ response << "expected get space details request"
+ end
+ end
+
+ user_permits = list_userpermits.get.not_nil!
+ user_permits.as_a.first.["RecordCount"].as_i.should eq 453
+
+ # Products
+
+ list_products = exec(:list_products, 1, nil)
+
+ expect_http_request do |request, response|
+ case "#{request.path}"
+ when "/api/payment/productList"
+ response.status_code = 200
+ response << %([
+ {
+ "productID": "VALET",
+ "description": "Premium Valet",
+ "tenantID": 321,
+ "carParkName": "Long Term Car Park",
+ "carParkID": 321,
+ "groupId": 321,
+ "groupCode": "VALET",
+ "groupDescription": "Valet Parking",
+ "default": false,
+ "permitType": 2,
+ "yearType": 0,
+ "yearDate": "2024-04-11T14:54:02.728Z",
+ "monthType": 0,
+ "monthDay": 0,
+ "monthWeek": 0,
+ "monthWeekDay": 0,
+ "weekType": 0,
+ "weekDay": 0,
+ "multiDay": false,
+ "recurring": false,
+ "autoRenew": false,
+ "autoApprove": false,
+ "reminder": true,
+ "reminderDays": true,
+ "daysInAdvance": true,
+ "effectiveFrom": "2020-02-01 00:00",
+ "effectiveTo": "2025-02-01 00:00",
+ "minPurchaseDate": "2025-02-01 00:00",
+ "maxPurchaseDate": "2025-02-01 00:00",
+ "previousDays": 0,
+ "notes": "Billed every month in advance. Must park in speacial section.",
+ "maxAvailable": 40,
+ "addressRequired": false,
+ "contactAddressRequired": false,
+ "confirmLicensePlate": false,
+ "vehicleDetailsRequired": false,
+ "postPaymentRequired": false,
+ "postcodeRequired": false,
+ "occupantsRequired": false,
+ "promoCode": true,
+ "airlineDisplay": true,
+ "airlineRequired": true,
+ "permitReasonDisplay": true,
+ "permitReasonRequired": true,
+ "maxPurchaseDays": 12,
+ "qrCode": true,
+ "reservedBays": true,
+ "overstayCharge": false,
+ "staffIDRequired": false,
+ "schedule": false,
+ "userAccountRequired": false,
+ "captureNumberPlate": false,
+ "numberPlateRequired": false,
+ "groupPermit": false,
+ "paymentMessage": "false",
+ "displayIfZeroAmount": false,
+ "displayTermsAndConditions": false,
+ "displayMarketing": false,
+ "customText": "false",
+ "customTextDisplay": false,
+ "customTextRequired": false,
+ "customWarningDisplay": false,
+ "customWarning": "Details are required.",
+ "eventType": "false",
+ "eventDates": "false",
+ "eventStartDate": "false",
+ "eventEndDate": "false",
+ "timezone": "AUS Eastern Standard Time",
+ "closedDays": false
+ }
+ ])
+ when "/api/payment/productList?#{request.query}"
+ response.status_code = 200
+ response << %([
+ {
+ "productID": "VALET",
+ "description": "Premium Valet",
+ "tenantID": 321,
+ "carParkName": "Long Term Car Park",
+ "carParkID": 321,
+ "groupId": 321,
+ "groupCode": "VALET",
+ "groupDescription": "Valet Parking",
+ "default": false,
+ "permitType": 2,
+ "yearType": 0,
+ "yearDate": "2024-04-11T14:54:02.728Z",
+ "monthType": 0,
+ "monthDay": 0,
+ "monthWeek": 0,
+ "monthWeekDay": 0,
+ "weekType": 0,
+ "weekDay": 0,
+ "multiDay": false,
+ "recurring": false,
+ "autoRenew": false,
+ "autoApprove": false,
+ "reminder": true,
+ "reminderDays": true,
+ "daysInAdvance": true,
+ "effectiveFrom": "2020-02-01 00:00",
+ "effectiveTo": "2025-02-01 00:00",
+ "minPurchaseDate": "2025-02-01 00:00",
+ "maxPurchaseDate": "2025-02-01 00:00",
+ "previousDays": 0,
+ "notes": "Billed every month in advance. Must park in speacial section.",
+ "maxAvailable": 40,
+ "addressRequired": false,
+ "contactAddressRequired": false,
+ "confirmLicensePlate": false,
+ "vehicleDetailsRequired": false,
+ "postPaymentRequired": false,
+ "postcodeRequired": false,
+ "occupantsRequired": false,
+ "promoCode": true,
+ "airlineDisplay": true,
+ "airlineRequired": true,
+ "permitReasonDisplay": true,
+ "permitReasonRequired": true,
+ "maxPurchaseDays": 12,
+ "qrCode": true,
+ "reservedBays": true,
+ "overstayCharge": false,
+ "staffIDRequired": false,
+ "schedule": false,
+ "userAccountRequired": false,
+ "captureNumberPlate": false,
+ "numberPlateRequired": false,
+ "groupPermit": false,
+ "paymentMessage": "false",
+ "displayIfZeroAmount": false,
+ "displayTermsAndConditions": false,
+ "displayMarketing": false,
+ "customText": "false",
+ "customTextDisplay": false,
+ "customTextRequired": false,
+ "customWarningDisplay": false,
+ "customWarning": "Details are required.",
+ "eventType": "false",
+ "eventDates": "false",
+ "eventStartDate": "false",
+ "eventEndDate": "false",
+ "timezone": "AUS Eastern Standard Time",
+ "closedDays": false
+ }
+ ])
+ else
+ response.status_code = 500
+ response << "expected get space details request"
+ end
+ end
+
+ products = list_products.get.not_nil!
+ products.as_a.first.["productID"].as_s.should eq "VALET"
+
+ # Reasons
+
+ list_reasons = exec(:list_reasons, 1)
+
+ expect_http_request do |request, response|
+ case "#{request.path}"
+ when "/api/payment/reasonList"
+ response.status_code = 200
+ response << %([
+ {
+ "tenantID": 12,
+ "reasonID": "HOL",
+ "description": "Holiday"
+ }
+ ])
+ when "/api/payment/reasonList?#{request.query}"
+ response.status_code = 200
+ response << %([
+ {
+ "tenantID": 12,
+ "reasonID": "HOL",
+ "description": "Holiday"
+ }
+ ])
+ else
+ response.status_code = 500
+ response << "expected get space details request"
+ end
+ end
+
+ reasons = list_reasons.get.not_nil!
+ reasons.as_a.first.["reasonID"].as_s.should eq "HOL"
+
+ # Payments
+
+ # payment_id : String, promise_pay_card_name : String, user_id : String, tenant_id : Int32, product_id : String, from_date : String, to_date : String, amount : Float64
+ make_payment = exec(:make_payment, "1", "2", "3", 4, "5", "6", "7", 20.841)
+
+ expect_http_request do |request, response|
+ case "#{request.path}"
+ when "/api/payment/makepayment"
+ response.status_code = 200
+ response << %({
+ "success": true,
+ "errors": [
+ "string"
+ ],
+ "gatewayTimeout": false,
+ "paymentHeld": false,
+ "paymentID": "12345",
+ "receiptNo": "987651",
+ "amount": 20.84
+ })
+ else
+ response.status_code = 500
+ response << "expected get space details request"
+ end
+ end
+
+ payment = make_payment.get.not_nil!
+ payment["success"].as_bool.should eq true
+end
diff --git a/drivers/vecos/releezme.cr b/drivers/vecos/releezme.cr
new file mode 100644
index 00000000000..bb37d9ea44c
--- /dev/null
+++ b/drivers/vecos/releezme.cr
@@ -0,0 +1,353 @@
+require "placeos-driver"
+require "oauth2"
+require "./releezme/*"
+
+# documentation: https://acc-sapi.releezme.net/swagger/ui/index
+
+class Vecos::Releezme < PlaceOS::Driver
+ descriptive_name "Vecos Releezme Gateway"
+ generic_name :ReleezmeLockers
+ uri_base "https://acc-sapi.releezme.net"
+
+ default_settings({
+ client_id: "8537d5c8-a85c-4657-bc6b-7c35b1405464",
+ client_secret: "856b5b85d3eb4697369",
+ username: "admin",
+ password: "admin",
+ releezme_authentication_domain: "acc-identity.releezme.net",
+ })
+
+ def on_update
+ client_id = setting(String, :client_id)
+ client_secret = setting(String, :client_secret)
+ username = setting(String, :username)
+ password = setting(String, :password)
+ releezme_authentication_domain = setting(String, :releezme_authentication_domain)
+
+ transport.before_request do |req|
+ access_token = get_access_token(client_id, client_secret, username, password, releezme_authentication_domain)
+ req.headers["Authorization"] = access_token
+ req.headers["Content-Type"] = "application/json"
+ logger.debug { "requesting #{req.method} #{req.path}?#{req.query}\n#{req.headers}\n#{req.body}" }
+ end
+ end
+
+ @expires : Time = Time.utc
+ @bearer_token : String = ""
+ @access_token : OAuth2::AccessToken? = nil
+
+ protected def get_access_token(client_id, client_secret, username, password, releezme_authentication_domain)
+ return @bearer_token if 1.minute.from_now < @expires
+
+ # check if we are running a spec
+ if config.uri.as(String).includes?("127.0.0.1")
+ uri = URI.parse config.uri.as(String)
+ auth_domain = uri.host.as(String)
+ port = uri.port.as(Int32)
+ scheme = "http"
+ else
+ auth_domain = releezme_authentication_domain
+ scheme = "https"
+ end
+
+ # use the built in crystal oauth client
+ client = OAuth2::Client.new(auth_domain, client_id, client_secret, scheme: scheme, port: port, token_uri: "/connect/token")
+ token = if (access_token = @access_token) && access_token.refresh_token.presence
+ begin
+ client.get_access_token_using_refresh_token(access_token.refresh_token)
+ rescue error : OAuth2::Error
+ logger.warn(exception: error) { "failed to refresh token" }
+ client.get_access_token_using_resource_owner_credentials(username: username, password: password, scope: "Vecos.Releezme.Web.SAPI offline_access")
+ end
+ else
+ client.get_access_token_using_resource_owner_credentials(username: username, password: password, scope: "Vecos.Releezme.Web.SAPI offline_access")
+ end
+ @expires = token.expires_in.as(Int64).seconds.from_now
+ @access_token = token
+ @bearer_token = "Bearer #{token.access_token}"
+ end
+
+ @[Security(Level::Support)]
+ def fetch_pages(location : String) : Array(JSON::Any)
+ append = location.includes?('?') ? '&' : '?'
+ next_page = "#{location}#{append}pageNumber=#{1}"
+ data = [] of JSON::Any
+
+ loop do
+ response = get(next_page)
+ @expires = 1.minute.ago if response.status_code == 401
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+
+ payload = JSON.parse(response.body).as_h
+ pages = if has_paging = payload.delete("Paging")
+ Paging.from_json has_paging.to_json
+ end
+ data.concat payload[payload.keys.first].as_a
+
+ break unless pages && pages.has_next_page
+
+ next_page = "#{location}#{append}pageNumber=#{pages.page_number + 1}"
+ end
+
+ data
+ end
+
+ @[Security(Level::Support)]
+ def fetch_item(location : String) : String
+ response = get(location)
+ @expires = 1.minute.ago if response.status_code == 401
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ logger.debug { "response body:\n#{response.body}" }
+ response.body
+ end
+
+ @[Security(Level::Support)]
+ def bearer_token
+ @bearer_token
+ end
+
+ # ===============
+ # COMPANIES
+ # ===============
+
+ def companies
+ JSON.parse(fetch_item("/api/companies"))["Companies"]
+ end
+
+ # =======================
+ # LOCATIONS / Buildings
+ # =======================
+
+ # typically these are buildings
+ def locations
+ fetch_pages("/api/locations?pageSize=200")
+ end
+
+ def location(location_id : String)
+ Location.from_json fetch_item("/api/locations/#{location_id}")
+ end
+
+ # typically these are floors in the building
+ def location_sections(location_id : String)
+ fetch_pages("/api/locations/#{location_id}/sections?pageSize=200")
+ end
+
+ # ===================
+ # SECTIONS / Levels
+ # ===================
+
+ # all floors from all buildings in one request
+ def sections
+ fetch_pages("/api/sections?pageSize=200")
+ end
+
+ def section(section_id : String)
+ Section.from_json fetch_item("/api/locations/#{section_id}")
+ end
+
+ def section_locker_banks(section_id : String)
+ fetch_pages("/api/sections/#{section_id}/lockerbanks?pageSize=200")
+ end
+
+ # banks and groups in the banks that the user can allocate to themselves
+ def section_banks_allocatable(section_id : String, user_id : String)
+ params = URI::Params.build do |form|
+ form.add "externalUserId", user_id
+ form.add "pageSize", "200"
+ end
+ fetch_pages("/api/sections/#{section_id}/lockerbanklockergroups/allocatable?#{params}")
+ end
+
+ # =====================================
+ # BANKS / lockers physically together
+ # =====================================
+
+ def banks
+ fetch_pages("/api/lockerbanks?pageSize=200")
+ end
+
+ def bank(bank_id : String)
+ LockerBank.from_json fetch_item("/api/lockerbanks/#{bank_id}")
+ end
+
+ def bank_groups(bank_id : String)
+ fetch_pages("/api/lockerbanks/#{bank_id}/lockergroups?pageSize=200")
+ end
+
+ # returns all the lockers in the bank without paging (but paging json is included)
+ def bank_lockers(bank_id : String)
+ fetch_pages("/api/lockerbanks/#{bank_id}/lockers?pageSize=200")
+ end
+
+ def bank_group_lockers_available(bank_id : String, group_id : String)
+ fetch_pages("/api/lockerbanks/#{bank_id}/#{group_id}/availablelockers?pageSize=200")
+ end
+
+ # NOTE:: Only accessible to System Control Clients
+ def bank_locker_allocations(bank_id : String)
+ fetch_pages("/api/lockerbanks/#{bank_id}/allocations?pageSize=200")
+ end
+
+ # ===============================================
+ # GROUPS / lockers assigned to a group of users
+ # ===============================================
+
+ def groups
+ fetch_pages("/api/lockergroups?pageSize=200")
+ end
+
+ def group(group_id : String)
+ Array(LockerGroup).from_json fetch_item("/api/lockergroups/#{group_id}")
+ end
+
+ def group_locker_banks(group_id : String)
+ fetch_pages("/api/lockergroups/#{group_id}/lockerbanks?pageSize=200")
+ end
+
+ # =====================================
+ # BOOKINGS
+ # =====================================
+
+ def bookings(user_id : String)
+ params = URI::Params.build do |form|
+ form.add "externalUserId", user_id
+ form.add "pageSize", "200"
+ end
+ fetch_pages("/api/bookings?#{params}")
+ end
+
+ def bookings_availability(
+ user_id : String,
+ starting : Int64,
+ ending : Int64,
+ section_id : String? = nil,
+ location_id : String? = nil,
+ bank_id : String? = nil,
+ group_id : String? = nil,
+ locker_id : String? = nil
+ )
+ params = URI::Params.build do |form|
+ form.add "externalUserId", user_id
+ form.add "startDateTimeUtc", Time.unix(starting).to_rfc3339
+ form.add "endDateTimeUtc", Time.unix(ending).to_rfc3339
+ form.add "sectionId", section_id.as(String) if section_id.presence
+ form.add "locationId", location_id.as(String) if location_id.presence
+ form.add "lockerBankId", bank_id.as(String) if bank_id.presence
+ form.add "lockerBankId", group_id.as(String) if bank_id.presence && group_id.presence
+ form.add "lockerId", locker_id.as(String) if locker_id.presence
+ form.add "pageSize", "200"
+ end
+ fetch_pages("/api/bookings/availability?#{params}")
+ end
+
+ def book_locker(starting : Int64, ending : Int64, user_id : String, locker_id : String? = nil, group_id : String? = nil, bank_id : String? = nil, timezone : String = "UTC")
+ tz = Time::Location.load(timezone)
+ response = post("/api/bookings", body: {
+ # this seems like a stupid data format? I assume as the locker bank has the timezone?
+ "StartDateTimeUtc" => Time.unix(starting).in(tz).to_s("%m-%d-%Y %H:%M:%S"),
+ "EndDateTimeUtc" => Time.unix(ending).in(tz).to_s("%m-%d-%Y %H:%M:%S"),
+ "LockerGroupId" => group_id,
+ "LockerBankId" => bank_id,
+ "LockerId" => locker_id,
+ "ExternalUserId" => user_id,
+ }.to_json)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ # =====================================
+ # LOCKERS
+ # =====================================
+
+ # the lockers that are currently allocated to the specified user
+ # the user ID is typically email - defined by the client
+ def lockers_allocated_to(user_id : String)
+ params = URI::Params.build do |form|
+ form.add "externalUserId", user_id
+ form.add "pageSize", "200"
+ end
+ fetch_pages("/api/lockers/allocated?#{params}")
+ end
+
+ # check if a user can be allocated a new locker
+ def can_allocate_locker?(user_id : String) : String
+ params = URI::Params.build do |form|
+ form.add "externalUserId", user_id
+ end
+ response = get("/api/lockers/canallocate?#{params}")
+ response.body
+ end
+
+ def locker_allocate(locker_id : String, user_id : String)
+ params = URI::Params.build do |form|
+ form.add "externalUserId", user_id
+ end
+ response = post("/api/lockers/#{locker_id}/allocate?#{params}")
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def locker_allocate_random(bank_id : String, group_id : String, user_id : String)
+ params = URI::Params.build do |form|
+ form.add "lockerBankId", bank_id
+ form.add "lockerGroupId", group_id
+ form.add "externalUserId", user_id
+ end
+ response = post("/api/lockers/allocate?#{params}")
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ JSON.parse(response.body)
+ end
+
+ def locker_release(locker_id : String, user_id : String? = nil) : Nil
+ params = URI::Params.build do |form|
+ form.add "externalUserId", user_id if user_id.presence
+ end
+ response = post("/api/lockers/#{locker_id}/release?#{params}")
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ end
+
+ def locker_unlock(locker_id : String, pin_code : String? = nil)
+ pin_route = pin_code ? nil : "/withoutpincode"
+ response = post("/api/lockers/#{locker_id}/pincode/unlock#{pin_route}", body: pin_code)
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ end
+
+ # =====================================
+ # SHARING
+ # =====================================
+
+ def share_locker_with(locker_id : String, owner_id : String, user_id : String) : Bool
+ params = URI::Params.build do |form|
+ form.add "externalUserId", owner_id
+ form.add "sharedUserId", user_id
+ end
+ response = post("/api/lockers/#{locker_id}/share?#{params}")
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ true
+ end
+
+ def unshare_locker(locker_id : String, owner_id : String, shared_with_internal_id : String? = nil) : Bool
+ params = URI::Params.build do |form|
+ form.add "externalUserId", owner_id
+ end
+ response = post("/api/lockers/#{locker_id}/unshare/#{shared_with_internal_id}?#{params}")
+ raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success?
+ true
+ end
+
+ def can_share_locker_with?(locker_id : String, owner_id : String, search : String)
+ params = URI::Params.build do |form|
+ form.add "externalUserId", owner_id
+ form.add "searchString", search
+ end
+ Array(LockerUsers).from_json(fetch_item("/api/lockers/#{locker_id}/shareablelockerusers?#{params}"), root: "LockerUsers")
+ end
+
+ def locker_shared_with?(locker_id : String, owner_id : String)
+ params = URI::Params.build do |form|
+ form.add "externalUserId", owner_id
+ end
+ Array(LockerUsers).from_json(fetch_item("/api/lockers/#{locker_id}/shareablelockerusers?#{params}"), root: "LockerUsers")
+ end
+end
diff --git a/drivers/vecos/releezme/allocation.cr b/drivers/vecos/releezme/allocation.cr
new file mode 100644
index 00000000000..6bb593c92fb
--- /dev/null
+++ b/drivers/vecos/releezme/allocation.cr
@@ -0,0 +1,35 @@
+require "json"
+
+module Vecos
+ struct Allocation
+ include JSON::Serializable
+
+ # This is the internal user_id - not the user email etc
+ @[JSON::Field(key: "UserId")]
+ getter user_id : String
+
+ @[JSON::Field(key: "LockerId")]
+ getter locker_id : String
+
+ @[JSON::Field(key: "SelfReleaseAllowed")]
+ getter? self_releasable : Bool
+
+ @[JSON::Field(key: "DynamicAllocated")]
+ getter? dynamically_allocated : Bool
+
+ @[JSON::Field(key: "StartDateTimeUtc")]
+ getter starting : Time
+
+ @[JSON::Field(key: "ExpiresDateTimeUtc")]
+ getter expiring : Time
+
+ @[JSON::Field(key: "SharedToUser")]
+ getter? shared_to_user : Bool
+
+ @[JSON::Field(key: "AllocatedForPackage")]
+ getter? allocated_for_package : Bool
+
+ @[JSON::Field(key: "AllocatedByLockerActionOnRelease")]
+ getter? allocated_by_locker_action_on_release : Bool
+ end
+end
diff --git a/drivers/vecos/releezme/booking.cr b/drivers/vecos/releezme/booking.cr
new file mode 100644
index 00000000000..7a84389616f
--- /dev/null
+++ b/drivers/vecos/releezme/booking.cr
@@ -0,0 +1,28 @@
+require "json"
+
+module Vecos
+ struct Booking
+ include JSON::Serializable
+
+ @[JSON::Field(key: "BookingId")]
+ getter id : String
+
+ @[JSON::Field(key: "LockerId")]
+ getter locker_id : String
+
+ @[JSON::Field(key: "LockerBankId")]
+ getter locker_bank_id : String
+
+ @[JSON::Field(key: "LockerGroupId")]
+ getter locker_group_id : String
+
+ @[JSON::Field(key: "FullDoorNumber")]
+ getter full_door_number : String
+
+ @[JSON::Field(key: "StartDateTimeUtc")]
+ getter starting : Time
+
+ @[JSON::Field(key: "EndDateTimeUtc")]
+ getter ending : Time
+ end
+end
diff --git a/drivers/vecos/releezme/location.cr b/drivers/vecos/releezme/location.cr
new file mode 100644
index 00000000000..ac3671bb9ac
--- /dev/null
+++ b/drivers/vecos/releezme/location.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module Vecos
+ struct Location
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Id")]
+ getter id : String
+
+ @[JSON::Field(key: "Name")]
+ getter name : String
+
+ @[JSON::Field(key: "TimeZoneId")]
+ getter time_zone : String? = nil
+ end
+end
diff --git a/drivers/vecos/releezme/locker.cr b/drivers/vecos/releezme/locker.cr
new file mode 100644
index 00000000000..c5b8236f807
--- /dev/null
+++ b/drivers/vecos/releezme/locker.cr
@@ -0,0 +1,70 @@
+require "json"
+
+module Vecos
+ struct Locker
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Id")]
+ getter id : String
+
+ @[JSON::Field(key: "LockerGroupId")]
+ getter locker_group_id : String
+
+ @[JSON::Field(key: "LockerBankId")]
+ getter locker_bank_id : String
+
+ @[JSON::Field(key: "LockerBrickId")]
+ getter locker_brick_id : String
+
+ @[JSON::Field(key: "Blocked")]
+ getter blocked : Bool
+
+ @[JSON::Field(key: "IsUsable")]
+ getter is_usable : Bool
+
+ @[JSON::Field(key: "IsDetected")]
+ getter is_detected : Bool
+
+ @[JSON::Field(key: "FullDoorNumber")]
+ getter full_door_number : String
+
+ @[JSON::Field(key: "DoorNumberPrefix")]
+ getter door_number_prefix : String
+
+ @[JSON::Field(key: "DoorNumber")]
+ getter door_number : Int32
+
+ @[JSON::Field(key: "SelfReleaseAllowed")]
+ getter self_release_allowed : Bool?
+
+ @[JSON::Field(key: "DynamicAllocated")]
+ getter dynamic_allocated : Bool?
+
+ @[JSON::Field(key: "OpenTime")]
+ getter open_time : Int32
+
+ @[JSON::Field(key: "NrOfAllocations")]
+ getter number_of_allocations : Int32?
+
+ @[JSON::Field(key: "SharedToUser")]
+ getter shared_to_user : Bool?
+
+ @[JSON::Field(key: "IsShared")]
+ getter is_shared : Bool?
+
+ @[JSON::Field(key: "IsShareable")]
+ getter is_shareable : Bool?
+
+ @[JSON::Field(key: "SequenceNumber")]
+ getter sequence_number : Int32
+
+ @[JSON::Field(key: "ExpiresDateTimeUtc")]
+ getter expires_date_time_utc : Time?
+
+ @[JSON::Field(key: "StartDateTimeUtc")]
+ getter start_date_time_utc : Time?
+
+ @[JSON::Field(key: "NumberOfDigitsForDoorNumber")]
+ getter number_of_digits_for_door_number : Int32
+ end
+end
diff --git a/drivers/vecos/releezme/locker_bank.cr b/drivers/vecos/releezme/locker_bank.cr
new file mode 100644
index 00000000000..133de99ea77
--- /dev/null
+++ b/drivers/vecos/releezme/locker_bank.cr
@@ -0,0 +1,31 @@
+require "json"
+
+module Vecos
+ struct LockerBank
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Id")]
+ getter id : String
+
+ @[JSON::Field(key: "Name")]
+ getter name : String
+
+ @[JSON::Field(key: "SectionId")]
+ getter section_id : String
+
+ @[JSON::Field(key: "LocationId")]
+ getter location_id : String?
+
+ @[JSON::Field(key: "Published")]
+ getter published : Bool
+
+ @[JSON::Field(key: "RandomAllocation")]
+ getter random_allocation : Bool?
+
+ @[JSON::Field(key: "ServiceMode")]
+ getter service_mode : Bool
+
+ @[JSON::Field(key: "Description")]
+ getter description : String?
+ end
+end
diff --git a/drivers/vecos/releezme/locker_bank_and_locker_group.cr b/drivers/vecos/releezme/locker_bank_and_locker_group.cr
new file mode 100644
index 00000000000..9002aacacb3
--- /dev/null
+++ b/drivers/vecos/releezme/locker_bank_and_locker_group.cr
@@ -0,0 +1,14 @@
+require "./locker_bank"
+require "./locker_group"
+
+module Vecos
+ struct LockerBankAndLockerGroup
+ include JSON::Serializable
+
+ @[JSON::Field(key: "LockerBank")]
+ getter locker_bank : LockerBank
+
+ @[JSON::Field(key: "LockerGroup")]
+ getter locker_group : LockerGroup
+ end
+end
diff --git a/drivers/vecos/releezme/locker_group.cr b/drivers/vecos/releezme/locker_group.cr
new file mode 100644
index 00000000000..ff3fc7f0db2
--- /dev/null
+++ b/drivers/vecos/releezme/locker_group.cr
@@ -0,0 +1,22 @@
+require "json"
+
+module Vecos
+ struct LockerGroup
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Id")]
+ getter id : String
+
+ @[JSON::Field(key: "Name")]
+ getter name : String
+
+ @[JSON::Field(key: "LockMode")]
+ getter lock_mode : Int32
+
+ @[JSON::Field(key: "LockerBookingFeatureEnabled")]
+ getter locker_booking_feature_enabled : Bool
+
+ @[JSON::Field(key: "PostalServiceFeatureEnabled")]
+ getter postal_service_feature_enabled : Bool
+ end
+end
diff --git a/drivers/vecos/releezme/locker_group_status.cr b/drivers/vecos/releezme/locker_group_status.cr
new file mode 100644
index 00000000000..8f8845c3060
--- /dev/null
+++ b/drivers/vecos/releezme/locker_group_status.cr
@@ -0,0 +1,46 @@
+require "json"
+
+module Vecos
+ struct LockerGroupStatus
+ include JSON::Serializable
+
+ @[JSON::Field(key: "LockerGroupId")]
+ getter locker_group_id : String
+
+ @[JSON::Field(key: "LockerGroupName")]
+ getter locker_group_name : String
+
+ @[JSON::Field(key: "UsableLockers")]
+ getter usable_lockers : Int32
+
+ @[JSON::Field(key: "UnusableLockers")]
+ getter unusable_lockers : Int32
+
+ @[JSON::Field(key: "PublicLockers")]
+ getter public_lockers : Int32
+
+ @[JSON::Field(key: "AvailableDynamicLockers")]
+ getter available_dynamic_lockers : Int32
+
+ @[JSON::Field(key: "AllocatedDynamicLockers")]
+ getter allocated_dynamic_lockers : Int32
+
+ @[JSON::Field(key: "BlockedAllocatedDynamicLockers")]
+ getter blocked_allocated_dynamic_lockers : Int32
+
+ @[JSON::Field(key: "BlockedUnallocatedDynamicLockers")]
+ getter blocked_unallocated_dynamic_lockers : Int32
+
+ @[JSON::Field(key: "AvailableStaticLockers")]
+ getter available_static_lockers : Int32
+
+ @[JSON::Field(key: "AllocatedStaticLockers")]
+ getter allocated_static_lockers : Int32
+
+ @[JSON::Field(key: "BlockedAllocatedStaticLockers")]
+ getter blocked_allocated_static_lockers : Int32
+
+ @[JSON::Field(key: "BlockedUnallocatedStaticLockers")]
+ getter blocked_unallocated_static_lockers : Int32
+ end
+end
diff --git a/drivers/vecos/releezme/locker_users.cr b/drivers/vecos/releezme/locker_users.cr
new file mode 100644
index 00000000000..6d7226fe4ca
--- /dev/null
+++ b/drivers/vecos/releezme/locker_users.cr
@@ -0,0 +1,22 @@
+require "json"
+
+module Vecos
+ struct LockerUsers
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Id")]
+ getter id : String
+
+ @[JSON::Field(key: "FirstName")]
+ getter first_name : String?
+
+ @[JSON::Field(key: "LastName")]
+ getter last_name : String?
+
+ @[JSON::Field(key: "EmailAddress")]
+ getter email : String?
+
+ @[JSON::Field(key: "ExternalUserId")]
+ getter user_id : String
+ end
+end
diff --git a/drivers/vecos/releezme/paging.cr b/drivers/vecos/releezme/paging.cr
new file mode 100644
index 00000000000..7eaa9affe74
--- /dev/null
+++ b/drivers/vecos/releezme/paging.cr
@@ -0,0 +1,37 @@
+require "json"
+
+module Vecos
+ struct Paging
+ include JSON::Serializable
+
+ @[JSON::Field(key: "FirstItemOnPage")]
+ getter first_item_on_page : Int32
+
+ @[JSON::Field(key: "HasNextPage")]
+ getter has_next_page : Bool
+
+ @[JSON::Field(key: "HasPreviousPage")]
+ getter has_previous_page : Bool
+
+ @[JSON::Field(key: "IsFirstPage")]
+ getter is_first_page : Bool
+
+ @[JSON::Field(key: "IsLastPage")]
+ getter is_last_page : Bool
+
+ @[JSON::Field(key: "LastItemOnPage")]
+ getter last_item_on_page : Int32
+
+ @[JSON::Field(key: "PageCount")]
+ getter page_count : Int32
+
+ @[JSON::Field(key: "PageNumber")]
+ getter page_number : Int32
+
+ @[JSON::Field(key: "PageSize")]
+ getter page_size : Int32
+
+ @[JSON::Field(key: "TotalItemCount")]
+ getter total_item_count : Int32
+ end
+end
diff --git a/drivers/vecos/releezme/section.cr b/drivers/vecos/releezme/section.cr
new file mode 100644
index 00000000000..73aca27dbea
--- /dev/null
+++ b/drivers/vecos/releezme/section.cr
@@ -0,0 +1,16 @@
+require "json"
+
+module Vecos
+ struct Section
+ include JSON::Serializable
+
+ @[JSON::Field(key: "Id")]
+ getter id : String
+
+ @[JSON::Field(key: "Name")]
+ getter name : String
+
+ @[JSON::Field(key: "LocationId")]
+ getter location_id : String
+ end
+end
diff --git a/drivers/vecos/releezme_locations.cr b/drivers/vecos/releezme_locations.cr
new file mode 100644
index 00000000000..06f1f0e7f30
--- /dev/null
+++ b/drivers/vecos/releezme_locations.cr
@@ -0,0 +1,307 @@
+require "placeos-driver"
+require "placeos-driver/interface/lockers"
+require "placeos-driver/interface/locatable"
+require "./releezme/*"
+
+class Vecos::ReleezmeLocations < PlaceOS::Driver
+ include Interface::Locatable
+ include Interface::Lockers
+
+ alias PlaceLocker = PlaceOS::Driver::Interface::Lockers::PlaceLocker
+
+ descriptive_name "Vecos Locker Locations"
+ generic_name :LockerLocations
+
+ accessor staff_api : StaffAPI_1
+ accessor releezme : ReleezmeLockers_1
+
+ default_settings({
+ # the users id
+ user_id_key: "email",
+ vecos_floor_mappings: {
+ "placeos_zone_id": {
+ section_id: "level",
+ name: "friendly name for documentation",
+ },
+ },
+ door_number_lookup: false,
+ })
+
+ def on_update
+ @door_number_lookup = setting?(Bool, :door_number_lookup) || false
+ @user_id_key = setting?(String, :user_id_key) || "email"
+ @floor_mappings = setting(Hash(String, Mapping), :vecos_floor_mappings).transform_values(&.section_id)
+ @zone_filter = @floor_mappings.keys
+ @building_id = nil
+
+ if @door_number_lookup
+ schedule.clear
+ schedule.in(rand(10).seconds) do
+ @floor_mappings.each_key do |zone_id|
+ device_locations(zone_id)
+ end
+ end
+ end
+ end
+
+ # place_zone_id => releexme_section_id
+ @floor_mappings : Hash(String, String) = {} of String => String
+ @zone_filter : Array(String) = [] of String
+ @user_id_key : String = "email"
+ @door_number_lookup : Bool = false
+ @last_mapped : Time = 4.hours.ago
+
+ struct Mapping
+ include JSON::Serializable
+ getter section_id : String
+ end
+
+ # Finds the building ID for the current location services object
+ def get_building_id
+ zone_ids = staff_api.zones(tags: "building").get.as_a.map(&.[]("id").as_s)
+ (zone_ids & system.zones).first
+ rescue error
+ logger.warn(exception: error) { "unable to determine building zone id" }
+ raise error
+ end
+
+ getter building_id : String { get_building_id }
+
+ def lookup_id(locker_id : String) : String
+ if @door_number_lookup
+ status?(String, locker_id.downcase) || locker_id
+ else
+ locker_id
+ end
+ end
+
+ # ========================================
+ # Lockers Interface
+ # ========================================
+
+ class PlaceLocker
+ def initialize(
+ locker : Vecos::Locker,
+ allocated : Bool = false,
+ @building = nil,
+ @level = nil
+ )
+ @locker_uid = locker.id
+ @locker_id = locker.full_door_number
+ @bank_id = locker.locker_bank_id
+ @group_id = locker.locker_group_id
+ @locker_name = locker.full_door_number
+ @expires_at = locker.expires_date_time_utc
+ @allocated = if allocations = locker.number_of_allocations
+ (allocations > 0) || allocated
+ else
+ allocated
+ end
+ end
+
+ def initialize(booking : Vecos::Booking)
+ @locker_uid = booking.locker_id
+ @locker_id = booking.full_door_number
+ @bank_id = booking.locker_bank_id
+ @group_id = booking.locker_group_id
+ @locker_name = booking.full_door_number
+ @expires_at = booking.ending
+ @allocated = true
+ @allocation_id = booking.id
+ end
+
+ getter group_id : String? = nil
+ getter locker_uid : String? = nil
+ end
+
+ protected def get_group_id(user_id, bank_id)
+ section_id = releezme.bank(bank_id).get["SectionId"].as_s
+ groups = Array(LockerBankAndLockerGroup).from_json releezme.section_banks_allocatable(section_id, user_id).get.to_json
+ group = groups.find { |group| group.locker_bank.id == bank_id }
+ raise "there are no lockers available to the user in the selected locker bank" unless group
+ group.locker_group.id
+ end
+
+ protected def get_user_key(user_id)
+ return user_id.downcase if @user_id_key == "email" && user_id.includes?("@")
+ user = staff_api.user(user_id).get
+ user[@user_id_key].as_s
+ end
+
+ # allocates a locker now, the allocation may expire
+ @[Security(Level::Administrator)]
+ def locker_allocate(
+ # PlaceOS user id, recommend using email
+ user_id : String,
+
+ # the locker location
+ bank_id : String | Int64,
+
+ # allocates a random locker if this is nil
+ locker_id : String | Int64? = nil,
+
+ # attempts to create a booking that expires at the time specified
+ expires_at : Int64? = nil
+ ) : PlaceLocker
+ user_id = get_user_key(user_id)
+ locker_id = locker_id ? lookup_id(locker_id.to_s) : nil
+
+ if expires_at
+ timezone = system.timezone || "UTC"
+ booking = if locker_id
+ releezme.book_locker(1.minute.ago.to_unix, expires_at, user_id, locker_id, timezone: timezone).get
+ else
+ group_id = get_group_id(user_id, bank_id)
+ releezme.book_locker(1.minute.ago.to_unix, expires_at, user_id, group_id: group_id, bank_id: bank_id, timezone: timezone).get
+ end
+ PlaceLocker.new(Vecos::Booking.from_json booking.to_json)
+ elsif locker_id
+ vlocker = Vecos::Locker.from_json releezme.locker_allocate(locker_id, user_id).get.to_json
+ PlaceLocker.new(vlocker, true)
+ else
+ group_id = get_group_id(user_id, bank_id)
+ vlocker = Vecos::Locker.from_json releezme.locker_allocate_random(bank_id, group_id, user_id).get.to_json
+ PlaceLocker.new(vlocker, true)
+ end
+ end
+
+ # return the locker to the pool
+ @[Security(Level::Administrator)]
+ def locker_release(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # release / unshare just this user - otherwise release the whole locker
+ owner_id : String? = nil
+ ) : Nil
+ locker_id = lookup_id(locker_id.to_s)
+ owner_id = get_user_key(owner_id) if owner_id
+ releezme.locker_release(locker_id, owner_id).get
+ end
+
+ # a list of lockers that are allocated to the user
+ @[Security(Level::Administrator)]
+ def lockers_allocated_to(user_id : String) : Array(PlaceLocker)
+ user_id = get_user_key user_id
+ lockers = Array(Vecos::Locker).from_json releezme.lockers_allocated_to(user_id).get.to_json
+ lockers.map { |locker| PlaceLocker.new(locker, true) }
+ end
+
+ @[Security(Level::Administrator)]
+ def locker_share(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ share_with : String
+ ) : Nil
+ locker_id = lookup_id(locker_id.to_s)
+ releezme.share_locker_with(locker_id, get_user_key(owner_id), get_user_key(share_with)).get
+ end
+
+ @[Security(Level::Administrator)]
+ def locker_unshare(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String,
+ # the individual you previously shared with
+ shared_with_id : String? = nil
+ ) : Nil
+ owner_id = get_user_key(owner_id)
+ locker_id = lookup_id(locker_id.to_s)
+
+ # we need the internal id if we want to unshare an individual
+ if shared_with_id
+ shared_with_external_id = get_user_key(shared_with_id)
+ shared_with = Array(Vecos::LockerUsers).from_json releezme.locker_shared_with?(locker_id, owner_id).get.to_json
+ shared_user = shared_with.find { |user| user.user_id == shared_with_external_id }
+ return unless shared_user
+ shared_with_id = shared_user.id
+ end
+ releezme.unshare_locker(locker_id, owner_id, shared_with_id).get
+ end
+
+ # a list of user-ids that the locker is shared with.
+ # this can be placeos user ids or emails
+ @[Security(Level::Administrator)]
+ def locker_shared_with(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+ owner_id : String
+ ) : Array(String)
+ owner_id = get_user_key(owner_id)
+ locker_id = lookup_id(locker_id.to_s)
+ shared_with = Array(Vecos::LockerUsers).from_json releezme.locker_shared_with?(locker_id, owner_id).get.to_json
+ shared_with.map { |user| user.email || user.user_id }
+ end
+
+ @[Security(Level::Administrator)]
+ def locker_unlock(
+ bank_id : String | Int64,
+ locker_id : String | Int64,
+
+ # sometimes required by locker systems
+ owner_id : String? = nil,
+ # time in seconds the locker should be unlocked
+ # (can be ignored if not implemented)
+ open_time : Int32 = 60,
+ # optional pin code - if user entered from a kiosk
+ pin_code : String? = nil
+ ) : Nil
+ locker_id = lookup_id(locker_id.to_s)
+ releezme.locker_unlock(locker_id, pin_code).get
+ end
+
+ # ========================================
+ # Locatable Interface
+ # ========================================
+
+ # array of devices and their x, y coordinates, that are associated with this user
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "releezme incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ # return an array of MAC address strings
+ # lowercase with no seperation characters abcdeffd1234 etc
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "releezme incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ # return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}`
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "releezme incapable of tracking #{mac_address}" }
+ nil
+ end
+
+ # array of lockers on this level
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching for lockers in zone #{zone_id}" }
+
+ if building_id == zone_id
+ return @zone_filter.flat_map { |level_id| device_locations(level_id, location) }
+ end
+ return [] of Nil unless @zone_filter.includes?(zone_id)
+
+ # grab all the lockers for the current zone_id
+ releexme_section_id = @floor_mappings[zone_id]
+ banks = Array(Vecos::LockerBank).from_json releezme.section_locker_banks(releexme_section_id).get.to_json
+
+ if @door_number_lookup && @last_mapped < 3.hour.ago
+ # periodically save the locker name => id mappings in redis
+ @last_mapped = Time.utc
+ banks.flat_map do |bank|
+ lockers = Array(Vecos::Locker).from_json releezme.bank_lockers(bank.id).get.to_json
+ lockers.map do |locker|
+ self[locker.full_door_number.downcase] = locker.id
+ PlaceLocker.new(locker, building: building_id, level: zone_id)
+ end
+ end
+ else
+ banks.flat_map do |bank|
+ lockers = Array(Vecos::Locker).from_json releezme.bank_lockers(bank.id).get.to_json
+ lockers.map { |locker| PlaceLocker.new(locker, building: building_id, level: zone_id) }
+ end
+ end
+ end
+end
diff --git a/drivers/vecos/releezme_locations_spec.cr b/drivers/vecos/releezme_locations_spec.cr
new file mode 100644
index 00000000000..0ae517777aa
--- /dev/null
+++ b/drivers/vecos/releezme_locations_spec.cr
@@ -0,0 +1,4 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Vecos::ReleezmeLocations" do
+end
diff --git a/drivers/vecos/releezme_spec.cr b/drivers/vecos/releezme_spec.cr
new file mode 100644
index 00000000000..ed059c5da8e
--- /dev/null
+++ b/drivers/vecos/releezme_spec.cr
@@ -0,0 +1,80 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Vecos::Releezme" do
+ resp = exec :locations
+
+ expect_http_request do |request, response|
+ case request.path
+ when "/connect/token"
+ response.status_code = 200
+ response << %({
+ "access_token": "1234",
+ "expires_in": 300
+ })
+ else
+ response.status_code = 500
+ response << "expected token request"
+ end
+ end
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/locations?pageSize=200&pageNumber=1"
+ response.status_code = 200
+ response << %({
+ "Locations": [{
+ "Id": "loc-1234",
+ "Name": "Level 2"
+ }],
+ "Paging": {
+ "FirstItemOnPage": 1,
+ "HasNextPage": true,
+ "HasPreviousPage": false,
+ "IsFirstPage": true,
+ "IsLastPage": false,
+ "LastItemOnPage": 1,
+ "PageCount": 2,
+ "PageNumber": 1,
+ "PageSize": 2,
+ "TotalItemCount": 2
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected locations request"
+ end
+ end
+
+ expect_http_request do |request, response|
+ case "#{request.path}?#{request.query}"
+ when "/api/locations?pageSize=200&pageNumber=2"
+ response.status_code = 200
+ response << %({
+ "Locations": [{
+ "Id": "loc-5678",
+ "Name": "Level 3"
+ }],
+ "Paging": {
+ "FirstItemOnPage": 1,
+ "HasNextPage": false,
+ "HasPreviousPage": true,
+ "IsFirstPage": false,
+ "IsLastPage": true,
+ "LastItemOnPage": 1,
+ "PageCount": 2,
+ "PageNumber": 2,
+ "PageSize": 2,
+ "TotalItemCount": 2
+ }
+ })
+ else
+ response.status_code = 500
+ response << "expected locations request"
+ end
+ end
+
+ resp.get.should eq [
+ {"Id" => "loc-1234", "Name" => "Level 2"},
+ {"Id" => "loc-5678", "Name" => "Level 3"},
+ ]
+end
diff --git a/drivers/vergesense/location_service.cr b/drivers/vergesense/location_service.cr
new file mode 100644
index 00000000000..aceae678728
--- /dev/null
+++ b/drivers/vergesense/location_service.cr
@@ -0,0 +1,258 @@
+require "json"
+require "oauth2"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+require "placeos-driver/interface/sensor"
+require "./models"
+
+class Vergesense::LocationService < PlaceOS::Driver
+ include Interface::Locatable
+ include Interface::Sensor
+
+ descriptive_name "Vergesense Location Service"
+ generic_name :VergesenseLocationService
+ description %(collects desk booking data from the staff API and overlays Vergesense data for visualising on a map)
+
+ accessor area_manager : AreaManagement_1
+ accessor vergesense : Vergesense_1
+
+ default_settings({
+ floor_mappings: {
+ "vergesense_building_id-floor_id": {
+ building_id: "zone-building",
+ level_id: "zone-level",
+ name: "friendly name for documentation",
+ },
+ },
+ return_empty_spaces: true,
+ desk_space_types: ["desk"],
+ notify_updates: false,
+ })
+
+ @floor_mappings : Hash(String, NamedTuple(building_id: String?, level_id: String)) = {} of String => NamedTuple(building_id: String?, level_id: String)
+ @zone_filter : Array(String) = [] of String
+ @building_mappings : Hash(String, String?) = {} of String => String?
+ @desk_space_types : Array(String) = ["desk"]
+ @notify_updates : Bool = false
+
+ def on_update
+ @return_empty_spaces = setting?(Bool, :return_empty_spaces) || false
+ @notify_updates = setting?(Bool, :notify_updates) || false
+ @desk_space_types = setting?(Array(String), :desk_space_types) || ["desk"]
+ @floor_mappings = setting(Hash(String, NamedTuple(building_id: String?, level_id: String)), :floor_mappings)
+ @zone_filter = @floor_mappings.values.map do |z|
+ level = z[:level_id]
+ @building_mappings[level] = z[:building_id]
+ level
+ end
+
+ bind_floor_status
+ end
+
+ # ===================================
+ # Bindings into Vergesense data
+ # ===================================
+ protected def bind_floor_status
+ subscriptions.clear
+
+ @floor_mappings.each do |floor_id, details|
+ zone_id = details[:level_id]
+ vergesense.subscribe(floor_id) do |_sub, payload|
+ level_state_change(zone_id, Floor.from_json(payload))
+ end
+ end
+ end
+
+ # Zone_id => Floor
+ @occupancy_mappings : Hash(String, Floor) = {} of String => Floor
+
+ protected def level_state_change(zone_id, floor)
+ @occupancy_mappings[zone_id] = floor
+ area_manager.update_available({zone_id}) if @notify_updates
+ rescue error
+ logger.error(exception: error) { "error updating level #{zone_id} space changes" }
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "sensor incapable of tracking #{mac_address}" }
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+ return [] of Nil unless @zone_filter.includes?(zone_id)
+
+ floor = @occupancy_mappings[zone_id]?
+ return [] of Nil unless floor
+
+ desk_types = @desk_space_types
+ floor.spaces.compact_map do |space|
+ loc_type = space.space_type.in?(desk_types) ? "desk" : "area"
+ next if location.presence && location != loc_type
+
+ people_count = space.people.try(&.count)
+
+ if @return_empty_spaces || people_count && people_count > 0
+ if env = space.environment
+ humidity = env.humidity.value
+ temperature = env.temperature.value
+ iaq = env.iaq.try &.value
+ end
+
+ {
+ location: loc_type,
+ at_location: people_count || 0,
+ map_id: space.name,
+ level: zone_id,
+ building: @building_mappings[zone_id]?,
+ capacity: space.capacity,
+
+ vergesense_space_id: space.ref_id,
+ vergesense_space_type: space.space_type,
+ area_humidity: humidity,
+ area_temperature: temperature,
+ area_air_quality: iaq,
+ signs_of_life: space.signs_of_life,
+ }
+ end
+ end
+ end
+
+ # ===================================
+ # Sensor Interface functions
+ # ===================================
+ def sensor(mac : String, id : String? = nil) : Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+
+ # https://crystal-lang.org/api/1.1.0/String.html#rpartition(search:Char%7CString):Tuple(String,String,String)-instance-method
+ zone_id, _, space_id = mac.rpartition('-')
+ return nil if zone_id.empty? || space_id.empty?
+
+ floor = @occupancy_mappings[zone_id]?
+ return nil unless floor
+
+ floor_space = floor.spaces.find { |space| space.ref_id == space_id }
+ return nil unless floor_space
+
+ case id
+ when "people"
+ build_sensor_details(zone_id, floor, floor_space, :people_count)
+ when "presence"
+ build_sensor_details(zone_id, floor, floor_space, :presence)
+ when "humidity"
+ build_sensor_details(zone_id, floor, floor_space, :humidity)
+ when "temp"
+ build_sensor_details(zone_id, floor, floor_space, :temperature)
+ when "air"
+ build_sensor_details(zone_id, floor, floor_space, :air_quality)
+ end
+ rescue error
+ logger.warn(exception: error) { "checking for sensor" }
+ nil
+ end
+
+ SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence, SensorType::Humidity, SensorType::Temperature, SensorType::AirQuality}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+
+ if mac
+ level_zone, _, space_id = mac.rpartition('-')
+ return NO_MATCH if zone_id && zone_id != level_zone || space_id.empty?
+ zone_id = level_zone
+ end
+
+ return NO_MATCH if zone_id && !@occupancy_mappings.has_key?(zone_id)
+
+ if space_id
+ floor = @occupancy_mappings[zone_id]
+ floor_space = floor.spaces.find { |space| space.ref_id == space_id }
+ return NO_MATCH unless floor_space
+ spaces = [{zone_id, floor, floor_space}]
+ elsif zone_id
+ floor = @occupancy_mappings[zone_id]
+ spaces = floor.spaces.map { |space| {zone_id, floor, space} }
+ else
+ spaces = @occupancy_mappings.flat_map { |(zone, floor)|
+ floor.spaces.map { |space| {zone, floor, space} }
+ }
+ end
+
+ if sensor_type
+ spaces.compact_map { |(zone, floor, space)| build_sensor_details(zone.not_nil!, floor, space, sensor_type) }
+ else
+ spaces.flat_map { |(zone, floor, space)| space_sensors(zone.not_nil!, floor, space) }.compact
+ end
+ end
+
+ protected def build_sensor_details(zone_id : String, floor : Vergesense::Floor, space : Vergesense::Space, sensor : SensorType) : Detail?
+ time = space.timestamp
+ id = "people"
+ limit_high = nil
+ value = case sensor
+ when .people_count?
+ limit_high = (space.max_capacity || space.capacity).try &.to_f64
+ space.people.try &.count.try &.to_f64
+ when .presence?
+ id = "presence"
+ space.people.try &.count.try { |count| count > 0 ? 1.0 : 0.0 } || 0.0
+ when .humidity?
+ id = "humidity"
+ time = space.environment.try &.timestamp
+ space.environment.try &.humidity.value
+ when .temperature?
+ id = "temp"
+ time = space.environment.try &.timestamp
+ space.environment.try &.temperature.value
+ when .air_quality?
+ id = "air"
+ time = space.environment.try &.timestamp
+ space.environment.try(&.iaq.try(&.value))
+ else
+ raise "sensor type unavailable: #{sensor}"
+ end
+ return nil unless value
+
+ detail = Detail.new(
+ type: sensor,
+ value: value,
+ last_seen: (time || Time.utc).to_unix,
+ mac: "#{zone_id}-#{space.ref_id}",
+ id: id,
+ name: "#{floor.name} #{space.name} (#{space.space_type})",
+ limit_high: limit_high
+ )
+ detail.level = zone_id
+ detail
+ end
+
+ protected def space_sensors(zone_id : String, floor : Vergesense::Floor, space : Vergesense::Space)
+ [
+ build_sensor_details(zone_id, floor, space, :people_count),
+ build_sensor_details(zone_id, floor, space, :presence),
+ build_sensor_details(zone_id, floor, space, :humidity),
+ build_sensor_details(zone_id, floor, space, :temperature),
+ build_sensor_details(zone_id, floor, space, :air_quality),
+ ].compact!
+ end
+end
diff --git a/drivers/vergesense/location_service_spec.cr b/drivers/vergesense/location_service_spec.cr
new file mode 100644
index 00000000000..fed5f031f19
--- /dev/null
+++ b/drivers/vergesense/location_service_spec.cr
@@ -0,0 +1,94 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Vergesense::LocationService" do
+ system({
+ Vergesense: {VergesenseMock},
+ AreaManagement: {AreaManagementMock},
+ })
+
+ resp = exec(:device_locations, "zone-level").get
+ resp.should eq([
+ {
+ "location" => "area",
+ "at_location" => 21,
+ "map_id" => "Conference Room 0721",
+ "level" => "zone-level",
+ "building" => "zone-building",
+ "capacity" => 30,
+ "vergesense_space_id" => "CR_0721",
+ "vergesense_space_type" => "conference_room",
+ "area_humidity" => nil,
+ "area_temperature" => nil,
+ "area_air_quality" => nil,
+ "signs_of_life" => nil,
+ },
+ {
+ "location" => "desk",
+ "at_location" => 1,
+ "map_id" => "desk-1234",
+ "level" => "zone-level",
+ "building" => "zone-building",
+ "capacity" => 1,
+ "vergesense_space_id" => "CR_0722",
+ "vergesense_space_type" => "desk",
+ "area_humidity" => nil,
+ "area_temperature" => nil,
+ "area_air_quality" => nil,
+ "signs_of_life" => nil,
+ },
+ ])
+end
+
+# :nodoc:
+class VergesenseMock < DriverSpecs::MockDriver
+ def on_load
+ self["vergesense_building_id-floor_id"] = {
+ "floor_ref_id" => "floor_id",
+ "name" => "Floor 1",
+ "capacity" => 84,
+ "max_capacity" => 60,
+ "spaces" => [
+ {
+ "building_ref_id" => "vergesense_building_id",
+ "floor_ref_id" => "floor_id",
+ "space_ref_id" => "CR_0721",
+ "space_type" => "conference_room",
+ "name" => "Conference Room 0721",
+ "capacity" => 30,
+ "max_capacity" => 32,
+ "geometry" => {"type" => "Polygon", "coordinates" => [[[93.850772, 44.676952], [93.850739, 44.676929], [93.850718, 44.67695], [93.850751, 44.676973], [93.850772, 44.676952], [93.850772, 44.676952]]]},
+ "people" => {
+ "count" => 21,
+ "coordinates" => [[[2.2673, 4.3891], [6.2573, 1.5303]]],
+ },
+ "timestamp" => "2019-08-21T21:10:25Z",
+ "motion_detected" => true,
+ },
+ {
+ "building_ref_id" => "vergesense_building_id",
+ "floor_ref_id" => "floor_id",
+ "space_ref_id" => "CR_0722",
+ "space_type" => "desk",
+ "name" => "desk-1234",
+ "capacity" => 1,
+ "max_capacity" => 1,
+ "geometry" => {"type" => "Polygon", "coordinates" => [[[93.850772, 44.676952], [93.850739, 44.676929], [93.850718, 44.67695], [93.850751, 44.676973], [93.850772, 44.676952], [93.850772, 44.676952]]]},
+ "people" => {
+ "count" => 1,
+ "coordinates" => [[[2.2673, 4.3891], [6.2573, 1.5303]]],
+ },
+ "timestamp" => "2019-08-21T21:10:25Z",
+ "motion_detected" => true,
+ },
+ ],
+ }
+ end
+end
+
+# :nodoc:
+class AreaManagementMock < DriverSpecs::MockDriver
+ def update_available(zones : Array(String))
+ logger.info { "requested update to #{zones}" }
+ nil
+ end
+end
diff --git a/drivers/vergesense/models.cr b/drivers/vergesense/models.cr
new file mode 100644
index 00000000000..b4053cb02be
--- /dev/null
+++ b/drivers/vergesense/models.cr
@@ -0,0 +1,123 @@
+require "json"
+
+# Vergesense Data Models
+module Vergesense
+ struct Building
+ include JSON::Serializable
+
+ property name : String
+ property building_ref_id : String
+ property address : String?
+ end
+
+ struct BuildingWithFloors
+ include JSON::Serializable
+
+ property building_ref_id : String
+ property floors : Array(Floor)
+ end
+
+ struct Floor
+ include JSON::Serializable
+
+ property floor_ref_id : String
+ property name : String
+ property capacity : UInt32?
+ property max_capacity : UInt32?
+ property spaces : Array(Space)
+ end
+
+ struct Sensor
+ include JSON::Serializable
+
+ property units : String
+ property value : Float64
+ end
+
+ struct Environment
+ include JSON::Serializable
+
+ property sensor : String
+ property timestamp : Time
+
+ property humidity : Sensor
+ property iaq : Sensor?
+ property temperature : Sensor
+ end
+
+ struct Report
+ include JSON::Serializable
+
+ property timestamp : Time
+ property person_count : Int32?
+ property signs_of_life : Bool?
+ end
+
+ struct Pagination
+ include JSON::Serializable
+
+ def initialize
+ @next_page = nil
+ end
+
+ @[JSON::Field(key: "next")]
+ property next_page : String?
+ end
+
+ class Space
+ include JSON::Serializable
+
+ property building_ref_id : String?
+ property floor_ref_id : String?
+ property space_ref_id : String?
+ property space_type : String?
+ property name : String?
+ property capacity : UInt32?
+ property max_capacity : UInt32?
+ # property geometry : Geometry?
+ property people : People?
+ property last_reports : Array(Report)?
+ property environment : Environment?
+ property timestamp : Time?
+ property motion_detected : Bool?
+
+ def signs_of_life? : Bool?
+ if report = last_reports.try &.first?
+ report.signs_of_life if report.timestamp >= 2.hours.ago
+ end
+ end
+
+ def last_report_time? : Time?
+ if report = last_reports.try &.first?
+ report.timestamp
+ elsif env = environment
+ env.timestamp
+ end
+ end
+
+ # NOTE:: not returned by the API, we fill this in
+ property signs_of_life : Bool?
+
+ def floor_key
+ "#{building_ref_id}-#{floor_ref_id}".strip
+ end
+
+ def ref_id
+ self.space_ref_id || self.floor_ref_id || self.space_type
+ end
+ end
+
+ struct Geometry
+ include JSON::Serializable
+
+ property type : String
+ property coordinates : Array(Array(Array(Float64)))
+ end
+
+ struct People
+ include JSON::Serializable
+
+ property count : UInt32?
+ # property coordinates : Array(Array(Float64)?)?
+ end
+end
diff --git a/drivers/vergesense/mqtt_export.cr b/drivers/vergesense/mqtt_export.cr
new file mode 100644
index 00000000000..7b1f12944d9
--- /dev/null
+++ b/drivers/vergesense/mqtt_export.cr
@@ -0,0 +1,64 @@
+require "placeos-driver"
+require "./models"
+
+class Vergesense::MqttExport < PlaceOS::Driver
+ descriptive_name "Vergesense to MQTT Exporter"
+ generic_name :VergesenseToMQTT
+ description %(Export Vergesense people count data to an MQTT consumer)
+
+ accessor vergesense : Vergesense_1
+ accessor mqtt : GenericMQTT_1
+
+ default_settings({
+ mqtt_root_topic: "/t/root-topic/",
+ floors_to_export: [
+ "vergesense_building_id-floor_id",
+ ],
+ debug: false,
+ })
+
+ @mqtt_root_topic : String = "/t/root-topic/"
+ @floors_to_export : Array(String) = [] of String
+ @debug : Bool = false
+
+ @subscriptions : Int32 = 0
+ @previous_counts = Hash(String, UInt32).new
+
+ def on_update
+ @mqtt_root_topic = setting(String, :mqtt_root_topic) || "/t/root-topic"
+ @floors_to_export = setting(Array(String), :floors_to_export) || [] of String
+ @debug = setting(Bool, :debug) || false
+
+ subscriptions.clear
+ @subscriptions = 0
+ @floors_to_export.each do |floor|
+ system.subscribe(:Vergesense_1, floor) do |_subscription, vergesense_floor_json|
+ vergesense_to_mqtt(Floor.from_json(vergesense_floor_json))
+ end
+ @subscriptions += 1
+ end
+ end
+
+ def inspect_state
+ {
+ vergesense_subscriptions: @subscriptions,
+ people_counts: @previous_counts,
+ }
+ end
+
+ private def vergesense_to_mqtt(vergesense_floor : Floor)
+ # Determine which spaces have had their people count change
+ changed_spaces = vergesense_floor.spaces.reject { |s| s.people.try &.count == @previous_counts[s.space_ref_id]? }
+ logger.debug { "#{changed_spaces.size}/#{vergesense_floor.spaces.size} spaces have changed" } if @debug
+ # Publish the new values
+ changed_spaces.each do |s|
+ next unless s.space_ref_id
+ space_id = s.space_ref_id.not_nil!.gsub(/[ \/]/, "")
+ topic = [@mqtt_root_topic, s.building_ref_id, "-", s.floor_ref_id, ".", s.space_type, ".", space_id, ".", "count"].join.downcase
+ # Store the current value, for comparison next time
+ @previous_counts[space_id] = payload = s.people.try &.count || 0_u32
+ mqtt.publish(topic, payload.to_s).get
+ logger.debug { "Published #{payload} to #{topic}" } if @debug
+ end
+ end
+end
diff --git a/drivers/vergesense/room_sensor.cr b/drivers/vergesense/room_sensor.cr
new file mode 100644
index 00000000000..fb3c4ab703d
--- /dev/null
+++ b/drivers/vergesense/room_sensor.cr
@@ -0,0 +1,162 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "./models"
+
+class Vergesense::RoomSensor < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ descriptive_name "Vergesense Room Sensor"
+ generic_name :Sensor
+
+ default_settings({
+ space_ref_id: "vergesense-room-id",
+ })
+
+ accessor vergesense : Vergesense_1
+
+ @space_id : String = ""
+ @floor_key : String = ""
+
+ getter! space : Space
+ getter! floor_name : String
+
+ def on_update
+ @space_id = setting(String, :space_ref_id)
+ subscriptions.clear
+ schedule.clear
+
+ # Level sensors
+ system.subscribe(:Vergesense, 1, "init_complete") do |_sub, value|
+ subscribe_to_sensor if value == "true"
+ end
+ end
+
+ protected def subscribe_to_sensor : Nil
+ @floor_key = vergesense.floor_key(@space_id).get.as_s
+ system.subscribe(:Vergesense, 1, @floor_key) { |_sub, floor| update_sensor_state(floor) }
+ self[:floor_key] = @floor_key
+ rescue error
+ schedule.in(15.seconds) { subscribe_to_sensor }
+ logger.warn(exception: error) { "attempting to bind to sensor details" }
+ self[:last_error] = error.message
+ self[:floor_key] = "unknown space_ref_id in settings"
+ end
+
+ protected def update_sensor_state(level : String)
+ floor = Floor.from_json(level)
+ @floor_name = floor.name
+ @space = floor_space = floor.spaces.find { |space| space.space_ref_id == @space_id }
+ raise "space '#{@space_id}' not found" unless floor_space
+
+ self[:last_changed] = Time.utc.to_unix
+
+ people_count = floor_space.people.try &.count
+ if people_count
+ self[:presence] = people_count > 0
+ self[:people] = people_count
+ else
+ self[:presence] = false
+ self[:people] = 0
+ end
+
+ self[:humidity] = floor_space.environment.try &.humidity.value
+ self[:temperature] = floor_space.environment.try &.temperature.value
+ self[:air_quality] = floor_space.environment.try(&.iaq.try(&.value))
+
+ self[:capacity] = floor_space.max_capacity || floor_space.capacity
+ end
+
+ # ======================
+ # Sensor interface
+ # ======================
+
+ SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence, SensorType::Humidity, SensorType::Temperature, SensorType::AirQuality}
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+
+ return NO_MATCH if mac && mac != "verg-#{@space_id}"
+ if type
+ sensor_type = SensorType.parse(type)
+ return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type)
+ end
+ return NO_MATCH if zone_id && !system.zones.includes?(zone_id)
+
+ if sensor_type
+ sensor = build_sensor_details(sensor_type)
+ return NO_MATCH unless sensor
+ [sensor]
+ else
+ space_sensors
+ end
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+ return nil unless id
+ return nil unless mac == "verg-#{@space_id}"
+
+ case id
+ when "people"
+ build_sensor_details(:people_count)
+ when "presence"
+ build_sensor_details(:presence)
+ when "humidity"
+ build_sensor_details(:humidity)
+ when "temperature"
+ build_sensor_details(:temperature)
+ when "air_quality"
+ build_sensor_details(:air_quality)
+ end
+ end
+
+ protected def build_sensor_details(sensor : SensorType) : Detail?
+ time = space.timestamp || space.environment.try(&.timestamp) || Time.utc
+ id = "people"
+ limit_high = nil
+ value = case sensor
+ when .people_count?
+ limit_high = (space.max_capacity || space.capacity).try &.to_f64
+ space.people.try &.count.try &.to_f64
+ when .presence?
+ id = "presence"
+ space.people.try &.count.try { |count| count > 0 ? 1.0 : 0.0 } || 0.0
+ when .humidity?
+ id = "humidity"
+ space.environment.try &.humidity.value
+ when .temperature?
+ id = "temperature"
+ space.environment.try &.temperature.value
+ when .air_quality?
+ id = "air_quality"
+ space.environment.try(&.iaq.try(&.value))
+ else
+ raise "sensor type unavailable: #{sensor}"
+ end
+ return nil unless value
+
+ Detail.new(
+ type: sensor,
+ value: value,
+ last_seen: time.to_unix,
+ mac: "verg-#{@space_id}",
+ id: id,
+ name: "#{floor_name} #{space.name} (#{space.space_type})",
+ limit_high: limit_high,
+ module_id: module_id,
+ binding: id
+ )
+ end
+
+ protected def space_sensors
+ [
+ build_sensor_details(:people_count),
+ build_sensor_details(:presence),
+ build_sensor_details(:humidity),
+ build_sensor_details(:temperature),
+ build_sensor_details(:air_quality),
+ ].compact
+ end
+end
diff --git a/drivers/vergesense/room_sensor_spec.cr b/drivers/vergesense/room_sensor_spec.cr
new file mode 100644
index 00000000000..fbe9ece2b75
--- /dev/null
+++ b/drivers/vergesense/room_sensor_spec.cr
@@ -0,0 +1,54 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Vergesense::RoomSensor" do
+ system({
+ Vergesense: {VergesenseMock},
+ })
+
+ sleep 200.milliseconds
+
+ status[:presence].should eq(true)
+ status[:people].should eq(21)
+ status[:capacity].should eq(3)
+
+ sensors = exec(:sensors).get.not_nil!.as_a
+ sensors.size.should eq 2
+
+ sensor = exec(:sensor, sensors[0]["mac"], sensors[0]["id"]).get
+ sensors[0].should eq sensor
+end
+
+# :nodoc:
+class VergesenseMock < DriverSpecs::MockDriver
+ def on_load
+ self[:floor_key] = {
+ "floor_ref_id" => "FL1",
+ "name" => "Floor 1",
+ "capacity" => 84,
+ "max_capacity" => 60,
+ "spaces" => [
+ {
+ "building_ref_id" => "HQ1",
+ "floor_ref_id" => "FL1",
+ "space_ref_id" => "vergesense-room-id",
+ "space_type" => "conference_room",
+ "name" => "Conference Room 0721",
+ "capacity" => 4,
+ "max_capacity" => 3,
+ "geometry" => {"type" => "Polygon", "coordinates" => [[[93.850772, 44.676952], [93.850739, 44.676929], [93.850718, 44.67695], [93.850751, 44.676973], [93.850772, 44.676952], [93.850772, 44.676952]]]},
+ "people" => {
+ "count" => 21,
+ "coordinates" => [[2.2673, 4.3891], [6.2573, 1.5303]],
+ },
+ "timestamp" => "2019-08-21T21:10:25Z",
+ "motion_detected" => true,
+ },
+ ],
+ }
+ self[:init_complete] = true
+ end
+
+ def floor_key(space_id : String)
+ "floor_key"
+ end
+end
diff --git a/drivers/vergesense/vergesense_api.cr b/drivers/vergesense/vergesense_api.cr
new file mode 100644
index 00000000000..b2ae77a15bd
--- /dev/null
+++ b/drivers/vergesense/vergesense_api.cr
@@ -0,0 +1,199 @@
+require "placeos-driver"
+require "./models"
+
+# docs: https://vergesense.readme.io/reference/reference-getting-started
+
+class Vergesense::VergesenseAPI < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Vergesense API"
+ generic_name :Vergesense
+ uri_base "https://api.vergesense.com"
+ description "for more information visit: https://vergesense.readme.io/"
+
+ default_settings({
+ vergesense_api_key: "VS-API-KEY",
+ })
+
+ @api_key : String = ""
+
+ getter buildings : Array(Building) = [] of Building
+ getter floors : Hash(String, Floor) = {} of String => Floor
+ getter spaces : Hash(String, String) = {} of String => String
+
+ @debug_payload : Bool = false
+ @poll_every : Time::Span? = nil
+ @sync_lock : Mutex = Mutex.new
+
+ def on_load
+ on_update
+ schedule.in(200.milliseconds) { init_sync }
+ end
+
+ def on_update
+ @api_key = setting(String, :vergesense_api_key)
+ @debug_payload = setting?(Bool, :debug_payload) || false
+
+ @poll_every = setting?(Int32, :poll_every).try &.seconds
+
+ schedule.clear
+ if poll_time = @poll_every
+ schedule.every(poll_time) { init_sync }
+ end
+ end
+
+ # Performs initial sync by loading buildings / floors / spaces
+ def init_sync
+ @sync_lock.synchronize do
+ init_buildings
+
+ if @buildings
+ init_floors
+ init_spaces
+ init_floors_status
+
+ self["init_complete"] = true
+ end
+ end
+ rescue e
+ logger.error { "failed to perform vergesense API sync\n#{e.inspect_with_backtrace}" }
+ end
+
+ EMPTY_HEADERS = {} of String => String
+ SUCCESS_RESPONSE = {HTTP::Status::OK, EMPTY_HEADERS, nil}
+
+ # Webhook endpoint for space_report API, expects version 2
+ def space_report_api(method : String, headers : Hash(String, Array(String)), body : String)
+ logger.debug { "space_report API received: #{method},\nheaders #{headers},\nbody size #{body.size}" }
+ logger.debug { body } if @debug_payload
+
+ # Parse the data posted
+ begin
+ remote_space = Space.from_json(body)
+ logger.debug { "parsed vergesense payload" }
+
+ update_floor_space(remote_space)
+ update_single_floor_status(remote_space.floor_key, @floors[remote_space.floor_key]?)
+ rescue e
+ logger.error { "failed to parse vergesense space_report API payload\n#{e.inspect_with_backtrace}" }
+ logger.debug { "failed payload body was\n#{body}" }
+ end
+
+ # Return a 200 response
+ SUCCESS_RESPONSE
+ end
+
+ def floor_key(space_id : String)
+ @spaces[space_id]
+ end
+
+ private def init_buildings
+ @buildings = Array(Building).from_json(get_request("/buildings"))
+ end
+
+ private def init_floors
+ @buildings.not_nil!.each do |building|
+ building_with_floors = BuildingWithFloors.from_json(get_request("/buildings/#{building.building_ref_id}"))
+ if building_with_floors
+ building_with_floors.floors.each do |floor|
+ floor_key = "#{building.building_ref_id}-#{floor.floor_ref_id}".strip
+ @floors[floor_key] = floor
+ end
+ end
+ end
+ @floors
+ end
+
+ private def init_spaces
+ spaces = Array(Space).from_json(get_request("/spaces"))
+ spaces.each do |remote_space|
+ update_floor_space(remote_space)
+ end
+
+ spaces
+ end
+
+ private def init_floors_status
+ @floors.each do |floor_key, floor|
+ update_single_floor_status(floor_key, floor)
+ end
+ end
+
+ private def update_single_floor_status(floor_key, floor)
+ if floor_key && floor
+ self[floor_key] = floor.not_nil!
+ end
+ end
+
+ # Finds a space on a given floor and updates it in place.
+ private def update_floor_space(remote_space)
+ floor = @floors[remote_space.floor_key]?
+ if floor
+ floor_space = floor.spaces.find { |space| space.ref_id == remote_space.ref_id }
+ if floor_space
+ floor_space.building_ref_id = remote_space.building_ref_id
+ floor_space.floor_ref_id = remote_space.floor_ref_id
+ floor_space.people = remote_space.people
+ floor_space.motion_detected = remote_space.motion_detected
+ floor_space.timestamp = remote_space.last_report_time?
+ floor_space.environment = remote_space.environment
+ floor_space.name = remote_space.name if remote_space.name
+ floor_space.capacity = remote_space.capacity if remote_space.capacity
+ floor_space.max_capacity = remote_space.max_capacity if remote_space.max_capacity
+ floor_space.signs_of_life = remote_space.signs_of_life?
+ else
+ remote_space.signs_of_life = remote_space.signs_of_life?
+ floor.spaces << remote_space
+ end
+
+ # cache the lookups for simple floor discovery
+ if space_ref = remote_space.ref_id
+ @spaces[space_ref] = remote_space.floor_key
+ end
+ end
+ end
+
+ private def check_hardware_state
+ # paginated_request("/hardware/sensors") do |raw_json|
+ # end
+ end
+
+ private def get_request(path)
+ response = get(path,
+ headers: {
+ "vs-api-key" => @api_key,
+ }
+ )
+
+ if response.success?
+ response.body
+ else
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+ end
+
+ private def paginated_request(path)
+ max_pages = 100
+ loop do
+ response = get(path,
+ headers: {
+ "vs-api-key" => @api_key,
+ }
+ )
+
+ if response.success?
+ body_json = response.body
+ yield body_json
+
+ max_pages -= 1
+ break if max_pages <= 0
+
+ pagination = Pagination.from_json(body_json, "links") rescue Pagination.new
+ next_request = pagination.next_page
+ break unless next_request
+ path = next_request
+ else
+ raise "unexpected response #{response.status_code}\n#{response.body}"
+ end
+ end
+ end
+end
diff --git a/drivers/vergesense/vergesense_api_spec.cr b/drivers/vergesense/vergesense_api_spec.cr
new file mode 100644
index 00000000000..dac15cc826a
--- /dev/null
+++ b/drivers/vergesense/vergesense_api_spec.cr
@@ -0,0 +1,187 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Vergesense::VergesenseAPI" do
+ expect_http_request do |request, response|
+ case request.path
+ when "/buildings"
+ response.status_code = 200
+ response << %([{
+ "name": "HQ 1",
+ "building_ref_id": "HQ1",
+ "address": null
+ }])
+ end
+ end
+
+ puts "SENT BUILDINGS"
+
+ expect_http_request do |request, response|
+ case request.path
+ when "/buildings/HQ1"
+ response.status_code = 200
+ response << %({
+ "building_ref_id": "HQ1",
+ "capacity": 84,
+ "minimum_social_distance": 2.0,
+ "floors": [
+ {
+ "name": "Floor 1",
+ "floor_ref_id": "FL1",
+ "capacity": 84,
+ "max_capacity": 60,
+ "spaces": [
+ {
+ "name": "Conference Room 0721",
+ "space_ref_id": "CR_0721",
+ "space_type": "conference_room",
+ "capacity": 4,
+ "max_capacity": 3,
+ "sensors": [
+ {
+ "id": "L_000018",
+ "partitions": [
+ {
+ "id": "L_000018/321"
+ }
+ ]
+ }
+ ],
+ "geometry": {
+ "type": "Polygon",
+ "coordinates": [
+ [
+ [
+ 93.850772,
+ 44.676952
+ ],
+ [
+ 93.850739,
+ 44.676929
+ ],
+ [
+ 93.850718,
+ 44.67695
+ ],
+ [
+ 93.850751,
+ 44.676973
+ ],
+ [
+ 93.850772,
+ 44.676952
+ ],
+ [
+ 93.850772,
+ 44.676952
+ ]
+ ]
+ ]
+ }
+ }
+ ]
+ }
+ ]
+})
+ end
+ end
+
+ puts "SENT FLOORS"
+
+ expect_http_request do |request, response|
+ case request.path
+ when "/spaces"
+ response.status_code = 200
+ response << %([
+ {
+ "building_ref_id": "HQ1",
+ "floor_ref_id": "FL1",
+ "space_ref_id": "CR_0721",
+ "name": "Conference Room 0721",
+ "space_type": "conference_room",
+ "last_reports": [
+ {
+ "id": "W91-IGI",
+ "person_count": 2,
+ "signs_of_life": null,
+ "motion_detected": null,
+ "timestamp": "2019-07-29T18:42:19Z"
+ }
+ ],
+ "people": {
+ "count": 2,
+ "distances": {
+ "units": "meters",
+ "values": [2.42]
+ }
+ }
+ }
+])
+ end
+ end
+
+ puts "SENT SPACES"
+
+ status["HQ1-FL1"].should eq({
+ "floor_ref_id" => "FL1",
+ "name" => "Floor 1",
+ "capacity" => 84,
+ "max_capacity" => 60,
+ "spaces" => [
+ {
+ "building_ref_id" => "HQ1",
+ "floor_ref_id" => "FL1",
+ "space_ref_id" => "CR_0721",
+ "space_type" => "conference_room",
+ "name" => "Conference Room 0721",
+ "capacity" => 4,
+ "max_capacity" => 3,
+ "people" => {"count" => 2},
+ },
+ ],
+ })
+
+ # Testing webhook save
+ webhook_space_report_event = %({
+ "building_ref_id": "HQ1",
+ "floor_ref_id": "FL1",
+ "space_ref_id": "CR_0721",
+ "sensor_ids": ["VS0-123", "VS1-321"],
+ "person_count": 21,
+ "signs_of_life": null,
+ "motion_detected": true,
+ "event_type": "space_report",
+ "timestamp": "2019-08-21T21:10:25Z",
+ "people": {
+ "count": 21,
+ "distances": {
+ "units": "meters",
+ "values": [1.5]
+ }
+ }
+ })
+
+ exec(:space_report_api, method: "update", headers: {"test" => ["test"]}, body: webhook_space_report_event).get
+
+ status["HQ1-FL1"].should eq({
+ "floor_ref_id" => "FL1",
+ "name" => "Floor 1",
+ "capacity" => 84,
+ "max_capacity" => 60,
+ "spaces" => [
+ {
+ "building_ref_id" => "HQ1",
+ "floor_ref_id" => "FL1",
+ "space_ref_id" => "CR_0721",
+ "space_type" => "conference_room",
+ "name" => "Conference Room 0721",
+ "capacity" => 4,
+ "max_capacity" => 3,
+ "people" => {
+ "count" => 21,
+ },
+ "timestamp" => "2019-08-21T21:10:25Z",
+ "motion_detected" => true,
+ },
+ ],
+ })
+end
diff --git a/drivers/whispir/messages.cr b/drivers/whispir/messages.cr
new file mode 100644
index 00000000000..f2c8cfbd33d
--- /dev/null
+++ b/drivers/whispir/messages.cr
@@ -0,0 +1,55 @@
+require "placeos-driver"
+require "placeos-driver/interface/sms"
+
+# Documentation: https://whispir.github.io/api/#messages
+
+class Whispir::Messages < PlaceOS::Driver
+ include Interface::SMS
+
+ # Discovery Information
+ generic_name :SMS
+ descriptive_name "Whispir messages service"
+ uri_base "https://api.au.whispir.com"
+
+ # For whatever reason, you need both basic auth and an API key
+ default_settings({
+ basic_auth: {
+ username: "username",
+ password: "password",
+ },
+ api_key: "12345",
+ })
+
+ @api_key : String = ""
+
+ def on_update
+ @api_key = setting(String, :api_key)
+ end
+
+ def send_sms(
+ phone_numbers : String | Array(String),
+ message : String,
+ format : String? = "SMS",
+ source : String? = nil
+ )
+ phone_numbers = [phone_numbers] unless phone_numbers.is_a?(Array)
+
+ response = post("/messages?apikey=#{@api_key}", body: {
+ to: phone_numbers.join(";"),
+ # As far as I can tell, this field is not passed to the recipients
+ subject: "PlaceOS Notification",
+ body: message,
+ }.to_json, headers: {
+ "Content-Type" => "application/vnd.whispir.message-v1+json",
+ "Accept" => "application/vnd.whispir.message-v1+json",
+ "x-api-key" => @api_key,
+ })
+
+ raise "request failed with #{response.status_code}" unless response.status_code == 202
+
+ location = response.headers["Location"]?
+ logger.debug { "message sent: #{location}" }
+
+ location
+ end
+end
diff --git a/drivers/whispir/messages_spec.cr b/drivers/whispir/messages_spec.cr
new file mode 100644
index 00000000000..ad58dc179d1
--- /dev/null
+++ b/drivers/whispir/messages_spec.cr
@@ -0,0 +1,32 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Whispir::Messages" do
+ # Send the request
+ retval = exec(:send_sms,
+ phone_numbers: "+61418419954",
+ message: "hello steve"
+ )
+
+ # sms should send a HTTP request
+ expect_http_request do |request, response|
+ headers = request.headers
+ io = request.body
+ if io
+ data = io.gets_to_end
+ request = JSON.parse(data)
+ if request["to"] == "+61418419954" &&
+ headers["x-api-key"]? == "12345" &&
+ headers["Authorization"]? == "Basic #{Base64.strict_encode("username:password")}"
+ response.status_code = 202
+ response.headers["Location"] = "https://api.au.whispir.com/messages/id"
+ else
+ response.status_code = 401
+ end
+ else
+ raise "expected request to include dialing details #{request.inspect}"
+ end
+ end
+
+ # What the sms function should return
+ retval.get.should eq("https://api.au.whispir.com/messages/id")
+end
diff --git a/drivers/wiegand/models.cr b/drivers/wiegand/models.cr
new file mode 100644
index 00000000000..cb2a08b3aa4
--- /dev/null
+++ b/drivers/wiegand/models.cr
@@ -0,0 +1,103 @@
+module Wiegand
+ # Ported from: https://github.com/acaprojects/ruby-engine-drivers/blob/beta/lib/hid/algorithms.rb
+ class Base
+ property wiegand : UInt64
+ property facility : UInt32
+ property card_number : UInt32
+
+ def initialize(wiegand : UInt64, facility : UInt32, card_number : UInt32)
+ @wiegand = wiegand
+ @facility = facility
+ @card_number = card_number
+ end
+
+ def self.count_1s(int : UInt32 | UInt64)
+ int.to_s(2).gsub("0", "").size
+ end
+ end
+
+ class Wiegand26 < Base
+ FAC_PAR_MASK = 0b11111111100000000000000000
+ FACILITY_MASK = 0b01111111100000000000000000
+ CARD_MASK = 0b00000000011111111111111110
+ CARD_PAR_MASK = 0b00000000011111111111111111
+
+ # Convert wiegand 26 card data to components
+ #
+ # Hex card data: 0x21a6616
+ # Card Number: 13067
+ # Card Facility Code: 13
+ def from_wiegand(wiegand : UInt64)
+ card_number = (wiegand & CARD_MASK) >> 1
+ card_1s = count_1s(wiegand & CARD_PAR_MASK)
+
+ facility = (wiegand & FACILITY_MASK) >> 17
+ facility_1s = count_1s(wiegand & FAC_PAR_MASK)
+
+ parity_passed = card_1s.odd? && facility_1s.even?
+ raise "parity check error" unless parity_passed
+
+ Wiegand26.new(wiegand.to_u64, facility, card_number)
+ end
+
+ # Convert components to wiegand 26 card data
+ def self.from_components(facility : UInt32, card_number : UInt32)
+ wiegand = 0
+
+ wiegand += card_number << 1
+ # Build the card parity bit (should be an odd number of ones)
+ wiegand += (FAC_PAR_MASK ^ FACILITY_MASK) if count_1s(card_number).odd?
+
+ wiegand += facility << 17
+ # Build facility parity bit (should be an even number of ones)
+ wiegand += 1 if count_1s(facility).even?
+
+ Wiegand26.new(wiegand.to_u64, facility, card_number)
+ end
+ end
+
+ class Wiegand35 < Base
+ PAR_EVEN_MASK = 0b01101101101101101101101101101101100
+ PAR_ODD_MASK = 0b00110110110110110110110110110110110
+ CARD_MASK = 0b00000000000001111111111111111111100
+ FACILITY_MASK = 0b01111111111110000000000000000000000
+
+ # Outputs the HEX code of what is written to the swipe card
+ #
+ # Hex card data: 0x06F20107F
+ # Card Number: 2540
+ # Card Facility Code: 4033
+ def from_components(facility : UInt32, card_number : UInt32)
+ wiegand = (facility << 22) + (card_number << 2)
+ even_count = count_1s(wiegand & PAR_EVEN_MASK)
+ odd_count = count_1s(wiegand & PAR_ODD_MASK)
+
+ # Even Parity
+ wiegand += (1 << 34) if even_count.odd?
+
+ # Odd Parity
+ wiegand += 2 if odd_count.even?
+ wiegand = wiegand.to_s(2).rjust(35, '0').reverse.to_i(2)
+
+ Wiegand35.new(wiegand.to_u64, facility, card_number)
+ end
+
+ # Convert wiegand 35 card data to components
+ #
+ # 1 + 12 + 20 + 2
+ # 1 + facility + card num + 2
+ def self.from_wiegand(wiegand)
+ str = wiegand.to_s(2).rjust(35, '0').reverse
+ data = str.to_i(2)
+ even_count = count_1s(data & PAR_EVEN_MASK) + (str[0] == '1' ? 1 : 0)
+ odd_count = count_1s(data & PAR_ODD_MASK)
+
+ parity_passed = odd_count.odd? && even_count.even?
+ raise "parity check error" unless parity_passed
+
+ facility = (data & FACILITY_MASK) >> 22
+ card_number = (data & CARD_MASK) >> 2
+ Wiegand35.new(wiegand.to_u64, facility, card_number)
+ end
+ end
+end
diff --git a/drivers/williams_av/wave_cast_fm.cr b/drivers/williams_av/wave_cast_fm.cr
new file mode 100644
index 00000000000..9c7f46ad2a8
--- /dev/null
+++ b/drivers/williams_av/wave_cast_fm.cr
@@ -0,0 +1,281 @@
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/WilliamsAV/WaveCAST-MAN-262D-WCFM.pdf
+
+class WilliamsAV::WaveCastFM < PlaceOS::Driver
+ # Discovery Information:
+ generic_name :HearingAugmentation
+ descriptive_name "WilliamsAV WaveCast / FM"
+ uri_base "http://192.168.0.1"
+
+ default_settings({
+ # only a single connection can be maintained at a time
+ http_max_requests: 0,
+ # supports 0-7
+ channel_number: 0,
+
+ hearing_notice_text: "Hearing augmentation code:",
+ })
+
+ @hearing_notice_text : String = "Hearing augmentation code:"
+
+ def on_update
+ @channel_number = setting?(Int32, :channel_number)
+ @hearing_notice_text = setting?(String, :hearing_notice_text) || "Hearing augmentation code:"
+ schedule.clear
+
+ # ensure query run at the same time for offset to work
+ schedule.cron("* * * * *") { connected }
+ end
+
+ def channel_offset
+ (3000 * (@channel_number || 0)) + rand(750)
+ end
+
+ def connected
+ schedule.in(channel_offset.milliseconds) { query_state }
+ end
+
+ getter channel_number : Int32? = nil
+
+ enum Command
+ TDU8_REBOOT
+ TDU8_RESTORE_DEFAULTS
+ TDU8_VU_METER_VALUE
+ TDU8_INPUT_GAIN
+ TDU8_INPUT_SOURCE
+ TDU8_PRESET
+ TDU8_HIGH_PASS
+ TDU8_LOW_PASS
+ TDU8_COMPRESSION
+ TDU8_USE_DHCP
+ TDU8_AUDIO_TX_MODE
+ TDU8_TTL
+ TDU8_SECURE_MODE
+ TDU8_PANEL_LOCK
+ TDU32_RF_TIMEOUT
+ TDU8_RF_CHANNEL
+ TDU8_RF_17_CHANNEL_MODE
+ TDU8_RF_POWER
+ TDSTR_SERVER_NAME
+ TDSTR_STATIC_IP_ADDR
+ TDSTR_STATIC_SUBNET_MASK
+ TDSTR_STATIC_GATEWAY_ADDR
+ TDSTR_MULTICAST_ADDR
+ TDSTR_JOIN_CODE
+ end
+
+ enum Type
+ TT_FLOAT # float
+ TT_U8 # uint8
+ TT_U32 # uint32
+ TT_S8 # int8
+ TT_S32 # int32
+ TT_STRING
+ end
+
+ TYPES = {
+ Command::TDU8_REBOOT => Type::TT_U8,
+ Command::TDU8_RESTORE_DEFAULTS => Type::TT_U8,
+ Command::TDU8_VU_METER_VALUE => Type::TT_U8,
+ Command::TDU8_INPUT_GAIN => Type::TT_U8,
+ Command::TDU8_INPUT_SOURCE => Type::TT_U8,
+ Command::TDU8_PRESET => Type::TT_U8,
+ Command::TDU8_HIGH_PASS => Type::TT_U8,
+ Command::TDU8_LOW_PASS => Type::TT_U8,
+ Command::TDU8_COMPRESSION => Type::TT_U8,
+ Command::TDU8_USE_DHCP => Type::TT_U8,
+ Command::TDU8_AUDIO_TX_MODE => Type::TT_U8,
+ Command::TDU8_TTL => Type::TT_U8,
+ Command::TDU8_SECURE_MODE => Type::TT_U8,
+ Command::TDU8_PANEL_LOCK => Type::TT_U8,
+ Command::TDU32_RF_TIMEOUT => Type::TT_U32,
+ Command::TDU8_RF_CHANNEL => Type::TT_U8,
+ Command::TDU8_RF_17_CHANNEL_MODE => Type::TT_U8,
+ Command::TDU8_RF_POWER => Type::TT_U8,
+ Command::TDSTR_SERVER_NAME => Type::TT_STRING,
+ Command::TDSTR_STATIC_IP_ADDR => Type::TT_STRING,
+ Command::TDSTR_STATIC_SUBNET_MASK => Type::TT_STRING,
+ Command::TDSTR_STATIC_GATEWAY_ADDR => Type::TT_STRING,
+ Command::TDSTR_MULTICAST_ADDR => Type::TT_STRING,
+ Command::TDSTR_JOIN_CODE => Type::TT_STRING,
+ }
+
+ def query_state
+ if channel = channel_number
+ body_data = URI::Params.build { |form|
+ form.add "type", "TT_U8"
+ form.add "id", "TDU8_CURRENT_CHANNEL"
+ form.add "value", channel.to_s
+ }.to_s
+
+ logger.debug { "switching current channel to: #{channel}" }
+
+ response = post("/TBL-WRITE", body: body_data)
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+ end
+
+ response = get("/TBL-READ?All")
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ count = 0
+ response.body.split('\n').each do |line|
+ next unless line.presence
+ parts = line.split(",").map!(&.strip)
+ begin
+ type = Type.parse(parts[0])
+ command = Command.parse?(parts[1]) || parts[1]
+ value_raw = parts[2]
+
+ value = case type
+ in Type::TT_FLOAT
+ value_raw.to_f
+ in Type::TT_U8, Type::TT_U32, Type::TT_S8, Type::TT_S32
+ value_raw.to_i
+ in Type::TT_STRING
+ value_raw
+ end
+
+ set_status(command, value)
+ count += 1
+ rescue error
+ raise "error parsing response line\n#{error.inspect_with_backtrace}"
+ end
+ end
+ "#{count} values updated"
+ end
+
+ protected def set_status(command : Command | String, value)
+ command_str = command.to_s.split('_', 2)[1].downcase
+
+ case command
+ when Command::TDU8_SECURE_MODE
+ command_str = "join_code_enabled"
+ value = value == 1
+ when Command::TDU8_AUDIO_TX_MODE
+ command_str = "transmit_multicast"
+ value = value == 1
+ when Command::TDU8_PANEL_LOCK
+ value = value == 1
+ when Command::TDU8_INPUT_SOURCE
+ value = InputSource.from_value?(value.to_i) || value
+ when Command::TDU8_PRESET
+ value = Preset.from_value?(value.to_i) || value
+ when Command::TDSTR_JOIN_CODE
+ self[:hearing_notice_text] = "#{@hearing_notice_text} #{value}"
+ end
+
+ self[command_str] = value
+ end
+
+ @[Security(Level::Administrator)]
+ def write(command : Command, value : UInt8 | UInt32 | String)
+ body_data = URI::Params.build { |form|
+ if channel = channel_number
+ form.add "type", "TT_U8"
+ form.add "id", "TDU8_CURRENT_CHANNEL"
+ form.add "value", channel.to_s
+ end
+ form.add "type", TYPES[command].to_s
+ form.add "id", command.to_s
+ form.add "value", value.to_s
+ }.to_s
+
+ logger.debug { "updating setting: #{body_data}" }
+
+ response = post("/TBL-WRITE", body: body_data)
+ raise "request failed with #{response.status_code}\n#{response.body}" unless response.success?
+
+ set_status(command, value)
+ end
+
+ @[Security(Level::Support)]
+ def enable_join_code(state : Bool)
+ write(Command::TDU8_SECURE_MODE, state ? 1_u8 : 0_u8)
+ end
+
+ @[Security(Level::Support)]
+ def set_join_code(pin : String)
+ write(Command::TDSTR_JOIN_CODE, pin)
+ end
+
+ # creates a numeric pin size digits long
+ @[Security(Level::Support)]
+ def set_random_join_code(size : Int32 = 4)
+ pin = String.build do |str|
+ size.times do
+ rand(9).to_s(str)
+ end
+ end
+ set_join_code(pin)
+ end
+
+ @[Security(Level::Support)]
+ def reboot
+ write(Command::TDU8_REBOOT, 1_u8)
+ end
+
+ @[Security(Level::Administrator)]
+ def restore_defaults
+ write(Command::TDU8_RESTORE_DEFAULTS, 1_u8)
+ end
+
+ @[Security(Level::Administrator)]
+ def set_vu_meter(value : UInt8, overload : Bool = false)
+ value = value.clamp(0_u8, 9_u8) unless overload
+ write(Command::TDU8_VU_METER_VALUE, value)
+ end
+
+ @[Security(Level::Support)]
+ def input_gain(value : UInt8)
+ value = value.clamp(0_u8, 50_u8)
+ write(Command::TDU8_INPUT_GAIN, value)
+ end
+
+ enum InputSource
+ AnalogLineIn = 1
+ Mic = 2
+ PhantomMic = 3
+ AES = 4
+ S_PDIF = 5
+ TestTone = 6
+ end
+
+ @[Security(Level::Support)]
+ def input_source(value : InputSource)
+ write(Command::TDU8_INPUT_SOURCE, value.to_u8)
+ end
+
+ enum Preset
+ Voice = 1
+ Music = 2
+ HearingAssist = 3
+ Custom = 4
+ end
+
+ @[Security(Level::Support)]
+ def preset(value : Preset)
+ write(Command::TDU8_PRESET, value.to_u8)
+ end
+
+ @[Security(Level::Administrator)]
+ def transmit_multicast(state : Bool)
+ write(Command::TDU8_AUDIO_TX_MODE, state ? 1_u8 : 0_u8)
+ end
+
+ @[Security(Level::Administrator)]
+ def set_ttl(value : UInt8)
+ value = value.clamp(0_u8, 30_u8)
+ write(Command::TDU8_TTL, value)
+ end
+
+ @[Security(Level::Support)]
+ def lock_front_panel(state : Bool)
+ write(Command::TDU8_PANEL_LOCK, state ? 1_u8 : 0_u8)
+ end
+
+ @[Security(Level::Administrator)]
+ def set_multicast_address(ip_address : String)
+ write(Command::TDSTR_MULTICAST_ADDR, ip_address)
+ end
+end
diff --git a/drivers/williams_av/wave_cast_fm_spec.cr b/drivers/williams_av/wave_cast_fm_spec.cr
new file mode 100644
index 00000000000..fedbe773ab8
--- /dev/null
+++ b/drivers/williams_av/wave_cast_fm_spec.cr
@@ -0,0 +1,89 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "MessageMedia::SMS" do
+ # select channel
+ expect_http_request do |request, response|
+ data = request.body.try(&.gets_to_end)
+ if data == "type=TT_U8&id=TDU8_CURRENT_CHANNEL&value=0"
+ response.status_code = 201
+ else
+ response.status_code = 400
+ response << "badly formatted"
+ end
+ end
+
+ # request state
+ expect_http_request do |request, response|
+ response.status_code = 200
+ response << %(TT_FLOAT, FLOAT_DATA_0, 0.0000
+TT_U32, TDU32_DANTE_LINK_STATE, 0
+TT_U32, TDU32_DANTE_SAMPLE_RATE, 0
+TT_U32, TDU32_RF_TIMEOUT, 1800000
+TT_U32, TDU32_RTC_UNIX_TIME, 981868966
+TT_S32, S32_DATA_0, 0
+TT_U8, TDU8_VU_METER_VALUE, 0
+TT_U8, TDU8_INPUT_GAIN, 10
+TT_U8, TDU8_INPUT_SOURCE, 1
+TT_U8, TDU8_PRESET, 3
+TT_U8, TDU8_HIGH_PASS, 1
+TT_U8, TDU8_LOW_PASS, 8
+TT_U8, TDU8_COMPRESSION, 1
+TT_U8, TDU8_USE_DHCP, 1
+TT_U8, TDU8_AUDIO_TX_MODE, 1
+TT_U8, TDU8_TTL, 1
+TT_U8, TDU8_SECURE_MODE, 0
+TT_U8, TDU8_PANEL_LOCK, 0
+TT_U8, TDU8_REBOOT, 0
+TT_U8, TDU8_RESTORE_DEFAULTS, 0
+TT_U8, TDU8_DANTE_PRESENT, 0
+TT_U8, TDU8_RF_CHANNEL, 1
+TT_U8, TDU8_RF_17_CHANNEL_MODE, 1
+TT_U8, TDU8_RF_POWER, 3
+TT_S8, TDS8_SERVER_CHANNEL_NO, 0
+TT_STRING, TDSTR_SERVER_NAME, Exemplar Room
+TT_STRING, TDSTR_CURRENT_IP_ADDR, 138.80.96.228
+TT_STRING, TDSTR_STATIC_IP_ADDR, 10.0.0.2
+TT_STRING, TDSTR_STATIC_SUBNET_MASK, 255.0.0.0
+TT_STRING, TDSTR_STATIC_GATEWAY_ADDR,
+TT_STRING, TDSTR_CURRENT_MC_ADDR,
+TT_STRING, TDSTR_MULTICAST_ADDR, 0.0.0.0
+TT_STRING, TDSTR_JOIN_CODE, 000000
+TT_STRING, TDSTR_USER_NAME, cduadmin
+TT_STRING, TDSTR_DANTE_DEVICE_NAME,
+TT_STRING, TDSTR_DANTE_DEFAULT_NAME,
+TT_STRING, TDSTR_DANTE_IP_ADDR,
+TT_STRING, TDSTR_DANTE_SUBNET_MASK,
+TT_STRING, TDSTR_DANTE_GATEWAY,
+TT_STRING, TDSTR_DANTE_MAC_ADDR,
+TT_STRING, TDSTR_WEBSERVER_VERSION, 2.2.0
+TT_STRING, TDSTR_293_FIRMWARE_VERSION, 2.4.0
+TT_STRING, TDSTR_DEVICE_MODEL_NAME, WF_T5
+TT_STRING, TDSTR_LAST_LOGGED_ERROR, 981868965 ERR_WEB_404_RESPONSE
+
+)
+ end
+
+ sleep 1.second
+
+ # check the status updated
+ status["join_code_enabled"]?.should eq false
+ status["panel_lock"]?.should eq false
+ status["transmit_multicast"]?.should eq true
+ status["reboot"]?.should eq 0
+ status["rf_power"]?.should eq 3
+ status["input_source"]?.should eq "AnalogLineIn"
+ status["preset"]?.should eq "HearingAssist"
+
+ # check the various methods work
+ retval = exec(:reboot)
+ expect_http_request do |request, response|
+ data = request.body.try(&.gets_to_end)
+ if data == "type=TT_U8&id=TDU8_CURRENT_CHANNEL&value=0&type=TT_U8&id=TDU8_REBOOT&value=1"
+ response.status_code = 201
+ else
+ response.status_code = 400
+ response << "badly formatted"
+ end
+ end
+ retval.get.should eq(1)
+end
diff --git a/drivers/winmate/led_light_bar.cr b/drivers/winmate/led_light_bar.cr
new file mode 100644
index 00000000000..7867d269bfa
--- /dev/null
+++ b/drivers/winmate/led_light_bar.cr
@@ -0,0 +1,182 @@
+require "placeos-driver"
+
+# Documentation: https://aca.im/driver_docs/Winmate/LED%20Light%20Bar%20SDK.pdf
+
+class Winmate::LedLightBar < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Winmate PC - LED Light Bar"
+ generic_name :StatusLight
+ tcp_port 8000
+
+ def on_load
+ queue.delay = 100.milliseconds
+ on_update
+ end
+
+ DEFAULT_COLOURS = {
+ "red" => {
+ red: 255_u8,
+ green: 0_u8,
+ blue: 0_u8,
+ },
+ "green" => {
+ red: 0_u8,
+ green: 255_u8,
+ blue: 0_u8,
+ },
+ "blue" => {
+ red: 0_u8,
+ green: 0_u8,
+ blue: 255_u8,
+ },
+ "orange" => {
+ red: 200_u8,
+ green: 0_u8,
+ blue: 0_u8,
+ },
+ "off" => {
+ red: 0_u8,
+ green: 0_u8,
+ blue: 0_u8,
+ },
+ }
+
+ alias Colours = Hash(String, NamedTuple(red: UInt8, green: UInt8, blue: UInt8))
+
+ @colours : Colours = Colours.new
+
+ def on_update
+ colours = setting?(Colours, :colours) || Colours.new
+ @colours = colours.merge(DEFAULT_COLOURS)
+ end
+
+ def connected
+ @buffer = String.new
+
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.peek # for demonstration purposes
+ bytes[0].to_i
+ end
+
+ do_poll
+ schedule.every(50.seconds) do
+ logger.debug { "-- Polling Winmate LED" }
+ do_poll
+ end
+ end
+
+ def disconnected
+ schedule.clear
+ end
+
+ def colour(colour : String)
+ colours = @colours[colour]
+ self[:colour_name] = colour
+ colours.each do |component, intensity|
+ led = Led.parse(component.to_s)
+ set led, intensity
+ end
+ end
+
+ enum Led
+ Red
+ Green
+ Blue
+ end
+
+ COLOURS = {
+ Led::Red => 0x10_u8,
+ Led::Green => 0x11_u8,
+ Led::Blue => 0x12_u8,
+ }
+
+ COLOUR_LOOKUP = {
+ 0x10 => Led::Red,
+ 0x11 => Led::Green,
+ 0x12 => Led::Blue,
+ }
+
+ COMMANDS = {
+ set: 0x61_u8,
+ get: 0x60_u8,
+ }
+
+ def query(led : Led, **options)
+ do_send(**options.merge({
+ command: :get,
+ colour: led,
+ }))
+ end
+
+ def set(led : Led, value : UInt8, **options)
+ self[led.to_s.downcase] = value
+
+ do_send(**options.merge({
+ command: :set,
+ colour: led,
+ value: value,
+ }))
+ end
+
+ def do_poll
+ query(:red, priority: 0)
+ query(:green, priority: 0)
+ query(:blue, priority: 0)
+ end
+
+ def received(bytes, task)
+ logger.debug { "received: #{bytes.hexstring}" }
+
+ unless check_checksum(bytes)
+ logger.warn { "Error processing response. Possibly incorrect baud rate configured" }
+ return task.try(&.abort)
+ end
+
+ # first byte is the message length, so we can ignore that
+ indicator = bytes[1]
+ colour = COLOUR_LOOKUP[indicator]?
+ if colour
+ self[colour.to_s.downcase] = bytes[2]
+ task.try(&.success(bytes[2]))
+ else
+ return task.try(&.abort) unless indicator == 0x0C
+ task.try(&.success)
+ end
+ end
+
+ # 2’s complement, &+ operators ignore overflows
+ protected def build_checksum(data : Array(UInt8))
+ result = data.reduce(0_u8) { |sum, byte| sum &+= byte }
+ ((~(result & 0xFF_u8)) &+ 1_u8)
+ end
+
+ protected def check_checksum(data : Bytes)
+ check = data.to_a
+ result = check.pop
+ result == build_checksum(check)
+ end
+
+ protected def do_send(command : Symbol, colour : Led, value : UInt8? = nil, **options)
+ cmd = COMMANDS[command]
+ led = COLOURS[colour]
+
+ # Build core request
+ req = [cmd, led]
+ req << value if value
+
+ # Add length indicator
+ len = (req.size + 2).to_u8
+ req.unshift len
+
+ # Calculate checksum
+ req << build_checksum(req)
+ bytes = Slice.new(req.to_unsafe, req.size)
+ logger.debug { "requesting #{bytes}" }
+
+ options = options.merge({
+ name: "#{command}_#{colour}_#{!!value}",
+ })
+
+ send(bytes, **options)
+ end
+end
diff --git a/drivers/winmate/led_light_bar_spec.cr b/drivers/winmate/led_light_bar_spec.cr
new file mode 100644
index 00000000000..dfc812313b1
--- /dev/null
+++ b/drivers/winmate/led_light_bar_spec.cr
@@ -0,0 +1,15 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Winmate::LedLightBar" do
+ should_send(Bytes[0x04, 0x60, 0x10, 140]).responds Bytes[0x04, 0x10, 0xFF, 0xED]
+ should_send(Bytes[0x04, 0x60, 0x11, 139]).responds Bytes[0x04, 0x11, 0xFF, 0xEC]
+ should_send(Bytes[0x04, 0x60, 0x12, 138]).responds Bytes[0x04, 0x12, 0xFF, 0xEB]
+ status[:red].should eq 0xFF
+ status[:green].should eq 0xFF
+ status[:blue].should eq 0xFF
+
+ resp = exec(:set, "red", 100)
+ should_send(Bytes[0x05, 0x61, 0x10, 0x64, 0x26]).responds Bytes[0x03, 0x0C, 0xF1]
+ resp.get
+ status[:red].should eq 100
+end
diff --git a/drivers/xovis/sensor_api.cr b/drivers/xovis/sensor_api.cr
new file mode 100644
index 00000000000..6b67ecce884
--- /dev/null
+++ b/drivers/xovis/sensor_api.cr
@@ -0,0 +1,279 @@
+require "placeos-driver"
+require "placeos-driver/interface/sensor"
+require "xml"
+
+# Documentation: https://aca.im/driver_docs/Xovis/Xovis-REST-API.pdf
+
+class Xovis::SensorAPI < PlaceOS::Driver
+ include Interface::Sensor
+
+ # Discovery Information
+ generic_name :XovisSensor
+ descriptive_name "Xovis Flow Sensor"
+
+ uri_base "https://192.168.0.1"
+
+ default_settings({
+ basic_auth: {
+ username: "account",
+ password: "password!",
+ },
+ poll_rate: 15,
+ })
+
+ @poll_rate : Time::Span = 15.seconds
+ @mac : String = ""
+ @state : Hash(String, Array(SensorDetail)) = {} of String => Array(SensorDetail)
+
+ def on_update
+ @poll_rate = (setting?(Int32, :poll_rate) || 15).seconds
+ @mac = URI.parse(config.uri.not_nil!).hostname.not_nil!
+
+ query_capacity = setting?(Bool, :query_capacity)
+ query_counts = setting?(Bool, :query_counts)
+
+ schedule.clear
+ schedule.every(@poll_rate) do
+ capacity_data unless query_capacity == false
+ count_data unless query_counts == false
+ end
+ schedule.every(5.minutes) { device_status }
+ schedule.in(@poll_rate / 3) do
+ device_status
+ capacity_data unless query_capacity == false
+ count_data unless query_counts == false
+ end
+ end
+
+ class SensorDetail < Interface::Sensor::Detail
+ property capacity : Int32? = nil
+ property first_entry : Int64? = nil
+ property last_entry : Int64? = nil
+ end
+
+ def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail?
+ logger.debug { "sensor mac: #{mac}, id: #{id} requested" }
+
+ return nil unless @mac == mac
+ return nil unless id
+
+ # https://crystal-lang.org/api/1.1.0/String.html#rpartition(search:Char%7CString):Tuple(String,String,String)-instance-method
+ sensor, _, index_str = id.rpartition('-')
+ return nil if sensor.empty?
+ index = index_str.to_i
+
+ if sensors = @state["#{sensor}-counts"]?
+ sensors[index]?
+ end
+ rescue error
+ logger.warn(exception: error) { "checking for sensor" }
+ nil
+ end
+
+ TYPES = {
+ "line-counts" => SensorType::QueueSize,
+ "zone-occupancy-counts" => SensorType::PeopleCount,
+ "zone-in-out-counts" => SensorType::Counter,
+ }
+
+ NO_MATCH = [] of Interface::Sensor::Detail
+
+ def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail)
+ logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" }
+ return NO_MATCH if mac && mac != @mac
+ return @state.values.flatten.map &.as(Interface::Sensor::Detail) unless type
+
+ sensor_type = SensorType.parse(type)
+ matches = [] of Array(Interface::Sensor::Detail)
+ TYPES.each { |key, key_type| matches << @state[key].map &.as(Interface::Sensor::Detail) if key_type == sensor_type }
+
+ matches.flatten
+ rescue error
+ logger.warn(exception: error) { "searching for sensors" }
+ NO_MATCH
+ end
+
+ # Alternative to using basic auth, but here really only for testing with postman
+ @[Security(Level::Support)]
+ def get_token
+ response = get("/api/auth/token", headers: {"Accept" => "text"})
+ raise "issue obtaining token: #{response.status_code}\n#{response.body}" unless response.success?
+ response.body
+ end
+
+ @[Security(Level::Support)]
+ def get_logs
+ response = get("/api/info/log", headers: {"Accept" => "text"})
+ raise "issue obtaining logs: #{response.status_code}\n#{response.body}" unless response.success?
+ response.body
+ end
+
+ @[Security(Level::Support)]
+ def reset_count
+ response = get("/api/count-data/reset", headers: {"Accept" => "text/xml"})
+ check_success(response)
+ true
+ end
+
+ def is_alive?
+ response = get("/api/info/alive", headers: {"Accept" => "text/xml"})
+ check_success(response)
+ true
+ rescue
+ false
+ end
+
+ def count_data
+ response = get("/api/count-data", headers: {"Accept" => "text/xml"})
+ document = check_success(response)
+
+ lines = {} of String => NamedTuple(name: String, id: String, type: String, sensor: String, data: Hash(String, String | Int32 | Float32))
+ lines_xml = document.xpath_nodes("//lines/line")
+
+ self[:lines] = lines_xml.map do |line|
+ attrs = {} of String => String | Hash(String, Int32)
+ counts = {} of String => Int32
+ line.attributes.each { |attr| attrs[attr.name] = attr.content }
+ line.children.each { |child|
+ next if child.name == "text"
+ counts[child.name] = child.text.to_i
+ }
+ attrs["counts"] = counts
+ attrs
+ end
+ end
+
+ def capacity_data
+ response = get("/api/info/persistence", headers: {"Accept" => "text/xml"})
+ document = check_success(response)
+
+ last_checked = Time.utc.to_unix
+
+ {"line", "zone-occupancy", "zone-in-out"}.each do |count_name|
+ xml_key_name = "//count-#{count_name}-storage"
+ if count_data = document.xpath_nodes(xml_key_name).first?
+ count_type = count_name.split("-", 2)[0]
+ capacity = xpath_text(document, "#{xml_key_name}/capacity", &.to_i)
+
+ key = "#{count_name}-counts"
+ @state[key] = self[key] = document.xpath_nodes("#{xml_key_name}/count-#{count_type}s/count-#{count_type}").map_with_index { |zone, index|
+ attrs = {} of String => String | Int32 | Int64 | Nil
+
+ zone.children.each do |child|
+ content = child.text.strip
+ attrs[child.name] = case child.name
+ when "entry-count"
+ content.to_i
+ when "first-entry", "last-entry"
+ content.empty? ? nil : Time.parse!(content, "%Y-%m-%dT%H:%M:%S%z").to_unix
+ when "text"
+ next
+ else
+ content
+ end
+ end
+
+ last_entry = attrs["last-entry"].as(Int64?)
+ sensor = case count_name
+ when "line"
+ SensorDetail.new(SensorType::QueueSize, attrs["entry-count"].as(Int32).to_f, last_entry || last_checked, @mac, "line-#{index}", attrs["name"].as(String))
+ when "zone-occupancy"
+ SensorDetail.new(SensorType::PeopleCount, attrs["entry-count"].as(Int32).to_f, last_entry || last_checked, @mac, "zone-occupancy-#{index}", "Occupancy #{attrs["name"].as(String)}")
+ when "zone-in-out"
+ SensorDetail.new(SensorType::Counter, attrs["entry-count"].as(Int32).to_f, last_entry || last_checked, @mac, "zone-in-out-#{index}", "In Out #{attrs["name"].as(String)}")
+ else
+ next
+ end
+ sensor.capacity = capacity
+ sensor.last_entry = last_entry
+ sensor.first_entry = attrs["first-entry"].as(Int64?)
+ sensor
+ }.compact
+ end
+ end
+ true
+ end
+
+ # Combined `/info` and `/info/status`
+ def device_status
+ response = get("/api/info/sensor-status", headers: {"Accept" => "text/xml"})
+ document = check_success(response)
+
+ parse_type_info(document, "version")
+ parse_type_info(document, "temperature")
+
+ if sensor = parse_text_info(document, "sensor")
+ @mac = sensor["serial-number"]? || @mac
+ end
+ parse_text_info(document, "illumination")
+ parse_text_info(document, "configuration")
+ parse_text_info(document, "operation")
+
+ true
+ end
+
+ protected def xpath_text(document, path)
+ document.xpath_nodes(path).first?.try(&.text.strip)
+ end
+
+ protected def xpath_text(document, path)
+ if node = document.xpath_nodes(path).first?
+ yield node.text.strip
+ end
+ end
+
+ protected def parse_type_info(document, xpath_key) : Nil
+ ver_data = document.xpath_nodes("//#{xpath_key}s/#{xpath_key}")
+ attrs = {} of String => String
+ ver_data.each do |data|
+ key = data.attributes.find(&.name.==("type")).try &.content
+ next unless key
+ attrs[key] = data.text.strip
+ end
+ self[xpath_key] = attrs.empty? ? nil : attrs
+ end
+
+ protected def parse_text_info(document, status) : Hash(String, String)?
+ if keys = document.xpath_nodes("//#{status}").first?.try(&.children)
+ attrs = {} of String => String
+ keys.each do |data|
+ key = data.name
+ next if key == "text"
+ attrs[key.underscore] = data.text.strip
+ end
+ self[status] = attrs.empty? ? nil : attrs
+ else
+ self[status] = nil
+ end
+ end
+
+ protected def check_success(response)
+ raise "issue with request: #{response.status_code}\n#{response.body}" unless response.success?
+ document = parse_without_namespaces(response.body)
+ status = document.xpath_nodes("//request-status/status").first?.try &.text.strip
+ raise "request failed with #{status}\n#{response.body}" unless status == "OK"
+ sensor_time(document)
+ document
+ end
+
+ protected def sensor_time(document) : Time?
+ if time_text = document.xpath_nodes("//sensor-time").first?.try &.text
+ self[:sensor_time] = Time.parse!(time_text, "%Y-%m-%dT%H:%M:%S%z")
+ end
+ end
+
+ protected def parse_without_namespaces(xml : String)
+ xml = xml.strip
+ document = XML.parse(xml)
+ namespace_node = document.children[0].name == "xml" ? document.children[1].name : document.children[0].name
+ namespaces = document.children[0].namespaces.keys.compact_map { |name| name.starts_with?("xmlns:") ? "#{name[6..-1]}\\:" : nil }
+
+ # Clean up namespaces from node names
+ xml = xml.gsub(Regex.new(namespaces.join("|")), "")
+ # Replace namespace node
+ xml = xml.sub(Regex.new("<#{namespace_node}.+>"), "<#{namespace_node}>")
+
+ # Return the parsed document
+ XML.parse(xml)
+ end
+end
diff --git a/drivers/xovis/sensor_api_spec.cr b/drivers/xovis/sensor_api_spec.cr
new file mode 100644
index 00000000000..3674d7b8e37
--- /dev/null
+++ b/drivers/xovis/sensor_api_spec.cr
@@ -0,0 +1,278 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Xovis::SensorAPI" do
+ # =========================
+ # GET TOKEN
+ # =========================
+ retval = exec(:get_token)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |request, response|
+ auth = request.headers["Authorization"]
+ if auth == "Basic YWNjb3VudDpwYXNzd29yZCE="
+ response.status_code = 200
+ response.output << "jwt_token"
+ else
+ response.status_code = 401
+ puts "invalid auth header #{auth}"
+ end
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq("jwt_token")
+
+ # =========================
+ # RESET COUNT
+ # =========================
+ retval = exec(:reset_count)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output << %(
+
+ 2020-05-04T12:34:46Z
+
+ OK
+
+ )
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq(true)
+ status["sensor_time"].should eq("2020-05-04T12:34:46Z")
+
+ # =========================
+ # COUNT DATA
+ # =========================
+ retval = exec(:count_data)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output << %(
+
+
+
+
+ 0
+ 0
+
+
+
+ 2020-05-05T11:20:47+02:00
+
+ OK
+
+ )
+ end
+
+ # What the function should return (for use in making further requests)
+ line_data = [{
+ "name" => "Line 0",
+ "id" => "0",
+ "sensor-type" => "SINGLE_SENSOR",
+ "counts" => {
+ "fw-count" => 0,
+ "bw-count" => 0,
+ },
+ }]
+ retval.get.should eq(line_data)
+ status["lines"].should eq(line_data)
+
+ # =========================
+ # DEVICE INFO
+ # =========================
+ retval = exec(:device_status)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output << <<-XML
+
+
+ OK
+ 2020-05-05T13:11:38+02:00 81551360
+
+ 5
+ AB E
+ B
+ 1.2.12 4.3.1 (5b57718)
+
+ 80:1F:12:73:2F:A4 192.168.1.115
+ S01
+ Test
+ PC2S
+ PC2S
+
+ 49.0
+ 46.0
+
+ 8.0 1.20166 0.0 0.0139272 true
+
+ true 85.823784 8.23138
+ 0.143171 0.98707 0.0720739
+
+ false
+
+ false 492810AF623279442C87D2D65D4A6240 2020-05-05T12:22:10+02:00 false
+
+ tracking 1018951 1 true Europe/Zurich
+
+ OK
+
+ XOVIS-PC
+ 192.168.1.115 255.255.255.0 192.168.1.1
+ 192.168.1.1
+
+ false
+ false
+
+ true 2020-05-05T13:06:58+02:00 91 false
+
+
+ sensor-support.xovis.com:443 true
+ true true true true 2020-05-05T02:11:07+02:00 2020-05-05T02:02:53+02:00false
+
+
+ XML
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq(true)
+ status["version"].should eq({
+ "HW" => "5",
+ "PROD" => "AB",
+ "BOM" => "E",
+ "PCB" => "B",
+ "FW" => "1.2.12",
+ "SW" => "4.3.1 (5b57718)",
+ })
+ status["temperature"].should eq({
+ "die" => "49.0",
+ "housing" => "46.0",
+ })
+ status["sensor"].should eq({
+ "serial-number" => "80:1F:12:73:2F:A4",
+ "ip-address" => "192.168.1.115",
+ "name" => "S01",
+ "group" => "Test",
+ "type" => "PC2S",
+ "device-type" => "PC2S",
+ })
+
+ # =========================
+ # ALIVE CHECK
+ # =========================
+ retval = exec(:is_alive?)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output << %(
+
+ 2020-05-04T12:34:46Z
+
+ OK
+
+ )
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq(true)
+
+ # =========================
+ # CAPACITY DATA
+ # =========================
+ retval = exec(:capacity_data)
+
+ # We should request a new token from Floorsense
+ expect_http_request do |_request, response|
+ response.status_code = 200
+ response.output << %(
+
+ 2020-05-05T13:06:06+02:00
+
+ OK
+
+
+ 100
+
+
+ Line 0
+ 0
+ 2020-04-27T11:40:00+02:00
+ 2020-05-05T13:05:00+02:00
+ 9
+
+
+
+
+ 80
+
+
+ Zone 0
+ 0
+ 2020-05-05T12:22:00+02:00
+ 2020-05-05T13:05:00+02:00
+ 1
+
+
+
+
+ 80
+
+
+ Zone 0
+ 0
+ 2020-05-05T12:22:00+02:00
+ 2020-05-05T13:05:00+02:00
+ 1
+
+
+
+ )
+ end
+
+ # What the function should return (for use in making further requests)
+ retval.get.should eq(true)
+ status["line-counts"].should eq([{
+ "status" => "normal",
+ "type" => "queue_size",
+ "value" => 9.0,
+ "last_seen" => 1588676700,
+ "mac" => "80:1F:12:73:2F:A4",
+ "id" => "line-0",
+ "name" => "Line 0",
+ "location" => "sensor",
+ "capacity" => 100,
+ "first_entry" => 1587980400,
+ "last_entry" => 1588676700,
+ }])
+ status["zone-occupancy-counts"].should eq([{
+ "status" => "normal",
+ "type" => "people_count",
+ "value" => 1.0,
+ "last_seen" => 1588676700,
+ "mac" => "80:1F:12:73:2F:A4",
+ "id" => "zone-occupancy-0",
+ "name" => "Occupancy Zone 0",
+ "location" => "sensor",
+ "capacity" => 80,
+ "first_entry" => 1588674120,
+ "last_entry" => 1588676700,
+ }])
+ status["zone-in-out-counts"].should eq([{
+ "status" => "normal",
+ "type" => "counter",
+ "value" => 1.0,
+ "last_seen" => 1588676700,
+ "mac" => "80:1F:12:73:2F:A4",
+ "id" => "zone-in-out-0",
+ "name" => "In Out Zone 0",
+ "location" => "sensor",
+ "capacity" => 80,
+ "first_entry" => 1588674120,
+ "last_entry" => 1588676700,
+ }])
+end
diff --git a/drivers/xy_sense/location_service.cr b/drivers/xy_sense/location_service.cr
new file mode 100644
index 00000000000..c67cf9097d9
--- /dev/null
+++ b/drivers/xy_sense/location_service.cr
@@ -0,0 +1,233 @@
+require "json"
+require "oauth2"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+
+class XYSense::LocationService < PlaceOS::Driver
+ include Interface::Locatable
+
+ descriptive_name "XY Sense Locations"
+ generic_name :XYLocationService
+ description %(collects desk booking data from the staff API and overlays XY Sense data for visualising on a map)
+
+ accessor area_manager : AreaManagement_1
+ accessor xy_sense : XYSense_1
+ bind XYSense_1, :floors, :floor_details_changed
+
+ default_settings({
+ floor_mappings: {
+ "xy-sense-floor-id": {
+ zone_id: "placeos-zone-id",
+ name: "friendly name for documentation",
+ },
+ },
+ })
+
+ @floor_mappings : Hash(String, NamedTuple(zone_id: String)) = {} of String => NamedTuple(zone_id: String)
+ @zone_filter : Array(String) = [] of String
+
+ def on_update
+ @floor_mappings = setting(Hash(String, NamedTuple(zone_id: String)), :floor_mappings)
+ @zone_filter = @floor_mappings.map { |_, detail| detail[:zone_id] }
+
+ schedule.clear
+ schedule.every(30.minutes) { sync_floor_states }
+ end
+
+ # ===================================
+ # Bindings into xy-sense data
+ # ===================================
+ class FloorDetails
+ include JSON::Serializable
+
+ property floor_id : String
+ property floor_name : String
+ property location_id : String
+ property location_name : String
+
+ property spaces : Array(SpaceDetails)
+ end
+
+ class SpaceDetails
+ include JSON::Serializable
+
+ property id : String
+ property name : String?
+ property capacity : Int32
+ property category : String
+ end
+
+ class Occupancy
+ include JSON::Serializable
+
+ property status : String
+ property headcount : Int32
+ property space_id : String
+
+ @[JSON::Field(converter: Time::Format.new("%FT%T", Time::Location::UTC))]
+ property collected : Time
+
+ @[JSON::Field(ignore: true)]
+ property! details : SpaceDetails
+ end
+
+ # Floor id => subscription
+ @floor_subscriptions = {} of String => PlaceOS::Driver::Subscriptions::Subscription
+ @space_details = {} of String => SpaceDetails
+ @change_lock = Mutex.new
+
+ def update_space_details
+ @change_lock.synchronize do
+ # Get the floor details from either the status push event or module update
+ floors = xy_sense.status(Hash(String, FloorDetails), :floors)
+ space_details = {} of String => SpaceDetails
+
+ floors.each do |floor_id, floor|
+ mapping = @floor_mappings[floor_id]?
+ next unless mapping
+
+ # track space data
+ floor.spaces.each { |space| space_details[space.id] = space }
+ end
+
+ # update to new space details
+ @space_details = space_details
+ end
+ end
+
+ protected def floor_details_changed(_sub = nil, payload = nil)
+ @change_lock.synchronize do
+ # Get the floor details from either the status push event or module update
+ floors = payload ? Hash(String, FloorDetails).from_json(payload) : xy_sense.status(Hash(String, FloorDetails), :floors)
+ space_details = {} of String => SpaceDetails
+
+ # work out what we should be watching
+ monitor = {} of String => String
+ floors.each do |floor_id, floor|
+ mapping = @floor_mappings[floor_id]?
+ next unless mapping
+
+ monitor[floor_id] = mapping[:zone_id]
+
+ # track space data
+ floor.spaces.each { |space| space_details[space.id] = space }
+ end
+
+ # unsubscribe from floors we're not interested in
+ existing = @floor_subscriptions.keys
+ desired = monitor.keys
+ (existing - desired).each { |sub| subscriptions.unsubscribe @floor_subscriptions.delete(sub).not_nil! }
+
+ # update to new space details
+ @space_details = space_details
+
+ # Subscribe to new data
+ (desired - existing).each { |floor_id|
+ zone_id = monitor[floor_id]
+ @floor_subscriptions[floor_id] = xy_sense.subscribe(floor_id) do |_sub, message|
+ level_state_change(zone_id, Array(Occupancy).from_json(message))
+ end
+ }
+ end
+ end
+
+ def floor_subscriptions
+ @floor_subscriptions.keys
+ end
+
+ def sync_floor_states
+ logger.debug { "-- updating space details..." }
+ details = update_space_details
+ logger.debug { "-- details:\n#{details}" }
+
+ logger.debug { "-- grabbing floor details..." }
+ xy = xy_sense
+ @change_lock.synchronize do
+ floor_subscriptions.each do |zone_id|
+ level_state_change(zone_id, xy.status(Array(Occupancy), zone_id))
+ end
+ end
+ logger.debug { "-- floor states synced!" }
+ @occupancy_mappings
+ end
+
+ # Zone_id => area => occupancy details
+ @occupancy_mappings : Hash(String, Hash(String, Occupancy)) = {} of String => Hash(String, Occupancy)
+
+ def level_state_change(zone_id : String, spaces : Array(Occupancy))
+ area_occupancy = {} of String => Occupancy
+ spaces.each do |space|
+ space.details = @space_details[space.space_id]
+ space_name = space.details.name
+ unless space_name
+ logger.warn { "missing space name for id #{space.details.id}" }
+ next
+ end
+ area_occupancy[space_name] = space
+ end
+ @occupancy_mappings[zone_id] = area_occupancy
+ area_manager.update_available({zone_id})
+ rescue error
+ logger.error(exception: error) { "error updating level #{zone_id} space changes" }
+ end
+
+ # ===================================
+ # Locatable Interface functions
+ # ===================================
+ def locate_user(email : String? = nil, username : String? = nil)
+ logger.debug { "sensor incapable of locating #{email} or #{username}" }
+ [] of Nil
+ end
+
+ def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String)
+ logger.debug { "sensor incapable of tracking #{email} or #{username}" }
+ [] of String
+ end
+
+ def check_ownership_of(mac_address : String) : OwnershipMAC?
+ logger.debug { "sensor incapable of tracking #{mac_address}" }
+ nil
+ end
+
+ def device_locations(zone_id : String, location : String? = nil)
+ logger.debug { "searching locatable in zone #{zone_id}" }
+ return [] of Nil unless @zone_filter.includes?(zone_id)
+
+ @occupancy_mappings[zone_id].compact_map do |space_name, space|
+ # Assume this means we're looking at a desk
+ capacity = space.details.capacity
+ if capacity == 1
+ next unless space.headcount > 0
+ next if location.presence && location != "desk"
+
+ {
+ location: :desk,
+ at_location: space.headcount,
+ map_id: space_name,
+ level: zone_id,
+ capacity: capacity,
+
+ xy_sense_space_id: space.space_id,
+ xy_sense_status: space.status,
+ xy_sense_collected: space.collected.to_unix,
+ xy_sense_category: space.details.category,
+ }
+ else
+ next if location.presence && location != "area"
+
+ {
+ location: :area,
+ at_location: space.headcount,
+ map_id: space_name,
+ level: zone_id,
+ capacity: capacity,
+
+ xy_sense_space_id: space.space_id,
+ xy_sense_status: space.status,
+ xy_sense_collected: space.collected.to_unix,
+ xy_sense_category: space.details.category,
+ }
+ end
+ end
+ end
+end
diff --git a/drivers/xy_sense/location_service_spec.cr b/drivers/xy_sense/location_service_spec.cr
new file mode 100644
index 00000000000..3a1142ed47f
--- /dev/null
+++ b/drivers/xy_sense/location_service_spec.cr
@@ -0,0 +1,80 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "XYSense::LocationService" do
+ system({
+ XYSense: {XYSenseMock},
+ AreaManagement: {AreaManagementMock},
+ })
+
+ now = Time.local
+ start = now.at_beginning_of_day.to_unix
+ ending = now.at_end_of_day.to_unix
+
+ resp = exec(:device_locations, "placeos-zone-id").get
+ puts resp
+ resp.should eq([
+ {"location" => "desk", "at_location" => 1, "map_id" => "desk-456", "level" => "placeos-zone-id", "capacity" => 1, "xy_sense_space_id" => "xysense-desk-456-id", "xy_sense_status" => "recentlyOccupied", "xy_sense_collected" => 1605088820, "xy_sense_category" => "Workpoint"},
+ {"location" => "area", "at_location" => 8, "map_id" => "area-567", "level" => "placeos-zone-id", "capacity" => 20, "xy_sense_space_id" => "xysense-area-567-id", "xy_sense_status" => "currentlyOccupied", "xy_sense_collected" => 1605088820, "xy_sense_category" => "Lobby"},
+ ])
+end
+
+# :nodoc:
+class XYSenseMock < DriverSpecs::MockDriver
+ def on_load
+ self[:floors] = {
+ "xy-sense-floor-id" => {
+ floor_id: "xy-sense-floor-id",
+ floor_name: "Fancy floor",
+ location_id: "xysense-building",
+ location_name: "Fancy building",
+ spaces: [{
+ id: "xysense-desk-123-id",
+ name: "desk-123",
+ capacity: 1,
+ category: "Workpoint",
+ },
+ {
+ id: "xysense-desk-456-id",
+ name: "desk-456",
+ capacity: 1,
+ category: "Workpoint",
+ },
+ {
+ id: "xysense-area-567-id",
+ name: "area-567",
+ capacity: 20,
+ category: "Lobby",
+ }],
+ },
+ }
+
+ self["xy-sense-floor-id"] = [
+ {
+ status: "notOccupied",
+ headcount: 0,
+ space_id: "xysense-desk-123-id",
+ collected: "2020-11-11T10:00:20",
+ },
+ {
+ status: "recentlyOccupied",
+ headcount: 1,
+ space_id: "xysense-desk-456-id",
+ collected: "2020-11-11T10:00:20",
+ },
+ {
+ status: "currentlyOccupied",
+ headcount: 8,
+ space_id: "xysense-area-567-id",
+ collected: "2020-11-11T10:00:20",
+ },
+ ]
+ end
+end
+
+# :nodoc:
+class AreaManagementMock < DriverSpecs::MockDriver
+ def update_available(zones : Array(String))
+ logger.info { "requested update to #{zones}" }
+ nil
+ end
+end
diff --git a/drivers/zencontrol/advanced_tpi.cr b/drivers/zencontrol/advanced_tpi.cr
new file mode 100644
index 00000000000..540a5948a5e
--- /dev/null
+++ b/drivers/zencontrol/advanced_tpi.cr
@@ -0,0 +1,226 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+require "bindata"
+
+# Documentation: https://aca.im/driver_docs/zencontrol/Advanced_Third_Party_Interface_API_Document.pdf
+
+class Zencontrol::AdvancedTPI < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ generic_name :Lighting
+ descriptive_name "Zencontrol Advanced Lighting API"
+ description "Uses the advanced zencontrol third party interface UDP or TCP API"
+
+ # NOTE:: Multicast update events are sent on address: 239.255.90.67 port: 6969
+ udp_port 5108
+
+ default_settings({
+ api_version: 4,
+ })
+
+ def on_load
+ # size is the 3rd byte
+ transport.tokenizer = Tokenizer.new do |io|
+ bytes = io.peek
+ # Ensure message indicator is well-formed
+ logger.debug { "Received: #{bytes.hexstring}" }
+ # [type, sequence, length, [data], checksum]
+ # return 0 if the message is incomplete
+ bytes.size < 3 ? 0 : (bytes[2].to_i + 4)
+ end
+
+ on_update
+ end
+
+ def on_update
+ @version = setting?(UInt8, :api_version) || 4_u8
+ end
+
+ @version : UInt8 = 4_u8
+ @sequence : UInt8 = 0_u8
+
+ protected def next_sequence_num
+ seq = @sequence
+ if seq == 255_u8
+ @sequence = 0_u8
+ else
+ @sequence = seq + 1_u8
+ end
+ seq
+ end
+
+ # Using indirect commands
+ def trigger(area : UInt32, scene : UInt32)
+ area = Area.new(area)
+ set_lighting_scene(scene, area)
+ end
+
+ # Using direct command
+ def light_level(area : UInt32, level : Float64)
+ area = Area.new(area)
+ set_lighting_level(level, area)
+ end
+
+ # ==================
+ # Lighting Interface
+ # ==================
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ # Most likely you just want to call a scene on a paricular group by adding 64 to the group number for Address.
+ area = area.as(Area)
+ area_id = area.id.as(UInt32)
+ area_id = area_id.clamp(0, 191) + 64 unless area_id == 0xFF_u32
+
+ # DALI_SCENE
+ self[area.to_s] = scene
+ basic_request(0xA1_u8, area_id.to_u8, scene)
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ # DALI_QUERY_LAST_SCENE
+ area_id = area.as(Area).id.as(UInt32).clamp(0, 191) + 64
+ basic_request(0xAD_u8, area_id.to_u8)
+ end
+
+ LEVEL_PERCENTAGE = 0xFF / 100
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ # Levels can be called on groups by adding 64 to the group number
+ area = area.as(Area)
+ area_id = area.id.as(UInt32)
+ area_id = area_id.clamp(0, 191) + 64 unless area_id == 0xFF_u32
+
+ # Levels are percentage based (on the PlaceOS side)
+ level = level.clamp(0.0, 100.0)
+ level_actual = (level * LEVEL_PERCENTAGE).round.to_u32
+
+ # DALI_ARC_LEVEL
+ basic_request(0xA2_u8, area_id.to_u8, level_actual)
+ end
+
+ def lighting_level?(area : Area? = nil)
+ # DALI_QUERY_LEVEL
+ area_id = area.as(Area).id.as(UInt32).clamp(0, 191) + 64
+ basic_request(0xAA_u8, area_id.to_u8)
+ end
+
+ # ==================
+ # Request Building
+ # ==================
+
+ class BasicRequest < BinData
+ endian big
+
+ uint8 :version
+ uint8 :sequence
+ uint8 :command
+ uint8 :address
+ bit_field do
+ bits 24, :data
+ end
+ uint8 :checksum, value: ->{
+ version ^ sequence ^ command ^ address ^ (data >> 16 & 0xFF).to_u8 ^ (data >> 8 & 0xFF).to_u8 ^ (data & 0xFF).to_u8
+ }
+ end
+
+ class ::PlaceOS::Driver::Task
+ property request_payload : Zencontrol::AdvancedTPI::BasicRequest? = nil
+ end
+
+ protected def basic_request(command : UInt8, address : UInt8, data : UInt32 = 0_u32, **options)
+ # build the message
+ request = BasicRequest.new
+ request.version = @version
+ request.sequence = next_sequence_num
+ request.command = command
+ request.address = address
+ request.data = data
+
+ # send the request
+ send(request, **options)
+ end
+
+ # ====================
+ # RESPONSE PROCESSING
+ # ====================
+
+ ERROR_CODES = {
+ 0x01_u8 => "The checksum check failed",
+ 0x02_u8 => "A short on the DALI line was detected",
+ 0x03_u8 => "A receive error occured",
+ 0x04_u8 => "The command in the request is unrecognised",
+ 0xB0_u8 => "The command requested relies on a paid feature that hasn't been purchsed",
+ 0xB1_u8 => "Invalid arguments supplied for the given command in the re quest",
+ 0xB2_u8 => "The command couldn't be processed",
+ 0xB3_u8 => "The queue or buffer that's required to process the command in the request
+ is full or broken",
+ 0xB4_u8 => "The command in the request may stream multiple responses back, but this
+ feature isn't available for some reason",
+ 0xB5_u8 => "The DALI related request couldn't be processed due to an error",
+ 0xB6_u8 => "There are an insufficient number of the required resource remaining service
+ the request",
+ 0xB7_u8 => "An unexpected result occurred",
+ }
+
+ enum ResponseType
+ Okay = 0xA0
+ Answer = 0xA1
+ NoAnswer = 0xA2
+ Error = 0xA3
+ end
+
+ class ResponseFrame < BinData
+ endian big
+
+ enum_field UInt8, type : ResponseType = ResponseType::Error
+ uint8 :sequence
+ uint8 :size
+ bytes :bytes, length: ->{ size }
+ uint8 :checksum, verify: ->{
+ sum = type.to_u8 ^ sequence ^ size
+ checksum == bytes.reduce(sum) { |acc, i| i ^ acc }
+ }
+ end
+
+ def received(data, task)
+ logger.debug { "Zencontrol sent: #{data.hexstring}" }
+
+ io = IO::Memory.new(data)
+ response = io.read_bytes ResponseFrame
+
+ case response.type
+ when .okay?, .no_answer?
+ # no processing required
+ when .answer?
+ if (request = task.try(&.request_payload)) && request.sequence == response.sequence
+ case request.command
+ when 0xAD_u8 # DALI_QUERY_LAST_SCENE
+ area = Area.new((request.address - 64_u8).to_u32)
+ self[area.to_s] = response.bytes[0]
+ when 0xAA_u8 # DALI_QUERY_LEVEL
+ area = Area.new((request.address - 64_u8).to_u32)
+ self[area.append("level").to_s] = response.bytes[0]
+ else
+ logger.debug { "unknown answer for #{request.command.to_s(16)}\n - req: #{request.to_slice.hexstring}\n - resp: #{response.to_slice.hexstring}" }
+ end
+ end
+ when .error?
+ error_code = response.bytes[0]
+ error_message = ERROR_CODES[error_code]?
+ logger.error { "request failed with code #{error_code}, message: #{error_message}" }
+ return task.try &.abort(error_message)
+ end
+
+ # check if we are expecting a frame
+ if request = task.try(&.request_payload)
+ if request.sequence == response.sequence
+ return task.try &.success
+ else # ignore this packet
+ return
+ end
+ end
+ task.try &.success
+ end
+end
diff --git a/drivers/zencontrol/advanced_tpi_spec.cr b/drivers/zencontrol/advanced_tpi_spec.cr
new file mode 100644
index 00000000000..64ffdaca7de
--- /dev/null
+++ b/drivers/zencontrol/advanced_tpi_spec.cr
@@ -0,0 +1,13 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Zencontrol::AdvancedTPI" do
+ exec(:trigger, 0xFF, 1)
+ should_send Bytes[0x04, 0x00, 0xA1, 0xFF, 0x00, 0x00, 0x01, 0x5B]
+ responds Bytes[0xA0, 0x00, 0x00, 0xA0]
+ status["area255"].should eq(1)
+
+ exec(:lighting_scene?, {id: 2})
+ should_send Bytes[0x04, 0x01, 0xAD, 66, 0x00, 0x00, 0x00, 0xea]
+ responds Bytes[0xA1, 0x01, 0x01, 4, 0xA5]
+ status["area2"].should eq(4)
+end
diff --git a/drivers/zencontrol/classic_tpi.cr b/drivers/zencontrol/classic_tpi.cr
new file mode 100644
index 00000000000..4e91eba5fb1
--- /dev/null
+++ b/drivers/zencontrol/classic_tpi.cr
@@ -0,0 +1,105 @@
+require "placeos-driver"
+require "placeos-driver/interface/lighting"
+
+# this is the original third party interface
+# Documentation: https://aca.im/driver_docs/zencontrol/lighting_udp.pdf
+
+class Zencontrol::ClassicTPI < PlaceOS::Driver
+ include Interface::Lighting::Scene
+ include Interface::Lighting::Level
+ alias Area = Interface::Lighting::Area
+
+ generic_name :Lighting
+ descriptive_name "Zencontrol Classic Lighting API"
+ description "Uses the classic zencontrol third party interface UDP API"
+
+ udp_port 5108
+
+ default_settings({
+ version: 1,
+ controller_id: "ffffffffffff",
+ })
+
+ def on_load
+ # Communication settings
+ queue.wait = false
+ on_update
+ end
+
+ BROADCAST = Bytes[0xff, 0xff, 0xff, 0xff, 0xff, 0xff]
+
+ @version : UInt8 = 1_u8
+ @controller : Bytes = BROADCAST
+
+ def on_update
+ @version = setting?(UInt8, :version) || 1_u8
+ controller = setting?(String, :controller_id)
+
+ if controller
+ @controller = controller.rjust(12, '0').hexbytes
+ else
+ @controller = BROADCAST
+ end
+ end
+
+ # Using indirect commands
+ def trigger(area : UInt32, scene : UInt32)
+ area = Area.new(area)
+ set_lighting_scene(scene, area)
+ end
+
+ # Using direct command
+ def light_level(area : UInt32, level : Float64)
+ area = Area.new(area)
+ set_lighting_level(level, area)
+ end
+
+ # ==================
+ # Lighting Interface
+ # ==================
+
+ def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area = area.as(Area)
+ scene = scene.clamp(0, 15) + 16
+ area_id = area.id.as(UInt32).clamp(0, 127) + 128
+
+ self[area.to_s] = scene
+ do_send(area_id.to_u8, scene.to_u8)
+ end
+
+ def lighting_scene?(area : Area? = nil)
+ self[area.to_s]? if area
+ end
+
+ LEVEL_PERCENTAGE = 0xFF / 100
+
+ def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32)
+ area = area.as(Area)
+
+ # Levels are percentage based (on the PlaceOS side)
+ level = level.clamp(0.0, 100.0)
+ level_actual = (level * LEVEL_PERCENTAGE).round.to_u8
+ area_id = area.id.as(UInt32).clamp(0, 127).to_u8
+
+ self[area.append("level").to_s] = level
+ do_send(area_id, level_actual)
+ end
+
+ def lighting_level?(area : Area? = nil)
+ self[area.append("level").to_s]? if area
+ end
+
+ protected def do_send(address : UInt8, command : UInt8, **options)
+ io = IO::Memory.new
+ io.write_byte @version
+ io.write @controller
+ io.write_byte address
+ io.write_byte command
+ send(io.to_slice, **options)
+ end
+
+ def received(data, task)
+ logger.debug { "Zencontrol sent: #{data.hexstring}" }
+ task.try &.success
+ end
+end
diff --git a/drivers/zencontrol/classic_tpi_spec.cr b/drivers/zencontrol/classic_tpi_spec.cr
new file mode 100644
index 00000000000..89ff035421e
--- /dev/null
+++ b/drivers/zencontrol/classic_tpi_spec.cr
@@ -0,0 +1,7 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Zencontrol::ClassicTPI" do
+ exec(:light_level, 0x4F, 94.2)
+ should_send("\x01\xFF\xFF\xFF\xFF\xFF\xFF\x4F\xF0")
+ status["area79_level"].should eq(94.2)
+end
diff --git a/harness b/harness
new file mode 100755
index 00000000000..8934361abc5
--- /dev/null
+++ b/harness
@@ -0,0 +1,142 @@
+#! /usr/bin/env bash
+
+# `e`: fail script if a command's exitcode is non-zero
+# `u`: fail script if a variable is unset (unitialized)
+set -eu
+
+say_done() {
+ printf "░░░ Done.\n"
+}
+
+fail() {
+ echo "${@}" >&2
+ exit 1
+}
+
+# Called when Ctrl-C is sent
+function trap_ctrlc ()
+{
+ echo ">~"
+ echo "░░░ Cleaning up..."
+ down
+ exit 2
+}
+
+trap "trap_ctrlc" 2
+
+up() {
+ echo '░░░ PlaceOS Driver Harness'
+ echo '░░░ -> Pulling latest code...'
+ git pull
+ echo '░░░ -> Pulling latest images...'
+ docker compose pull
+ echo '░░░ -> Starting environment...'
+ docker compose up -d
+ printf "░░░ The harness can be found at http://localhost:8085/index.html\n"
+ echo '░░░ Stop the harness with `harness down`'
+ say_done
+}
+
+down() {
+ echo '░░░ Stopping PlaceOS Driver Harness...'
+ docker compose down --remove-orphans &> /dev/null
+ say_done
+}
+
+format() {
+ echo '░░░ Running `crystal tool format` over `drivers` and `repositories`'
+ docker compose run \
+ -v "${PWD}/drivers:/wd/drivers" \
+ -v "${PWD}/repositories:/wd/repositories" \
+ --no-deps \
+ --rm \
+ install-shards \
+ crystal tool format
+ say_done
+}
+
+report() {
+ echo '░░░ PlaceOS Driver Compilation Report'
+ echo '░░░ Pulling images...'
+ docker compose pull &> /dev/null
+
+ # Ensure shards are satisfied before running the report
+ echo '░░░ Installing shards...'
+ docker compose run \
+ --rm \
+ install-shards > /dev/null
+
+ echo '░░░ Starting environment...'
+ docker compose up -d &> /dev/null
+
+ exit_code=0
+
+ echo '░░░ Starting report...'
+ docker exec placeos-drivers report $@ || exit_code=$?
+
+ down
+ exit ${exit_code}
+}
+
+build() {
+ docker compose run \
+ --rm \
+ --no-deps \
+ -v "${PWD}/repositories:/app/repositories" \
+ -v "${PWD}/drivers:/app/repositories/drivers" \
+ --entrypoint="/app/scripts/entrypoint.sh" \
+ build build $@
+}
+
+usage() {
+ cat < 0.6.0
+
+ bindata:
+ github: spider-gazelle/bindata
+ version: ~> 2.0
+
+ placeos-models:
+ github: placeos/models
+ version: ">= 9.37"
+
+ inactive-support:
+ github: spider-gazelle/inactive-support
+
+ jwt:
+ github: crystal-community/jwt
+
+ pinger:
+ github: spider-gazelle/pinger
+
+ ntlm:
+ github: spider-gazelle/ntlm
+
+ link-header:
+ github: spider-gazelle/link-header
+
+ place_calendar:
+ github: PlaceOS/calendar
+
+ office365:
+ github: PlaceOS/office365
+
+ placeos-compiler:
+ github: placeos/compiler
+
+ placeos-driver:
+ github: placeos/driver
+ version: ~> 7.1
+
+ # The PlaceOS API client
+ placeos:
+ github: placeos/crystal-client
+ version: ">= 2.10"
+
+ # HTTP Client helper
+ responsible:
+ github: place-labs/responsible
+
+ rwlock:
+ github: spider-gazelle/readers-writer
+
+ # Driver deps:
+ telnet:
+ github: spider-gazelle/telnet.cr
+
+ pars:
+ github: spider-gazelle/pars
+
+ # For lat lon location indexing
+ s2_cells:
+ github: spider-gazelle/s2_cells
+
+ # TODO:: deprecated, we should aim to remove at some point
+ qr-code:
+ github: spider-gazelle/qr-code
+
+ # A faster and better QR code implementation
+ goban:
+ github: soya-daizu/goban
+ branch: master
+
+ # For QR .png file export
+ stumpy_png:
+ github: stumpycr/stumpy_png
+
+ # the STOMP protocol
+ stomp:
+ github: spider-gazelle/stomp
+
+ # KNX protocol
+ knx:
+ github: spider-gazelle/knx
+
+ # MQTT protocol
+ mqtt:
+ github: spider-gazelle/crystal-mqtt
+
+ # XML to JSON convertor
+ oq:
+ github: blacksmoke16/oq
+ version: ~> 1.2
+
+ # signed requests to AWS APIs
+ awscr-signer:
+ github: taylorfinnell/awscr-signer
+ version: ~> 0.8
+
+ # BACnet protocol support
+ bacnet:
+ github: spider-gazelle/crystal-bacnet
+ version: ~> 0.10
+
+ # HTTP client
+ halite:
+ github: icyleaf/halite
+
+ # token bucket algorithm for rate limiting
+ rate_limiter:
+ github: lbarasti/rate_limiter
+ version: ~> 1.0
+
+ # fake data for mocks
+ faker:
+ github: askn/faker
+
+ # SOAP client
+ sabo:
+ github: place-technology/sabo
+
+ # AxiomXa client
+ axio:
+ github: place-technology/axio
+
+ # Lutron Quantum protocol
+ quantum:
+ github: place-technology/quantum
+
+ # Stripe API wrapper
+ stripetease:
+ github: place-technology/stripetease
+
+ # Siemens Desigo API wrapper
+ desigo:
+ github: place-technology/desigo
+
+ # debugging drivers
+ perf_tools:
+ github: crystal-lang/perf-tools
diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr
deleted file mode 100644
index e008dcfd222..00000000000
--- a/spec/spec_helper.cr
+++ /dev/null
@@ -1,7 +0,0 @@
-require "spec"
-
-# Your application config
-require "../src/config"
-
-# Helper methods for testing controllers (curl, with_server, context)
-require "../lib/action-controller/spec/curl_context"
diff --git a/spec/welcome_spec.cr b/spec/welcome_spec.cr
deleted file mode 100644
index 5666e9ad6e3..00000000000
--- a/spec/welcome_spec.cr
+++ /dev/null
@@ -1,25 +0,0 @@
-require "./spec_helper"
-
-describe Welcome do
- # ==============
- # Unit Testing
- # ==============
- it "should generate a date string" do
- # instantiate the controller you wish to unit test
- welcome = Welcome.new(context("GET", "/"))
-
- # Test the instance methods of the controller
- welcome.time_now.should contain("GMT")
- end
-
- # ==============
- # Test Responses
- # ==============
- with_server do
- it "should welcome you" do
- result = curl("GET", "/")
- result.body.should eq("\n\nWelcome \nYou're riding on Spider-Gazelle!\n\n")
- result.headers["Date"]?.nil?.should eq(false)
- end
- end
-end
diff --git a/src b/src
new file mode 120000
index 00000000000..86e5b5e04d4
--- /dev/null
+++ b/src
@@ -0,0 +1 @@
+./drivers
\ No newline at end of file
diff --git a/src/app.cr b/src/app.cr
deleted file mode 100644
index 4a6e7bbdbb3..00000000000
--- a/src/app.cr
+++ /dev/null
@@ -1,46 +0,0 @@
-require "option_parser"
-require "./config"
-
-# Server defaults
-port = 3000
-host = "127.0.0.1"
-
-# Command line options
-OptionParser.parse! do |parser|
- parser.banner = "Usage: #{PROGRAM_NAME} [arguments]"
-
- parser.on("-b HOST", "--bind=HOST", "Specifies the server host") { |h| host = h }
- parser.on("-p PORT", "--port=PORT", "Specifies the server port") { |p| port = p.to_i }
-
- parser.on("-r", "--routes", "List the application routes") do
- ActionController::Server.print_routes
- exit 0
- end
-
- parser.on("-v", "--version", "Display the application version") do
- puts "#{APP_NAME} v#{VERSION}"
- exit 0
- end
-
- parser.on("-h", "--help", "Show this help") do
- puts parser
- exit 0
- end
-end
-
-# Load the routes
-puts "Launching #{APP_NAME} v#{VERSION}"
-server = ActionController::Server.new(port, host)
-
-# Detect ctr-c to shutdown gracefully
-Signal::INT.trap do
- puts " > terminating gracefully"
- server.close
-end
-
-# Start the server
-puts "Listening on tcp://#{host}:#{port}"
-server.run
-
-# Shutdown message
-puts "#{APP_NAME} leaps through the veldt\n"
diff --git a/src/config.cr b/src/config.cr
deleted file mode 100644
index f48255a4f35..00000000000
--- a/src/config.cr
+++ /dev/null
@@ -1,21 +0,0 @@
-# Application dependencies
-require "action-controller"
-require "active-model"
-
-# Application code
-require "./controllers/application"
-require "./controllers/*"
-require "./models/*"
-
-# Server required after application controllers
-require "action-controller/server"
-
-# Configure session cookies
-# NOTE:: Change these from defaults
-ActionController::Session.configure do
- settings.key = "_spider_gazelle_"
- settings.secret = "4f74c0b358d5bab4000dd3c75465dc2c"
-end
-
-APP_NAME = "Spider-Gazelle"
-VERSION = "1.0.0"
diff --git a/src/controllers/application.cr b/src/controllers/application.cr
deleted file mode 100644
index 9bdbc79b557..00000000000
--- a/src/controllers/application.cr
+++ /dev/null
@@ -1,15 +0,0 @@
-# Require kilt for template support
-require "kilt"
-
-abstract class Application < ActionController::Base
- before_action :set_date_header
-
- # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date
- def time_now
- Time.utc_now.to_s("%a, %d %b %Y %H:%M:%S GMT")
- end
-
- def set_date_header
- response.headers["Date"] = time_now
- end
-end
diff --git a/src/controllers/welcome.cr b/src/controllers/welcome.cr
deleted file mode 100644
index 7695e78845b..00000000000
--- a/src/controllers/welcome.cr
+++ /dev/null
@@ -1,18 +0,0 @@
-class Welcome < Application
- base "/"
-
- def index
- welcome_text = "You're riding on Spider-Gazelle!"
-
- respond_with do
- html Kilt.render("src/views/welcome.ecr")
- text "Welcome, #{welcome_text}"
- json({welcome: welcome_text})
- xml do
- XML.build(indent: " ") do |xml|
- xml.element("welcome") { xml.text welcome_text }
- end
- end
- end
- end
-end
diff --git a/src/views/welcome.ecr b/src/views/welcome.ecr
deleted file mode 100644
index 69f2e46c394..00000000000
--- a/src/views/welcome.ecr
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-Welcome
-<%= welcome_text %>
-