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..4217a413c27
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,42 @@
+name: Build and Publish Drivers
+on:
+ push:
+ branches: [master]
+
+env:
+ CRYSTAL_VERSION: 1.4.1
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ environment: Build
+ steps:
+ - uses: actions/checkout@v3
+
+ # 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 }}
+ 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 }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000000..3f143f88658
--- /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@v3
+ - 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:
+ - 1.3.2
+ - 1.4.1
+ include:
+ - stable: false
+ crystal: nightly
+ steps:
+ - id: changes
+ uses: trilom/file-changes-action@v1.2.4
+ with:
+ output: ' '
+ - uses: actions/checkout@v3
+ - 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 }}
+ CRYSTAL_VERSION: ${{ matrix.crystal }}
+ - name: Upload failure logs
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@v3
+ 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:
+ - 1.3.2
+ - 1.4.1
+ include:
+ - stable: false
+ crystal: nightly
+ steps:
+ - uses: actions/checkout@v3
+
+ - 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 }}
+ 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@v3
+ with:
+ name: logs-${{ matrix.crystal }}-${{ github.sha }}
+ path: .logs/*.log
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..cc4d9fbfebb 100644
--- a/README.md
+++ b/README.md
@@ -1,41 +1,63 @@
-# 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`
-* [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
+`harness` is a helper for easing development of PlaceOS Drivers.
+```
+Usage: ./harness [-h|--help] [command]
-Spider-Gazelle builds on the amazing performance of **router.cr** [here](https://github.com/tbrand/which_is_the_fastest).:rocket:
+Helper script for interfacing with the PlaceOS Driver spec runner
+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
+```
-## Testing
+To spin up the test harness, clone the repository and run...
-`crystal spec`
+```shell-session
+$ ./harness up
+```
-* to run in development mode `crystal ./src/app.cr`
+Point a browser to [localhost:8085](http://localhost:8085), and you're good to go.
-## Compiling
+When the environment is not in use, remember to run...
-`crystal build ./src/app.cr`
+```shell-session
+$ ./harness down
+```
-### Deploying
+Before committing, please run...
-Once compiled you are left with a binary `./app`
+```shell-session
+$ ./harness format
+```
+
+## Documentation
-* for help `./app --help`
-* viewing routes `./app --routes`
-* run on a different port or host `./app -h 0.0.0.0 -p 80`
+- [Writing a PlaceOS Driver](docs/writing-a-driver.md)
+- [Testing a PlaceOS Driver](docs/writing-a-spec.md)
+- [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)
+
+## Contributing
+
+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..d84681006fd
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,70 @@
+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
+
+ build:
+ image: placeos/build:${PLACE_BUILD_TAG:-latest}
+ 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
+
+ redis:
+ image: eqalpha/keydb:alpine
+ restart: always
+ hostname: redis
+ environment:
+ TZ: $TZ
+
+ # Ensures shards are installed.
+ install-shards:
+ image: crystallang/crystal:${CRYSTAL_VERSION:-latest}-alpine
+ restart: "no"
+ working_dir: /wd
+ command: ash -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
\ No newline at end of file
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..1547493f1f7
--- /dev/null
+++ b/drivers/amber_tech/grandview.cr
@@ -0,0 +1,151 @@
+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)
+ case state
+ 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..2467fb80bfa
--- /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
+ 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/n_series_decoder.cr b/drivers/amx/svsi/n_series_decoder.cr
new file mode 100644
index 00000000000..9b326c6e5a6
--- /dev/null
+++ b/drivers/amx/svsi/n_series_decoder.cr
@@ -0,0 +1,218 @@
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+require "inactive-support/mapped_enum"
+
+# 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)
+
+ 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..54f0593835a
--- /dev/null
+++ b/drivers/amx/svsi/n_series_encoder.cr
@@ -0,0 +1,121 @@
+require "placeos-driver"
+require "placeos-driver/interface/muteable"
+require "placeos-driver/interface/switchable"
+require "inactive-support/mapped_enum"
+
+# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf
+
+class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver
+ include Interface::Muteable
+
+ 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..4ed0a1169c3
--- /dev/null
+++ b/drivers/amx/svsi/virtual_switcher.cr
@@ -0,0 +1,55 @@
+require "placeos-driver"
+require "placeos-driver/interface/switchable"
+
+# This driver provides an abstraction layer for systems using SVSI based signal
+# distribution. In place of referencing specific decoders 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
+
+ accessor encoders : Array(Encoder), implementing: InputSelection
+ accessor decoders : Array(Decoder), implementing: InputSelection
+
+ def switch_to(input : Int32)
+ decoders.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
+
+ 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 = encoders[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 = decoders[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..740998a1960
--- /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(same_thread: true) { 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..53100498216
--- /dev/null
+++ b/drivers/ashrae/bacnet_datapoints.cr
@@ -0,0 +1,30 @@
+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_load
+ on_update
+ end
+
+ 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..eefd6007461
--- /dev/null
+++ b/drivers/aver/cam520_pro.cr
@@ -0,0 +1,350 @@
+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(same_thread: true) 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)
+ @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(same_thread: true) do
+ post("/camera_move", body: {
+ method: "SetPtzf",
+ axis: AxisSelect::Tilt.to_i,
+ dir: 0,
+ cmd: 2,
+ }.to_json)
+ end
+
+ # pan
+ spawn(same_thread: true) 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..451e0c9fae2
--- /dev/null
+++ b/drivers/aws/sns_sms.cr
@@ -0,0 +1,78 @@
+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",
+ })
+
+ def on_load
+ on_update
+ end
+
+ 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/collaboration_endpoint.cr b/drivers/cisco/collaboration_endpoint.cr
new file mode 100644
index 00000000000..a490d169391
--- /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(same_thread: true) 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(same_thread: true) 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(same_thread: true) 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(same_thread: true) { 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..3ffec6acf6d
--- /dev/null
+++ b/drivers/cisco/dna_spaces.cr
@@ -0,0 +1,637 @@
+require "set"
+require "jwt"
+require "s2_cells"
+require "simple_retry"
+require "placeos-driver"
+require "placeos-driver/interface/locatable"
+
+class Cisco::DNASpaces < PlaceOS::Driver
+ include Interface::Locatable
+
+ # Discovery Information
+ descriptive_name "Cisco DNA Spaces"
+ generic_name :DNA_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",
+
+ # Time before a user location is considered probably too old (in minutes)
+ max_location_age: 10,
+
+ 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_load
+ on_update
+ if !@api_key.empty?
+ @streaming = true
+ spawn(same_thread: true) { start_streaming_events }
+ end
+ end
+
+ def on_unload
+ @channel.close
+ @stream_active = false
+ update_monitoring_status(running: false)
+ end
+
+ @activation_token : String = ""
+ @api_key : String = ""
+ @tenant_id : String = ""
+ @channel : Channel(String) = Channel(String).new
+ @max_location_age : Time::Span = 10.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
+
+ 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, :tenant_id)
+ else
+ @api_key = setting?(String, :dna_spaces_api_key) || ""
+ @tenant_id = setting?(String, :tenant_id) || ""
+
+ # Activate the API key using the activation_token
+ schedule.in(5.seconds) { activate } if @api_key.empty?
+ end
+
+ if !@streaming && !@api_key.empty?
+ @streaming = true
+ spawn(same_thread: true) { start_streaming_events }
+ 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)
+ 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
+ if !@streaming
+ @streaming = true
+ spawn(same_thread: true) { start_streaming_events }
+ 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) = {} of String => DeviceLocationUpdate | IotTelemetry
+ @loc_lock : Mutex = Mutex.new
+
+ def locations
+ @loc_lock.synchronize { yield @locations }
+ 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: 10.milliseconds,
+ max_interval: 5.seconds
+ ) { stream_events unless terminated? }
+ 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
+ if !payload.has_position?
+ iot_payload = payload.as(IotTelemetry)
+ # process other IoT telemetry such as presense or temperature etc
+ self[iot_payload.device.mac_address] = payload
+ next
+ end
+
+ # Keep track of device location
+ device_mac = format_mac(payload.device.mac_address)
+ 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
+ elsif (level_data = @floorplan_mappings[payload.location.location_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
+ 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
+
+ payload.last_seen = payload.last_seen // 1000
+
+ 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(same_thread: true) { 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)
+ 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::LatLon.new(lat, lon).to_token(@s2_level),
+ "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.map do |loc|
+ 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::LatLon.new(lat, lon).to_token(@s2_level),
+ 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"
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..1f67da326e6
--- /dev/null
+++ b/drivers/cisco/dna_spaces/events.cr
@@ -0,0 +1,134 @@
+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,
+ }
+
+ @[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
diff --git a/drivers/cisco/dna_spaces/iot_telemetry.cr b/drivers/cisco/dna_spaces/iot_telemetry.cr
new file mode 100644
index 00000000000..c39a646795c
--- /dev/null
+++ b/drivers/cisco/dna_spaces/iot_telemetry.cr
@@ -0,0 +1,232 @@
+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 air_quality
+ if index = @air_quality_index
+ index[:airQualityIndex]
+ else
+ 0.0
+ end
+ end
+
+ def temperature
+ if temp = @temperature_celsius
+ temp[:temperatureInCelsius]
+ else
+ 0.0
+ end
+ end
+
+ def humidity
+ if humidity = @humidity_percent
+ humidity[:humidityInPercentage]
+ else
+ 0.0
+ end
+ end
+
+ def air_pressure
+ if pressure = @air_pressure_actual
+ pressure[:pressure]
+ else
+ 0.0
+ end
+ end
+
+ def pir_triggered
+ if pir_trigger = @pir_trigger
+ pir_trigger[:timestamp]
+ else
+ 0_i64
+ 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
+ position.time_located
+ end
+
+ def last_seen=(time)
+ position.time_located = 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/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_spec.cr b/drivers/cisco/dna_spaces_spec.cr
new file mode 100644
index 00000000000..d58064080a2
--- /dev/null
+++ b/drivers/cisco/dna_spaces_spec.cr
@@ -0,0 +1,18 @@
+require "placeos-driver/spec"
+
+DriverSpecs.mock_driver "Cisco::DNASpaces" do
+ # 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/guests.cr b/drivers/cisco/ise/guests.cr
new file mode 100644
index 00000000000..39ad954385f
--- /dev/null
+++ b/drivers/cisco/ise/guests.cr
@@ -0,0 +1,219 @@
+require "placeos-driver"
+require "./models/internal_user"
+
+# Tested with Cisco ISE API v3.1
+# https://developer.cisco.com/docs/identity-services-engine/v1/#!internaluser
+
+class Cisco::Ise::Guests < PlaceOS::Driver
+ # Discovery Information
+ descriptive_name "Cisco ISE REST API"
+ 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: "UTC",
+ 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 => String,
+ debug: false,
+ })
+
+ @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 => String
+
+ TYPE_HEADER = "application/json"
+ TIME_FORMAT = "%m/%d/%Y %H:%M"
+
+ def on_load
+ on_update
+ end
+
+ 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
+
+ @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, String), :custom_data) || {} of String => String
+
+ logger.debug { "Basic auth details: #{@basic_auth}" } if @debug
+ end
+
+ def create_internal(
+ 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)
+ password = genererate_password(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
+
+ internal_user = Models::InternalUser.from_json(%({}))
+
+ # 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)
+
+ internal_user.name = username
+ internal_user.password = password
+ internal_user.first_name = first_name
+ internal_user.last_name = last_name
+ internal_user.email = attendee_email
+
+ # internal_user.custom_attributes = custom_attributes
+
+ logger.debug { "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(username)
+ 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)
+ internal_user = get_internal_user_by_id(id)
+
+ response = put("/internaluser/#{internal_user.id}", body: {"InternalUser" => {"password" => password}}.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_password_by_email(email : String, password : String)
+ internal_user = get_internal_user_by_email(email)
+
+ update_internal_user_password_by_id(internal_user.id.to_s, password)
+ 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
+
+ # Will be 9 characters in length until 2081-08-05 10:16:46.208000000 UTC
+ # when it will increase to 10
+ private def genererate_password(firstname, lastname)
+ "P!#{lastname[0].downcase}#{firstname[0].downcase}#{Time.utc.to_unix_ms.to_s(31)}"
+ end
+end
diff --git a/drivers/cisco/ise/guests_spec.cr b/drivers/cisco/ise/guests_spec.cr
new file mode 100644
index 00000000000..0556830c34d
--- /dev/null
+++ b/drivers/cisco/ise/guests_spec.cr
@@ -0,0 +1,45 @@
+require "placeos-driver"
+require "./guests"
+require "./models/internal_user"
+require "placeos-driver/spec"
+
+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(Cisco::Ise::Guests::TIME_FORMAT)
+ end_date = start_time.at_end_of_day.to_s(Cisco::Ise::Guests::TIME_FORMAT)
+ attendee_email = "attendee@test.com"
+ company_name = "PlaceOS"
+
+ sms = "Global Default"
+ exec(:create_internal, start_time.to_unix, attendee_email, "First Last", company_name, phone, sms, "Daily")
+
+ # 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
+
+ first_name = internal_user.first_name
+ first_name.should eq "First"
+
+ last_name = internal_user.last_name
+ last_name.should eq "Last"
+
+ 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/ise/models/internal_user.cr b/drivers/cisco/ise/models/internal_user.cr
new file mode 100644
index 00000000000..41c73a1d1e2
--- /dev/null
+++ b/drivers/cisco/ise/models/internal_user.cr
@@ -0,0 +1,38 @@
+require "json"
+
+class Cisco::Ise::Models::InternalUser
+ include JSON::Serializable
+
+ @[JSON::Field(key: "name")]
+ property name : String = ["internalUser", UUID.random.to_s.split("-").last].join("-")
+
+ @[JSON::Field(key: "id")]
+ property id : 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/meraki/captive_portal.cr b/drivers/cisco/meraki/captive_portal.cr
new file mode 100644
index 00000000000..f42f0ad542e
--- /dev/null
+++ b/drivers/cisco/meraki/captive_portal.cr
@@ -0,0 +1,142 @@
+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",
+ })
+
+ def on_load
+ on_update
+ end
+
+ @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..5869d1bfa7e
--- /dev/null
+++ b/drivers/cisco/meraki/dashboard.cr
@@ -0,0 +1,218 @@
+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, 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)
+ clients = [] of Client
+ req_all_pages "/api/v1/networks/#{network_id}/clients?perPage=1000×pan=#{timespan}" do |response|
+ clients.concat Array(Client).from_json(response.body)
+ end
+ clients
+ 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
+
+ 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..e2f6c424f6b
--- /dev/null
+++ b/drivers/cisco/meraki/meraki_locations.cr
@@ -0,0 +1,1151 @@
+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
+
+ 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,
+ })
+
+ 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
+
+ @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
+ 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
+
+ # 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
+
+ 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)
+ network_id = network_id.presence || @default_network
+ Array(Client).from_json dashboard.poll_clients(network_id, timespan).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
+
+ 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::LatLon.new(lat.not_nil!, lon.not_nil!).to_token(@s2_level) : 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_locations(zone_id)
+ when nil
+ wireless_locs = wireless_locations(zone_id)
+ desk_locs = desk_locations(zone_id)
+ combind = Array(typeof(wireless_locs[0]) | typeof(desk_locs[0])).new(wireless_locs.size + desk_locs.size)
+ combind.concat(wireless_locs)
+ combind.concat(desk_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::LatLon.new(lat.not_nil!, lon.not_nil!).to_token(@s2_level) : 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
+
+ # ======================
+ # 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
+
+ # ==========
+ # 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
+
+ # ======================
+ # 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]?
+ return [] of Nil if !serials || serials.empty?
+
+ 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..5941f8d81dc
--- /dev/null
+++ b/drivers/cisco/meraki/meraki_locations_spec.cr
@@ -0,0 +1,187 @@
+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
+
+DriverSpecs.mock_driver "Cisco::Meraki::Locations" do
+ system({
+ Dashboard: {DashboardMock},
+ MerakiMQTT: {MQTTMock},
+ })
+
+ 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..5cfac1ce7b6
--- /dev/null
+++ b/drivers/cisco/meraki/mqtt.cr
@@ -0,0 +1,322 @@
+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",
+ },
+ ],
+ })
+
+ 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",
+ }
+
+ @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
+
+ 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
+
+ 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}" }
+ 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
+
+ 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
+ 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)
+ when .people_count?
+ add_people_counts(sensors, mac, serial_filter)
+ when .counter?
+ add_vehicle_counts(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_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..b10de7d0521
--- /dev/null
+++ b/drivers/cisco/meraki/mqtt_models.cr
@@ -0,0 +1,74 @@
+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
+end
diff --git a/drivers/cisco/meraki/mqtt_spec.cr b/drivers/cisco/meraki/mqtt_spec.cr
new file mode 100644
index 00000000000..8692f91d7b4
--- /dev/null
+++ b/drivers/cisco/meraki/mqtt_spec.cr
@@ -0,0 +1,149 @@
+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],
+ ],
+ })
+end
diff --git a/drivers/cisco/meraki/scanning_api.cr b/drivers/cisco/meraki/scanning_api.cr
new file mode 100644
index 00000000000..ad1bbf6c9c4
--- /dev/null
+++ b/drivers/cisco/meraki/scanning_api.cr
@@ -0,0 +1,335 @@
+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 : String
+
+ property manufacturer : String?
+ property os : 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
+ 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
+end
diff --git a/drivers/cisco/room_kit.cr b/drivers/cisco/room_kit.cr
new file mode 100644
index 00000000000..ffd3f734255
--- /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/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..7c8d9e3e354
--- /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(same_thread: true) 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(same_thread: true) 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..788425efed2
--- /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(same_thread: true) {
+ 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/booking.cr b/drivers/cisco/webex/booking.cr
new file mode 100644
index 00000000000..0754eea1c20
--- /dev/null
+++ b/drivers/cisco/webex/booking.cr
@@ -0,0 +1,69 @@
+require "placeos-driver"
+require "placeos-driver/interface/chat_bot"
+require "placeos-driver/interface/locatable"
+require "place_calendar"
+
+module Cisco
+ module Webex
+ class Booking < PlaceOS::Driver
+ default_settings({keyword: "book", organization_id: ""})
+
+ def on_load
+ on_update
+ end
+
+ def on_update
+ organization_id = setting(String, :organization_id)
+ monitor("chat/webex/#{organization_id}/message") { |_subscription, payload| on_message(payload) }
+ end
+
+ def on_message(message : String)
+ message = Interface::ChatBot::Message.from_json(message)
+
+ keyword = message.text.split.first.downcase
+
+ # An example message text would look something like this:
+ # {% keyword %} a room for 30 minutes
+ text = message
+ .text
+ .sub(keyword, "")
+ .sub("a room", "")
+ .strip
+
+ # Ignore the message if the keyword doesn't match the booking keyword specified in the settings
+ if keyword != setting(String, :keyword)
+ send_message(message.id, "Specified keyword is not recognized as a valid acommand for the PlaceOS Bot, #{keyword}.")
+ send_message(message.id, "An example booking command would look something like this: #{setting(String, :keyword)} a room for 30 minutes")
+
+ return
+ end
+
+ # Notify the user to await for a free room
+ send_message(message.id, "Looking for an available room to book, please wait!")
+
+ # Split the remaining text into chunks to process them
+ conjunction, period, measurement = text.split
+
+ case measurement
+ when "hours"
+ period_in_seconds = (period.to_i * 3600).to_i64
+ event = PlaceCalendar::Event.from_json(system.implementing(Interface::Locatable).book_now(period_in_seconds).get.first.to_json)
+ send_message(message.id, "Successfully booked an event #{event.title}, from #{event.event_start}, to #{event.event_end}, in #{event.timezone}, on #{event.host}.")
+ when "minutes"
+ period_in_seconds = (period.to_i * 60).to_i64
+ event = PlaceCalendar::Event.from_json(system.implementing(Interface::Locatable).book_now(period_in_seconds).get.first.to_json)
+ send_message(message.id, "Successfully booked an event #{event.title}, from #{event.event_start}, to #{event.event_end}, in #{event.timezone}, on #{event.host}.")
+ when "seconds"
+ event = PlaceCalendar::Event.from_json(system.implementing(Interface::Locatable).book_now(period.to_i64).get.first.to_json)
+ send_message(message.id, "Successfully booked an event #{event.title}, from #{event.event_start}, to #{event.event_end}, in #{event.timezone}, on #{event.host}.")
+ else
+ send_message(message.id, "Specified measurement is not recognized as a valid measurement, please use: minutes, seconds or hours.")
+ end
+ end
+
+ private def send_message(id : Interface::ChatBot::Id, response : String)
+ system.implementing(Interface::ChatBot).reply(Interface::ChatBot::Message.new(id, response).to_json)
+ end
+ end
+ end
+end
diff --git a/drivers/cisco/webex/communication.cr b/drivers/cisco/webex/communication.cr
new file mode 100644
index 00000000000..8f47af61bdd
--- /dev/null
+++ b/drivers/cisco/webex/communication.cr
@@ -0,0 +1,61 @@
+require "placeos-driver"
+require "placeos-driver/interface/chat_bot"
+require "http"
+
+require "./models/**"
+
+module Cisco
+ module Webex
+ class Communication < PlaceOS::Driver
+ include Interface::ChatBot
+
+ descriptive_name "Cisco Webex Bot Communication"
+ generic_name :Communication
+ uri_base "wss://webex.placeos.com/ws/messages"
+
+ default_settings({
+ organization_id: "",
+ api_key: "",
+ })
+
+ protected getter! socket : HTTP::WebSocket
+
+ def on_load
+ on_update
+ end
+
+ def on_update
+ headers = HTTP::Headers.new
+
+ organization_id = setting(String, :organization_id)
+
+ headers.merge!({"Organization-ID" => organization_id})
+ headers.merge!({"X-API-Key" => setting(String, :api_key)})
+
+ @socket = HTTP::WebSocket.new(URI.parse(config.uri.not_nil!.to_s), headers)
+
+ spawn do
+ socket.try(&.on_message do |message|
+ event = Models::Event.from_json(JSON.parse(message).as_h.["event"].to_json)
+ event_message = Models::Message.from_json(JSON.parse(message).as_h.["message"].to_json)
+
+ id = Interface::ChatBot::Id.new(event_message.id.to_s, event_message.room_id.to_s, event.data.activity.actor.id, event.data.activity.actor.organization_id)
+ bot_message = Interface::ChatBot::Message.new(id, event_message.text.to_s)
+
+ publish("chat/webex/#{organization_id}/message", bot_message.to_json)
+ end)
+
+ socket.try(&.run)
+ end
+ end
+
+ def notify_typing(id : Interface::ChatBot::Id)
+ end
+
+ def reply(id : Interface::ChatBot::Id, response : String, url : String? = nil, attachment : Interface::ChatBot::Attachment? = nil)
+ files = [url.to_s] if url
+ socket.try(&.send({"roomId" => id.room_id.to_s, "text" => response, "files" => files || [] of String}.to_json))
+ 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..0d2493989f4
--- /dev/null
+++ b/drivers/cisco/webex/models/event.cr
@@ -0,0 +1,29 @@
+require "./events/**"
+
+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..8ea659c923d
--- /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 organization_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..bb2f02f291c
--- /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(Attachment)?
+
+ # 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/crestron/cres_next.cr b/drivers/crestron/cres_next.cr
new file mode 100644
index 00000000000..1eb75ba9a06
--- /dev/null
+++ b/drivers/crestron/cres_next.cr
@@ -0,0 +1,96 @@
+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"
+
+ # This is just to maintain our session at HTTP level
+ schedule.clear
+ schedule.every(10.minutes) { maintain_session }
+
+ headers
+ 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)
+
+ # check if the response path is included
+ if parts.map(&.in?(raw_json)).includes?(false)
+ # process as an out of band update (not the response)
+ received(data, nil)
+ else
+ logger.debug { "Crestron sent: #{raw_json}" }
+ # 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
+
+ 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?
+
+ # we can parse this value as if it came in via the websocket
+ received response.body.to_slice, nil
+ end
+
+ # payload is expected to be a hash or named tuple
+ protected def update(path : String, value, **options)
+ queue(**options) do |task|
+ 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})
+
+ 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: #{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..4c12d194dba
--- /dev/null
+++ b/drivers/crestron/fusion.cr
@@ -0,0 +1,187 @@
+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_load
+ on_update
+ end
+
+ 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..75606c49e37
--- /dev/null
+++ b/drivers/crestron/nvx_address_manager.cr
@@ -0,0 +1,76 @@
+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_load
+ on_update
+ end
+
+ 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..dc4b4295d49
--- /dev/null
+++ b/drivers/crestron/nvx_models.cr
@@ -0,0 +1,20 @@
+require "json"
+
+module Crestron
+ # Interface for enumerating devices
+ module Transmitter
+ end
+
+ module Receiver
+ end
+
+ enum AspectRatio
+ MaintainAspectRatio
+ StretchToFit
+ end
+
+ enum SourceType
+ Audio
+ Video
+ end
+end
diff --git a/drivers/crestron/nvx_rx.cr b/drivers/crestron/nvx_rx.cr
new file mode 100644
index 00000000000..2d6a6a081c7
--- /dev/null
+++ b/drivers/crestron/nvx_rx.cr
@@ -0,0 +1,240 @@
+require "./cres_next"
+require "placeos-driver/interface/switchable"
+
+class Crestron::NvxRx < Crestron::CresNext # < PlaceOS::Driver
+ alias Input = String | Int32?
+ include PlaceOS::Driver::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
+
+ def connected
+ # 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)
+ input = input.downcase if input.is_a?(String)
+ do_switch = case input
+ when "none", "break", "clear", "blank", "black", nil, 0
+ blank
+ when "input1", "hdmi", "hdmi1"
+ switch_local "Input1"
+ when "input2", "hdmi2"
+ switch_local "Input2"
+ else
+ switch_stream input
+ end
+
+ do_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
+
+ # aspect ratio defined in nvx_rx_models
+ def aspect_ratio(mode : AspectRatio)
+ logger.debug { "setting output aspect ratio mode: #{mode}" }
+
+ 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
+
+ protected def switch_stream(stream_reference : String | Int32)
+ 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})"
+ end
+
+ payload = {
+ AvRouting: {
+ Routes: [{VideoSource: uuid, AudioSource: uuid}],
+ },
+ DeviceSpecific: {
+ VideoSource: "Stream",
+ AudioSource: "AudioFollowsVideo",
+ },
+ }
+
+ update "/", payload, name: :switch
+ end
+
+ protected def switch_local(input)
+ logger.debug { "switching to #{input}" }
+ update(
+ "/DeviceSpecific",
+ {VideoSource: input, AudioSource: "AudioFollowsVideo"},
+ name: :switch
+ )
+ end
+
+ protected def blank
+ logger.debug { "blanking output" }
+
+ payload = {
+ AvRouting: {
+ Routes: [{VideoSource: "", AudioSource: ""}],
+ },
+ DeviceSpecific: {
+ VideoSource: "None",
+ AudioSource: "AudioFollowsVideo",
+ },
+ }
+
+ update("/", payload, name: :switch)
+ 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
+ end
+end
diff --git a/drivers/crestron/nvx_rx_spec.cr b/drivers/crestron/nvx_rx_spec.cr
new file mode 100644
index 00000000000..9337e4f1cd2
--- /dev/null
+++ b/drivers/crestron/nvx_rx_spec.cr
@@ -0,0 +1,86 @@
+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/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")
+
+ # 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..df1f0dcf110
--- /dev/null
+++ b/drivers/crestron/nvx_scaler_control.cr
@@ -0,0 +1,71 @@
+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_load
+ on_update
+ end
+
+ 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..67541e6ad38
--- /dev/null
+++ b/drivers/crestron/nvx_tx.cr
@@ -0,0 +1,131 @@
+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
+ # 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
+
+ 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_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"
+ payload = JSON.parse(raw_json)
+
+ # 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
+ end
+end
diff --git a/drivers/crestron/nvx_tx_spec.cr b/drivers/crestron/nvx_tx_spec.cr
new file mode 100644
index 00000000000..88eab595f0a
--- /dev/null
+++ b/drivers/crestron/nvx_tx_spec.cr
@@ -0,0 +1,33 @@
+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/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}]}
+ ]}}})
+
+ 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..9648cb4e0aa
--- /dev/null
+++ b/drivers/crestron/occupancy_sensor.cr
@@ -0,0 +1,139 @@
+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",
+ })
+
+ @mac : String = ""
+ @name : String? = nil
+ @occupied : Bool = false
+ @connected : Bool = false
+ getter last_update : Int64 = 0_i64
+ getter poll_counter : UInt64 = 0_u64
+
+ @long_polling = false
+
+ def on_load
+ 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")
+ 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?
+
+ # Start long polling once we have state
+ @poll_counter += 1
+ long_poll unless @long_polling
+ end
+
+ protected def format_mac(address : String)
+ address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase
+ 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
+ @long_polling = true
+ response = get("/Device/Longpoll")
+
+ authenticate if response.status_code == 301
+ raise "unexpected response code: #{response.status_code}" unless response.success?
+
+ raw_json = response.body
+ logger.debug { "long poll sent: #{raw_json}" }
+
+ return 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
+ rescue timeout : IO::TimeoutError
+ logger.debug { "timeout waiting for long poll to complete" }
+ rescue error
+ logger.warn(exception: error) { "during long polling" }
+ ensure
+ if @connected
+ spawn(same_thread: true) { long_poll }
+ else
+ @long_polling = false
+ 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
+
+ [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 @mac == mac
+ get_sensor_details
+ end
+
+ def get_sensor_details
+ 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
diff --git a/drivers/crestron/occupancy_sensor_spec.cr b/drivers/crestron/occupancy_sensor_spec.cr
new file mode 100644
index 00000000000..b163777e7d0
--- /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" => "occupied",
+ "location" => "sensor",
+ })
+end
diff --git a/drivers/crestron/virtual_switcher.cr b/drivers/crestron/virtual_switcher.cr
new file mode 100644
index 00000000000..f82c27996c4
--- /dev/null
+++ b/drivers/crestron/virtual_switcher.cr
@@ -0,0 +1,105 @@
+require "placeos-driver"
+require "placeos-driver/interface/switchable"
+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)
+
+ def transmitters
+ system.implementing(Crestron::Transmitter)
+ end
+
+ def receivers
+ system.implementing(Crestron::Receiver)
+ end
+
+ def switch_to(input : Input)
+ # todo need to lookup the input stream
+ receivers.switch_to(input)
+ 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
+
+ def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil)
+ # TODO:: allow layered switching
+ layer ||= SwitchLayer::All
+ return unless layer.all? || layer.video?
+
+ connect(map) do |mod, stream|
+ mod.switch_to(stream)
+ end
+ end
+
+ private def connect(inouts : Hash(Input, Array(Output)), &)
+ inouts.each do |input, outputs|
+ if int_input = input.to_i?
+ if int_input == 0
+ stream = 0 # disconnected
+ else
+ # Subtract one as Encoder_1 on the system would be encoder[0] here
+ if tx = transmitters[int_input - 1]?
+ stream = tx[:stream_name]
+ else
+ logger.warn { "could not find Encoder_#{input}" }
+ next
+ end
+ end
+ else
+ stream = input
+ end
+
+ 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/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..e96ef9df3e7
--- /dev/null
+++ b/drivers/echo360/device_capture.cr
@@ -0,0 +1,171 @@
+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_load
+ on_update
+ end
+
+ 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
+ Some other capture
+
+
+ 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
+
+
+
+ graphics1-archive
+
+
+
+ graphics2-archive
+
+
+
+
+
+
+
+
+
+
+
+ HEREDOC
diff --git a/drivers/epson/projector/esc_vp21.cr b/drivers/epson/projector/esc_vp21.cr
new file mode 100644
index 00000000000..73ad5e58641
--- /dev/null
+++ b/drivers/epson/projector/esc_vp21.cr
@@ -0,0 +1,231 @@
+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
+
+ @power_target : Bool? = nil
+ @unmute_volume : Float64 = 60.0
+
+ def on_load
+ transport.tokenizer = Tokenizer.new("\r")
+ self[:type] = :projector
+ 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")
+ else
+ @power_target = false
+ logger.debug { "-- epson Proj, requested to power off" }
+ do_send(:power, "OFF", delay: 10.seconds, name: "power")
+ end
+ self[:power] = state
+ power?
+ end
+
+ def power?(**options) : Bool
+ do_send(:power, **options).get
+ !!self[:power]?.try(&.as_bool)
+ 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 (mute = vol == 0.0) && self[:volume]?
+ do_send(:volume, vol_actual, **options, name: :volume)
+
+ # for a responsive UI
+ self[:volume] = vol
+ self[:audio_mute] = mute
+ volume?
+ end
+
+ def volume?
+ 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
+ )
+ 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?
+ 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 { "epson Proj sent: #{data}" }
+
+ data = data.split('=')
+ case RESPONSE[data[0]]
+ 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
+ self[:power] = state < 3
+ self[:warming] = state == 2
+ self[:cooling] = state == 3
+
+ if self[:warming].as_bool || self[:cooling].as_bool
+ schedule.in(5.seconds) { power?(priority: 0) }
+ end
+
+ if (power_target = @power_target) && self[:power] == power_target
+ @power_target = nil
+ self[:video_mute] = false unless self[:power].as_bool
+ 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].to_i
+ when :input
+ self[:input] = Input.from_value(data[1].to_i(16)) || "unknown"
+ end
+
+ task.try(&.success)
+ end
+
+ def do_poll
+ if power?(priority: 0)
+ if power_target = @power_target
+ if self[:power]? != power_target
+ power(power_target)
+ else
+ @power_target = nil
+ end
+ else
+ input?
+ video_mute?
+ volume?
+ end
+ end
+ do_send(:lamp, priority: 0)
+ end
+
+ private def do_send(command, param = nil, **options)
+ command = COMMAND[command]
+ cmd = param ? "#{command} #{param}\r" : "#{command}?\r"
+ logger.debug { "Epson proj sending #{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..102ea5747d0
--- /dev/null
+++ b/drivers/exterity/avedia_player/m93xx.cr
@@ -0,0 +1,181 @@
+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,
+ },
+ max_waits: 100,
+ 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
+
+ @ready : Bool = false
+ @channel_lookup : Hash(String, ChannelDetail) = {} of String => ChannelDetail
+
+ def on_load
+ on_update
+ end
+
+ def on_update
+ channel_lookup = {} of String => ChannelDetail
+ if channel_details = setting?(Array(ChannelDetail), :channel_details)
+ self[:channel_details] = channel_details
+ channel_details.each { |lookup| channel_lookup[lookup.channel] = lookup }
+ else
+ self[:channel_details] = nil
+ end
+ @channel_lookup = channel_lookup
+ end
+
+ 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)
+ set(:playChannelUri, uri, name: :channel).get
+ name = @channel_lookup[uri]?.try &.name
+
+ schedule.in(2.second) do
+ current_channel.get
+ if name && uri == self[:current_channel]
+ channel_name name
+ end
+ end
+
+ name
+ 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 && 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
+
+ # 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..c55987759bb
--- /dev/null
+++ b/drivers/exterity/avedia_player/m93xx_spec.cr
@@ -0,0 +1,29 @@
+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!
+^cur_channel:udp://239.193.3.169:5000?hwchan=4!)
+
+ status[:cur_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: