diff --git a/.ameba.yml b/.ameba.yml new file mode 100644 index 00000000000..5e2fb336458 --- /dev/null +++ b/.ameba.yml @@ -0,0 +1,61 @@ +Excluded: + - repositories/**/*.cr + +Lint/DebugCalls: + Excluded: + - drivers/**/*_spec.cr + +# NOTE: These should all be reviewed on an individual basis to see if their +# complexity can be reasonably reduced. +Metrics/CyclomaticComplexity: + Description: Disallows methods with a cyclomatic complexity higher than `MaxComplexity` + MaxComplexity: 10 + Excluded: + - drivers/helvar/net.cr + - drivers/mulesoft/booking_api.cr + - drivers/samsung/displays/mdc_protocol.cr + - drivers/cisco/dna_spaces.cr + - drivers/cisco/meraki/dashboard.cr + - drivers/cisco/switch/snooping_catalyst.cr + - drivers/gantner/relaxx/protocol_json.cr + - drivers/place/bookings.cr + - drivers/place/area_management.cr + - drivers/place/smtp.cr + - drivers/hitachi/projector/cp_tw_series_basic.cr + - drivers/panasonic/projector/nt_control.cr + - drivers/lumens/dc193.cr + Enabled: false + Severity: Convention + +Lint/UselessAssign: + Description: Disallows useless variable assignments + # NOTE: Not enabled due to the extremely large hit count. + # Discussion with driver authors on whether this pattern is intended. + Enabled: false + Severity: Warning + +Style/VerboseBlock: + Description: Identifies usage of collapsible single expression blocks. + ExcludeCallsWithBlock: false + ExcludeMultipleLineBlocks: true + ExcludeOperators: false + ExcludePrefixOperators: false + ExcludeSetters: true + Enabled: false + Severity: Convention + +Style/VariableNames: + Description: Enforces variable names to be in underscored case + # NOTE: Not enabled due to the extremely large hit count. + # Discussion with driver authors on whether this pattern is intended. + Enabled: false + Severity: Convention + +# NOTE: These appear to be triggered by assignment in case expressions, could be an ameba bug +Lint/ShadowingOuterLocalVar: + Description: Disallows the usage of the same name as outer local variables for block + or proc arguments. + Excluded: + - drivers/cisco/switch/snooping_catalyst.cr + Enabled: true + Severity: Warning diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000000..abad59c9f43 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: 'Bug: A concise description of the behaviour' +labels: bug +assignees: '' + +--- + +**Describe the bug** + +A clear and concise description of what the bug is. + +**To Reproduce** + +Steps to reproduce the behaviour or a minimal code snippet that demonstrates the behaviour. + +**Expected behaviour** + +A clear and concise description of what you expected to happen. + +**Screenshots or a paste of terminal output** + +If applicable, add screenshots to help explain your problem. + +**Versions (please complete the following information):** + +- Output of `$ crystal version` +- Driver version [e.g. 3.x] + +**Additional context** + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/driver_migration.md b/.github/ISSUE_TEMPLATE/driver_migration.md new file mode 100644 index 00000000000..bc50ed19207 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/driver_migration.md @@ -0,0 +1,20 @@ +--- +name: Driver Migration +about: Migrate existing Ruby Engine Driver to Crystal +title: 'Driver Migration: Migrate existing Ruby driver' +labels: driver +assignees: '' + +--- + +**Driver to be Migrated** + +Information about the driver to be migrated. + +**Link to Existing Driver** + +Link to existing Driver on Ruby Drivers Repo. + +**Additional context** + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/driver_request.md b/.github/ISSUE_TEMPLATE/driver_request.md new file mode 100644 index 00000000000..b68b3c805a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/driver_request.md @@ -0,0 +1,32 @@ +--- +name: Driver Request +about: Request a new driver to be created +title: 'Driver Request: Information required to create a new driver' +labels: driver +assignees: '' + +--- + +**Driver Type** + +Logic/Device/SSH/Websocket + +**Manufacturer** + +Manufacturer of device, software or service + +**Model/Service** + +Model or Service + +**Link to or Attach Device API or Protocol** + +If applicable, add screenshots to help explain your problem. + +**Describe any desired functionality** + +- Control all aspects of device + +**Additional context** + +Add any other context about the driver request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..01f460a18d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: 'RFC: Concise description of desired feature' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** + +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** + +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** + +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** + +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..5ace4600a1f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000000..482c1694993 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Build and Publish Drivers +on: + push: + branches: [master] + +env: + CRYSTAL_VERSION: latest + PLACE_BUILD_TAG: nightly + +jobs: + build: + name: Build + runs-on: ubuntu-latest + environment: Build + steps: + - uses: actions/checkout@v4 + + # Binary Cache Logic + ############################################################################################# + + - uses: actions/cache@v3 + with: + path: binaries + key: drivers-${{ env.CRYSTAL_VERSION }}-${{ github.run_id }} + restore-keys: drivers-${{ env.CRYSTAL_VERSION }}- + + ############################################################################################# + + - uses: FranzDiebold/github-env-vars-action@v2 # https://github.com/github/feedback/discussions/5251 + - name: Build Drivers + run: | + ./harness build \ + --discover \ + --strict-driver-info \ + --repository-uri https://github.com/${{ github.repository }} \ + --repository-path ./repositories/local \ + --ref ${{ github.sha }} \ + --branch ${{ github.ref_name }} + env: + CRYSTAL_VERSION: ${{ env.CRYSTAL_VERSION }} + AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + AWS_SECRET: ${{ secrets.AWS_SECRET }} + AWS_KEY: ${{ secrets.AWS_KEY }} + AWS_REGION: ${{ secrets.AWS_REGION }} + # CLOUD_BUILD_SERVER: CLOUD_BUILD_SERVICE_ROOT_ENDPOINT + # GIT_TOKEN: GIT_TOKEN_FOR_PRIVATE_REPO_IF_REQUIRED + PLACE_BUILD_TAG: ${{ env.PLACE_BUILD_TAG }} + BUILD_SERVICE_DISABLED: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..cba025fcd2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,136 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: "0 6 * * 1" + +env: + PARALLEL_TESTS: 10 + PARALLEL_BUILDS: 2 + +jobs: + docs: + if: false # Temporarily disable as docs just _do not work_ for a driver + name: "Crystal Docs" + runs-on: ubuntu-latest + continue-on-error: true + container: crystallang/crystal + steps: + - uses: actions/checkout@v4 + - name: Install Shards + run: shards install --ignore-crystal-version + - name: Docs + run: crystal docs + + style: + name: "Style" + uses: PlaceOS/.github/.github/workflows/crystal-style.yml@main + + subset-report: + name: "Subset Report - crystal: ${{ matrix.crystal }}, stable: ${{ matrix.stable }}" + runs-on: ubuntu-latest + continue-on-error: ${{ !matrix.stable }} + strategy: + fail-fast: false + matrix: + stable: [true] + crystal: + - latest + include: + - stable: false + crystal: nightly + steps: + - id: changes + uses: trilom/file-changes-action@v1.2.4 + with: + output: ' ' + - uses: actions/checkout@v4 + - name: Cache shards + uses: actions/cache@v3 + with: + path: lib + key: ${{ hashFiles('shard.lock') }} + - name: Driver Report + # Skip subset report if dependencies have changed + if: ${{ !contains(steps.changes.outputs.files, 'shard.yml') && !contains(steps.changes.outputs.files, 'shard.lock') }} + run: | + ./harness \ + report \ + --verbose \ + --tests=${{ env.PARALLEL_TESTS }} \ + --builds=${{ env.PARALLEL_BUILDS }} \ + ${{ steps.changes.outputs.files }} + env: + AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + AWS_REGION: ${{ secrets.AWS_REGION }} + BUILD_SERVICE_DISABLED: true + CRYSTAL_VERSION: ${{ matrix.crystal }} + - name: Upload failure logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: logs-${{ matrix.crystal }}-${{ github.sha }} + path: .logs/*.log + + full-report: + name: "Full Report - crystal: ${{ matrix.crystal }}, stable: ${{ matrix.stable }}" + needs: subset-report + runs-on: ubuntu-latest + continue-on-error: ${{ !matrix.stable }} + strategy: + fail-fast: false + matrix: + stable: [true] + crystal: + - latest + include: + - stable: false + crystal: nightly + steps: + - uses: actions/checkout@v4 + + - name: Cache shards + uses: actions/cache@v3 + with: + path: lib + key: ${{ hashFiles('shard.lock') }} + + # Binary Cache Logic + ############################################################################################# + + - uses: actions/cache@v3 + with: + path: binaries + key: drivers-${{ env.CRYSTAL_VERSION }}-${{ github.run_id }} + restore-keys: drivers-${{ env.CRYSTAL_VERSION }}- + + ############################################################################################# + + - name: Driver Report + run: | + ./harness \ + report \ + --verbose \ + --tests=${{ env.PARALLEL_TESTS }} \ + --builds=${{ env.PARALLEL_BUILDS }} + env: + AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + AWS_REGION: ${{ secrets.AWS_REGION }} + BUILD_SERVICE_DISABLED: false + CRYSTAL_VERSION: ${{ matrix.crystal }} + - name: Show build container logs + if: ${{ failure() }} + run: docker compose logs build + - name: Show drivers container logs + if: ${{ failure() }} + run: docker compose logs drivers + - name: Upload failure logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: logs-${{ matrix.crystal }}-${{ github.sha }} + path: .logs/*.log diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000000..430da3ac9f2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,24 @@ +name: Deploy docs + +on: + push: + branches: [ master ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - uses: crystal-lang/install-crystal@v1 + with: + crystal: latest + - name: "Install shards" + run: shards install --skip-postinstall --skip-executables + - name: "Generate docs" + run: crystal docs + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs diff --git a/.gitignore b/.gitignore index 0792935e4a3..4313fa25e98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,14 @@ -doc -lib +*.dwarf +*.rdb +.DS_Store .crystal .shards app -*.dwarf +bin +doc +docs +binaries +lib +.logs +repositories/* +src diff --git a/src/models/.keep b/.logs/.keep similarity index 100% rename from src/models/.keep rename to .logs/.keep diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ffc7b6ac56d..00000000000 --- a/.travis.yml +++ /dev/null @@ -1 +0,0 @@ -language: crystal diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000000..c1424e57eb7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "gdb", + "request": "launch", + "target": "./bin/test-harness", + "cwd": "${workspaceRoot}", + "preLaunchTask": "Compile", + "setupCommands": [ + { "text": "-gdb-set follow-fork-mode child" } + ] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..ce3aa5cfd9f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,10 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Compile", + "command": "shards build --debug drivers", + "type": "shell" + } + ] +} diff --git a/LICENSE b/LICENSE index 58b2d5683ef..c4e5872a57e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2018 *YOUR COMPANY NAME HERE* +Copyright (c) 2021 Place Technology Limited. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 1bd8efef9e8..08ff49a12a1 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,64 @@ -# Spider-Gazelle Application Template +# PlaceOS Drivers -[![Build Status](https://travis-ci.org/spider-gazelle/spider-gazelle.svg?branch=master)](https://travis-ci.org/spider-gazelle/spider-gazelle) +[![CI](https://github.com/PlaceOS/drivers/actions/workflows/ci.yml/badge.svg)](https://github.com/PlaceOS/drivers/actions/workflows/ci.yml) -Clone this repository to start building your own spider-gazelle based application +Manage and test [PlaceOS](https://place.technology) drivers. -## Documentation +## Development + +### `harness` + +`harness` is a helper for easing development of PlaceOS Drivers. -* [Action Controller](https://github.com/spider-gazelle/action-controller) base class for building [Controllers](http://guides.rubyonrails.org/action_controller_overview.html) -* [Active Model](https://github.com/spider-gazelle/active-model) base class for building [ORMs](https://en.wikipedia.org/wiki/Object-relational_mapping) -* [Habitat](https://github.com/luckyframework/habitat) configuration and settings for Crystal projects -* [router.cr](https://github.com/tbrand/router.cr) base request handling -* [Radix](https://github.com/luislavena/radix) Radix Tree implementation for request routing -* [HTTP::Server](https://crystal-lang.org/api/latest/HTTP/Server.html) built-in Crystal Lang HTTP server - * Request - * Response - * Cookies - * Headers - * Params etc +``` +Usage: ./harness [-h|--help] [command] +Helper script for interfacing with the PlaceOS Driver spec runner -Spider-Gazelle builds on the amazing performance of **router.cr** [here](https://github.com/tbrand/which_is_the_fastest).:rocket: +Command: + report check all drivers' compilation status + up starts the harness + down stops the harness + build builds drivers and uploads them to S3 + format formats driver code + help display this message +``` +To spin up the test harness, clone the repository and run... -## Testing +```shell-session +$ ./harness up +``` -`crystal spec` +Point a browser to [localhost:8085/index.html](http://localhost:8085/index.html), and you're good to go. -* to run in development mode `crystal ./src/app.cr` +When the environment is not in use, remember to run... -## Compiling +```shell-session +$ ./harness down +``` -`crystal build ./src/app.cr` +Before committing, please run... + +```shell-session +$ ./harness format +``` + +## Documentation -### Deploying +- [Existing Driver Docs](https://placeos.github.io/drivers/) +- [Writing a PlaceOS Driver](https://docs.placeos.com/tutorials/backend/write-a-driver) +- [Testing a PlaceOS Driver](https://docs.placeos.com/tutorials/backend/write-a-driver/testing-drivers) +- [Sending Emails](docs/guide-event-emails.md) +- [Environment Setup](docs/setup.md) +- [Runtime Debugging](docs/runtime-debugging.md) +- [Directory Structure](docs/directory_structure.md) +- [PlaceOS Spec Runner HTTP API](docs/http-api.md) -Once compiled you are left with a binary `./app` +## Contributing -* for help `./app --help` -* viewing routes `./app --routes` -* run on a different port or host `./app -h 0.0.0.0 -p 80` +1. [Fork it](https://github.com/PlaceOS/drivers/fork) +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000000..c1974446079 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,74 @@ +x-build-client-env: &build-client-env + PLACEOS_BUILD_HOST: ${PLACEOS_BUILD_HOST:-build} + PLACEOS_BUILD_PORT: ${PLACEOS_BUILD_PORT:-3000} + +services: + # Driver test harness + drivers: + image: placeos/drivers-spec:latest + restart: always + container_name: placeos-drivers + hostname: drivers + depends_on: + - build + - redis + - install-shards + ports: + - 127.0.0.1:8085:8080 + - 127.0.0.1:4444:4444 + volumes: + - ${PWD}/.logs:/app/report_failures + - ${PWD}/repositories:/app/repositories + - ${PWD}:/app/repositories/local + environment: + <<: *build-client-env + CI: ${CI:-} + CRYSTAL_PATH: lib:/lib/local-shards:/usr/share/crystal/src + REDIS_URL: redis://redis:6379 + TZ: $TZ + cap_add: + - "SYS_PTRACE" + security_opt: + - "seccomp:unconfined" + + build: + image: placeos/build:${PLACE_BUILD_TAG:-nightly} + restart: always + hostname: build + volumes: + - ${PWD}/repositories:/app/repositories + - ${PWD}:/app/repositories/local + - ${PWD}/binaries:/app/bin/drivers + environment: + AWS_REGION: ${AWS_REGION:-ap-southeast-2} + AWS_S3_BUCKET: ${AWS_S3_BUCKET:-placeos-drivers} + AWS_KEY: ${AWS_KEY} + AWS_SECRET: ${AWS_SECRET} + GIT_DISCOVERY_ACROSS_FILESYSTEM: 1 + PLACEOS_BUILD_LOCAL: 1 + PLACEOS_ENABLE_TRACE: 1 + TZ: $TZ + BUILD_SERVICE_DISABLED: ${BUILD_SERVICE_DISABLED:-true} + + redis: + image: eqalpha/keydb + restart: always + hostname: redis + environment: + TZ: $TZ + + # Ensures shards are installed. + install-shards: + image: placeos/crystal:${CRYSTAL_VERSION:-latest} + restart: "no" + working_dir: /wd + entrypoint: '' + command: sh -c 'shards check -q || shards install' + environment: + SHARDS_OPTS: "--ignore-crystal-version" + volumes: + - ${PWD}/shard.lock:/wd/shard.lock + - ${PWD}/shard.yml:/wd/shard.yml + - ${PWD}/shard.override.yml:/wd/shard.override.yml + - ${PWD}/.shards:/wd/.shards + - ${PWD}/lib:/wd/lib diff --git a/docs/directory_structure.md b/docs/directory_structure.md new file mode 100644 index 00000000000..f70e5b68b5b --- /dev/null +++ b/docs/directory_structure.md @@ -0,0 +1,23 @@ +# Directory Structures + +[PlaceOS Core](https://github.com/PlaceOS/core) and [PlaceOS Driver Spec Runner](https://github.com/PlaceOS/driver-spec-runner) make the assumption that the working directory is one level +up from the `drivers` directory. + +An example deployment structure: + +* Working directory: `/home/placeos/core` +* Executable: `/home/placeos/core/bin/core` +* Driver repositories: `/home/placeos/repositories` + * PlaceOS Drivers: `/home/placeos/repositories/drivers` +* Driver executables: `/home/placeos/core/bin/drivers` + * Samsung driver: `/home/placeos/core/bin/drivers/353b53_samsung_display_md_series_cr` + +However when developing the structure will look more like: + +* Working directory: `/home/placeos/drivers` +* Driver repository: `/home/placeos/drivers` +* Driver executables: `/home/placeos/drivers/bin/drivers` + * Samsung driver: `/home/placeos/core/bin/drivers/353b53_samsung_display_md_series_cr` + +The primary difference between production and development is [PlaceOS Core](https://github.com/PlaceOS/core). +In a production environment, PlaceOS Core handles cloning repositories, installing packages, and building Drivers as required. diff --git a/docs/gdb-entitlement.xml b/docs/gdb-entitlement.xml new file mode 100644 index 00000000000..9d9251f55d9 --- /dev/null +++ b/docs/gdb-entitlement.xml @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.debugger + + + + + diff --git a/docs/guide-event-emails.md b/docs/guide-event-emails.md new file mode 100644 index 00000000000..d9176bf4bae --- /dev/null +++ b/docs/guide-event-emails.md @@ -0,0 +1,443 @@ +# How to email people when an event occurs + +There are three aspects to this + +1. Sending an email in real-time as an event occurs +2. Batching events (either periodically or via a [CRON](https://crontab.guru/)) +3. Managing state (state machine management) + +For example... +- Send an email straight away if the event is today, otherwise, send them at 7 am every morning and mark the emails as sent. +- Poll every 15min to send any emails that were missed due to an outage (by checking state) + + +## Example logic driver + +```crystal +require "placeos-driver/interface/mailer" + +class DeskBookingNotification < PlaceOS::Driver + descriptive_name "Desk Booking Approval" + generic_name :BookingApproval + + default_settings({ + # https://www.iana.org/time-zones + timezone: "Australia/Sydney", + # https://crystal-lang.org/api/latest/Time/Format.html + date_time_format: "%c", + time_format: "%l:%M%p", + date_format: "%A, %-d %B", + booking_type: "desk", + buildings: ["zone-123", "zone-456"], + }) + + # this ensures these variables are not nilable + @time_zone : Time::Location = Time::Location.load("Australia/Sydney") + @date_time_format : String = "%c" + @time_format : String = "%l:%M%p" + @date_format : String = "%A, %-d %B" + @booking_type : String = "desk" + @buildings : Array(String) = [] of String + + def on_update + # Update the instance variables based on the settings + time_zone = setting?(String, :calendar_time_zone).presence || "Australia/Sydney" + @time_zone = Time::Location.load(time_zone) + @date_time_format = setting?(String, :date_time_format) || "%c" + @time_format = setting?(String, :time_format) || "%l:%M%p" + @date_format = setting?(String, :date_format) || "%A, %-d %B" + @booking_type = setting?(String, :booking_type).presence || "desk" + @buildings = setting?(Array(String), :buildings) || [] of String + + # configure any schedules here + # https://github.com/spider-gazelle/tasker + schedule.clear + schedule.every(5.minutes) { poll_bookings } + schedule.cron("30 7 * * *", @time_zone) { poll_bookings } + end + + def on_load + # Some form of asset booking has occurred (such as a desk booking) + monitor("staff/booking/changed") { |_subscription, payload| check_booking(payload) } + + on_update + end + + # Get a reference to a module that can be used to send emails + def mailer + system.implementing(Interface::Mailer) + end + + # Access another module in the system + accessor staff_api : StaffAPI_1 + + protected def check_booking(payload : String) + logger.debug { "received booking event payload: #{payload}" } + booking_details = Booking.from_json payload + process_booking(booking_details) + end + + # ensure we don't have two fibers processing this at once + # (technically the driver is thread-safe, but it is concurrent) + @check_bookings_mutex = Mutex.new + + @[Security(Level::Support)] + def poll_bookings(months_from_now : Int32 = 2) + # Clean up old debounce data + expired = 5.minutes.ago.to_unix + @debounce.reject! { |_, (_event, entered)| expired > entered } + + now = Time.utc.to_unix + later = months_from_now.months.from_now.to_unix + + @check_bookings_mutex.synchronize do + @buildings.each do |building_zone| + # bookings that haven't been approved + bookings = staff_api.query_bookings( + type: @booking_type, + period_start: now, + period_end: later, + zones: [building_zone], + approved: false, + rejected: false, + created_before: 2.minutes.ago.to_unix + ).get.as_a + + # bookings that have been approved + bookings = bookings + staff_api.query_bookings( + type: @booking_type, + period_start: now, + period_end: later, + zones: [building_zone], + approved: true, + rejected: false, + created_before: 2.minutes.ago.to_unix + ).get.as_a + + # Convert to nice objects + bookings = Array(Booking).from_json(bookings.to_json) + + logger.debug { "checking #{bookings.size} requested bookings in #{building_zone}" } + bookings.each { |booking_details| process_booking(booking_details) } + end + end + end + + # Booking id => event action, timestamp + @debounce = {} of Int64 => {String?, Int64} + @bookings_checked = 0_u64 + + # See the booking model at the end of this document + protected def process_booking(booking_details : Booking) + # Ignore when a bookings state is updated + return if {"process_state", "metadata_changed"}.includes?(booking_details.action) + + # Ignore the same event in a short period of time + previous = @debounce[booking_details.id]? + return if previous && previous[0] == booking_details.action + @debounce[booking_details.id] = {booking_details.action, Time.utc.to_unix} + + # timezone, if different from the default + timezone = booking_details.timezone.presence || @time_zone.name + location = Time::Location.load(timezone) + + # https://crystal-lang.org/api/0.35.1/Time/Format.html + # date and time (Tue Apr 5 10:26:19 2016) + starting = Time.unix(booking_details.booking_start).in(location) + ending = Time.unix(booking_details.booking_end).in(location) + + # Ignore changes to meetings that have already ended + return if Time.utc > ending + + building_zone, building_name = get_building_details(booking_details.zones) + + # These are the available keys for use in the templates + args = { + booking_id: booking_details.id, + start_time: starting.to_s(@time_format), + start_date: starting.to_s(@date_format), + start_datetime: starting.to_s(@date_time_format), + end_time: ending.to_s(@time_format), + end_date: ending.to_s(@date_format), + end_datetime: ending.to_s(@date_time_format), + starting_unix: booking_details.booking_start, + + desk_id: booking_details.asset_id, + user_id: booking_details.user_id, + user_email: booking_details.user_email, + user_name: booking_details.user_name, + reason: booking_details.title, + + level_zone: booking_details.zones.reject { |z| z == building_zone }.first?, + building_zone: building_zone, + building_name: building_name, + support_email: support_email, + + approver_name: booking_details.approver_name, + approver_email: booking_details.approver_email, + + booked_by_name: booking_details.booked_by_name, + booked_by_email: booking_details.booked_by_email, + } + + case booking_details.action + when "create", "changed" + # check if email already sent and we can ignore this one + next if booking_details.process_state == "notification_sent" + + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", "booking_notification"}, + args: args + ) + + # update the booking state (if there are multiple states a booking can be in) + staff_api.booking_state(booking_details.id, "notification_sent").get + when "approved" + # if there is an approval process + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", "booking_approved"}, + args: args + ) + + staff_api.booking_state(booking_details.id, "approval_sent").get + when "rejected", "checked_in" + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", booking_details.action}, + args: args + ) + when "cancelled" + # maybe someone else cancelled your booking and you have a custom template for that + third_party = booking_details.approver_email && booking_details.approver_email != booking_details.user_email.downcase + + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", third_party ? "cancelled_by" : "cancelled"}, + args: args + ) + + # maybe you want to notifty the persons manager about this + if manager_email = get_manager(user_email).try(&.at(0)) + mailer.send_template( + to: manager_email, + template: {"bookings", "manager_notify_cancelled"}, + args: args + ) + end + end + + # nice to see some status in backoffice + @bookings_checked += 1 + self[:bookings_checked] = @bookings_checked + end + + # id => tags, name + @zone_cache = {} of String => Tuple(Array(String), String) + + def get_building_details(zones : Array(String)) + zones.each do |zone_id| + zone_info = @zone_cache[zone_id]? || get_zone(zone_id) + next unless zone_info + next unless zone_info[0].includes?("building") + + return {zone_id, zone_info[1]} + end + + nil + end + + def get_zone(zone_id : String) + zone = staff_api.zone(zone_id).get + tags = zone["tags"].as_a.map(&.as_s) + name = zone["name"].as_s + tuple = {tags, name} + @zone_cache[zone_id] = tuple + tuple + rescue error + logger.warn(exception: error) { "error obtaining zone details for #{zone_id}" } + nil + end + + @[Security(Level::Support)] + def get_manager(staff_email : String) + # The Calendar driver is hooked up to MS Graph API for example + # could have used an accessor here like `staff_api`, that's optional + manager = system[:Calendar_1].get_user_manager(staff_email).get + {(manager["email"]? || manager["username"]).as_s, manager["name"].as_s} + rescue error + logger.warn(exception: error) { "failed to obtain manager of #{staff_email}" } + {nil, nil} + end +end + +``` + + +### List of Staff API events + +These are events that can be monitored `monitor("event/path") { |sub, payload| }` + +* booking (desk, car space etc) - `"staff/booking/changed"` + * [boooking event model](https://github.com/place-labs/staff-api/blob/master/src/controllers/bookings.cr#L80) + * `action` types: create, cancelled, changed, metadata_changed, approved, rejected, checked_in, process_state +* events (calendar events) - `"staff/event/changed"` + * [event event model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L130) + * `action` types: create, update, cancelled +* a guest has been invited onsite - `"staff/guest/attending"` + * [guest attending model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L195) + * `action` types: meeting_created, meeting_update +* a guest has arrived onsite - `"staff/guest/checkin"` + * [guest checkin model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L723) + + +### Booking Model + +This model covers events and API responses + +```crystal + +class Booking + include JSON::Serializable + + # This is to support events + property action : String? + + property id : Int64 + property booking_type : String + property booking_start : Int64 + property booking_end : Int64 + property timezone : String? + + # events use resource_id instead of asset_id + property asset_id : String? + property resource_id : String? + + def asset_id : String + (@asset_id || @resource_id).not_nil! + end + + property user_id : String + property user_email : String + property user_name : String + + property zones : Array(String) + + property checked_in : Bool? + property rejected : Bool? + property approved : Bool? + property process_state : String? + property last_changed : Int64? + + property approver_name : String? + property approver_email : String? + + property booked_by_name : String + property booked_by_email : String + + property checked_in : Bool? + property title : String? + property description : String? + + property extension_data : Hash(String, JSON::Any) + + def in_progress? + now = Time.utc.to_unix + now >= @booking_start && now < @booking_end + end + + def changed + Time.unix(last_changed.not_nil!) + end +end + +``` + +### Email templates + +Email templates are applied to the mailer driver and then other drivers can use them to send emails. + +see the [mailer interface](https://github.com/PlaceOS/driver/blob/master/src/placeos-driver/interface/mailer.cr#L27) for details on available params + +The templates are settings, structured like: + +```yaml + +email_templates: + category: + template_name: + subject: the email subject line with %{variables} + text: the text version of an email + html:

the HTML version of the email

+ +``` + +typically only the `html` version of an email is required + +```yaml + +email_templates: + bookings: + rejected: + subject: 'Desk Booking: Manager rejection' + html: > + + + This is a short note to advise that your desk booking request for + %{start_date} at %{building_name} has been rejected. + +

+ + Please reach out to your manager %{approver_name} if you would like + to follow up. + +

+ + Your request has been removed from the system and we look forward to + welcoming you to our workplace in the future. + +

+ + Kind Regards + +
+ + The Corporate Real Estate Team + + + cancelled: + subject: Desk booking cancellation confirmation + text: > + Thank you for taking the time to cancel your booking which we appreciate + so we can continue to operate with efficiency and excellence. + + + Your desk booking on %{start_date} at %{building_name} has been + cancelled. + + + Please reach out to your workplace support team should you have any + other queries, otherwise we look forward to seeing you soon + html: > + + + Thank you for taking the time to cancel your booking which we appreciate + so we can continue to operate with efficiency and excellence. + +

+ + Your desk booking on %{start_date} at %{building_name} has been + cancelled. + +

+ + Please reach out to your workplace support team should you have any other queries, + otherwise, we look forward to seeing you soon + + + +``` diff --git a/docs/http-api.md b/docs/http-api.md new file mode 100644 index 00000000000..9e908404b81 --- /dev/null +++ b/docs/http-api.md @@ -0,0 +1,154 @@ +# HTTP API + +Primarily for development. + + +## GET /build + +Returns the list of available drivers + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `compiled=true` (optional) if you only want the list of compiled drivers + +```json + +["drivers/place/spec_helper.cr", "..."] +``` + + +### GET /build/repositories + +Returns the list of 3rd party repositories + +```json + +["private_drivers", "..."] +``` + + +### GET /build/repository_commits + +Returns the list of available commits at the repository level + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### GET /build/{{escaped driver path}} + +Returns the list of compiled versions of the specified file are available + +```json + +["private_drivers_cr_01519d6", "..."] +``` + + +### GET /build/{{escaped driver path}}/commits + +Returns the list of available commits for the current driver + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### POST /build + +compiles a driver + +* `driver=drivers/path.cr` (required) the path to the driver +* `commit=01519d6` (optional) defaults to head + + +### DELETE /build/{{escaped driver path}} + +deletes compiled versions of a driver + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `commit=01519d6` (optional) deletes all versions of a driver if not specified + + +## GET /test + +Lists the available specs + +```json + +["drivers/place/spec_helper_spec.cr", "..."] +``` + + +### GET /test/{{escaped spec path}}/commits + +Returns the list of available commits for the specified spec + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### POST /test + +Compiles and runs a spec and returns the output + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `driver=drivers/path/to/file.cr` (required) the driver you want to test +* `spec=drivers/path/to/file_spec.cr` (required) the spec you want to run on the driver +* `commit=01519d6` (optional) the commit you would like the driver to be running at +* `spec_commit=01519d6` (optional) the commit you would like the spec to be running at +* `force=true` (optional) forces a re-compilation of the driver and spec +* `debug=true` (optional) compiles the files with debugging symbols + +```text +Launching spec runner +Launching driver: /Users/steve/Documents/projects/placeos/drivers/bin/drivers/drivers_place_private_helper_cr_4f6e0cd +... starting driver IO services +... starting module +... waiting for module +... module connected +... enabling debug output +... starting spec +... spec complete +... terminating driver gracefully +Driver terminated with: 0 + + +Finished in 15.65 milliseconds +0 examples, 0 failures, 0 errors, 0 pending + +spec runner exited with 0 +``` + + +### WebSocket /test/run_spec + +Same requirements as `POST /test` above however it streams the response diff --git a/docs/runtime-debugging.md b/docs/runtime-debugging.md new file mode 100644 index 00000000000..9fe51783978 --- /dev/null +++ b/docs/runtime-debugging.md @@ -0,0 +1,195 @@ +# Runtime Debugging + +This is supported via VS Code on OSX or Linux platforms. +It might be possible to do remote debugging on Windows in conjunction with the Linux Layer. + +* Requires [VS Code](https://code.visualstudio.com/) + * install [Crystal Lang](https://marketplace.visualstudio.com/items?itemName=faustinoaq.crystal-lang) extension + * install [Native Debug](https://marketplace.visualstudio.com/items?itemName=webfreak.debug) extension +* Requires [GDB](https://www.gnu.org/software/gdb/) + * On OSX install using [Homebrew](https://brew.sh/) + * Then code sign the executable: https://sourceware.org/gdb/wiki/PermissionsDarwin + * The `gdb-entitlement.xml` file is in this folder + * When creating the signing certificate follow [this guide](https://apple.stackexchange.com/questions/309017/unknown-error-2-147-414-007-on-creating-certificate-with-certificate-assist) + +This should also work with [LLDB](https://lldb.llvm.org/) on OSX however [has issues](https://github.com/crystal-lang/crystal/issues/4457). + + +## Debug on VSCode + +By convention the project directory name is the same as your application name, if you have changed it, please update `${workspaceFolderBasename}` with the name configured inside `shards.yml` + +### 1. `tasks.json` configuration to compile a crystal project + +```javascript +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Compile", + "command": "shards build --debug ${workspaceFolderBasename}", + "type": "shell" + } + ] +} +``` + +### 2. `launch.json` configuration to debug a binary + +#### Using GDB + +```javascript +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "gdb", + "request": "launch", + "target": "./bin/${workspaceFolderBasename}", + "cwd": "${workspaceRoot}", + "preLaunchTask": "Compile" + } + ] +} +``` + +#### Using LLDB + +```javascript +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "lldb-mi", + "request": "launch", + "target": "./bin/${workspaceFolderBasename}", + "cwd": "${workspaceRoot}", + "preLaunchTask": "Compile" + } + ] +} +``` + +### 3. Then hit the DEBUG green play button + +![debugging](https://i.imgur.com/GsGT1h0.png) + +## 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 + +![Using VSCode GUI](https://i.imgur.com/EpQinL7.png) + +### 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..68beeea2a19 --- /dev/null +++ b/docs/writing-a-driver.md @@ -0,0 +1,549 @@ +# Writing A Driver + +There are three main uses of drivers: + +* Streaming IO (TCP, SSH, UDP, Multicast etc.) +* HTTP Client +* Logic + +From a driver structure standpoint there is no difference between these types. + +* The same driver works over a TCP, UDP or SSH transport +* All drivers support HTTP methods (except logic modules) + * for example a websocket driver or tcp driver will also be provided a default HTTP client at the base URI of the websocket and IP address of the tcp driver. + * this default client URL can be overwritten, for example where the [HTTP port](https://github.com/PlaceOS/drivers/blob/master/drivers/aver/cam520_pro.cr#L43-L45) is different to the websocket port\ + `transport.http_uri_override = URI.new` +* All drivers have access to logic helpers when associated with a System + +### Code documentation + +For detailed automatically generated documentation please see the: [Driver API](https://placeos.github.io/driver/PlaceOS/Driver.html) + +1. All drivers should require placeos-driver before anything else. +2. There should be a single class that inherits \`PlaceOS::Driver\` + +```crystal +require "placeos-driver" +require "..." + +class MyDriver < PlaceOS::Driver + ... +end +``` + +### Lifecycle + +All PlaceOS Drivers have a lifecycle that is managed by the system. + +There are 5 lifecycle events: + +* `#on_load` - Called when a driver instance (a module) is started. +* `#on_update` - Called when settings are updated or on start if on_load is not defined +* `#on_unload` - Called when a module is stopped. +* `#connected` - Called when the TCP/UDP/SSH transport has established a connection. +* `#disconnected` - Called when a connection is lost or a connection failure occurs (unable to connect). Whilst connections will continually attempt to be established, this is only called on state changes. So the first failed connection attempt or state change from connected to disconnected. + +### 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 have names - if there's a name conflict, the newer task overwrites the older one +* Tasks have a timeout (defaults to `5.seconds`) +* Tasks a set amount of re-tries (defaults to `3` before failing) + +Tasks have a callback which can 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, but it's good to understand that how it functions. + +### State + +Drivers expose state via a hash like object exposed through `self`. + +```crystal + # state can be any JSON parsable object + self["state_index#{idx}"] = true + self[:state_name] = "state value" +``` + +Drivers can be thought of as a digitial twin for the hardware they control. The state of that hardware should be exposed via this method. + +```crystal + # it's also possible to read state back + self[:state_name] # => JSON::Any + + # if you know the type you can use status helpers + status(String, :state_name) # => "state value" + + # use this method if the status may not be set at time of retrieval + status?(Bool, :other_state) # => nil.as(Bool?) +``` + +NOTE: if there is some state that will be queried often, it's good practice to also have an instance variable hold the state as the status is stored in redis. + +```crystal + @state_cached_locally : Bool? = nil + # ... + self[:state] = @state_cached_locally = get_state_from_device + # ... + if @state_cached_locally + # ... + else + # ... + end +``` + +### Transport + +The transport loaded is defined by settings in the database. + +#### Streaming IO + +You should always tokenize your streams. You can do this automatically with the [built-in tokenizer](https://github.com/spider-gazelle/tokenizer) + +```crystal +def on_load + transport.tokenizer = Tokenizer.new("\r\n") +end +``` + +Here are the 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 +``` + +1. 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 +``` + +1. 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. For example, if you are using Telnet and want to remove the telnet signals leaving the raw data for tokenizing + +```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 drivers have built-in methods for performing HTTP requests. + +* For streaming IO devices this defaults to `http://device.ip.address` (`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 +``` + +You can define a `before_request` callback on the transport if you'd like to inject headers or debug requests: + +```crystal +def on_update + openai_key = setting(String, :openai_key) + openai_org = setting?(String, :openai_org) + + transport.before_request do |request| + logger.debug { "requesting #{request.method} #{request.path}?#{request.query}" } + + request.headers["Authorization"] = "Bearer #{openai_key}" + request.headers["OpenAI-Organization"] = openai_org if openai_org + request.headers["Content-Type"] = "application/json" + end +end +``` + +#### Special SSH methods + +SSH connections will attempt to open a shell to the remote device. 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 + +Logic drivers belong to a System and cannot be shared, which makes them different from other transports. All other drivers can appear in any number of 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 (JSON::Any) +system[:Display].status(Bool, :power) #=> true (Bool) +system[:Display].status?(Bool, :power) #=> true (Bool | Nil) + +# 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 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. It allows 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 +``` + +Like subscriptions, channels can be setup for broadcasting any data that might not need be exposed as 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](http://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, serializing 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 + +{% hint style="info" %} +All settings will raise an error if they exist but fail to serialize (due to incorrect formatting etc.) +{% endhint %} + +```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 serializable + +```crystal +define_setting(:my_setting_name, "some JSON serialisable data") +``` + +### Logger + +There is a [logger available](https://crystal-lang.org/api/master/Log.html) + +* `warn` 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 parsed as-is + +### Metadata + +Many components use metadata to simplify configuration. + +* `generic_name` => the name that a system should use 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` => Default or example settings that for configuring 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. You can limit who is able to 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` + +When a user initiates a function call, within a driver, you can access that users id via the `invoked_by_user_id` function, which returns a `String` if a user initiated the call. + +### Interfaces + +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. + +Though optional, they're recommended as they make drivers more modular and less complex. + +A full list of interfaces is [available in the 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 default implementation for other methods. These may be overridden if the device or service provides a more efficient way to do the same thing. To keep compatibility, overridden methods must maintain feature and functional parity with the original. + +#### Using an Interface + +You can use the `system.implementing` method from any logic module. It returns a list of all drivers in the system which implement the Interface. + +The `accessor` macro provides a way to declare a dependency on a sibling driver for a specific function. + +For more information on these and for usage examples, see [logic drivers](./#logic-drivers). + +### Handling errors + +Where multiple functions are likely to raise similar errors, the errors can be handled generically using the `rescue_from` helper. + +```crystal +class MyDevice < PlaceOS::Driver + rescue_from JSON::ParseException do |error| + logger.warn(exception: error) { "error parsing JSON payload" } + {} of String => JSON::Any + end + + # any external call to this function will result in the empty hash above + # being returned to the caller. Internally in the driver the error will + # be raised as normal. + def no_error_externally + JSON.parse %({invalid: 'json') + end +end +``` + +Alternatively this can be handled via an explicit function. Useful if it's desirable to use the same code in the received function. + +```crystal +class MyDevice < PlaceOS::Driver + rescue_from JSON::ParseException, :handle_parse_error + + protected def handle_parse_error(error) + logger.warn(exception: error) { "error parsing JSON payload" } + {} of String => JSON::Any + end + + # The above might be used as follows: + + def no_error_externally + # externally returns {} + JSON.parse %({invalid: 'json') + end + + # Keep error parsing DRY + def received(data, task) + result = JSON.parse(String.new data) + task.try &.success(result) + rescue error : JSON::ParseException + result = handle_parse_error(error) + task.try &.success(result) + end +end +``` diff --git a/docs/writing-a-spec.md b/docs/writing-a-spec.md new file mode 100644 index 00000000000..566867236c3 --- /dev/null +++ b/docs/writing-a-spec.md @@ -0,0 +1,221 @@ +# Testing drivers + +There are three kind of drivers + +* Streaming IO (TCP, SSH, UDP, Multicast, ect) +* HTTP Client +* Logic + +From a driver code 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 + +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. + +### Code documentation + +For detailed automatically generated documentation please see the: [Driver Spec API](https://placeos.github.io/driver/DriverSpecs.html) + +## Expectations + +Specs have access to Crystal lang [spec expectations](https://crystal-lang.org/api/latest/Spec/Expectations.html). This allows you to confirm expectations. + +```crystal +variable = 34 +variable.should eq(34) +``` + +There is a good overview on [how to use expectations](https://crystal-lang.org/reference/guides/testing.html) on the crystal lang docs site + +### 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 updated as expected +status[:area2001].should eq(1) +``` + +## Testing HTTP requests + +The test suite emulates a 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 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 + +This allows you to request actions 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 interacts 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 +``` + +An action you perform on your driver might be expected to update 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 updated in you mock device + system(:Display_1)[:power].should eq(true) + + # manually execute a function on a mock device + # need to cast the mock to the appropriate class + system(:Display_1).as(Display).power false +end +``` + +All status queried in this manner is returned as a `JSON::Any` object + +#### Publishing events + +Emulating notifications is also possible + +```crystal +DriverSpecs.mock_driver "Place::LogicExample" do + publish("channel/path", {payload: "data"}.to_json) +end +``` diff --git a/drivers/amber_tech/grandview.cr b/drivers/amber_tech/grandview.cr new file mode 100644 index 00000000000..037a755907c --- /dev/null +++ b/drivers/amber_tech/grandview.cr @@ -0,0 +1,155 @@ +require "placeos-driver" +require "placeos-driver/interface/moveable" +require "placeos-driver/interface/stoppable" + +# Documentation: https://aca.im/driver_docs/AmberTech/grandview-screen.pdf +# https://www.ambertech.com.au/Documents/GV_IP%20CONTROL_Smart%20Screen_Trifold_Manual_April2020.pdf +require "./grandview_models" + +class AmberTech::Grandview < PlaceOS::Driver + include Interface::Moveable + include Interface::Stoppable + + # Discovery Information + generic_name :Screen + descriptive_name "Ambertech Grandview Projector Screen" + uri_base "http://192.168.0.2" + + # The device requires the HTTP port closed after every request + # (even though it responds with HTTP1.1 and doesn't return any headers) + default_settings({ + http_max_requests: 1, + }) + + def on_load + queue.delay = 2.seconds + schedule.every(1.minute) { status } + end + + # moveable interface + def move(position : MoveablePosition, index : Int32 | String = 0) + command = case position + when .up?, .close?, .in? + "/Close.js?a=100" + when .down?, .open?, .out? + "/Open.js?a=100" + else + raise "unsupported move option: #{position}" + end + + queue(name: "move") do |task| + response = get(command, headers: build_headers) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + self[:status] = status = parse_state StatusResp.from_json(response.body).status + task.success status + end + end + + # stoppable interface + def stop(index : Int32 | String = 0, emergency : Bool = false) + queue(name: "stop", priority: 999, clear_queue: emergency) do |task| + response = get("/Stop.js?a=100", headers: build_headers) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + + self[:status] = status = parse_state StatusResp.from_json(response.body).status + task.success status + end + end + + def status + if queue.online + queue(name: "status", priority: 0) do |task| + response = perform_status_request + if response.success? + task.success parse_status(response) + else + task.abort "request failed with #{response.status_code}\n#{response.body}" + end + end + else + response = perform_status_request + parse_status(response) if response.success? + end + end + + protected def perform_status_request + get("/GetDevInfoList.js", headers: build_headers) + end + + protected def build_headers + { + "Host" => URI.parse(config.uri.not_nil!).host.not_nil!, + "Connection" => "keep-alive", + } + end + + protected def parse_status(response) + info = AmberTech::Devices.from_json(response.body) + state = info.device_info.first + + self[:ver] = state.ver + self[:id] = state.id + self[:ip] = state.ip + self[:ip_subnet] = state.ip_subnet + self[:ip_gateway] = state.ip_gateway + self[:name] = state.name + self[:status] = parse_state state.status + info + end + + # compatibility with Screen Technics + def up(index : Int32 = 0) + move :up + end + + def up? + {"opened", "opening"}.includes?(self["status"]?) + end + + def down(index : Int32 = 0) + move :down + end + + def down? + {"closed", "closing"}.includes?(self["status"]?) + end + + protected def parse_state(state : AmberTech::Status | String) + case state + in String + self[:moving0] = false + self[:position0] = nil + self[:screen0] = "stopped" + in .stop? + self[:moving0] = false + self[:position0] = nil + self[:screen0] = "stopped" + in .opening? + self[:moving0] = true + self[:position0] = MoveablePosition::Open + self[:screen0] = "moving_bottom" + poll_state + in .opened? + self[:moving0] = false + self[:position0] = MoveablePosition::Open + self[:screen0] = "at_bottom" + in .closing? + self[:moving0] = true + self[:position0] = MoveablePosition::Close + self[:screen0] = "moving_top" + poll_state + in .closed? + self[:moving0] = false + self[:position0] = MoveablePosition::Close + self[:screen0] = "at_top" + end + + state.to_s.downcase + end + + protected def poll_state + schedule.clear + schedule.every(1.minute) { status; nil } + schedule.in(2.seconds) { status; nil } + end +end diff --git a/drivers/amber_tech/grandview_models.cr b/drivers/amber_tech/grandview_models.cr new file mode 100644 index 00000000000..9691cdffe55 --- /dev/null +++ b/drivers/amber_tech/grandview_models.cr @@ -0,0 +1,45 @@ +require "json" + +module AmberTech + enum Status + Stop + Opening + Opened + Closing + Closed + end + + class DevInfo + include JSON::Serializable + + getter ver : String + getter id : String + getter ip : String + + @[JSON::Field(key: "sub")] + getter ip_subnet : String + + @[JSON::Field(key: "gw")] + getter ip_gateway : String + getter name : String + getter pass : String? + getter pass2 : String? + getter status : Status + end + + class Devices + include JSON::Serializable + + @[JSON::Field(key: "devInfo")] + getter device_info : Array(DevInfo) + + @[JSON::Field(key: "currentIp")] + getter current_ip : String + end + + class StatusResp + include JSON::Serializable + + getter status : Status | String + end +end diff --git a/drivers/amber_tech/grandview_spec.cr b/drivers/amber_tech/grandview_spec.cr new file mode 100644 index 00000000000..aea60dd10fd --- /dev/null +++ b/drivers/amber_tech/grandview_spec.cr @@ -0,0 +1,40 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "AmberTech::Grandview" do + retval = exec(:status) + + expect_http_request do |request, response| + raise "unexpected path #{request.path} for info" unless request.path == "/GetDevInfoList.js" + response.status_code = 200 + response << %({ + "currentIp":"10.142.196.27", + "devInfo":[ + { + "ver":"1.0", + "id":"1015095851", + "ip":"10.142.196.27", + "sub":"255.255.255.128", + "gw":"10.142.196.1", + "name":"CII_Scrn", + "pass":"admin", + "pass2":"config", + "status":"Closed" + } + ] + }) + end + + retval.get + sleep 1 + status[:status].should eq "closed" + + retval = exec(:move, "down") + sleep 2 + expect_http_request do |request, response| + raise "unexpected path #{request.path} for move down" unless request.path == "/Open.js" + response.status_code = 200 + response << %({"status":"Opening"}) + end + retval.get.should eq "opening" + status[:status].should eq "opening" +end diff --git a/drivers/amx/svsi/models.cr b/drivers/amx/svsi/models.cr new file mode 100644 index 00000000000..53adb1770d1 --- /dev/null +++ b/drivers/amx/svsi/models.cr @@ -0,0 +1,10 @@ +require "json" + +module Amx + # Interface for enumerating devices + module Transmitter + end + + module Receiver + end +end diff --git a/drivers/amx/svsi/n_series_decoder.cr b/drivers/amx/svsi/n_series_decoder.cr new file mode 100644 index 00000000000..9e0d018890a --- /dev/null +++ b/drivers/amx/svsi/n_series_decoder.cr @@ -0,0 +1,220 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "inactive-support/mapped_enum" +require "./models" + +# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf + +class Amx::Svsi::NSeriesDecoder < PlaceOS::Driver + include Interface::Muteable + include PlaceOS::Driver::Interface::InputSelection(Int32) + include Amx::Receiver + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Decoder" + generic_name :Decoder + + @previous_stream : Int32? = nil + @mute : Bool = false + @stream : Int32? = nil + + private DELIMITER = "\r" + + mapped_enum Command do + GetStatus = "getStatus" + Set = "set" + SetSettings = "setSettings" + SwitchKVM = "KVMMasterIP" + Mute = "mute" + Unmute = "unmute" + SetAudio = "seta" + Live = "live" + Local = "local" + ScalerEnable = "scalerenable" + ScalerDisable = "scalerdisable" + ModeSet = "modeset" + end + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + do_send(Command::GetStatus, priority: 0) + end + + def switch_to(input : Int32) + switch_video(input) + switch_audio(0) # enable AFV + end + + def switch_video(stream_id : Int32) + do_send(Command::Set, stream_id) + end + + def switch_audio(stream_id : Int32) + @previous_stream = stream_id + unmute + end + + def switch_kvm(ip_address : String, video_follow : Bool = true) + host = "#{ip_address},#{video_follow ? 1 : 0}" + do_send(Command::SwitchKVM, host) + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo, + ) + if state + do_send(Command::Mute, name: :mute) + do_send(Command::SetAudio, 0) + else + do_send(Command::SetAudio, @previous_stream || 0) + do_send(Command::Unmute, name: :mute) + end + end + + def live(state : Bool = true) + state ? do_send(Command::Live) : local(self[:playlist].as_i) + end + + def local(playlist : Int32 = 0) + do_send(Command::Local, playlist) + end + + def scaler(state : Bool) + action = state ? Command::ScalerEnable : Command::ScalerDisable + do_send(action, name: :scaler) + end + + OutputModes = [ + "auto", + "1080p59.94", + "1080p60", + "720p60", + "4K30", + "4K25", + ] + + def output_resolution(mode : String) + unless OutputModes.includes?(mode) + logger.error { "\"#{mode}\" is not a valid resolution" } + return + end + do_send(Command::ModeSet, mode) + end + + def videowall( + width : Int32, + height : Int32, + x_pos : Int32, + y_pos : Int32, + scale : VideowallScalingMode = VideowallScalingMode::Auto, + ) + if width > 1 && height > 1 + videowall_size(width, height) + videowall_position(x_pos, y_pos) + videowall_scaling(scale) + videowall_enable + else + videowall_disable + end + end + + def videowall_enable(state : Bool = true) + state = state ? "on" : "off" + do_send(Command::SetSettings, "wallEnable", state) + end + + def videowall_disable + videowall_enable(false) + end + + def videowall_size(width : Int32, height : Int32) + do_send(Command::SetSettings, "wallHorMons", width) + do_send(Command::SetSettings, "wallVerMons", height) + end + + def videowall_position(x : Int32, y : Int32) + do_send(Command::SetSettings, "wallMonPosV", x) + do_send(Command::SetSettings, "wallMonPosH", y) + end + + enum VideowallScalingMode + Auto # decoder decides best method + Fit # aspect distort + Stretch # fill and crop + end + + def videowall_scaling(scaling_mode : VideowallScalingMode) + do_send(Command::SetSettings, "wallStretch", scaling_mode) + end + + mapped_enum Response do + Stream = "stream" + StreamAudio = "streamaudio" + Name = "name" + Playmode = "playmode" + Playlist = "playlist" + Mute = "mute" + ScalerBypass = "scalerbypass" + Mode = "mode" + InputRes = "inputres" + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + prop, value = data.split(':') + + case Response.from_mapped_value?(prop.downcase) + in Response::Stream + self[:video] = @stream = value.to_i + in Response::StreamAudio + stream_id = value.to_i + self[:audio_actual] = stream_id + self[:audio] = stream_id == 0 ? (@mute ? 0 : @stream) : stream_id + in Response::Name + self[:device_name] = value + in Response::Playmode + self[:local_playback] = value == "local" + in Response::Playlist + self[:playlist] = value.to_i + in Response::Mute + self[:mute] = @mute = value == "1" + in Response::ScalerBypass + self[:scaler_active] = value != "no" + in Response::Mode + self[:output_res] = value + in Response::InputRes + self[:input_res] = value + in Nil + raise "Unexpected response: #{prop}" + end + + task.try(&.success) + end + + def do_send(command : Command, *args, **options) + arguments = [command.mapped_value] + + unless (splat = args.to_a).is_a? Array(NoReturn) + arguments += splat + end + + request = "#{arguments.join(':')}#{DELIMITER}" + send(request, **options) + end +end diff --git a/drivers/amx/svsi/n_series_encoder.cr b/drivers/amx/svsi/n_series_encoder.cr new file mode 100644 index 00000000000..17a54988fef --- /dev/null +++ b/drivers/amx/svsi/n_series_encoder.cr @@ -0,0 +1,123 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "inactive-support/mapped_enum" +require "./models" + +# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf + +class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver + include Interface::Muteable + include Amx::Transmitter + + enum Input + Hdmionly + Vgaonly + Hdmivga + Vgahdmi + end + + include Interface::InputSelection(Input) + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Encoder" + generic_name :Encoder + + private DELIMITER = "\r" + + mapped_enum Command do + GetStatus = "getStatus" + VideoSource = "vidsrc" + Live = "live" + Local = "local" + Disable = "txdisable" + Mute = "mute" + Unmute = "unmute" + end + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + do_send(Command::GetStatus, priority: 0) + end + + def switch_to(input : Input, **options) + do_send(Command::VideoSource, input, **options) + end + + Modes = (1..8).map &.to_s + + def media_source(mode : String) + if mode == "live" + do_send(Command::Live) + elsif Modes.includes?(mode) + do_send(Command::Local, mode) + else + raise("invalid mode #{mode}") + end + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo, + ) + if state + do_send(Command::Disable) if layer.audio_video? || layer.video? + do_send(Command::Mute) if layer.audio_video? || layer.audio? + else + do_send(Command::Disable) if layer.audio_video? || layer.video? + do_send(Command::Unmute) if layer.audio_video? || layer.audio? + end + end + + enum Response + Name + Stream + Playmode + Mute + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + prop, value = data.split(':') + + case Response.parse? prop + in Response::Name + self[:device_name] = value + in Response::Stream + self[:stream_id] = value.to_i + in Response::Playmode + self[:mute] = value == "off" + in Response::Mute + self[:audio_mute] = value == "1" + in Nil + raise "Invalid response: #{prop}" + end + + task.try(&.success) + end + + def do_send(command : Command, *args, **options) + arguments = [command.mapped_value] + + unless (splat = args.to_a).is_a? Array(NoReturn) + arguments += splat + end + + request = "#{arguments.join(':')}#{DELIMITER}" + send(request, **options) + end +end diff --git a/drivers/amx/svsi/n_series_switcher.cr b/drivers/amx/svsi/n_series_switcher.cr new file mode 100644 index 00000000000..88ff69d7d3d --- /dev/null +++ b/drivers/amx/svsi/n_series_switcher.cr @@ -0,0 +1,228 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" + +# require "placeos-driver/interface/switchable" + +# Documentation: https://aca.im/driver_docs/AMX/N8000SeriesAPICommandListRev1.1.pdf + +class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver + include Interface::Muteable + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Switcher" + generic_name :Switcher + + alias InOut = String | Int32 + + @inputs : Hash(String, String) = {} of String => String + @outputs : Hash(String, String) = {} of String => String + @encoders = [] of String + @decoders = [] of String + @lookup : Hash(String, String) = {} of String => String + @list = [] of String + + def on_load + transport.tokenizer = Tokenizer.new("") + on_update + end + + def on_update + @inputs = setting?(Hash(String, String), :inputs) || {} of String => String + @outputs = setting?(Hash(String, String), :outputs) || {} of String => String + + @encoders = @inputs.keys + @decoders = @outputs.keys + + @lookup = @inputs.merge(@outputs) + @list = @encoders + @decoders + end + + def connected + @lookup.each_key do |ip_address| + monitor(ip_address, priority: 0) + monitornotify(ip_address, priority: 0) + end + + schedule.every(50.seconds) { + logger.debug { "-- Maintaining Connection --" } + monitornotify(@list.first, priority: 0) + } + end + + def disconnected + schedule.clear + end + + CommonCommands = [ + :monitor, :monitornotify, + :live, :local, :serial, :readresponse, :sendir, :sendirraw, :audioon, :audiooff, + :enablehdmiaudio, :disablehdmiaudio, :autohdmiaudio, + # recorder commands + :record, :dsrecord, :dvrswitch1, :dvrswitch2, :mpeg, :mpegall, :deletempegfile, + :play, :stop, :pause, :unpause, :fastforward, :rewind, :deletefile, :stepforward, + :stepreverse, :stoprecord, :recordhold, :recordrelease, :playhold, :playrelease, + :deleteallplaylist, :deleteallmpegs, :remotecopy, + # window processor commands + :wpswitch, :wpaudioin, :wpactive, :wpinactive, :wpaudioon, :wpaudiooff, :wpmodeon, + :wpmodeoff, :wparrange, :wpbackground, :wpcrop, :wppriority, :wpbordon, :wpbordoff, + :wppreset, + # audio transceiver commands + :atrswitch, :atrmute, :atrunmute, :atrtxmute, :atrtxunmute, :atrhpvol, :atrlovol, + :atrlovolup, :atrlovoldown, :atrhpvolup, :atrhpvoldown, :openrelay, :closerelay, + # video wall commands + :videowall, + # miscellaneous commands + :script, :goto, :tcpclient, :udpclient, :reboot, :gc_serial, :gc_openrelay, + :gc_closerelay, :gc_ir, + ] + + {% for name in CommonCommands %} + def {{name.id}}(ip_address : String, *args, **options) + do_send({{name.id.stringify}}, ip_address, *args, **options) + end + {% end %} + + def serialhex(ip_address : String, wait_time : Int32 = 1, *data, **options) + do_send("serialhex", wait_time, ip_address, *data, **options) + end + + # Encoder Commands + {% for name in [:modeoff, :enablecc, :disablecc, :autocc, :uncompressedoff] %} + def {{name.id}}(input : InOut, *args, **options) + do_send({{name.id.stringify}}, get_input(input), *args, **options) + end + {% end %} + + # Decoder Commands + {% for name in [:audiofollow, :volume, :dvion, :dvioff, :cropref, :getStatus] %} + def {{name.id}}(output : InOut, *args, **options) + do_send({{name.id.stringify}}, get_output(output), *args, **options) + end + {% end %} + + def switch(inouts : Hash(Int32, InOut | Array(InOut)), **options) + inouts.each do |input, output| + outputs = output.is_a?(InOut) ? [output] : output + if input != 0 + # 'in_ip' => ['ip1', 'ip2'] etc + input_actual = get_input(input) + outputs.each do |o| + output_actual = get_output(o) + + dvion(output_actual, **options) + audioon(output_actual, **options) + audiofollow(output_actual, **options) + + self["video#{output_actual}"] = input_actual + self["audio#{output_actual}"] = input_actual + do_send(:switch, output_actual, input_actual, **options) + end + else + # nil => ['ip1', 'ip2'] etc + outputs.each do |o| + output_actual = get_output(o) + dvioff(output_actual, **options) + audiooff(output_actual, **options) + end + end + end + end + + def switch_audio(inouts : Hash(Int32, InOut | Array(InOut)), **options) + inouts.each do |input, output| + outputs = output.is_a?(InOut) ? [output] : output + if input != 0 + # 'in_ip' => ['ip1', 'ip2'] etc + input_actual = get_input(input) + outputs.each do |o| + output_actual = get_output(o) + + audioon(input_actual, **options) + audioon(output_actual, **options) + + self["audio#{output_actual}"] = input_actual + do_send(:switchaudio, output_actual, input_actual, **options) + end + else + # nil => ['ip1', 'ip2'] etc + outputs.each do |o| + audiooff(get_output(o), **options) + end + end + end + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + address = index.is_a?(Int32) && (val = @encoders[index]? || @decoders[index]?) ? val : index.as(String) + if state + dvioff(address) if layer.audio_video? || layer.video? + audiooff(address) if layer.audio_video? || layer.audio? + else + dvion(address) if layer.audio_video? || layer.video? + audioon(address) if layer.audio_video? || layer.audio? + end + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + resp = data.split(':') + + case resp.size + when 13 # Encoder or decoder status + self[resp[0]] = { + communications: resp[1] == "1", + dvioff: resp[2] == "1", + scaler: resp[3] == "1", + source_detected: resp[4] == "1", + mode: resp[5], + audio_enabled: resp[6] == "1", + video_stream: resp[7].to_i, + audio_stream: resp[8] == "follow video" ? resp[8] : resp[8].to_i, + playlist: resp[9], + colorspace: resp[10], + hdmiaudio: resp[11], + resolution: resp[12], + } + when 10 # Audio Transceiver or window processor status + self[resp[0]] = resp + else + logger.warn { "unknown response type: #{resp}" } + end + + task.try(&.success) + end + + def do_send(*args, **options) + cmd = args.join(' ') + logger.debug { "sending #{cmd}" } + send("#{cmd}\r\n", **options) + end + + private def get_input(address : InOut) : String + if address.is_a?(String) && @inputs[address]? + address + elsif address.is_a?(Int32) && (input = @encoders[address]?) + input + else + logger.warn { "unknown address #{address}" } + address.to_s + end + end + + private def get_output(address : InOut) : String + if address.is_a?(String) && @outputs[address]? + address + elsif address.is_a?(Int32) && (output = @decoders[address]?) + output + else + logger.warn { "unknown address #{address}" } + address.to_s + end + end +end diff --git a/drivers/amx/svsi/virtual_switcher.cr b/drivers/amx/svsi/virtual_switcher.cr new file mode 100644 index 00000000000..23b3cacf6a1 --- /dev/null +++ b/drivers/amx/svsi/virtual_switcher.cr @@ -0,0 +1,69 @@ +require "placeos-driver" +require "placeos-driver/interface/switchable" +require "./models" + +# This driver provides an abstraction layer for systems using SVSI based signal +# distribution. In place of referencing specific receivers and stream id's, +# this may be used to enable all endpoints associated with a system to be +# grouped as a virtual matrix switcher and a familiar switcher API used. + +class Amx::Svsi::VirtualSwitcher < PlaceOS::Driver + include PlaceOS::Driver::Interface::Switchable(Int32, Int32) + + descriptive_name "AMX SVSI Virtual Switcher" + generic_name :Switcher + + private def transmitters + system.implementing(Amx::Transmitter) + end + + private def receivers + system.implementing(Amx::Receiver) + end + + def switch_to(input : Int32) + receivers.each(&.switch_to(input)) + end + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + layer ||= SwitchLayer::All + connect(map) do |mod, stream| + mod.switch_audio(stream) if layer.all? || layer.audio? + mod.switch_video(stream) if layer.all? || layer.video? + end + end + + def encoder_count + transmitters.size + end + + def decoder_count + receivers.size + end + + private def connect(inouts : Hash(Input, Array(Output)), &) + inouts.each do |input, outputs| + if input == 0 + stream = 0 # disconnected + else + # Subtract one as Encoder_1 on the system would be encoder[0] here + if encoder = transmitters[input - 1]? + stream = encoder[:stream_id] + else + logger.warn { "could not find Encoder_#{input}" } + break + end + end + + outputs = outputs.is_a?(Array) ? outputs : [outputs] + outputs.each do |output| + # Subtract one as Decoder_1 on the system would be decoder[0] here + if decoder = receivers[output - 1]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder_#{output}" } + end + end + end + end +end diff --git a/drivers/arista/wifi_webhook.cr b/drivers/arista/wifi_webhook.cr new file mode 100644 index 00000000000..9d321fffdd2 --- /dev/null +++ b/drivers/arista/wifi_webhook.cr @@ -0,0 +1,24 @@ +require "placeos-driver" + +class Arista::WifiWebhook < PlaceOS::Driver + descriptive_name "Arista Wifi Webhook Receiver" + generic_name :Arista_Webhook + + default_settings({ + debug: true, + }) + + def on_update + @debug = setting?(Bool, :debug) || false + end + + def receive_webhook(method : String, headers : Hash(String, Array(String)), body : String) + logger.warn do + "Received Webhook\n" + + "Method: #{method.inspect}\n" + + "Headers:\n#{headers.inspect}\n" + + "Body:\n#{body.inspect}" + end + # Process the webhook payload as needed + end +end diff --git a/drivers/arista/wifi_webhook_spec.cr b/drivers/arista/wifi_webhook_spec.cr new file mode 100644 index 00000000000..d7087e736e1 --- /dev/null +++ b/drivers/arista/wifi_webhook_spec.cr @@ -0,0 +1,4 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Arista::WifiWebhook" do +end diff --git a/drivers/ashrae/bacnet.cr b/drivers/ashrae/bacnet.cr new file mode 100644 index 00000000000..89717628ed7 --- /dev/null +++ b/drivers/ashrae/bacnet.cr @@ -0,0 +1,634 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "socket" +require "./bacnet_models" + +class Ashrae::BACnet < PlaceOS::Driver + include Interface::Sensor + + generic_name :BACnet + descriptive_name "BACnet Connector" + description %(makes BACnet data available to other drivers in PlaceOS) + + # Hookup dispatch to the BACnet BBMD device + uri_base "ws://dispatch/api/dispatch/v1/udp_dispatch?port=47808&accept=192.168.0.1" + + default_settings({ + dispatcher_key: "secret", + bbmd_ip: "192.168.0.1", + known_devices: [{ + ip: "192.168.86.25", + id: 389999, + net: 0x0F0F, + addr: "0A", + }], + verbose_debug: false, + poll_period: 3, + }) + + def websocket_headers + dispatcher_key = setting?(String, :dispatcher_key) + HTTP::Headers{ + "Authorization" => "Bearer #{dispatcher_key}", + "X-Module-ID" => module_id, + } + end + + protected getter! udp_server : UDPSocket + protected getter! bacnet_client : ::BACnet::Client::IPv4 + protected getter! device_registry : ::BACnet::Client::DeviceRegistry + + alias DeviceInfo = ::BACnet::Client::DeviceRegistry::DeviceInfo + + @packets_processed : UInt64 = 0_u64 + @verbose_debug : Bool = false + @bbmd_ip : Socket::IPAddress = Socket::IPAddress.new("127.0.0.1", 0xBAC0) + @devices : Hash(UInt32, DeviceInfo) = {} of UInt32 => DeviceInfo + @mutex : Mutex = Mutex.new(:reentrant) + @bbmd_forwarding : Array(UInt8) = [] of UInt8 + @seen_devices : Hash(UInt32, DeviceAddress) = {} of UInt32 => DeviceAddress + + protected def get_device(device_id : UInt32) + @mutex.synchronize { @devices[device_id]? } + end + + def on_load + # We only use dispatcher for broadcast messages, a local port for primary comms + server = UDPSocket.new + server.bind "0.0.0.0", 0xBAC0 + server.write_timeout = 200.milliseconds + @udp_server = server + + queue.timeout = 2.seconds + + # Hook up the client to the transport + client = ::BACnet::Client::IPv4.new(0, 2.seconds) + client.on_transmit do |message, address| + if address.address == Socket::IPAddress::BROADCAST + if @bbmd_forwarding.size == 4 + message.data_link.request_type = ::BACnet::Message::IPv4::Request::ForwardedNPDU + message.data_link.address.ip1 = @bbmd_forwarding[0] + message.data_link.address.ip2 = @bbmd_forwarding[1] + message.data_link.address.ip3 = @bbmd_forwarding[2] + message.data_link.address.ip4 = @bbmd_forwarding[3] + message.data_link.address.port = 47808_u16 + end + + logger.debug { "sending broadcase message #{message.inspect}" } + + # send to the known devices (in case BBMD does not forward message) + devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress + devices.each do |dev| + begin + server.send message, to: dev.address + rescue error + logger.warn(exception: error) { "error sending message to #{dev.address}" } + end + end + + # Send this message to the BBMD + message.data_link.request_type = ::BACnet::Message::IPv4::Request::DistributeBroadcastToNetwork + payload = DispatchProtocol.new + payload.message = DispatchProtocol::MessageType::WRITE + payload.ip_address = @bbmd_ip.address + payload.id_or_port = @bbmd_ip.port.to_u64 + payload.data = message.to_slice + transport.send payload.to_slice + else + server.send message, to: address + end + end + @bacnet_client = client + + # Track the discovery of devices + registry = ::BACnet::Client::DeviceRegistry.new(client, logger) + registry.on_new_device { |device| new_device_found(device) } + @device_registry = registry + + spawn { process_data(server, client) } + on_update + end + + # This is our input read loop, grabs the incoming data and pumps it to our client + protected def process_data(server, client) + loop do + break if server.closed? + bytes, client_addr = server.receive + + begin + message = IO::Memory.new(bytes).read_bytes(::BACnet::Message::IPv4) + client.received message, client_addr + @packets_processed += 1_u64 + rescue error + logger.warn(exception: error) { "error parsing BACnet packet from #{client_addr}: #{bytes.to_slice.hexstring}" } + end + end + end + + def on_unload + udp_server.close + end + + def on_update + bbmd_ip = setting?(String, :bbmd_ip) || "" + bbmd_forwarding = setting?(String, :bbmd_forwarding) || "" + + @bbmd_forwarding = bbmd_forwarding.strip.split(".").select(&.presence).map(&.to_u8) + @bbmd_ip = Socket::IPAddress.new(bbmd_ip, 0xBAC0) if bbmd_ip.presence + @verbose_debug = setting?(Bool, :verbose_debug) || false + + schedule.clear + schedule.in(5.seconds) { query_known_devices } + + poll_period = setting?(UInt32, :poll_period) || 3 + schedule.every(poll_period.minutes) do + logger.debug { "--- Polling all known bacnet devices" } + keys = @mutex.synchronize { @devices.keys } + keys.each { |device_id| poll_device(device_id) } + end + + perform_discovery if bbmd_ip.presence + end + + def packets_processed + @packets_processed + end + + def connected + bbmd_ip = setting?(String, :bbmd_ip) + perform_discovery if bbmd_ip.presence + end + + protected def object_value(obj) + val = obj.value.try &.value + case val + in ::BACnet::Time, ::BACnet::Date + val.value + in ::BACnet::BitString, BinData + nil + in ::BACnet::PropertyIdentifier + val.property_type + in ::BACnet::ObjectIdentifier + {val.object_type, val.instance_number} + in Nil, Bool, UInt64, Int64, Float32, Float64, String + val + end + rescue + nil + end + + protected def device_details(device) + { + name: device.name, + model_name: device.model_name, + vendor_name: device.vendor_name, + + ip_address: device.ip_address.to_s, + network: device.network, + address: device.address, + id: device.object_ptr.instance_number, + + objects: device.objects.map { |obj| + { + name: obj.name, + type: obj.object_type, + id: obj.instance_id, + + unit: obj.unit, + value: object_value(obj), + seen: obj.changed, + } + }, + } + end + + def device(device_id : UInt32) + device_details get_device(device_id).not_nil! + end + + def devices + device_registry.devices.map { |device| device_details device } + end + + def query_known_devices + sent = [] of UInt32 + @seen_devices.each_value do |info| + sent << info.id.not_nil! + logger.debug { "inspecting #{info.address} - #{info.id}" } + device_registry.inspect_device(info.address, info.identifier, info.net, info.addr) + end + devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress + devices.each do |info| + if id = info.id + next if id.in? sent + sent << id + logger.debug { "inspecting #{info.address} - #{info.id}" } + device_registry.inspect_device(info.address, info.identifier, info.net, info.addr) + end + end + "inspected #{sent.size} devices" + end + + def poll_device(device_id : UInt32) + device = get_device(device_id) + return false unless device + + client = bacnet_client + objects = @mutex.synchronize { device.objects.dup } + objects.each do |obj| + next unless obj.object_type.in?(::BACnet::Client::DeviceRegistry::OBJECTS_WITH_VALUES) + name = object_binding(device_id, obj) + queue(name: name, priority: 0, timeout: 500.milliseconds) do |task| + spawn_action(task) do + obj.sync_value(client) + self[name] = object_value(obj) + end + end + Fiber.yield + end + true + end + + protected def spawn_action(task, &block : -> Nil) + spawn { task.success block.call } + Fiber.yield + end + + # Performs a WhoIs discovery against the BACnet network + def perform_discovery : Nil + bacnet_client.who_is + end + + alias ObjectType = ::BACnet::ObjectIdentifier::ObjectType + + def update_value(device_id : UInt32, instance_id : UInt32, object_type : ObjectType) + obj = get_object_details(device_id, instance_id, object_type) + name = object_binding(device_id, obj) + + queue(name: name, priority: 50) do |task| + spawn_action(task) do + obj.sync_value(bacnet_client) + self[name] = object_value(obj) + end + end + end + + protected def get_object_details(device_id : UInt32, instance_id : UInt32, object_type : ObjectType) + device = get_device(device_id).not_nil! + device.objects.find { |obj| obj.object_ptr.object_type == object_type && obj.object_ptr.instance_number == instance_id }.not_nil! + end + + def write_real(device_id : UInt32, instance_id : UInt32, value : Float32, object_type : ObjectType = ObjectType::AnalogValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_double(device_id : UInt32, instance_id : UInt32, value : Float64, object_type : ObjectType = ObjectType::LargeAnalogValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_unsigned_int(device_id : UInt32, instance_id : UInt32, value : UInt64, object_type : ObjectType = ObjectType::PositiveIntegerValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_signed_int(device_id : UInt32, instance_id : UInt32, value : Int64, object_type : ObjectType = ObjectType::IntegerValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_string(device_id : UInt32, instance_id : UInt32, value : String, object_type : ObjectType = ObjectType::CharacterStringValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_binary(device_id : UInt32, instance_id : UInt32, value : Bool, object_type : ObjectType = ObjectType::BinaryValue) + val = value ? 1 : 0 + object = get_object_details(device_id, instance_id, object_type) + val = ::BACnet::Object.new.set_value(val) + val.short_tag = 9_u8 + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + val, + network: object.network, + address: object.address + ) + end + end + value + end + + protected def new_device_found(device) + logger.debug { "new device found: #{device.name}, #{device.model_name} (#{device.vendor_name}) with #{device.objects.size} objects" } + logger.debug { device.inspect } if @verbose_debug + + @mutex.synchronize { @devices[device.object_ptr.instance_number] = device } + + device_id = device.object_ptr.instance_number + device.objects.each { |obj| self[object_binding(device_id, obj)] = object_value(obj) } + end + + protected def object_binding(device_id, obj) + "#{device_id}.#{obj.object_type}[#{obj.instance_id}]" + end + + def received(data, task) + # we should only be receiving broadcasted messages here + protocol = IO::Memory.new(data).read_bytes(DispatchProtocol) + + logger.debug { "received message: #{protocol.message} #{protocol.ip_address}:#{protocol.id_or_port} (size #{protocol.data_size})" } + + if protocol.message.received? + message = IO::Memory.new(protocol.data).read_bytes(::BACnet::Message::IPv4) + logger.debug { "dispatch sent:\n#{message.inspect}" } if @verbose_debug + bacnet_client.received message, @bbmd_ip + + app = message.application + + is_iam = false + is_cov = case app + when ::BACnet::ConfirmedRequest + app.service.cov_notification? + when ::BACnet::UnconfirmedRequest + is_iam = app.service.i_am? + app.service.cov_notification? + else + false + end + network = message.network + + if network && is_cov + ip = if message.data_link.request_type.forwarded_npdu? + ip_add = message.data_link.address + "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}" + else + protocol.ip_address + end + if network.source_specifier + addr = network.source_address + net = network.source.network + end + device = message.objects.find { |obj| obj.tag == 1 }.not_nil!.to_object_id.instance_number + # prop = message.objects.find { |obj| obj.tag == 2 } + @seen_devices[device] = DeviceAddress.new(ip, device, net, addr) + end + + if network && is_iam + ip = if message.data_link.request_type.forwarded_npdu? + ip_add = message.data_link.address + "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}" + else + protocol.ip_address + end + details = ::BACnet::Client::Message::IAm.parse(message) + device = details[:object_id].instance_number + @seen_devices[device] = DeviceAddress.new(ip, device, details[:network], details[:address]) + end + end + + task.try &.success + end + + def seen_devices + @seen_devices + end + + # ====================== + # Sensor interface + # ====================== + + protected def to_sensor(device_id, device, object, filter_type = nil) : Interface::Sensor::Detail? + sensor_type = case object.unit + when Nil + # required for case statement to work + if object.name.includes? "count" + SensorType::Counter + end + when .degrees_fahrenheit?, .degrees_celsius?, .degrees_kelvin? + SensorType::Temperature + when .percent_relative_humidity? + SensorType::Humidity + when .pounds_force_per_square_inch? + SensorType::Pressure + # when + # SensorType::Presence + when .volts?, .millivolts?, .kilovolts?, .megavolts? + SensorType::Voltage + when .milliamperes?, .amperes? + SensorType::Current + when .millimeters_of_water?, .centimeters_of_water?, .inches_of_water?, .cubic_feet?, .cubic_meters?, .imperial_gallons?, .milliliters?, .liters?, .us_gallons? + SensorType::Volume + when .milliwatts?, .watts?, .kilowatts?, .megawatts?, .watt_hours?, .kilowatt_hours?, .megawatt_hours? + SensorType::Power + when .hertz?, .kilohertz?, .megahertz? + SensorType::Frequency + when .cubic_feet_per_second?, .cubic_feet_per_minute?, .cubic_feet_per_hour?, .cubic_meters_per_second?, .cubic_meters_per_minute?, .cubic_meters_per_hour?, .imperial_gallons_per_minute?, .milliliters_per_second?, .liters_per_second?, .liters_per_minute?, .liters_per_hour?, .us_gallons_per_minute?, .us_gallons_per_hour? + SensorType::Flow + when .percent? + SensorType::Level + when .no_units? + if object.name.includes? "count" + SensorType::Counter + end + end + return nil unless sensor_type + return nil if filter_type && sensor_type != filter_type + + unit = case object.unit + when Nil + when .degrees_fahrenheit? then "[degF]" + when .degrees_celsius? then "Cel" + when .degrees_kelvin? then "K" + when .pounds_force_per_square_inch? then "[psi]" + when .volts? then "V" + when .millivolts? then "mV" + when .kilovolts? then "kV" + when .megavolts? then "MV" + when .milliamperes? then "mA" + when .amperes? then "A" + when .cubic_feet? then "[cft_i]" + when .cubic_meters? then "m3" + when .imperial_gallons? then "[gal_br]" + when .milliliters? then "ml" + when .liters? then "l" + when .us_gallons? then "[gal_us]" + when .milliwatts? then "mW" + when .watts? then "W" + when .kilowatts? then "kW" + when .megawatts? then "MW" + when .watt_hours? then "Wh" + when .kilowatt_hours? then "kWh" + when .megawatt_hours? then "MWh" + when .hertz? then "Hz" + when .kilohertz? then "kHz" + when .megahertz? then "MHz" + when .cubic_feet_per_second? then "[cft_i]/s" + when .cubic_feet_per_minute? then "[cft_i]/min" + when .cubic_feet_per_hour? then "[cft_i]/h" + when .cubic_meters_per_second? then "m3/s" + when .cubic_meters_per_minute? then "m3/min" + when .cubic_meters_per_hour? then "m3/h" + when .imperial_gallons_per_minute? then "[gal_br]/min" + when .milliliters_per_second? then "ml/s" + when .liters_per_second? then "l/s" + when .liters_per_minute? then "l/min" + when .liters_per_hour? then "l/h" + when .us_gallons_per_minute? then "[gal_us]/min" + when .us_gallons_per_hour? then "[gal_us]/h" + end + + obj_value = object_value(object) + value = case obj_value + in String, Nil, ::Time, ::BACnet::PropertyIdentifier::PropertyType, Tuple(ObjectType, UInt32) + nil + in Bool + obj_value ? 1.0 : 0.0 + in UInt64, Int64, Float32, Float64 + obj_value.to_f64 + end + return nil if value.nil? + + Interface::Sensor::Detail.new( + type: sensor_type, + value: value, + last_seen: object.changed.to_unix, + mac: device_id.to_s, + id: "#{object.object_type}[#{object.instance_id}]", + name: "#{device.name}: #{object.name}", + module_id: module_id, + binding: object_binding(device_id, object), + unit: unit + ) + end + + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + filter = type ? Interface::Sensor::SensorType.parse?(type) : nil + + if mac + device_id = mac.to_u32? + return NO_MATCH unless device_id + device = get_device device_id + return NO_MATCH unless device + return device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) } + end + + matches = @mutex.synchronize do + @devices.map do |(device_id, device)| + device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) } + end + end + matches.flatten + rescue error + logger.warn(exception: error) { "searching for sensors" } + NO_MATCH + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + device_id = mac.to_u32? + return nil unless device_id + device = get_device device_id + return nil unless device + + # id should be in the format "object_type[instance_id]" + obj_type_string, instance_id_string = id.split('[', 2) + instance_id = instance_id_string.rchop.to_u32? + return nil unless instance_id + + object_type = ObjectType.parse?(obj_type_string) + return nil unless object_type + + object = get_object_details(device_id, instance_id, object_type) + + if object.changed < 1.minutes.ago + begin + object.sync_value(bacnet_client) + rescue error + logger.warn(exception: error) { "failed to obtain latest value for sensor at #{mac}.#{id}" } + end + end + + to_sensor(device_id, device, object) + end + + @[Security(Level::Support)] + def save_seen_devices + define_setting(:known_devices, @seen_devices.values) + end +end diff --git a/drivers/ashrae/bacnet_datapoints.cr b/drivers/ashrae/bacnet_datapoints.cr new file mode 100644 index 00000000000..9a09ae9b658 --- /dev/null +++ b/drivers/ashrae/bacnet_datapoints.cr @@ -0,0 +1,26 @@ +require "placeos-driver" +require "json" + +class Ashrae::BACnetDataPoints < PlaceOS::Driver + descriptive_name "BACnet Data Points" + generic_name :DataPoints + + default_settings({ + points: { + "power" => "101003.AnalogValue[45]", + "humidity" => "101005.AnalogValue[4]", + }, + }) + + accessor bacnet : BACnet_1 + + def on_update + subscriptions.clear + points = setting(Hash(String, String), :points) + points.each do |(key, status)| + bacnet.subscribe(status) do |_sub, payload| + self[key] = JSON.parse(payload) + end + end + end +end diff --git a/drivers/ashrae/bacnet_datapoints_spec.cr b/drivers/ashrae/bacnet_datapoints_spec.cr new file mode 100644 index 00000000000..05559525362 --- /dev/null +++ b/drivers/ashrae/bacnet_datapoints_spec.cr @@ -0,0 +1,20 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Qbic::TouchPanel" do + system({ + BACnet: {BACnetMock}, + }) + + sleep 0.2 + + status["power"].should eq true + status["humidity"].should eq 34.4 +end + +# :nodoc: +class BACnetMock < DriverSpecs::MockDriver + def on_load + self["101003.AnalogValue[45]"] = true + self["101005.AnalogValue[4]"] = 34.4 + end +end diff --git a/drivers/ashrae/bacnet_models.cr b/drivers/ashrae/bacnet_models.cr new file mode 100644 index 00000000000..dc0d0a1d672 --- /dev/null +++ b/drivers/ashrae/bacnet_models.cr @@ -0,0 +1,42 @@ +require "bacnet" +require "json" + +module Ashrae + class DeviceAddress + include JSON::Serializable + + def initialize(@ip, @id, @net, @addr) + end + + getter ip : String + getter id : UInt32? + getter net : UInt16? + getter addr : String? + + def address + Socket::IPAddress.new(@ip, 0xBAC0) + end + + def identifier + ::BACnet::ObjectIdentifier.new :device, @id.not_nil! + end + end + + class DispatchProtocol < BinData + endian big + + enum MessageType + OPENED + CLOSED + RECEIVED + WRITE + CLOSE + end + + enum_field UInt8, message : MessageType = MessageType::RECEIVED + string :ip_address + uint64 :id_or_port + uint32 :data_size, value: ->{ data.size } + bytes :data, length: ->{ data_size }, default: Bytes.new(0) + end +end diff --git a/drivers/ashrae/bacnet_spec.cr b/drivers/ashrae/bacnet_spec.cr new file mode 100644 index 00000000000..e6d768eca87 --- /dev/null +++ b/drivers/ashrae/bacnet_spec.cr @@ -0,0 +1,8 @@ +require "placeos-driver/spec" + +# NOTE:: this spec only works if there is a BACnet network configured locally +# such as https://github.com/chipkin/BACnetServerExampleCPP/releases +DriverSpecs.mock_driver "Ashrae::BACnet" do + exec(:query_known_devices).get + (exec(:devices).get.not_nil!.size > 0).should be_true +end diff --git a/drivers/aver/cam520_pro.cr b/drivers/aver/cam520_pro.cr new file mode 100644 index 00000000000..0a9c67dc43d --- /dev/null +++ b/drivers/aver/cam520_pro.cr @@ -0,0 +1,358 @@ +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 + @zooming : Bool = false + @panning : AxisSelect? = nil + + 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 + + if @panning && @panning != axis + # stop any previous move + spawn do + post("/camera_move", body: { + method: "SetPtzf", + axis: stop.to_i, + dir: dir, + cmd: 2, + }.to_json) + end + end + + @panning = cmd == 1 ? axis : nil + + # start moving in the desired direction + response = post("/camera_move", body: { + method: "SetPtzf", + axis: axis.to_i, + dir: dir, + cmd: cmd, + }.to_json) + + parse(response, Nil) + end + + alias Presets = Hash(String, Tuple(Int32, Int32, Int32)) + @presets : Presets = {} of String => Tuple(Int32, Int32, Int32) + + def recall(position : String, index : Int32 | String = 0) + if pos = @presets[position]? + pan_pos, tilt_pos, zoom_pos = pos + zoom_native(zoom_pos) + pan_direct(pan_pos) + tilt_direct(tilt_pos) + else + raise "unknown preset #{position}" + end + end + + def save_position(name : String, index : Int32 | String = 0) + pan? + tilt? + zoom? + @presets[name] = {@pan_pos, @tilt_pos, @zoom_pos} + save_presets + end + + def remove_position(name : String, index : Int32 | String = 0) + @presets.delete(name) + save_presets + end + + protected def save_presets + define_setting(:camera_presets, @presets) + self[:presets] = @presets.keys + end + + def pan_direct(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_p", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def tilt_direct(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_t", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def pan? + response = post("/get_option", body: { + method: "Get", + option: "ptz_p_s", + }.to_json) + + @pan_pos = parse(response, Int32) + end + + def tilt? + response = post("/get_option", body: { + method: "Get", + option: "ptz_t_s", + }.to_json) + + @tilt_pos = parse(response, Int32) + end + + # ====== Zoomable Interface ====== + + # Zooms to an absolute position + def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0) + position = position.clamp(0.0, 100.0) + percentage = position / 100.0 + zoom_native (percentage * @zoom_max.to_f).to_i + end + + def zoom(direction : ZoomDirection, index : Int32 | String = 0) + @zooming = true + case direction + in .stop? + dir = 0 + cmd = 2 + @zooming = false + in .out? + dir = 1 + cmd = 1 + in .in? + dir = 0 + cmd = 1 + end + + response = post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Zoom.to_i, + dir: dir, + cmd: cmd, + }.to_json) + + parse(response, Nil) + end + + def zoom_native(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_z", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def zoom? + response = post("/get_option", body: { + method: "Get", + option: "ptz_z_s", + }.to_json) + + @zoom_pos = value = parse(response, Int32) + self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f) + end + + # ====== Moveable Interface ====== + + # moves at 50% of max speed + def move(position : MoveablePosition, index : Int32 | String = 0) + case position + in .up? + joystick(pan_speed: 0.0, tilt_speed: 50.0) + in .down? + joystick(pan_speed: 0.0, tilt_speed: -50.0) + in .left? + joystick(pan_speed: -50.0, tilt_speed: 0.0) + in .right? + joystick(pan_speed: 50.0, tilt_speed: 0.0) + in .in? + zoom(:in) + in .out? + zoom(:out) + in .open?, .close? + # not supported + end + end + + # ====== Stoppable Interface ====== + + def stop(index : Int32 | String = 0, emergency : Bool = false) + # tilt + spawn do + post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Tilt.to_i, + dir: 0, + cmd: 2, + }.to_json) + end + + # pan + spawn do + post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Pan.to_i, + dir: 0, + cmd: 2, + }.to_json) + end + + # zoom + response = post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Zoom.to_i, + dir: 0, + cmd: 2, + }.to_json) + + @zooming = false + 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..926ea75b92c --- /dev/null +++ b/drivers/aver/cam520_pro_spec.cr @@ -0,0 +1,159 @@ +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) + + # Move pan + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["axis"] == 0 && request["cmd"] == 1 + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: nil, + }.to_json + else + response.status_code = 400 + end + end + resp.get +end diff --git a/drivers/aws/sns_sms.cr b/drivers/aws/sns_sms.cr new file mode 100644 index 00000000000..d303aa444a1 --- /dev/null +++ b/drivers/aws/sns_sms.cr @@ -0,0 +1,74 @@ +require "placeos-driver" +require "placeos-driver/interface/sms" +require "awscr-signer" +require "uri/params" + +# Documentation: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html + +class AWS::SnsSms < PlaceOS::Driver + include Interface::SMS + + # Discovery Information + generic_name :SMS + descriptive_name "Amazon SNS - SMS service" + uri_base "https://sns.us-west-2.amazonaws.com" + + default_settings({ + aws_access_key: "12345", + aws_secret: "random", + }) + + getter! signer : Awscr::Signer::Signers::V4 + + def on_update + access_key = setting(String, :aws_access_key) + secret = setting(String, :aws_secret) + + # grab the bits required for the signer + uri_parts = URI.parse(config.uri.not_nil!).host.not_nil!.split('.') + service = uri_parts[0] + region = uri_parts[1] + + @signer = Awscr::Signer::Signers::V4.new(service, region, access_key, secret) + transport.before_request { |request| signer.sign(request) } + end + + def send_sms( + phone_numbers : String | Array(String), + message : String, + format : String? = "SMS", + source : String? = nil + ) + phone_numbers = [phone_numbers] unless phone_numbers.is_a?(Array) + + responses = phone_numbers.map do |number| + params = URI::Params.build do |form| + form.add "Action", "Publish" + form.add "PhoneNumber", number + form.add "Message", message + + if source + if source =~ /^\+?\d{5,14}$/ + form.add "MessageAttributes.entry.1.Name", "AWS.MM.SMS.OriginationNumber" + form.add "MessageAttributes.entry.1.Value.DataType", "String" + form.add "MessageAttributes.entry.1.Value.StringValue", source + else + form.add "MessageAttributes.entry.1.Name", "AWS.SNS.SMS.SenderID" + form.add "MessageAttributes.entry.1.Value.DataType", "String" + form.add "MessageAttributes.entry.1.Value.StringValue", source.gsub(' ', '-') + end + end + end + + post("/?#{params}", headers: HTTP::Headers{ + "Accept" => "application/json", + }) + end + + responses.each do |response| + raise "request failed with #{response.status_code}: #{response.body}" unless response.success? + end + + nil + end +end diff --git a/drivers/aws/sns_sms_spec.cr b/drivers/aws/sns_sms_spec.cr new file mode 100644 index 00000000000..932a6227789 --- /dev/null +++ b/drivers/aws/sns_sms_spec.cr @@ -0,0 +1,24 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "AWS::SnsSms" do + # Send the request + retval = exec(:send_sms, + phone_numbers: "61418419954", + message: "hello steve" + ) + + # sms should send a HTTP request + expect_http_request do |request, response| + params = request.query_params + if {params["Action"], params["PhoneNumber"], params["Message"]} == {"Publish", "61418419954", "hello steve"} + response.status_code = 200 + response << "{\"PublishResponse\":{\"PublishResult\":{\"MessageId\":\"0b486c18-fa23-5f82-a5a0-35200c5f3d96\",\"SequenceNumber\":null},\"ResponseMetadata\":{\"RequestId\":\"6710f384-1a8a-56e3-8b63-aabcecf664f7\"}}}" + else + response.status_code = 400 + response << "{}" + end + end + + # What the sms function should return + retval.get.should eq(nil) +end diff --git a/drivers/biamp/nexia.cr b/drivers/biamp/nexia.cr new file mode 100644 index 00000000000..bd76ef17c1a --- /dev/null +++ b/drivers/biamp/nexia.cr @@ -0,0 +1,154 @@ +require "placeos-driver" +require "inactive-support/mapped_enum" +require "./ntp" + +class Biamp::Nexia < PlaceOS::Driver + include Biamp::NTP + + tcp_port 23 + descriptive_name "Biamp Nexia/Audia" + generic_name :Mixer + + protected property device_id = 0 + + def on_load + queue.delay = 30.milliseconds + transport.tokenizer = Tokenizer.new("\r\n", "\xFF\xFE\x01") + end + + def connected + send Bytes[0xFF, 0xFE, 0x01], wait: false # Echo off + schedule.every(60.seconds, true) do + query_device_id + end + end + + def disconnected + schedule.clear + end + + def query_device_id + send Command[:GETD, 0, "DEVID"] + end + + def preset(number : Int32) + send Command[:RECALL, 0, "PRESET", number], name: "preset_#{number}" + end + + mapped_enum Mixer do + Matrix = "MMMUTEXP" + Standard = "SMMUTEXP" + Auto = "AMMUTEXP" + end + + def mixer(id : Int32, inouts : Hash(Int32, Array(Int32)) | Array(Int32), mute : Bool = false, type : Mixer = Mixer::Matrix) + value = mute ? 0 : 1 + + if inouts.is_a? Hash + inouts.each do |input, outputs| + outputs.each do |output| + send Command[:SET, device_id, type.mapped_value, id, input, output, value] + end + end + else + inouts.each do |input| + send Command[:SET, device_id, Mixer::Auto.mapped_value, id, input, value] + end + end + end + + mapped_enum Faders do + Fader = "FDRLVL" + MatrixIn = "MMLVLIN" + MatrixOut = "MMLVLOUT" + MatrixCrosspoint = "MMLVLXP" + StdmatrixIn = "SMLVLIN" + StdmatrixOut = "SMLVLOUT" + AutoIn = "AMLVLIN" + AutoOut = "AMLVLOUT" + IoIn = "INPLVL" + IoOut = "OUTLVL" + end + + protected def get_range(type : Faders) + return -100..0 if type.matrix_crosspoint? + -100..12 + end + + def fader(id : Int32, level : Float64 | Int32, index : Int32 = 1, type : Faders = Faders::Fader) + level = level.to_f.clamp(0.0, 100.0) + percentage = level / 100.0 + range = get_range type + + # adjust into range + level_actual = percentage * (range.size - 1).to_f + level_actual = level_actual + range.begin.to_f + + send Command[:SETD, device_id, type.mapped_value, id, index, level_actual], name: "fader_#{id}" + end + + def query_fader(id : Int32, index : Int32 = 1, type : Faders = Faders::Fader) + send Command[:GETD, device_id, type.mapped_value, id, index] + end + + mapped_enum Mutes do + Fader = "FDRMUTE" + MatrixIn = "MMMUTEIN" + MatrixOut = "MMMUTEOUT" + AutoIn = "AMMUTEIN" + AutoOut = "AMMUTEOUT" + StdmatrixIn = "SMMUTEIN" + StdmatrixOut = "SMOUTMUTE" + IoIn = "INPMUTE" + IoOut = "OUTMUTE" + end + + def mute(id : Int32, state : Bool = true, index : Int32 = 1, type : Mutes = Mutes::Fader) + value = state ? 1 : 0 + send Command[:SETD, device_id, type.mapped_value, id, index, value], name: "mute_#{id}" + end + + def unmute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader) + mute(id, false, index, type) + end + + def query_mute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader) + send Command[:GETD, device_id, type.mapped_value, id, index] + end + + def received(data, task) + case response = Response.parse data + in Response::FullPath + logger.debug { "Device responded #{response.message}" } + result = process_full_path_response response + task.try &.success result + in Response::OK + logger.info { "OK" } + task.try &.success + in Response::Error + logger.warn { "Device error: #{data}" } + task.try &.abort(response.message) + in Response::Invalid + logger.error { "Invalid response structure" } + task.try &.abort(response.data) + end + end + + protected def process_full_path_response(response) + case response.attribute + when "DEVID" + self["device_id"] = self.device_id = response.value.to_i + else + if mute = Mutes.from_mapped_value? response.attribute + id, index = response.params + self["#{mute.to_s.underscore}#{id}_#{index}_mute"] = response.value == "1" + elsif fader = Faders.from_mapped_value? response.attribute + range = get_range fader + vol_percent = ((response.value.to_f - range.begin.to_f) / (range.size - 1).to_f) * 100.0 + + id, index = response.params + self["#{fader.to_s.underscore}#{id}_#{index}"] = vol_percent + end + end + end +end diff --git a/drivers/biamp/nexia_spec.cr b/drivers/biamp/nexia_spec.cr new file mode 100644 index 00000000000..c690a28c6b4 --- /dev/null +++ b/drivers/biamp/nexia_spec.cr @@ -0,0 +1,48 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Biamp::Nexia" do + should_send "\xFF\xFE\x01" + + should_send("GETD 0 DEVID\n") + responds("#GETD 0 DEVID 1\r\n") + status["device_id"].should eq(1) + + exec(:preset, 1001) + should_send("RECALL 0 PRESET 1001\n") + responds("#RECALL 0 PRESET 1001 +OK\r\n") + + exec(:fader, 1, 0.0) + should_send("SETD 1 FDRLVL 1 1 -100.0\n") + responds("#SETD 1 FDRLVL 1 1 -100.0 +OK\r\n") + status["fader1_1"].should eq(0.0) + + exec(:fader, 1, 100.0, 2, "matrix_in") + should_send("SETD 1 MMLVLIN 1 2 12.0\n") + responds("#SETD 1 MMLVLIN 1 2 12.0 +OK\r\n") + status["matrix_in1_2"].should eq(100.0) + + exec(:mute, 1234, false, 3) + should_send("SETD 1 FDRMUTE 1234 3 0\n") + responds("#SETD 1 FDRMUTE 1234 3 0 +OK\r\n") + status["fader1234_3_mute"].should eq(false) + + exec(:mute, 1234, true, 5, "auto_in") + should_send("SETD 1 AMMUTEIN 1234 5 1\n") + responds("#SETD 1 AMMUTEIN 1234 5 1 +OK\r\n") + status["auto_in1234_5_mute"].should eq(true) + + exec(:unmute, 111) + should_send("SETD 1 FDRMUTE 111 1 0\n") + responds("#SETD 1 FDRMUTE 111 1 0 +OK\r\n") + status["fader111_1_mute"].should eq(false) + + exec(:query_fader, 133) + should_send("GETD 1 FDRLVL 133 1\n") + responds("#GETD 1 FDRLVL 133 1 -100.0\r\n") + status["fader133_1"].should eq(0.0) + + exec(:query_mute, 155) + should_send("GETD 1 FDRMUTE 155 1\n") + responds("#GETD 1 FDRMUTE 155 1 0\r\n") + status["fader155_1_mute"].should eq(false) +end diff --git a/drivers/biamp/ntp.cr b/drivers/biamp/ntp.cr new file mode 100644 index 00000000000..236772fdfec --- /dev/null +++ b/drivers/biamp/ntp.cr @@ -0,0 +1,80 @@ +# Biamp ATP/NTP protocol utilities. +# https://support.biamp.com/Audia-Nexia/Control/Audia-Nexia_Text_Protocol +module Biamp::NTP + record Command, + type : Type, + device : Int32, + attribute : String, + instance : Int32? = nil, + index_1 : Int32? = nil, + index_2 : Int32? = nil, + value : String | Int32 | Float64 | Nil = nil do + macro [](type, *params) + {% if type == :GET || type == :GETD %} + {{@type.name}}.new({{type}}, {{params.splat}}) + {% else %} + {{@type.name}}.new({{type}}, {{params[0...-1].splat}}, value: {{params[-1]}}) + {% end %} + end + + enum Type + SET + SETD + GET + GETD + INC + INCD + DEC + DECD + RECALL + DIAL + end + + def to_io(io : IO, format = nil) + io << type + {device, attribute, instance, index_1, index_2, value}.each do |field| + next if field.nil? + io << ' ' << field + end + io << '\n' + end + end + + module Response + record FullPath, + message : String, + type : Command::Type, + device : Int32, + attribute : String, + params : Array(String), + value : String + record OK + record Error, message : String + record Invalid, data : Bytes + + def self.parse(data : Bytes) + case data[0] + when '#' + response = String.new data + if response.includes? " -ERR" + Error.new response + else + fields = response[1..].split + type = Command::Type.parse fields[0] + device = fields[1].to_i + attribute = fields[2] + params = fields[3..] + # All responses except GETD provide an "+OK" in the last field + value = type.getd? ? fields[-1] : fields[-2] + FullPath.new response, type, device, attribute, params, value + end + when '+' + OK.new + when '-' + Error.new String.new data + else + Invalid.new data + end + end + end +end diff --git a/drivers/biamp/tesira.cr b/drivers/biamp/tesira.cr new file mode 100644 index 00000000000..da5dc03d9ea --- /dev/null +++ b/drivers/biamp/tesira.cr @@ -0,0 +1,222 @@ +require "placeos-driver" +require "telnet" + +module Biamp; end + +class Biamp::Tesira < PlaceOS::Driver + # Discovery Information + tcp_port 23 # Telnet + descriptive_name "Biamp Tesira" + generic_name :Mixer + + default_settings({ + no_password: true, + username: "default", + password: "default", + }) + + alias Num = Int32 | Float64 + alias Ids = String | Array(String) + + def on_load + # Nexia requires some breathing room + queue.wait = false + queue.delay = 30.milliseconds + end + + def connected + @telnet = telnet = Telnet.new do |telnet_response| + transport.send telnet_response + end + transport.pre_processor { |bytes| telnet.buffer(bytes) } + + if setting(Bool, :no_password) + do_send setting(String, :username) || "admin", wait: false, delay: 200.milliseconds, priority: 98 + do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97 + end + do_send "SESSION set verbose false", priority: 96 + + schedule.clear + schedule.every(60.seconds) do + do_send "DEVICE get serialNumber", priority: 95 + end + end + + def disconnected + transport.tokenizer = nil + schedule.clear + end + + def preset(number_or_name : String | Int32) + if number_or_name.is_a? Int32 + do_send "DEVICE recallPreset #{number_or_name}", priority: 30, name: "preset_#{number_or_name}" + else + do_send build(:DEVICE, :recallPresetByName, number_or_name), priority: 30, name: "preset_#{number_or_name}" + end + end + + def start_audio + do_send "DEVICE startAudio" + end + + def reboot + do_send "DEVICE reboot" + end + + def get_aliases + do_send "SESSION get aliases" + end + + MIXERS = { + "matrix" => "crosspointLevelState", + "mixer" => "crosspoint", + } + + def mixer(id : String, inouts : Hash(Int32, Int32 | Array(Int32)) | Array(Int32), mute : Bool = false, type : String = "matrix") + mixer_type = MIXERS[type] || type + + if inouts.is_a? Hash + inouts.each do |input, outs| + outputs = ensure_array(outs) + outputs.each do |output| + do_send build(id, :set, mixer_type, input, output, mute), priority: 30, name: "mixmute_#{input}_#{output}" + end + end + else # assume array (auto-mixer) + inouts.each do |input| + do_send build(id, :set, mixer_type, input, mute), priority: 30, name: "mixmute_#{input}" + end + end + end + + FADERS = { + "fader" => "level", + "matrix_in" => "inputLevel", + "matrix_out" => "outputLevel", + "matrix_crosspoint" => "crosspointLevel", + "level" => "fader", + "inputLevel" => "matrix_in", + "outputLevel" => "matrix_out", + "crosspointLevel" => "matrix_crosspoint", + } + + def fader(fader_id : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + # value range: -100 ~ 12 + fader_type = FADERS[type] || type + + fader_ids = ensure_array(fader_id) + indicies = ensure_array(index) + fader_ids.each do |fad| + indicies.each do |i| + do_send build(fad, :set, fader_type, i, level), priority: 30, name: "fade_#{fad}_#{i}" + self["#{fader_type}_#{fad}_#{i}"] = level + end + end + end + + # Named params version + def faders(ids : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + fader(ids, level, index, type) + end + + MUTES = { + "fader" => "mute", + "matrix_in" => "inputMute", + "matrix_out" => "outputMute", + "mute" => "fader", + "inputMute" => "matrix_in", + "outputMute" => "matrix_out", + } + + def mute(fader_id : Ids, value : Bool = true, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute_type = MUTES[type] || type + + fader_ids = ensure_array(fader_id) + indicies = ensure_array(index) + fader_ids.each do |fad| + indicies.each do |i| + do_send build(fad, :set, mute_type, i, value), priority: 30, name: "mute_#{fad}_#{i}" + self["#{mute_type}_#{fad}_#{i}_mute"] = value + end + end + end + + # Named params version + def mutes(ids : Ids, muted : Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute(ids, muted, index, type) + end + + def unmute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute(fader_id, false, index, type) + end + + def query_fader(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + fad_type = FADERS[type] || type + fader_id = ensure_array(fader_id)[0] + index = ensure_array(index)[0] + + do_send build(fader_id, :get, fad_type, index) + end + + # Named params version + def query_faders(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + query_fader(ids, index, type) + end + + def query_mute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute_type = MUTES[type] || type + fader_id = ensure_array(fader_id)[0] + index = ensure_array(index)[0] + + do_send build(fader_id, :get, mute_type, index) + end + + # Named params version + def query_mutes(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + query_mute(ids, index, type) + end + + def received(data, task) + data = String.new(data).strip + + logger.debug { "Tesira responded -> data: #{data}" } + result = data.split(" ") + + if result[0] == "-" + task.try(&.abort) + end + + if data =~ /login:|server/i + transport.tokenizer = Tokenizer.new "\r\n" + end + + task.try(&.success) + end + + private def build(*args) + cmd = "" + args.each do |arg| + data = arg.to_s + next if data.blank? + cmd = cmd + " " if cmd.size > 0 + + if data.includes? " " + cmd = cmd + "\"" + cmd = cmd + data + cmd = cmd + "\"" + else + cmd = cmd + data + end + end + cmd + end + + private def do_send(command, **options) + logger.debug { "requesting #{command}" } + send @telnet.not_nil!.prepare(command), **options + end + + private def ensure_array(object) + object.is_a?(Array) ? object : [object] + end +end diff --git a/drivers/biamp/tesira_spec.cr b/drivers/biamp/tesira_spec.cr new file mode 100644 index 00000000000..52a6afd2d03 --- /dev/null +++ b/drivers/biamp/tesira_spec.cr @@ -0,0 +1,39 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Biamp::Tesira" do + transmit "login: " + should_send "default\r\n" + should_send "default\r\n" + should_send "SESSION set verbose false\r\n" + + exec(:preset, 1001) + should_send "DEVICE recallPreset 1001" + + exec(:preset, "1001-test") + should_send "DEVICE recallPresetByName 1001-test" + + exec(:start_audio) + should_send "DEVICE startAudio" + + exec(:reboot) + should_send "DEVICE reboot" + + exec(:get_aliases) + should_send "SESSION get aliases" + + exec(:mixer, "123", [1]) + should_send "123 set crosspointLevelState 1 false" + + exec(:fader, "Fader123", 11) + should_send "Fader123 set level 1 11" + responds("+OK\r\n") + status["level_Fader123_1"] = 11 + + exec(:mute, "Fader123") + should_send "Fader123 set mute 1 true" + responds("+OK\r\n") + status["level_Fader123_1_mute"] = true + + exec(:query_fader, "Fader123") + should_send "Fader123 get level 1" +end diff --git a/drivers/biamp/tesira_ssh.cr b/drivers/biamp/tesira_ssh.cr new file mode 100644 index 00000000000..e9b8a517b2f --- /dev/null +++ b/drivers/biamp/tesira_ssh.cr @@ -0,0 +1,225 @@ +require "placeos-driver" +require "telnet" + +module Biamp; end + +class Biamp::Tesira < PlaceOS::Driver + # Discovery Information + tcp_port 22 # SSH + descriptive_name "Biamp Tesira SSH" + generic_name :Mixer + + default_settings({ + ssh: { + username: :default, + password: "", + }, + }) + + alias Num = Int32 | Float64 + alias Ids = String | Array(String) + + getter ready : Bool = false + + def on_load + # Nexia requires some breathing room + queue.wait = false + queue.delay = 30.milliseconds + @ready = false + end + + def connected + # we need to disconnect if we don't see welcome message + schedule.in(20.seconds) do + if !@ready + logger.error { "Biamp connection failed to be ready after 20 seconds." } + disconnect + end + end + end + + def disconnected + @ready = false + 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) + logger.debug { "Tesira responded -> data: #{data.inspect}" } + data = data.strip + result = data.split(" ") + + if result[0] == "-" + task.try(&.abort) + end + + if data =~ /login:|server/i + @ready = true + transport.tokenizer = Tokenizer.new "\r\n" + do_send "SESSION set verbose false", priority: 96 + schedule.clear + schedule.every(60.seconds) do + do_send "DEVICE get serialNumber", priority: 95 + end + 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 "#{command}\n", **options + end + + private def ensure_array(object) + object.is_a?(Array) ? object : [object] + end +end diff --git a/drivers/bose/control_space_serial.cr b/drivers/bose/control_space_serial.cr new file mode 100644 index 00000000000..2044a9712bd --- /dev/null +++ b/drivers/bose/control_space_serial.cr @@ -0,0 +1,58 @@ +require "placeos-driver" + +# Documentation: https://aca.im/driver_docs/Bose/Bose-ControlSpace-SerialProtocol-v5.pdf + +class Bose::ControlSpaceSerial < PlaceOS::Driver + # Discovery Information + tcp_port 10055 + descriptive_name "Bose ControlSpace Serial Protocol" + generic_name :Mixer + + def on_load + # 0x0D ( carriage return \r) + transport.tokenizer = Tokenizer.new(Bytes[0x0D]) + on_update + end + + def on_update + end + + def connected + schedule.every(60.seconds) do + logger.debug { "-- maintaining connection" } + do_send "GS", priority: 99 + end + end + + def disconnected + schedule.clear + end + + private def do_send(data, **options) + logger.debug { "requesting: #{data}" } + send "#{data}\x0D", **options + end + + def set_parameter_group(id : UInt8) + do_send("SS #{id.to_s(16).upcase}", wait: false, name: "set_pgroup").get + self[:parameter_group] = id + end + + def get_parameter_group + do_send "GS" + end + + def received(data, task) + # Ignore the framing bytes + data = String.new(data).rchop + logger.debug { "ControlSpace sent: #{data}" } + + parts = data.split(" ") + case parts[0] + when "S" + self[:parameter_group] = parts[1].to_i(16) + end + + task.try &.success + end +end diff --git a/drivers/bose/control_space_serial_spec.cr b/drivers/bose/control_space_serial_spec.cr new file mode 100644 index 00000000000..939b6d6848e --- /dev/null +++ b/drivers/bose/control_space_serial_spec.cr @@ -0,0 +1,12 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Bose::ControlSpaceSerial" do + exec(:set_parameter_group, 12) + should_send("SS C\r") + status[:parameter_group].should eq(12) + + exec(:get_parameter_group) + should_send("GS\r") + responds("S FF\r") + status[:parameter_group].should eq(255) +end diff --git a/drivers/build.cr b/drivers/build.cr new file mode 100644 index 00000000000..84cf25664f1 --- /dev/null +++ b/drivers/build.cr @@ -0,0 +1,10 @@ +{% if env("COMPILE_DRIVER") %} + {% if env("COMPILE_DRIVER").ends_with?("_spec.cr") %} + require "placeos-driver/spec" + {% else %} + require "placeos-driver" + {% end %} + + # Dynamically require the desired driver + {{ ("require \"../" + env("COMPILE_DRIVER") + "\"").id }} +{% end %} diff --git a/drivers/cisco/booking_panel_led_sync.cr b/drivers/cisco/booking_panel_led_sync.cr new file mode 100644 index 00000000000..08e13bc53cb --- /dev/null +++ b/drivers/cisco/booking_panel_led_sync.cr @@ -0,0 +1,49 @@ +require "placeos-driver" + +class Cisco::BookingPanelLedSync < PlaceOS::Driver + descriptive_name "Cisco Webex Navigator Panel LED Sync" + generic_name :Navigator_LED_Sync + description "Sync Cisco Webex Navigator Panel LED to Bookings.in_use status" + + default_settings({ + led_color_when_room_booked: "Red", + led_color_when_room_available: "Green", + webex_panel_device_id: "Ensure this is set in each System's settings" + }) + + accessor webex_xapi : CloudXAPI + accessor room_bookings : Bookings + + @led_color_when_room_booked : String = "Red" + @led_color_when_room_available : String = "Green" + @webex_panel_device_id : String = "Ensure this is set in each System's settings" + + def on_load + on_update + sync_led_color_now + end + + def on_update + subscriptions.clear + @led_color_when_room_booked = setting(String, :led_color_when_room_booked) || "Red" + @led_color_when_room_available = setting(String, :led_color_when_room_available) || "Green" + @webex_panel_device_id = setting(String, :webex_panel_device_id) || "Ensure this is set in each System's settings" + + system.subscribe("Bookings_1", "in_use") do |_sub, value| + next unless ["true", "false"].includes?(value) + self[:room_in_use] = room_in_use = value == "true" + set_led_color(room_in_use) + end + end + + def sync_led_color_now + return unless ["true", "false"].includes?(room_in_use = system[:Bookings].status?(Bool, :in_use)) + set_led_color(room_in_use) unless room_in_use.nil? + end + + private def set_led_color(room_in_use : Bool) + new_led_color = room_in_use ? @led_color_when_room_booked : @led_color_when_room_available + webex_xapi.led_colour(@webex_panel_device_id, new_led_color) + self[:led_color] = new_led_color + end +end diff --git a/drivers/cisco/booking_panel_led_sync_spec.cr b/drivers/cisco/booking_panel_led_sync_spec.cr new file mode 100644 index 00000000000..ba2f8729286 --- /dev/null +++ b/drivers/cisco/booking_panel_led_sync_spec.cr @@ -0,0 +1,5 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::BookingPanelLedSync" do + +end diff --git a/drivers/cisco/collaboration_endpoint.cr b/drivers/cisco/collaboration_endpoint.cr new file mode 100644 index 00000000000..840797cbdf9 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint.cr @@ -0,0 +1,505 @@ +require "placeos-driver" +require "promise" +require "uuid" + +module Cisco::CollaborationEndpoint + macro included + @@status_mappings = {} of Symbol => String + + def self.map_status(**opts) + @@status_mappings.merge! opts.to_h + end + end + + # used by many of the commands + enum Toogle + On + Off + end + + getter peripheral_id : String do + uuid = generate_request_uuid + @ignore_update = true + define_setting(:peripheral_id, uuid) + uuid + end + + protected getter feedback : Feedback = Feedback.new + @ready : Bool = false + @init_called : Bool = false + + # Camera idx => Preset name => Preset id + alias Presets = Hash(Int32, Hash(String, Int32)) + @presets : Presets = {} of Int32 => Hash(String, Int32) + getter feedback_paths : Array(String) = [] of String + + def on_load + # NOTE:: on_load doesn't call on_update as on_update disconnects + queue.delay = 80.milliseconds + queue.timeout = 3.seconds + @peripheral_id = setting?(String, :peripheral_id) + @presets = setting?(Presets, :camera_presets) || @presets + self[:camera_presets] = @presets.transform_values { |val| val.keys } + driver = self + driver.load_settings if driver.responds_to?(:load_settings) + end + + # used when saving settings from the driver + # this prevents needless disconnects + @ignore_update : Bool = false + + def on_update + if @ignore_update + @ignore_update = false + return + end + @presets = setting?(Presets, :camera_presets) || @presets + self[:camera_presets] = @presets.transform_values { |val| val.keys } + driver = self + driver.load_settings if driver.responds_to?(:load_settings) + + # Force a reconnect and event resubscribe following module updates. + disconnect + end + + @last_received : Int64 = 0_i64 + + protected def reset_connection_flags + @ready = false + @init_called = false + @feedback_paths = [] of String + transport.tokenizer = nil + end + + def connected + reset_connection_flags + schedule.every(2.minutes) { ensure_feedback_registered } + schedule.every(30.seconds) do + if @last_received > 40.seconds.ago.to_unix + heartbeat timeout: 35 + else + disconnect + end + end + schedule.in(10.seconds) do + init_connection unless @ready || @init_called + schedule.in(15.seconds) { disconnect if !@ready || self["configuration"]?.nil? } + end + begin + transport.send "xPreferences OutputMode JSON\n" + rescue + end + queue.clear abort_current: true + end + + def disconnected + schedule.clear + reset_connection_flags + clear_feedback_subscriptions(false) + queue.clear abort_current: true + self[:ready] = false + end + + def generate_request_uuid + UUID.random.to_s + end + + def ensure_feedback_registered + send "xPreferences OutputMode JSON\n", priority: 0, wait: false, name: "output_json" + results = @feedback_paths.map do |path| + request = XAPI.xfeedback :register, path + # Always returns an empty response, nothing special to handle + do_send(request, priority: 0, name: path) + end + spawn do + success = 0 + results.each do |task| + begin + success += 1 if task.get.state.success? + rescue + end + end + logger.debug { "FEEDBACK REGISTERED #{success}" } + disconnect unless success > 0 + end + @feedback_paths.size + end + + # ------------------------------ + # Exec methods + + alias JSONBasic = Enumerable::JSONBasic + alias Config = Hash(String, Hash(String, JSONBasic)) + + # Push a configuration settings to the device. + def xconfigurations(config : Config) + config.each { |path, settings| xconfiguration(path, settings) } + end + + # Execute an xCommand on the device. + def xcommand( + command : String, + multiline_body : String? = nil, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + **kwargs + ) + request = XAPI.xcommand(command, **kwargs.merge({hash_args: hash_args})) + name = if kwargs.empty? + command + elsif kwargs.size == 1 + "#{command} #{kwargs.keys.to_a.first}" + end + + # use default queue priority is not specified + priority = kwargs[:priority]? || queue.priority + + do_send request, multiline_body, name: name, priority: priority do |response| + # The result keys are a little odd: they're a concatenation of the + # last two command elements and 'Result', unless the command + # failed in which case it's just 'Result'. + # For example: + # xCommand Video Input SetMainVideoSource ... + # becomes: + # InputSetMainVideoSourceResult + result_key = command.split(' ').last(2).join("") + "Result" + command_result = response["CommandResponse/#{result_key}/status"]? + failure_result = response["CommandResponse/Result/Reason"]? + + result = command_result || failure_result + + if result + if result == "OK" + result + else + failure_result ||= response["CommandResponse/#{result_key}/Reason"]? + logger.error { failure_result.inspect } + :abort + end + else + logger.warn { "Unexpected response format" } + :abort + end + end + end + + # Apply a single configuration on the device. + def xconfiguration( + path : String, + hash_args : Hash(String, JSONBasic) = {} of String => JSONBasic, + **kwargs + ) + promises = hash_args.map do |setting, value| + apply_configuration(path, setting, value) + end + kwargs.each do |setting, value| + promise = apply_configuration(path, setting, value) + promises << promise + end + Promise.all(promises).get.first + end + + protected def apply_configuration(path : String, setting : String, value : JSONBasic) + request = XAPI.xconfiguration(path, setting, value) + promise = Promise.new(Bool) + + task = do_send request, name: "#{path} #{setting}" do |response| + result = response["CommandResponse/Configuration/status"]? + + if result == "Error" + reason = response["CommandResponse/Configuration/Reason"]? + xpath = response["CommandResponse/Configuration/XPath"]? + + error_msg = "#{reason} (#{xpath})" + promise.reject(RuntimeError.new error_msg) + logger.error { error_msg } + :abort + else + promise.resolve true + true + end + end + + spawn do + task.get + promise.reject(RuntimeError.new "failed to set configuration: #{path} #{setting}: #{value}") if task.state == :abort + end + promise + end + + def xstatus(path : String) + request = XAPI.xstatus path + promise = Promise.new(Hash(String, Enumerable::JSONComplex)) + + task = do_send request do |response| + prefix = "Status/#{XAPI.tokenize(path).join('/')}" + results = {} of String => Enumerable::JSONComplex + response.each do |key, value| + results[key] = value if key.starts_with?(prefix) + end + + if !results.empty? + promise.resolve results + results + elsif error = response["Status/status"]? || response["CommandResponse/Status/status"]? + reason = response["Status/Reason"]? || response["CommandResponse/Status/Reason"]? + xpath = response["Status/XPath"]? || response["CommandResponse/Status/XPath"]? + error_msg = "#{reason} (#{xpath})" + promise.reject(RuntimeError.new error_msg) + logger.error { error_msg } + :abort + else + results[prefix] = nil + promise.resolve results + results + end + end + + spawn do + task.get + promise.reject(RuntimeError.new "failed to obtain status: #{path}") if task.state == :abort + end + promise.get + end + + # ------------------------------ + # Base comms + + protected def init_connection + @init_called = true + transport.tokenizer = Tokenizer.new do |io| + raw = io.gets_to_end + data = raw.lstrip + index = if data.starts_with?("{") + count = 0 + pos = 0 + data.each_char_with_index do |char, i| + pos = i + count += 1 if char == '{' + count -= 1 if char == '}' + break if count.zero? + end + pos if count.zero? + else + data =~ XAPI::COMMAND_RESPONSE + end + + if index + message = data[0..index] + index += raw.byte_index_to_char_index(raw.byte_index(message).not_nil!).not_nil! + index = raw.char_index_to_byte_index(index + 1) + end + + index || -1 + end + + raise "failed to register control system" unless register_control_system.get.state.success? + self[:ready] = @ready = true + + push_config + sync_config + @@status_mappings.each do |key, path| + begin + bind_status(path, key.to_s) + rescue error + logger.warn(exception: error) { "failed to bind status #{path} (#{key})" } + end + end + + driver = self + driver.connection_ready if driver.responds_to?(:connection_ready) + rescue error + @init_called = false + logger.warn(exception: error) { "error configuring xapi transport" } + end + + protected def do_send(command, multiline_body = nil, **options) + do_send(command, multiline_body, **options) { true } + end + + protected def do_send(command, multiline_body = nil, **options, &callback : ::PlaceOS::Driver::Task::ResponseCallback) + request_id = generate_request_uuid + request = "#{command} | resultId=\"#{request_id}\"\n" + + logger.debug { "-> #{request}" } + request = "#{request}#{multiline_body}\n.\n" if multiline_body + + task = send request, **options + task.xapi_request_id = request_id + task.xapi_callback = callback + task + end + + def received(data, task) + @last_received = Time.utc.to_unix + payload = String.new(data) + logger.debug { "<- #{payload}" } + + if transport.tokenizer.nil? && payload =~ XAPI::LOGIN_COMPLETE + queue.clear abort_current: true + sleep 500.milliseconds + transport.send "xPreferences OutputMode JSON\n" + logger.info { "initializing connection" } + spawn { init_connection } + return + end + + response = XAPI.parse payload + + return feedback.notify(response) if task.nil? + + if task.xapi_request_id == response["ResultId"]? + command_result = task.xapi_callback.try &.call(response) + + feedback.notify(response) if command_result.nil? + command_result == :abort ? task.abort : task.success(command_result) + else + feedback.notify(response) + end + rescue error : JSON::ParseException + payload = String.new(data).strip + case payload + when "OK" + task.try &.success payload + when "Command not recognized." + logger.error { "Command not recognized: `#{task.try &.request_payload}`" } + task.try &.abort payload + else + logger.debug { "Malformed device response: #{error}\n#{payload}" } + task.try &.abort "Malformed device response: #{error}" + end + end + + # ------------------------------ + # Event subscription + + # Subscribe to feedback from the device. + def register_feedback(path : String, &update_handler : Proc(String, Enumerable::JSONComplex, Nil)) + if !@ready + unless feedback.contains? path + @feedback_paths << path + @feedback_paths.uniq! + feedback.insert(path, &update_handler) + end + return true + end + + logger.debug { "Subscribing to device feedback for #{path}" } + + unless feedback.contains? path + @feedback_paths << path + @feedback_paths.uniq! + request = XAPI.xfeedback :register, path + # Always returns an empty response, nothing special to handle + result = do_send request, name: path + end + + feedback.insert path, &update_handler + + result.try(&.get) || true + end + + def unregister_feedback(path : String) + return clear_feedback_subscriptions if path == "/" + logger.debug { "Unsubscribing feedback for #{path}" } + feedback.remove path + @feedback_paths.delete path + do_send XAPI.xfeedback(:deregister, path) + end + + def clear_feedback_subscriptions(connected : Bool = true) + logger.debug { "Unsubscribing all feedback" } + @status_keys.clear + feedback.clear + @feedback_paths.clear + do_send XAPI.xfeedback(:deregister_all) if connected + end + + # ------------------------------ + # Module status + + @status_keys = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key| + hash[key] = {} of String => Enumerable::JSONComplex + end + + # Bind arbitary device feedback to a status variable. + def bind_feedback(path : String, status_key : String) + register_feedback path do |value_path, value| + if value_path == path + self[status_key] = value + else + key_path = value_path.sub(path, "") + hash = @status_keys[status_key] + hash[key_path] = value + self[status_key] = hash + end + end + end + + # Bind device status to a module status variable. + def bind_status(path : String, status_key : String) + bind_path = "Status/#{path.tr " ", "/"}" + bind_feedback "/#{bind_path}", status_key + payload = xstatus(path) + + # single value? + if payload.size == 1 && payload.has_key?(bind_path) + self[status_key] = payload[bind_path] + else + self[status_key] = @status_keys[status_key] = payload.transform_keys do |key| + key.sub(path, "") + end + end + payload + end + + def push_config + if config = setting?(Config, :configuration) + xconfigurations config + end + end + + def sync_config + bind_feedback "/Configuration", "configuration" + send "xConfiguration *\n", wait: false + end + + # ------------------------------ + # External feedback subscriptions + + # Subscribe another module to async device events. + # Callback methods must be of arity 1 and public. + def on_event(path : String, mod_id : String, channel : String) + logger.debug { "Registering callback for #{path} to #{mod_id}/#{channel}" } + register_feedback path do |event_path, value| + event_json = {event_path => value}.to_json + logger.debug { "Publishing #{path} event to #{mod_id}/#{channel} with payload #{event_json}" } + publish("#{mod_id}/#{channel}", event_json) + end + end + + # Clear external event subscribtions for a specific device path. + def clear_event(path : String) + logger.debug { "Clearing event subscription for #{path}" } + unregister_feedback path + end + + # ------------------------------ + # Connectivity management + + protected def register_control_system + xcommand "Peripherals Connect", + hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id}, + name: "PlaceOS", + type: :ControlSystem + end + + protected def heartbeat(timeout : Int32) + # high priority as otherwise the VC will indicate we've disconnected + xcommand "Peripherals HeartBeat", + hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id}, + timeout: timeout, + priority: 99 + end +end + +require "./collaboration_endpoint/xapi" diff --git a/drivers/cisco/collaboration_endpoint/cameras.cr b/drivers/cisco/collaboration_endpoint/cameras.cr new file mode 100644 index 00000000000..a8ae810b212 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/cameras.cr @@ -0,0 +1,193 @@ +require "placeos-driver/interface/camera" +require "./xapi" + +module Cisco::CollaborationEndpoint::Cameras + include PlaceOS::Driver::Interface::Camera + include Cisco::CollaborationEndpoint::XAPI + + alias Interface = PlaceOS::Driver::Interface + + protected def save_presets + @ignore_update = true + define_setting(:camera_presets, @presets) + self[:camera_presets] = @presets.transform_values { |val| val.keys } + end + + command({"Camera Preset Activate" => :camera_preset}, + preset_id: 1..35) + command({"Camera Preset Store" => :camera_store_preset}, + camera_id: 1..2, + preset_id: 1..35, # Optional - codec will auto-assign if omitted + name_: String, + take_snapshot_: Bool, + default_position_: Bool) + command({"Camera Preset Remove" => :camera_remove_preset}, + preset_id: 1..35) + + enum CameraAxis + All + Focus + PanTilt + Zoom + end + + enum FocusDirection + Far + Near + Stop + end + + command({"Camera PositionReset" => :camera_position_reset}, + camera_id: 1..2, + axis_: CameraAxis) + command({"Camera Ramp" => :camera_move}, + camera_id: 1..2, + pan_: Interface::Camera::PanDirection, + pan_speed_: 1..15, + tilt_: Interface::Camera::TiltDirection, + tilt_speed_: 1..15, + zoom_: Interface::Zoomable::ZoomDirection, + zoom_speed_: 1..15, + focus_: FocusDirection) + + # Camera Interface + # ================ + + def stop(index : Int32 | String = 0, emergency : Bool = false) + cam = index.to_i + cam = 1 if cam.zero? + + camera_move( + camera_id: cam, + pan: PanDirection::Stop, + tilt: TiltDirection::Stop, + zoom: ZoomDirection::Stop + ) + end + + def move(position : MoveablePosition, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + case position + in .open?, .close? + # iris not supported + in .down?, .up? + joystick( + pan_speed: 0.0, + tilt_speed: position.down? ? -50.0 : 50.0, + index: cam + ) + in .left?, .right? + joystick( + pan_speed: position.left? ? -50.0 : 50.0, + tilt_speed: 0.0, + index: cam + ) + in .in?, .out? + zoom(position.in? ? ZoomDirection::In : ZoomDirection::Out, cam) + end + end + + def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0) + raise "direct zoom unsupported on this camera" + end + + def zoom(direction : ZoomDirection, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + camera_move( + camera_id: cam, + zoom: direction, + zoom_speed: 6 + ) + end + + def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0) + pan_speed = pan_speed.clamp(-100.0, 100.0) + tilt_speed = tilt_speed.clamp(-100.0, 100.0) + + pan = if pan_speed.zero? + pan_speed = nil + PanDirection::Stop + else + pan_speed.negative? ? PanDirection::Left : PanDirection::Right + end + + tilt = if tilt_speed.zero? + tilt_speed = nil + TiltDirection::Stop + else + tilt_speed.negative? ? TiltDirection::Down : TiltDirection::Up + end + + cam = index.to_i + cam = 1 if cam.zero? + + if pan_speed + percentage = pan_speed.abs / 100.0 + pan_speed_actual = (percentage * 15.0).round.to_i + end + + if tilt_speed + percentage = tilt_speed.abs / 100.0 + tilt_speed_actual = (percentage * 15.0).round.to_i + end + + camera_move( + camera_id: cam, + pan: pan, + pan_speed: pan_speed_actual, + tilt: tilt, + tilt_speed: tilt_speed_actual, + zoom: ZoomDirection::Stop + ) + end + + def recall(position : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + preset = presets[position]? + raise "preset '#{position}' not found on camera #{index}" unless preset + + camera_preset(preset_id: preset) + end + + def save_position(name : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + in_use = @presets.values.flat_map(&.values) + next_available = ((1..35).to_a - in_use).first + presets[name] = next_available + + camera_store_preset( + camera_id: cam, + preset_id: next_available, # Optional - codec will auto-assign if omitted + name: name + ).get + + @presets[cam] = presets + save_presets + true + end + + def remove_position(name : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + presets.delete(name) + if presets.empty? + @presets.delete(cam) + else + @presets[cam] = presets + end + save_presets + true + end +end diff --git a/drivers/cisco/collaboration_endpoint/feedback.cr b/drivers/cisco/collaboration_endpoint/feedback.cr new file mode 100644 index 00000000000..0cf25f4333e --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/feedback.cr @@ -0,0 +1,49 @@ +class Cisco::CollaborationEndpoint::Feedback + def initialize + @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k| + h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil) + end + end + + # Nuke a subtree below the path + def remove(path : String) + remove = [] of String + @callbacks.each_key { |key| remove << key if key.starts_with?(path) } + remove.each { |key| @callbacks.delete(key) } + self + end + + # Insert a response handler block to be notified of updates effecting the + # specified feedback path. + def insert(path : String, &handler : Proc(String, Enumerable::JSONComplex, Nil)) + @callbacks[path] << handler + self + end + + def contains?(path : String) + found = false + @callbacks.each_key do |key| + if path.starts_with? key + found = true + break + end + end + found + end + + def notify(path : String, value : Enumerable::JSONComplex) + @callbacks.each do |key, callbacks| + callbacks.each &.call(path, value) if path.starts_with? key + end + end + + def notify(payload : Hash(String, Enumerable::JSONComplex)) + payload.each { |key, value| notify("/#{key}", value) } + end + + def clear + @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k| + h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil) + end + end +end diff --git a/drivers/cisco/collaboration_endpoint/powerable.cr b/drivers/cisco/collaboration_endpoint/powerable.cr new file mode 100644 index 00000000000..18962590cad --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/powerable.cr @@ -0,0 +1,40 @@ +require "placeos-driver/interface/powerable" +require "./xapi" + +module Cisco::CollaborationEndpoint::Powerable + include PlaceOS::Driver::Interface::Powerable + include Cisco::CollaborationEndpoint::XAPI + + alias Interface = PlaceOS::Driver::Interface + + # Powerable Interface: + # ==================== + + command({"Standby Deactivate" => :powerup}) + command({"Standby HalfWake" => :half_wake}) + command({"Standby Activate" => :standby}) + command({"Standby ResetTimer" => :reset_standby_timer}, delay: 1..480) + + def power(state : Bool) + state ? powerup : half_wake + self[:power] = state + end + + def power_state(state : Interface::Powerable::PowerState) + case state + in .on? + power true + in .off? + power false + in .full_off? + standby + self[:power] = false + end + self[:power_state] = state + end + + enum PowerOff + Restart + Shutdown + end +end diff --git a/drivers/cisco/collaboration_endpoint/presentation.cr b/drivers/cisco/collaboration_endpoint/presentation.cr new file mode 100644 index 00000000000..15e6ea535a7 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/presentation.cr @@ -0,0 +1,62 @@ +require "placeos-driver/interface/switchable" +require "./xapi" + +module Cisco::CollaborationEndpoint::Presentation + enum PresentationInputs + None + Input1 + Input2 + Input3 + Input4 + end + + include PlaceOS::Driver::Interface::InputSelection(PresentationInputs) + include Cisco::CollaborationEndpoint::XAPI + + enum SendingMode + LocalRemote + LocalOnly + end + + @sending_mode : SendingMode = SendingMode::LocalRemote + @presenting_input : Int32? = nil + + command({"Presentation Start" => :presentation_start}, + presentation_source_: 1..2, + sending_mode_: SendingMode, + connector_id_: 1..2, + instance_: 1..6) # TODO:: support "New" + command({"Presentation Stop" => :presentation_stop}, + instance_: 1..6, + presentation_source_: 1..4) + + # Provide compatabilty with the router module for activating presentation. + def switch_to(input : PresentationInputs) + if input.none? + @presenting_input = nil + presentation_stop + else + source = input.to_s[5..-1].to_i + @presenting_input = source + + presentation_start( + presentation_source: source, + sending_mode: @sending_mode + ) + end + + self[:presenting_input] = @presenting_input + end + + def send_presentation_to(remote : Bool) + @sending_mode = remote ? SendingMode::LocalRemote : SendingMode::LocalOnly + self[:present_to_remote] = remote + + if input = @presenting_input + presentation_start( + presentation_source: input, + sending_mode: @sending_mode + ) + end + end +end diff --git a/drivers/cisco/collaboration_endpoint/response.cr b/drivers/cisco/collaboration_endpoint/response.cr new file mode 100644 index 00000000000..46b73e4dcb9 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/response.cr @@ -0,0 +1,81 @@ +require "json" + +module Cisco::CollaborationEndpoint::XAPI + TRUTHY = {"true", "available", "standby", "on", "active"} + FALSEY = {"false", "unavailable", "off", "inactive"} + BOOLEAN = ->(val : String) { TRUTHY.includes?(val.downcase) } + BOOL_OR = ->(term : String) { ->(val : String) { val == term ? term : BOOLEAN.call(val) } } + PARSERS = { + "TTPAR_OnOff" => BOOLEAN, + "TTPAR_OnOffAuto" => BOOL_OR.call("Auto"), + "TTPAR_OnOffCurrent" => BOOL_OR.call("Current"), + "TTPAR_MuteEnabled" => BOOLEAN, + } + + def self.value_convert(value : String, valuespace : String? = nil) + parser = PARSERS[valuespace]? + return value.to_i64 unless parser + parser.call(value) + rescue + check = value.downcase + # probably wasn't an integer + if check.in? TRUTHY + true + elsif check.in? FALSEY + false + else + value + end + end + + def self.parse(data : String) + JSON.parse(data).as_h.flatten_xapi_json + end +end + +module Enumerable + alias JSONBasic = Bool | Float64 | Int64 | String | Nil + alias JSONComplex = JSONBasic | Hash(String, JSONComplex) + + def flatten_xapi_json(parent_prefix : String? = nil, delimiter : String = "/") + res = {} of String => JSONComplex + + self.each_with_index do |elem, i| + if elem.is_a?(Tuple) + k, v = elem + else + # this is an Array + k, v = i, elem + + # check if there is an ID element in the child + if id = v.as_h?.try &.delete("id") + k = id + end + end + + # assign key name for result hash + key = parent_prefix ? "#{parent_prefix}#{delimiter}#{k}" : k.to_s + raw = v.raw + + case raw + in Array(JSON::Any) + # recursive call to flatten child elements + res.merge!(raw.flatten_xapi_json(key, delimiter)) + in Hash(String, JSON::Any) + value = raw["Value"]? + if value && value.as_h?.nil? + valuespaceref = raw["valueSpaceRef"]?.try &.as_s.split('/').last + res[key] = Cisco::CollaborationEndpoint::XAPI.value_convert(value.as_s, valuespaceref) + elsif id + res[key] = raw.flatten_xapi_json(delimiter: delimiter) + else + res.merge!(raw.flatten_xapi_json(key, delimiter)) + end + in JSONBasic + res[key] = raw + end + end + + res + end +end diff --git a/drivers/cisco/collaboration_endpoint/ui_extensions.cr b/drivers/cisco/collaboration_endpoint/ui_extensions.cr new file mode 100644 index 00000000000..972923e91d8 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/ui_extensions.cr @@ -0,0 +1,76 @@ +require "./xapi" + +module Cisco::CollaborationEndpoint::UIExtensions + include Cisco::CollaborationEndpoint::XAPI + + command({"UserInterface Message Alert Clear" => :msg_alert_clear}) + command({"UserInterface Message Alert Display" => :msg_alert}, + text: String, + title_: String, + duration_: 0..3600) + + command({"UserInterface Message Prompt Clear" => :msg_prompt_clear}) + + def msg_prompt(text : String, options : Array(JSON::Any::Type), title : String? = nil, feedback_id : String? = nil, duration : Int64? = nil) + # TODO: return a promise, then prepend a async traffic monitor so it + # can be resolved with the response, or rejected after the timeout. + option_map = {} of String => JSON::Any::Type + ("Option.1".."Option.5").each_with_index do |key, i| + break if i >= options.size + option_map[key] = options[i] + end + + xcommand "UserInterface Message Prompt Display", + hash_args: Hash(String, JSON::Any::Type){ + "text" => text, + "title" => title, + "feedback_id" => feedback_id, + "duration" => duration, + }.merge(option_map) + end + + enum TextInputType + SingleLine + Numeric + Password + PIN + end + + enum TextKeyboardState + Open + Closed + end + + command({"UserInterface Message TextInput Clear" => :msg_text_clear}) + command({"UserInterface Message TextInput Display" => :msg_text}, + text: String, + feedback_id: String, + title_: String, + duration_: 0..3600, + input_type_: TextInputType, + keyboard_state_: TextKeyboardState, + place_holder_: String, + submit_text_: String) + + def ui_set_value(widget : String, value : JSON::Any::Type? = nil) + if value.nil? + xcommand "UserInterface Extensions Widget UnsetValue", + widget_id: widget + else + xcommand "UserInterface Extensions Widget SetValue", + value: value, widget_id: widget + end + end + + def ui_extensions_deploy(id : String, xml_def : String) + xcommand "UserInterface Extensions Set", xml_def, config_id: id + end + + def ui_extensions_list + xcommand "UserInterface Extensions List" + end + + def ui_extensions_clear + xcommand "UserInterface Extensions Clear" + end +end diff --git a/drivers/cisco/collaboration_endpoint/xapi.cr b/drivers/cisco/collaboration_endpoint/xapi.cr new file mode 100644 index 00000000000..5189c9164ef --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/xapi.cr @@ -0,0 +1,150 @@ +require "json" +require "./response" +require "./feedback" + +# monkey patching task is how we attach custom data +# request_payload is set by send if it's defined +class ::PlaceOS::Driver::Task + getter request_payload : String? = nil + + def request_payload=(payload : String) + @request_payload = payload.split("\n")[0] + end + + alias ResponseCallback = Proc(Hash(String, Enumerable::JSONComplex), Hash(String, Enumerable::JSONComplex) | Enumerable::JSONComplex | Symbol) + property xapi_request_id : String? = nil + property xapi_callback : ResponseCallback? = nil +end + +module Cisco::CollaborationEndpoint::XAPI + # Regexp's for tokenizing the xAPI command and response structure. + INVALID_COMMAND = /(?<=Command not recognized\.)[\r\n]+/ + + SUCCESS = /(?<=OK)[\r\n]+/ + + COMMAND_RESPONSE = Regex.union(INVALID_COMMAND, SUCCESS) + + LOGIN_COMPLETE = /Login successful/ + + enum ActionType + XConfiguration + XCommand + XStatus + XFeedback + XPreferences + end + + enum FeedbackAction + Register + Deregister + DeregisterAll + List + end + + # Serialize an xAPI action into transmittable command. + def self.create_action( + __action__ : ActionType, + *args, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + priority : Int32? = nil, # we want to ignore this param, hence we specified it here + **kwargs + ) + [ + __action__.to_s.camelcase(lower: true), + args.compact_map(&.to_s), + hash_args.map { |key, value| + if value + value = "\"#{value}\"" if value.is_a? String + "#{key.to_s.camelcase}: #{value}" + end + }, + kwargs.map { |key, value| + if value + value = "\"#{value}\"" if value.is_a? String + "#{key.to_s.camelcase}: #{value}" + end + }.to_a.compact!, + ].flatten.join " " + end + + # Serialize an xCommand into transmittable command. + def self.xcommand( + path : String, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + **kwargs + ) + create_action ActionType::XCommand, path, **kwargs.merge({hash_args: hash_args}) + end + + # Serialize an xConfiguration action into a transmittable command. + def self.xconfiguration(path : String, setting : String, value : JSON::Any::Type) + create_action ActionType::XConfiguration, path, hash_args: { + setting => value, + } + end + + # Serialize an xStatus request into transmittable command. + def self.xstatus(path : String) + create_action ActionType::XStatus, path + end + + # Serialize a xFeedback subscription request. + def self.xfeedback(action : FeedbackAction, path : String? = nil) + if path + xpath = tokenize path + create_action ActionType::XFeedback, action, "/#{xpath.join('/')}" + else + create_action ActionType::XFeedback, action + end + end + + def self.tokenize(path : String) + # Allow space or slash seperated paths + path.split(/[\s\/\\]/).reject(&.empty?) + end + + macro command(cmd_name, **params) + {% for cmd, name in cmd_name %} + def {{name.id}}( + {% for param, klass in params %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + + {% if klass.is_a?(RangeLiteral) %} + {{param.id}} : Int32{% if optional %}? = nil{% end %}, + {% else %} + {{param.id}} : {{klass}}{% if optional %}? = nil{% end %}, + {% end %} + {% end %} + ) + {% for param, klass in params %} + {% if klass.is_a?(RangeLiteral) %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + {% if optional %} if {{param.id}}{% end %} + raise ArgumentError.new("#{ {{param.stringify}} } must be within #{ {{klass}} }, was #{ {{param.id}} }") unless ({{klass}}).includes?({{param.id}}) + {% if optional %}end{% end %} + {% end %} + {% end %} + + # send the command + xcommand( + {{cmd}}, + {% for param, klass in params %} + {% if param.stringify.ends_with?("_") %} + {% param = param.stringify[0..-2] %} + {% end %} + + {{param.id}}: {{param.id}}, + {% end %} + ) + end + {% end %} + end +end diff --git a/drivers/cisco/dna_spaces.cr b/drivers/cisco/dna_spaces.cr new file mode 100644 index 00000000000..482a59756c4 --- /dev/null +++ b/drivers/cisco/dna_spaces.cr @@ -0,0 +1,677 @@ +require "placeos-driver" +require "set" +require "jwt" +require "s2_cells" +require "simple_retry" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" + +class Cisco::DNASpaces < PlaceOS::Driver + include Interface::Locatable + include Interface::Sensor + + # Discovery Information + descriptive_name "Cisco Spaces" + generic_name :Cisco_Spaces + uri_base "https://partners.dnaspaces.io" + + default_settings({ + _dna_spaces_activation_key: "provide this and the API / tenant ids will be generated automatically", + _dna_spaces_api_key: "X-API-KEY", + _tenant_id: "sfdsfsdgg", + verify_activation_key: false, + + # Time before a user location is considered probably too old (in minutes) + # we have a large time here as DNA spaces only updates when a user moves + # device exit is used to signal when a device has left the building + max_location_age: 300, + + floorplan_mappings: { + location_a4cb0: { + "level_name" => "optional name", + "building" => "zone-GAsXV0nc", + "level" => "zone-GAsmleH", + "offset_x" => 12.4, + "offset_y" => 5.2, + "map_width" => 50.3, + "map_height" => 100.9, + }, + }, + + debug_stream: false, + }) + + @streaming = false + @last_received = 0_i64 + @stream_active = false + + def on_unload + @channel.close + @stream_active = false + update_monitoring_status(running: false) + end + + @activation_token : String = "" + @verify_activation_key : Bool = false + @api_key : String = "" + @tenant_id : String = "" + @channel : Channel(String) = Channel(String).new + @max_location_age : Time::Span = 300.minutes + @s2_level : Int32 = 21 + @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new + @debug_stream : Bool = false + @events_received : UInt64 = 0_u64 + + def on_update + @max_location_age = (setting?(UInt32, :max_location_age) || 10).minutes + @s2_level = setting?(Int32, :s2_level) || 21 + @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings + @debug_stream = setting?(Bool, :debug_stream) || false + @verify_activation_key = setting?(Bool, :verify_activation_key) || false + + schedule.clear + schedule.every(30.minutes) { cleanup_caches } + schedule.every(5.minutes) { update_monitoring_status } + schedule.in(5.seconds) { update_monitoring_status } + + @activation_token = setting?(String, :dna_spaces_activation_key) || "" + if @activation_token.empty? + @api_key = setting(String, :dna_spaces_api_key) + @tenant_id = setting(String | Int64, :tenant_id).to_s + else + @api_key = setting?(String, :dna_spaces_api_key) || "" + @tenant_id = setting?(String | Int64, :tenant_id).try(&.to_s) || "" + + # Activate the API key using the activation_token + schedule.in(5.seconds) { activate } if @api_key.empty? + end + + @description_lock.synchronize do + if !@streaming && !@api_key.empty? + @streaming = true + spawn { start_streaming_events } + end + end + end + + @[Security(Level::Support)] + def activate + return if @activation_token.empty? + + response = get("/client/v1/partner/partnerPublicKey/") + raise "failed to obtain partner public key, code #{response.status_code}" unless response.success? + + logger.debug { "public key requested: #{response.body}" } + + payload = NamedTuple( + status: Bool, + message: String, + data: Array(ActivactionPublicKey)).from_json(response.body.not_nil!) + + raise "unexpected failure obtaining partner public key: #{payload[:message]}" unless payload[:status] + + public_key = payload[:data][0].public_key + payload, header = JWT.decode(@activation_token, public_key, JWT::Algorithm::RS256, @verify_activation_key) + app_id = payload["appId"].as_s + ref_id = payload["activationRefId"].as_s + tenant_id = payload["tenantId"].as_i64.to_s + + response = post("/client/v1/partner/activateOnPremiseApp", headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@activation_token}", + }, body: { + appId: app_id, + activationRefId: ref_id, + }.to_json) + raise "failed to obtain API key, code #{response.status_code}\n#{response.body}" unless response.success? + + logger.debug { "application activated: #{response.body}" } + + payload = NamedTuple( + status: Bool, + message: String, + data: NamedTuple(apiKey: String)).from_json(response.body.not_nil!) + + raise "unexpected failure obtaining API key: #{payload[:message]}" unless payload[:status] + + api_key = payload[:data][:apiKey] + logger.debug { "saving API key: #{tenant_id}, #{api_key}" } + + define_setting(:tenant_id, tenant_id) + define_setting(:dna_spaces_api_key, api_key) + define_setting(:dna_spaces_activation_key, "") + + logger.debug { "settings saved! Starting stream" } + @api_key = api_key + @tenant_id = tenant_id + + @description_lock.synchronize do + if !@streaming + @streaming = true + spawn { start_streaming_events } + end + end + end + + class LocationInfo + include JSON::Serializable + + getter location : Location + + @[JSON::Field(key: "locationDetails")] + getter details : LocationDetails + end + + def get_location_info(location_id : String) + response = get("/api/partners/v1/locations/#{location_id}?partnerTenantId=#{@tenant_id}", headers: { + "X-API-KEY" => @api_key, + }) + + raise "failed to obtain location id #{location_id}, code #{response.status_code}" unless response.success? + LocationInfo.from_json(response.body.not_nil!) + end + + @description_lock : Mutex = Mutex.new + @location_descriptions : Hash(String, String) = {} of String => String + + def seen_locations + @description_lock.synchronize { @location_descriptions.dup } + end + + # MAC Address => Location (including user) + @locations : Hash(String, DeviceLocationUpdate | IotTelemetry | WebexTelemetryUpdate) = {} of String => DeviceLocationUpdate | IotTelemetry | WebexTelemetryUpdate + @loc_lock : Mutex = Mutex.new + @devices : Hash(String, IotTelemetry | WebexTelemetryUpdate) = {} of String => IotTelemetry | WebexTelemetryUpdate + @dev_lock : Mutex = Mutex.new + + def locations + @loc_lock.synchronize { yield @locations } + end + + def devices + @dev_lock.synchronize { yield @devices } + end + + @user_lookup : Hash(String, Set(String)) = {} of String => Set(String) + @user_loc : Mutex = Mutex.new + + def user_lookup + @user_loc.synchronize { yield @user_lookup } + end + + def user_lookup(user_id : String) + formatted_user = format_username(user_id) + user_lookup { |lookup| lookup[formatted_user]? } + end + + def locate_mac(address : String) + formatted_address = format_mac(address) + locations { |locs| locs[formatted_address]? } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_state + logger.debug { + "MAC Locations: #{locations &.keys}" + } + {tracking: locations &.size, events_received: @events_received} + end + + @map_details : Hash(String, Dimension) = {} of String => Dimension + @map_lock : Mutex = Mutex.new + + def get_map_details(map_id : String) + map = @map_lock.synchronize { @map_details[map_id]? } + if !map + response = get("/api/partners/v1/maps/#{map_id}?partnerTenantId=#{@tenant_id}", headers: { + "X-API-KEY" => @api_key, + }) + if !response.success? + message = "failed to obtain map id #{map_id}, code #{response.status_code}" + logger.warn { message } + return nil + end + map = MapInfo.from_json(response.body.not_nil!).dimension + @map_lock.synchronize { @map_details[map_id] = map } + end + map + end + + @[Security(PlaceOS::Driver::Level::Support)] + def cleanup_caches : Nil + logger.debug { "removing location data that is over 30 minutes old" } + + old = 30.minutes.ago.to_unix + remove_keys = [] of String + locations do |locs| + locs.each { |mac, location| remove_keys << mac if location.last_seen < old } + remove_keys.each { |mac| locs.delete(mac) } + end + + logger.debug { "removed #{remove_keys.size} MACs" } + nil + end + + # we want to stream events until driver is terminated + protected def start_streaming_events + @streaming = true + SimpleRetry.try_to( + base_interval: 2.seconds, + max_interval: 10.seconds + ) do + logger.info { "connecting to event stream" } + stream_events unless terminated? + end + ensure + @streaming = false + end + + # as sometimes the map id is missing, but in the same location + # location id => map id + @location_id_maps = {} of String => String + + # Processes events as they come in, forces a disconnect if no events are sent + # for a period of time as the remote should be sending them periodically + protected def process_events(client) + loop do + select + when data = @channel.receive + logger.debug { "received push #{data}" } if @debug_stream + @events_received = @events_received &+ 1_u64 + begin + event = Cisco::DNASpaces::Events.from_json(data) + payload = event.payload + case payload + when DeviceExit + device_mac = format_mac(payload.device.mac_address) + locations &.delete(device_mac) + when DeviceEntry + # This is used entirely for + @description_lock.synchronize { payload.location.descriptions(@location_descriptions) } + when DeviceLocationUpdate, IotTelemetry, WebexTelemetryUpdate + device_mac = format_mac(payload.device.mac_address) + + # we want timestamps in seconds + payload.last_seen = payload.last_seen // 1000 + + case payload + when IotTelemetry + self[device_mac] = payload + devices { |dev| dev[device_mac] = payload } + + next unless payload.has_position? + when WebexTelemetryUpdate + if webex_obj = devices { |dev| dev[device_mac]? } + webex_obj = webex_obj.as(WebexTelemetryUpdate) + webex_obj.device = payload.device + webex_obj.location = payload.location + webex_obj.last_seen = payload.last_seen + webex_obj.telemetries = payload.telemetries + payload = webex_obj + else + @description_lock.synchronize { payload.location.descriptions(@location_descriptions) } + devices { |dev| dev[device_mac] = payload } + end + payload.update_telemetry + self[device_mac] = payload + end + + # Keep track of device location + existing = nil + + # ignore locations where we don't have enough details to put the device on a map + if payload.map_id.presence + @location_id_maps[payload.location.location_id] = payload.map_id + else + locations = payload.location_mappings.values + level_id = locations.find { |loc_id| @floorplan_mappings[loc_id]? } + + if level_id && (level_data = @floorplan_mappings[level_id]) && level_data["map_width"]? && level_data["map_height"]? + # we don't need the map ID as the x, y coordinates are defined by us + # we do need the map_id for grouping results, so we assign it the level id + payload.map_id = level_id + else + found = false + payload.location_mappings.values.each do |loc_id| + if map_id = @location_id_maps[loc_id]? + payload.map_id = map_id + found = true + break + end + end + + if !found + logger.debug { "ignoring device #{device_mac} location as map_id is empty, location id #{payload.location.location_id}, visit #{payload.visit_id}" } + next + end + end + end + + locations do |loc| + existing = loc[device_mac]? + loc[device_mac] = payload + end + + # Maintain user lookup + if payload.raw_user_id.presence + user_id = format_username(payload.raw_user_id) + + if existing && payload.raw_user_id != existing.raw_user_id + old_user_id = format_username(existing.raw_user_id) + + user_lookup do |lookup| + lookup[old_user_id]?.try &.delete(device_mac) + devices = lookup[old_user_id]? || Set(String).new + devices.delete(device_mac) + lookup.delete(old_user_id) if devices.empty? + + devices = lookup[user_id]? || Set(String).new + devices << device_mac + lookup[user_id] = devices + end + else + user_lookup do |lookup| + devices = lookup[user_id]? || Set(String).new + devices << device_mac + lookup[user_id] = devices + end + end + end + + # payload.location_mappings => { "ZONE" => loc_id, "FLOOR" => loc_id, "BUILDING" => loc_id, "CAMPUS" => loc_id } + else + logger.debug { "ignoring event: #{payload ? payload.class : event.class}" } + end + rescue error + logger.error(exception: error) { "parsing DNA Spaces event: #{data}" } + end + when timeout(20.seconds) + logger.debug { "no events received for 20 seconds, expected heartbeat at 15 seconds" } + @channel.close + break + end + end + ensure + client.close + end + + protected def stream_events + client = HTTP::Client.new URI.parse(config.uri.not_nil!) + client.get("/api/partners/v1/firehose/events", HTTP::Headers{ + "X-API-KEY" => @api_key, + }) do |response| + if !response.success? + @stream_active = false + logger.warn { "failed to connect to firehose api #{response.status_code}" } + raise "failed to connect to firehose api #{response.status_code}" + end + + @stream_active = true + + # We use a channel for event processing so we can make use of timeouts + @channel = Channel(String).new + spawn { process_events(client) } + + begin + loop do + if response.body_io.closed? + @channel.close + break + end + + if data = response.body_io.gets + @last_received = Time.utc.to_unix_ms + @channel.send data + else + @channel.close + break + end + end + rescue IO::Error + @channel.close + end + end + + # Trigger the retry behaviour + @stream_active = false + raise "stream closed" + end + + # ============================= + # Locatable interface + # ============================= + def locate_user(email : String? = nil, username : String? = nil) + if macs = user_lookup(username.presence || email.presence.not_nil!) + location_max_age = @max_location_age.ago.to_unix + + macs.compact_map { |mac| + if location = locate_mac(mac) + next if location.is_a?(WebexTelemetryUpdate) + if location.last_seen > location_max_age + # we update the mac_address to a formatted version + location.device.mac_address = mac + location + end + end + }.sort! { |a, b| + b.last_seen <=> a.last_seen + }.map { |location| + lat = location.latitude + lon = location.longitude + + loc = { + "location" => "wireless", + "coordinates_from" => "top-left", + "x" => location.x_pos, + "y" => location.y_pos, + "lon" => lon, + "lat" => lat, + "s2_cell_id" => S2Cells.at(lat, lon).parent(@s2_level).to_token, + "mac" => location.device.mac_address, + "variance" => location.unc, + "last_seen" => location.last_seen, + "dna_floor_id" => location.map_id, + "ssid" => location.ssid, + "manufacturer" => location.device.manufacturer, + "os" => location.device.os, + } + + map_width = 0.0 + map_height = 0.0 + offset_x = 0.0 + offset_y = 0.0 + + # Add our zone IDs to the response + location.location_mappings.each_value do |location_id| + if level_data = @floorplan_mappings[location_id]? + level_data.each do |key, value| + case key + when "offset_x" + offset_x = value.as(Float64) + loc["x"] = location.x_pos - offset_x + when "offset_y" + offset_y = value.as(Float64) + loc["y"] = location.y_pos - offset_y + when "map_width" + map_width = value.as(Float64) + when "map_height" + map_height = value.as(Float64) + else + loc[key] = value + end + end + break + end + end + + # Add map information to the response + if map_width > 0.0 && map_height > 0.0 + loc["map_width"] = map_width + loc["map_height"] = map_height + elsif map_size = get_map_details(location.map_id) + loc["map_width"] = map_width > 0.0 ? map_width : (map_size.length - offset_x) + loc["map_height"] = map_height > 0.0 ? map_height : (map_size.width - offset_y) + end + + loc + } + else + [] of Nil + end + end + + # Will return an array of MAC address strings + # lowercase with no seperation characters abcdeffd1234 etc + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + user_lookup(username.presence || email.presence.not_nil!).try(&.to_a) || [] of String + end + + # Will return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}` + def check_ownership_of(mac_address : String) : OwnershipMAC? + if location = locate_mac(mac_address) + { + location: "wireless", + assigned_to: format_username(location.raw_user_id), + mac_address: format_mac(mac_address), + } + end + end + + # Will return an array of devices and their x, y coordinates + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "looking up device locations in #{zone_id}" } + return [] of Nil if location.presence && location != "wireless" + + # Find the floors associated with the provided zone id + floors = [] of String + adjustments = {} of String => Tuple(Float64, Float64, Float64, Float64) + @floorplan_mappings.each do |floor_id, data| + if data.values.includes?(zone_id) + floors << floor_id + offset_x = (data["offset_x"]? || 0.0).as(Float64) + offset_y = (data["offset_y"]? || 0.0).as(Float64) + map_width = (data["map_width"]? || -1.0).as(Float64) + map_height = (data["map_height"]? || -1.0).as(Float64) + adjustments[floor_id] = {offset_x, offset_y, map_width, map_height} + end + end + logger.debug { "found matching meraki floors: #{floors}" } + return [] of Nil if floors.empty? + + checking_count = @locations.size + wrong_floor = 0 + too_old = 0 + + # Find the devices that are on the matching floors + oldest_location = @max_location_age.ago.to_unix + + matching = locations(&.compact_map { |mac, loc| + if loc.last_seen < oldest_location + too_old += 1 + next + end + if (floors & loc.location_mappings.values).empty? + wrong_floor += 1 + next + end + + # ensure the formatted mac is being used + loc.device.mac_address = mac + loc + }) + + logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" } + + matching.group_by(&.map_id).flat_map { |map_id, locations| + map_width = -1.0 + map_height = -1.0 + offset_x = 0.0 + offset_y = 0.0 + + # any adjustments required for these locations? + locations.first.location_mappings.each_value do |location_id| + if level_data = adjustments[location_id]? + offset_x, offset_y, map_width, map_height = level_data + break + end + end + + if map_width == -1.0 || map_height == -1.0 + if map_size = get_map_details(map_id) + map_width = map_width > -1.0 ? map_width : (map_size.length - offset_x) + map_height = map_height > -1.0 ? map_height : (map_size.width - offset_y) + end + end + + locations.compact_map do |loc| + next if loc.is_a?(WebexTelemetryUpdate) + lat = loc.latitude + lon = loc.longitude + + { + location: :wireless, + coordinates_from: "top-left", + x: loc.x_pos - offset_x, + y: loc.y_pos - offset_y, + lon: lon, + lat: lat, + s2_cell_id: S2Cells.at(lat, lon).parent(@s2_level).to_token, + mac: loc.device.mac_address, + variance: loc.unc, + last_seen: loc.last_seen, + map_width: map_width, + map_height: map_height, + ssid: loc.ssid, + manufacturer: loc.device.manufacturer, + os: loc.device.os, + } + end + } + end + + def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + def format_username(user : String) + if user.includes? "@" + user = user.split("@")[0] + elsif user.includes? "\\" + user = user.split("\\")[1] + end + user.downcase + end + + # This provides the DNA Spaces dashboard with stream consumption status + @[Security(PlaceOS::Driver::Level::Administrator)] + def update_monitoring_status(running : Bool = true) : Nil + response = put("/api/partners/v1/monitoring/status", headers: { + "Content-Type" => "application/json", + "X-API-KEY" => @api_key, + }, body: { + data: { + overallStatus: { + status: running ? "up" : "down", + notices: [] of Nil, + }, + instanceDetails: { + ipAddress: "", + instanceId: module_id, + }, + cloudFirehose: { + status: @stream_active ? "connected" : "disconnected", + lastReceived: @last_received, + }, + localFirehose: { + status: "disconnected", + lastReceived: 0, + }, + subsystems: [] of Nil, + }, + }.to_json) + raise "failed to update status, code #{response.status_code}\n#{response.body}" unless response.success? + end +end + +require "./dna_spaces/events" +require "./dna_spaces/sensor_interface" diff --git a/drivers/cisco/dna_spaces/activation_publickey.cr b/drivers/cisco/dna_spaces/activation_publickey.cr new file mode 100644 index 00000000000..eb30ec06dd3 --- /dev/null +++ b/drivers/cisco/dna_spaces/activation_publickey.cr @@ -0,0 +1,14 @@ +require "./events" + +class Cisco::DNASpaces::ActivactionPublicKey + include JSON::Serializable + + getter version : String + + @[JSON::Field(key: "publicKey")] + getter public_key : String + + def public_key + "-----BEGIN PUBLIC KEY-----\n#{@public_key}\n-----END PUBLIC KEY-----\n" + end +end diff --git a/drivers/cisco/dna_spaces/app_activaction.cr b/drivers/cisco/dna_spaces/app_activaction.cr new file mode 100644 index 00000000000..36202b21424 --- /dev/null +++ b/drivers/cisco/dna_spaces/app_activaction.cr @@ -0,0 +1,21 @@ +require "./events" + +class Cisco::DNASpaces::AppActivaction + include JSON::Serializable + + @[JSON::Field(key: "spacesTenantName")] + getter spaces_tenant_name : String + + @[JSON::Field(key: "spacesTenantId")] + getter spaces_tenant_id : String + + @[JSON::Field(key: "partnerTenantId")] + getter partner_tenant_id : String + getter name : String + + @[JSON::Field(key: "referenceId")] + getter reference_id : String + + @[JSON::Field(key: "instanceName")] + getter instance_name : String +end diff --git a/drivers/cisco/dna_spaces/ble_rssi_update.cr b/drivers/cisco/dna_spaces/ble_rssi_update.cr new file mode 100644 index 00000000000..b9f25eac962 --- /dev/null +++ b/drivers/cisco/dna_spaces/ble_rssi_update.cr @@ -0,0 +1,49 @@ +require "./events" +require "./location" + +class Cisco::DNASpaces::BlePayload + include JSON::Serializable + + property timestamp : Int64 + property data : String +end + +class Cisco::DNASpaces::RssiMeasurement + include JSON::Serializable + + @[JSON::Field(key: "apMacAddress")] + property access_point_mac : String + + @[JSON::Field(key: "ifSlotId")] + property if_slot_id : Int32 + + @[JSON::Field(key: "bandId")] + property band_id : Int32 + + @[JSON::Field(key: "antennaId")] + property antenna_id : Int32 + + property rssi : Int32 + property timestamp : Int64 +end + +class Cisco::DNASpaces::RssiNotification + include JSON::Serializable + + @[JSON::Field(key: "macAddress")] + property mac_address : String + + @[JSON::Field(key: "apRssiMeasurements")] + property measurements : Array(RssiMeasurement) + + @[JSON::Field(key: "blePayload")] + property payload : BlePayload +end + +class Cisco::DNASpaces::BleRssiUpdate + include JSON::Serializable + + @[JSON::Field(key: "rssiNotification")] + getter notification : RssiNotification + getter location : Location +end diff --git a/drivers/cisco/dna_spaces/device.cr b/drivers/cisco/dna_spaces/device.cr new file mode 100644 index 00000000000..cb917bcda95 --- /dev/null +++ b/drivers/cisco/dna_spaces/device.cr @@ -0,0 +1,48 @@ +require "./events" + +class Cisco::DNASpaces::Device + include JSON::Serializable + + @[JSON::Field(key: "deviceId")] + getter device_id : String + + @[JSON::Field(key: "userId")] + getter user_id : String + + getter tags : Array(String) = [] of String + getter mobile : String? + getter email : String? + + def email + @email.try &.downcase + end + + def email_raw + @email + end + + getter gender : String? + + @[JSON::Field(key: "firstName")] + getter first_name : String? + + @[JSON::Field(key: "lastName")] + getter last_name : String? + + @[JSON::Field(key: "postalCode")] + getter postal_code : String? + + # optIns + # otherFields + # socialNetworkInfo + + # We make this editable so we can store the formatted version here + @[JSON::Field(key: "macAddress")] + property mac_address : String + getter manufacturer : String? + getter os : String? + + @[JSON::Field(key: "osVersion")] + getter os_version : String? + getter type : String +end diff --git a/drivers/cisco/dna_spaces/device_count.cr b/drivers/cisco/dna_spaces/device_count.cr new file mode 100644 index 00000000000..c799d9b8286 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_count.cr @@ -0,0 +1,22 @@ +require "./events" + +class Cisco::DNASpaces::DeviceCount + include JSON::Serializable + + getter location : Location + + @[JSON::Field(key: "associatedCount")] + getter associated_count : Int32 + + @[JSON::Field(key: "estimatedProbingCount")] + getter estimated_probing_count : Int32 + + @[JSON::Field(key: "probingRandomizedPercentage")] + getter probing_randomized_percentage : Float64 + + @[JSON::Field(key: "estimatedDensity")] + getter estimated_density : Float64 + + @[JSON::Field(key: "estimatedCapacityPercentage")] + getter estimated_capacity_percentage : Float64 +end diff --git a/drivers/cisco/dna_spaces/device_entry.cr b/drivers/cisco/dna_spaces/device_entry.cr new file mode 100644 index 00000000000..befd6dd137e --- /dev/null +++ b/drivers/cisco/dna_spaces/device_entry.cr @@ -0,0 +1,26 @@ +require "./events" + +class Cisco::DNASpaces::DeviceEntry + include JSON::Serializable + + getter device : Device + getter location : Location + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "daysSinceLastVisit")] + getter days_sinc_last_visit : Int32 +end diff --git a/drivers/cisco/dna_spaces/device_exit.cr b/drivers/cisco/dna_spaces/device_exit.cr new file mode 100644 index 00000000000..151ef54cd5f --- /dev/null +++ b/drivers/cisco/dna_spaces/device_exit.cr @@ -0,0 +1,38 @@ +require "./events" + +class Cisco::DNASpaces::DeviceExit + include JSON::Serializable + + getter device : Device + getter location : Location + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_datetime : String + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "visitClassification")] + getter visit_classification : String +end diff --git a/drivers/cisco/dna_spaces/device_location_update.cr b/drivers/cisco/dna_spaces/device_location_update.cr new file mode 100644 index 00000000000..8be08d405f7 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_location_update.cr @@ -0,0 +1,55 @@ +require "./events" + +class Cisco::DNASpaces::DeviceLocationUpdate + include JSON::Serializable + + getter device : Device + getter location : Location + + getter ssid : String + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "lastSeen")] + property last_seen : Int64 + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "mapId")] + property map_id : String + + @[JSON::Field(key: "xPos")] + getter x_pos : Float64 + + @[JSON::Field(key: "yPos")] + getter y_pos : Float64 + + @[JSON::Field(key: "confidenceFactor")] + getter confidence_factor : Float64 + getter latitude : Float64 + getter longitude : Float64 + getter unc : Float64 + + def has_position? + true + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end +end diff --git a/drivers/cisco/dna_spaces/device_presence.cr b/drivers/cisco/dna_spaces/device_presence.cr new file mode 100644 index 00000000000..2c3973a4035 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_presence.cr @@ -0,0 +1,54 @@ +require "./events" + +class Cisco::DNASpaces::DevicePresence + include JSON::Serializable + + @[JSON::Field(key: "presenceEventType")] + getter presence_event_type : String + + @[JSON::Field(key: "wasInActive")] + getter was_in_active : Bool + getter device : Device + getter location : Location + + getter ssid : String + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "daysSinceLastVisit")] + getter days_since_last_visit : Int32 + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_date_time : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "visitClassification")] + getter visit_classification : String + + @[JSON::Field(key: "activeDevicesCount")] + getter active_devices_count : Int32 + + @[JSON::Field(key: "inActiveDevicesCount")] + getter inactive_devices_count : Int32 +end diff --git a/drivers/cisco/dna_spaces/events.cr b/drivers/cisco/dna_spaces/events.cr new file mode 100644 index 00000000000..2b0371a39a8 --- /dev/null +++ b/drivers/cisco/dna_spaces/events.cr @@ -0,0 +1,142 @@ +require "json" +require "../dna_spaces" +require "./location" +require "./device" +require "./*" + +# This is used to map the various events into a simpler data structure +abstract class Cisco::DNASpaces::Events + include JSON::Serializable + + # event type hint + use_json_discriminator "eventType", { + "KEEP_ALIVE" => KeepAlive, + "DEVICE_ENTRY" => DeviceEntryWrapper, + "DEVICE_EXIT" => DeviceExitWrapper, + "PROFILE_UPDATE" => ProfileUpdateWrapper, + "LOCATION_CHANGE" => LocationChangeWrapper, + "DEVICE_LOCATION_UPDATE" => DeviceLocationUpdateWrapper, + "TP_PEOPLE_COUNT_UPDATE" => PeopleCountUpdateWrapper, + "DEVICE_PRESENCE" => DevicePresenceWrapper, + "USER_PRESENCE" => UserPresenceWrapper, + "APP_ACTIVATION" => AppActivactionWrapper, + "DEVICE_COUNT" => DeviceCountWrapper, + "BLE_RSSI_UPDATE" => BleRssiUpdateWrapper, + "IOT_TELEMETRY" => IotTelemetryWrapper, + "WEBEX_TELEMETRY" => WebexTelemetryUpdateWrapper, + } + + @[JSON::Field(key: "recordUid")] + getter record_uid : String + + @[JSON::Field(key: "recordTimestamp")] + getter record_timestamp : Int64 + + @[JSON::Field(key: "spacesTenantId")] + getter spaces_tenant_id : String + + @[JSON::Field(key: "spacesTenantName")] + getter spaces_tenant_name : String + + @[JSON::Field(key: "partnerTenantId")] + getter partner_tenant_id : String +end + +class Cisco::DNASpaces::KeepAlive < Cisco::DNASpaces::Events + getter eventType : String = "KEEP_ALIVE" + + def payload + nil + end +end + +class Cisco::DNASpaces::DeviceEntryWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_ENTRY" + + @[JSON::Field(key: "deviceEntry")] + getter payload : DeviceEntry +end + +class Cisco::DNASpaces::DeviceExitWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_EXIT" + + @[JSON::Field(key: "deviceExit")] + getter payload : DeviceExit +end + +class Cisco::DNASpaces::ProfileUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "PROFILE_UPDATE" + + @[JSON::Field(key: "deviceProfileUpdate")] + getter payload : Device +end + +class Cisco::DNASpaces::LocationChangeWrapper < Cisco::DNASpaces::Events + getter eventType : String = "LOCATION_CHANGE" + + @[JSON::Field(key: "locationHierarchyChange")] + getter payload : LocationChange +end + +class Cisco::DNASpaces::DeviceLocationUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_LOCATION_UPDATE" + + @[JSON::Field(key: "deviceLocationUpdate")] + getter payload : DeviceLocationUpdate +end + +class Cisco::DNASpaces::PeopleCountUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "TP_PEOPLE_COUNT_UPDATE" + + @[JSON::Field(key: "tpPeopleCountUpdate")] + getter payload : PeopleCountUpdate +end + +class Cisco::DNASpaces::DevicePresenceWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_PRESENCE" + + @[JSON::Field(key: "devicePresence")] + getter payload : DevicePresence +end + +class Cisco::DNASpaces::UserPresenceWrapper < Cisco::DNASpaces::Events + getter eventType : String = "USER_PRESENCE" + + @[JSON::Field(key: "userPresence")] + getter payload : UserPresence +end + +class Cisco::DNASpaces::AppActivactionWrapper < Cisco::DNASpaces::Events + getter eventType : String = "APP_ACTIVATION" + + @[JSON::Field(key: "appActivation")] + getter payload : AppActivaction +end + +class Cisco::DNASpaces::DeviceCountWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_COUNT" + + @[JSON::Field(key: "deviceCounts")] + getter payload : DeviceCount +end + +class Cisco::DNASpaces::BleRssiUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "BLE_RSSI_UPDATE" + + @[JSON::Field(key: "bleRssiUpdate")] + getter payload : BleRssiUpdate +end + +class Cisco::DNASpaces::IotTelemetryWrapper < Cisco::DNASpaces::Events + getter eventType : String = "IOT_TELEMETRY" + + @[JSON::Field(key: "iotTelemetry")] + getter payload : IotTelemetry +end + +class Cisco::DNASpaces::WebexTelemetryUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "WEBEX_TELEMETRY" + + @[JSON::Field(key: "webexTelemetryUpdate")] + getter payload : WebexTelemetryUpdate +end diff --git a/drivers/cisco/dna_spaces/iot_telemetry.cr b/drivers/cisco/dna_spaces/iot_telemetry.cr new file mode 100644 index 00000000000..10084a8752a --- /dev/null +++ b/drivers/cisco/dna_spaces/iot_telemetry.cr @@ -0,0 +1,254 @@ +require "./events" +require "./location" + +class Cisco::DNASpaces::IotDeviceInfo + include JSON::Serializable + + @[JSON::Field(key: "deviceType")] + property type : String + + @[JSON::Field(key: "deviceId")] + property id : String + + @[JSON::Field(key: "deviceMacAddress")] + property mac_address : String + + @[JSON::Field(key: "deviceName")] + property device_name : String + + @[JSON::Field(key: "firmwareVersion")] + property firmware_version : String + + @[JSON::Field(key: "rawDeviceId")] + property raw_id : String + property manufacturer : String + + def os + type + end +end + +class Cisco::DNASpaces::IotPosition + include JSON::Serializable + + @[JSON::Field(key: "mapId")] + property map_id : String + + @[JSON::Field(key: "xPos")] + getter x_pos : Float64 + + @[JSON::Field(key: "yPos")] + getter y_pos : Float64 + + @[JSON::Field(key: "confidenceFactor")] + getter confidence_factor : Float64 + getter latitude : Float64 + getter longitude : Float64 + + @[JSON::Field(key: "locationId")] + property location_id : String + + @[JSON::Field(key: "lastLocatedTime")] + property time_located : Int64 +end + +class Cisco::DNASpaces::TpData + include JSON::Serializable + + @[JSON::Field(key: "peopleCount")] + property people_count : Int32 + + @[JSON::Field(key: "standbyState")] + property standby_state : Int32 + + @[JSON::Field(key: "ambientNoise")] + property ambient_noise : Int32 + + @[JSON::Field(key: "drynessScore")] + property dryness_score : Int32 + + @[JSON::Field(key: "activeCalls")] + property active_calls : Int32 + + @[JSON::Field(key: "presentationState")] + property presentation_state : Int32 + + @[JSON::Field(key: "timeStamp")] + property time_stamp : Int64 + + @[JSON::Field(key: "airQualityIndex")] + property air_quality_index : Float64 + + @[JSON::Field(key: "temperatureInCelsius")] + property temperature_in_celsius : Float64 + + @[JSON::Field(key: "humidityInPercentage")] + property humidity_in_percentage : Float64 + + getter presence : Bool +end + +class Cisco::DNASpaces::IotTelemetry + include JSON::Serializable + + @[JSON::Field(key: "deviceInfo")] + getter device : IotDeviceInfo + + @[JSON::Field(key: "detectedPosition")] + getter detected_position : IotPosition? + + @[JSON::Field(key: "placedPosition")] + getter placed_position : IotPosition? + + getter location : Location + + @[JSON::Field(key: "deviceRtcTime")] + getter device_rtc : Int64 + + @[JSON::Field(key: "rawHeader")] + getter raw_header : Int64 + + @[JSON::Field(key: "rawPayload")] + getter raw_payload : String + + @[JSON::Field(key: "sequenceNum")] + getter sequence_num : Int64 + + @[JSON::Field(key: "airQuality")] + getter air_quality_index : NamedTuple(airQualityIndex: Float64)? + + @[JSON::Field(key: "temperature")] + getter temperature_celsius : NamedTuple(temperatureInCelsius: Float64)? + + @[JSON::Field(key: "humidity")] + getter humidity_percent : NamedTuple(humidityInPercentage: Float64)? + + @[JSON::Field(key: "airPressure")] + getter air_pressure_actual : NamedTuple(pressure: Float64)? + + @[JSON::Field(key: "pirTrigger")] + getter pir_trigger : NamedTuple(timestamp: Int64)? + + @[JSON::Field(key: "tpData")] + getter tele_presence_data : TpData? + + def people_count + tele_presence_data.try &.people_count + end + + def presence + tele_presence_data.try &.presence + end + + def ambient_noise + tele_presence_data.try &.ambient_noise + end + + def air_quality + if index = @air_quality_index + index[:airQualityIndex] + end + end + + def temperature + if temp = @temperature_celsius + temp[:temperatureInCelsius] + end + end + + def humidity + if humidity = @humidity_percent + humidity[:humidityInPercentage] + end + end + + def air_pressure + if pressure = @air_pressure_actual + pressure[:pressure] + end + end + + def pir_triggered + if pir_trigger = @pir_trigger + pir_trigger[:timestamp] + end + end + + def binding(type : SensorType, mac : String) + case type + when .humidity? + "#{mac}->humidity->humidityInPercentage" + when .air_quality? + "#{mac}->airQuality->airQualityIndex" + when .people_count? + "#{mac}->tpData->peopleCount" + when .temperature? + "#{mac}->temperature->temperatureInCelsius" + end + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end + + def has_position? + !!(@detected_position || @placed_position) + end + + def position : IotPosition + (@detected_position || @placed_position).not_nil! + end + + # make this class quack like a wifi DeviceLocationUpdate + delegate latitude, to: position + delegate longitude, to: position + delegate confidence_factor, to: position + delegate x_pos, to: position + delegate y_pos, to: position + delegate map_id, to: position + + def map_id=(id) + position.map_id = id + end + + def visit_id + "unknown for IoT" + end + + def last_seen + tele_presence_data.try(&.time_stamp) || (has_position? ? position.time_located : device_rtc) + end + + def last_seen=(time) + if tele_data = tele_presence_data + tele_data.time_stamp = time + elsif has_position? + position.time_located = time + else + @device_rtc = time + end + time + end + + def raw_user_id + "" + end + + def unc : Float64 + 3.0 + end + + def ssid + "IoT" + end +end diff --git a/drivers/cisco/dna_spaces/location.cr b/drivers/cisco/dna_spaces/location.cr new file mode 100644 index 00000000000..26cb978eef1 --- /dev/null +++ b/drivers/cisco/dna_spaces/location.cr @@ -0,0 +1,30 @@ +require "./events" + +class Cisco::DNASpaces::Location + include JSON::Serializable + + @[JSON::Field(key: "locationId")] + getter location_id : String + getter name : String + + # TODO:: this might be better as an enum + # if there are only limited types + @[JSON::Field(key: "inferredLocationTypes")] + getter tags : Array(String) = [] of String + + getter parent : Location? + + # Maps tag names to location_ids + def details(mappings = {} of String => String) + parent.try &.details(mappings) + tags.each { |tag| mappings[tag] = location_id } + mappings + end + + # Maps location_ids to location names + def descriptions(mappings = {} of String => String) + parent.try &.descriptions(mappings) + mappings[location_id] = name + mappings + end +end diff --git a/drivers/cisco/dna_spaces/location_change.cr b/drivers/cisco/dna_spaces/location_change.cr new file mode 100644 index 00000000000..6043e172f14 --- /dev/null +++ b/drivers/cisco/dna_spaces/location_change.cr @@ -0,0 +1,32 @@ +require "./events" + +class Cisco::DNASpaces::LocationChange + include JSON::Serializable + + @[JSON::Field(key: "changeType")] + getter change_type : String + getter location : Location + + class Metadata + include JSON::Serializable + + getter key : String + getter values : Array(String) + end + + class LocationDetails + include JSON::Serializable + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + getter city : String + getter state : String + getter country : String + getter category : String + + getter latitude : Float64 + getter longitude : Float64 + + getter metadata : Array(Metadata) + end +end diff --git a/drivers/cisco/dna_spaces/location_details.cr b/drivers/cisco/dna_spaces/location_details.cr new file mode 100644 index 00000000000..c69048af78f --- /dev/null +++ b/drivers/cisco/dna_spaces/location_details.cr @@ -0,0 +1,16 @@ +require "./events" + +class Cisco::DNASpaces::LocationDetails + include JSON::Serializable + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + getter city : String + getter state : String + getter country : String + getter category : String + + getter latitude : Float64 + getter longitude : Float64 +end diff --git a/drivers/cisco/dna_spaces/map_info.cr b/drivers/cisco/dna_spaces/map_info.cr new file mode 100644 index 00000000000..31be13841d7 --- /dev/null +++ b/drivers/cisco/dna_spaces/map_info.cr @@ -0,0 +1,30 @@ +require "./events" + +class Cisco::DNASpaces::Dimension + include JSON::Serializable + + getter length : Float64 + getter width : Float64 + getter height : Float64 + + @[JSON::Field(key: "offsetX")] + getter offset_x : Float64 + + @[JSON::Field(key: "offsetY")] + getter offset_y : Float64 +end + +class Cisco::DNASpaces::MapInfo + include JSON::Serializable + + @[JSON::Field(key: "mapId")] + getter id : String + + @[JSON::Field(key: "imageWidth")] + getter image_width : Float64 + + @[JSON::Field(key: "imageHeight")] + getter image_height : Float64 + + getter dimension : Cisco::DNASpaces::Dimension +end diff --git a/drivers/cisco/dna_spaces/people_count_update.cr b/drivers/cisco/dna_spaces/people_count_update.cr new file mode 100644 index 00000000000..99d8f35cd22 --- /dev/null +++ b/drivers/cisco/dna_spaces/people_count_update.cr @@ -0,0 +1,32 @@ +require "./events" + +# This is triggered from telepresence devices +class Cisco::DNASpaces::PeopleCountUpdate + include JSON::Serializable + + @[JSON::Field(key: "tpDeviceId")] + getter tp_device_id : String + getter location : Location + getter presence : Bool + + @[JSON::Field(key: "peopleCount")] + getter people_count : Int32 + + @[JSON::Field(key: "standbyState")] + getter standby_state : Int32 + + @[JSON::Field(key: "ambientNoise")] + getter ambient_noise : Int32 + + @[JSON::Field(key: "drynessScore")] + getter dryness_score : Int32 + + @[JSON::Field(key: "activeCalls")] + getter active_calls : Int32 + + @[JSON::Field(key: "presentationState")] + getter presentation_state : Int32 + + @[JSON::Field(key: "timeStamp")] + getter timestamp : Int64 +end diff --git a/drivers/cisco/dna_spaces/sensor_interface.cr b/drivers/cisco/dna_spaces/sensor_interface.cr new file mode 100644 index 00000000000..9825f9af11a --- /dev/null +++ b/drivers/cisco/dna_spaces/sensor_interface.cr @@ -0,0 +1,129 @@ +class Cisco::DNASpaces + IOT_SENSORS = { + SensorType::Presence, SensorType::PeopleCount, SensorType::Humidity, + SensorType::AirQuality, SensorType::SoundPressure, SensorType::Temperature, + } + NO_MATCH = [] of Interface::Sensor::Detail + + protected def to_sensors(zone_id, filter, device : IotTelemetry | WebexTelemetryUpdate) + if level_loc = device.location_mappings["FLOOR"]? + if floorplan = @floorplan_mappings[level_loc]? + building = floorplan["building"]?.as(String?) + level = floorplan["level"]?.as(String?) + end + end + + sensors = [] of Interface::Sensor::Detail + return sensors if zone_id && (building || level) && !zone_id.in?({building, level}) + + formatted_mac = format_mac(device.device.mac_address) + time = device.last_seen + device_name = device.device.device_name.presence || device.device.id + + IOT_SENSORS.each do |type| + next if filter && filter != type + # next if device.is_a?(WebexTelemetryUpdate) && !filter.in?({SensorType::PeopleCount, SensorType::Presence}) + + unit = nil + value = nil + binding = device.binding(type, formatted_mac) + + case type + when SensorType::Presence + if !(presence = device.presence).nil? + value = presence ? 1.0 : 0.0 + end + when SensorType::Humidity + if humidity = device.humidity + value = humidity + unit = "%" + end + when SensorType::AirQuality + if air_quality = device.air_quality + value = air_quality + end + when SensorType::PeopleCount + if count = device.people_count + value = count.to_f + end + when SensorType::Temperature + if temp = device.temperature + value = temp + unit = "Cel" + end + when SensorType::SoundPressure + if noise = device.ambient_noise + value = noise.to_f + unit = "dB[SPL]" # NOTE:: this is a guess + end + else + next + end + + next unless value + + sensor = Interface::Sensor::Detail.new( + type: type, + value: value, + last_seen: time, + mac: formatted_mac, + id: type.to_s, + name: "#{device_name} #{device.device.type} (#{type})", + module_id: module_id, + binding: binding, + unit: unit + ) + + sensor.building = building + sensor.level = level + sensors << sensor + end + + sensors + end + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + filter = type ? SensorType.parse(type) : nil + return NO_MATCH if filter && !filter.in?(IOT_SENSORS) + + if mac + mac = format_mac(mac) + device = devices { |dev| dev[mac]? } + return NO_MATCH unless device + return case device + in IotTelemetry, WebexTelemetryUpdate + to_sensors(zone_id, filter, device) + in DeviceLocationUpdate + NO_MATCH + end + end + + device_values = devices &.values + device_values.flat_map do |device| + case device + in IotTelemetry, WebexTelemetryUpdate + to_sensors(zone_id, filter, device) + in DeviceLocationUpdate + NO_MATCH + end + end + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + + return nil unless id + mac = format_mac(mac) + device = devices { |dev| dev[mac]? } + return nil unless device + + filter = SensorType.parse(id) + case device + in IotTelemetry, WebexTelemetryUpdate + to_sensors(nil, filter, device).first? + in DeviceLocationUpdate + end + end +end diff --git a/drivers/cisco/dna_spaces/user_presence.cr b/drivers/cisco/dna_spaces/user_presence.cr new file mode 100644 index 00000000000..762c06de514 --- /dev/null +++ b/drivers/cisco/dna_spaces/user_presence.cr @@ -0,0 +1,83 @@ +require "./events" + +class Cisco::DNASpaces::UserPresence + include JSON::Serializable + + class User + include JSON::Serializable + + @[JSON::Field(key: "userId")] + getter user_id : String + + @[JSON::Field(key: "deviceIds")] + getter device_ids : Array(String) + getter tags : Array(String) = [] of String + getter mobile : String? + getter email : String? + getter gender : String? + + @[JSON::Field(key: "firstName")] + getter first_name : String? + + @[JSON::Field(key: "lastName")] + getter last_name : String? + + @[JSON::Field(key: "postalCode")] + getter postal_code : String? + + # otherFields + # socialNetworkInfo + end + + class UserCount + include JSON::Serializable + + @[JSON::Field(key: "usersWithUserId")] + getter users_with_user_id : Int64 + + @[JSON::Field(key: "usersWithoutUserId")] + getter users_without_user_id : Int64 + + @[JSON::Field(key: "totalUsers")] + getter total_users : Int64 + end + + @[JSON::Field(key: "presenceEventType")] + getter presence_event_type : String + + @[JSON::Field(key: "wasInActive")] + getter was_in_active : Bool + + getter user : User + getter location : Location + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_datetime : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "activeUsersCount")] + getter active_users_count : UserCount + + @[JSON::Field(key: "inActiveUsersCount")] + getter inactive_users_count : UserCount +end diff --git a/drivers/cisco/dna_spaces/webex_telemetry.cr b/drivers/cisco/dna_spaces/webex_telemetry.cr new file mode 100644 index 00000000000..fef7ac2c336 --- /dev/null +++ b/drivers/cisco/dna_spaces/webex_telemetry.cr @@ -0,0 +1,174 @@ +require "./events" +require "./location" + +# https://partners.dnaspaces.io/docs/v1/basic/c-dnas-firehose-api-references.html#!c-firehose-proto-buf-doc.html +class Cisco::DNASpaces::WebexDeviceInfo + include JSON::Serializable + + @[JSON::Field(key: "deviceId")] + getter id : String + + @[JSON::Field(key: "macAddress")] + property mac_address : String + + @[JSON::Field(key: "ipAddress")] + getter ip_address : String + + # these fields are named to be compatible with the IoT field names + @[JSON::Field(key: "product")] + getter type : String + + @[JSON::Field(key: "displayName")] + getter device_name : String + + @[JSON::Field(key: "serialNumber")] + getter serial_number : String + + @[JSON::Field(key: "softwareVersion")] + getter software_version : String + + @[JSON::Field(key: "workspaceId")] + getter workspace_id : String + + @[JSON::Field(key: "orgId")] + getter org_id : String +end + +struct Cisco::DNASpaces::WebexTelemetry + include JSON::Serializable + + getter presence : Bool? + + @[JSON::Field(key: "peopleCount")] + getter count : Int32? + + @[JSON::Field(key: "soundLevel")] + getter sound_level : Float64? + + @[JSON::Field(key: "airQuality")] + getter air_quality : Float64? + + @[JSON::Field(key: "ambientTemp")] + getter ambient_temp : Float64? + + @[JSON::Field(key: "ambientNoise")] + getter ambient_noise : Float64? + + @[JSON::Field(key: "relativeHumidity")] + getter relative_humidity : Float64? +end + +class Cisco::DNASpaces::WebexTelemetryUpdate + include JSON::Serializable + + @[JSON::Field(key: "deviceInfo")] + property device : WebexDeviceInfo + property location : Location + + @[JSON::Field(ignore_serialize: true)] + property telemetries : Array(WebexTelemetry) { [] of WebexTelemetry } + + getter people_count : Int32 do + telemetries.compact_map(&.count).first? || 0 + end + + getter presence : Bool do + telemetries.compact_map(&.presence).first? || (people_count > 0) + end + + getter humidity : Float64? do + telemetries.compact_map(&.relative_humidity).first? + end + + getter air_quality : Float64? do + telemetries.compact_map(&.air_quality).first? + end + + getter temperature : Float64? do + telemetries.compact_map(&.ambient_temp).first? + end + + getter ambient_noise : Float64? do + telemetries.compact_map(&.ambient_noise).first? + end + + def update_telemetry + telemetries.each do |telemetry| + if !telemetry.presence.nil? + @presence = telemetry.presence + next + end + + if count = telemetry.count + @people_count = count + next + end + + if float = telemetry.relative_humidity + @humidity = float + next + end + + if float = telemetry.air_quality + @air_quality = float + next + end + + if float = telemetry.ambient_temp + @temperature = float + next + end + + if float = telemetry.ambient_noise + @ambient_noise = float + end + end + end + + def binding(type : SensorType, mac : String) + case type + when .presence? + "#{mac}->presence" + when .humidity? + "#{mac}->humidity" + when .air_quality? + "#{mac}->air_quality" + when .people_count? + "#{mac}->people_count" + when .temperature? + "#{mac}->temperature" + when .sound_pressure? + "#{mac}->ambient_noise" + end + end + + @[JSON::Field(ignore: true)] + property last_seen : Int64 do + Time.utc.to_unix_ms + end + + @[JSON::Field(ignore: true)] + property map_id : String = "" + + def visit_id + nil + end + + def raw_user_id : String + "" + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end +end diff --git a/drivers/cisco/dna_spaces_spec.cr b/drivers/cisco/dna_spaces_spec.cr new file mode 100644 index 00000000000..87e6ec0656d --- /dev/null +++ b/drivers/cisco/dna_spaces_spec.cr @@ -0,0 +1,38 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::DNASpaces" do + settings({ + dna_spaces_activation_key: "provide this and the API / tenant ids will be generated automatically", + dna_spaces_api_key: "X-API-KEY", + tenant_id: "sfdsfsdgg", + verify_activation_key: false, + max_location_age: 300, + floorplan_mappings: { + location_a4cb0: { + "level_name" => "optional name", + "building" => "zone-GAsXV0nc", + "level" => "zone-GAsmleH", + "offset_x" => 12.4, + "offset_y" => 5.2, + "map_width" => 50.3, + "map_height" => 100.9, + }, + }, + debug_stream: false, + }) + + # The dashboard should request the streaming API + expect_http_request do |request, response| + headers = request.headers + if headers["X-API-KEY"]? == "X-API-KEY" + response.headers["Transfer-Encoding"] = "chunked" + response.status_code = 200 + response << %({"recordUid":"event-85b84f15","recordTimestamp":1605502585236,"spacesTenantId":"","spacesTenantName":"","partnerTenantId":"","eventType":"KEEP_ALIVE"}) + else + response.status_code = 401 + end + end + + # Should standardise the format of MAC addresses + exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b) +end diff --git a/drivers/cisco/ise/guest_users.cr b/drivers/cisco/ise/guest_users.cr new file mode 100644 index 00000000000..b79e2b81cec --- /dev/null +++ b/drivers/cisco/ise/guest_users.cr @@ -0,0 +1,194 @@ +require "placeos-driver" +require "xml" + +# Tested with Cisco ISE API v2.2 +# NOTE:: DO NOT USE, HERE FOR COMPATIBILITY REASONS +# https://developer.cisco.com/docs/identity-services-engine/3.0/#!guest-user/resource-definition +# However, should work and conform to v1.4 requirements +# https://www.cisco.com/c/en/us/td/docs/security/ise/1-4/api_ref_guide/api_ref_book/ise_api_ref_guest.html#79039 + +class Cisco::Ise::Guests < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco ISE Guest Control" + generic_name :Guests + uri_base "https://ise-pan:9060/ers/config" + + default_settings({ + username: "user", + password: "pass", + portal_id: "Required, ask cisco ISE admins", + timezone: "Australia/Sydney", + guest_type: "Required, ask cisco ISE admins for valid subset of values", # e.g. Contractor + location: "Required for ISE v2.2, ask cisco ISE admins for valid value. Else, remove for ISE v1.4", # e.g. New York + custom_data: {} of String => JSON::Any::Type, + }) + + @basic_auth : String = "" + @portal_id : String = "" + @sms_service_provider : String? = nil + @guest_type : String = "default_guest_type" + @timezone : Time::Location = Time::Location.load("Australia/Sydney") + @location : String? = nil + @custom_data = {} of String => JSON::Any::Type + + TYPE_HEADER = "application/vnd.com.cisco.ise.identity.guestuser.2.0+xml" + TIME_FORMAT = "%m/%d/%Y %H:%M" + + def on_update + @basic_auth = "Basic #{Base64.strict_encode("#{setting?(String, :username)}:#{setting?(String, :password)}")}" + @portal_id = setting?(String, :portal_id) || "portal101" + @guest_type = setting?(String, :guest_type) || "default_guest_type" + @location = setting?(String, :location) + @sms_service_provider = setting?(String, :sms_service_provider) + + time_zone = setting?(String, :timezone).presence + @timezone = Time::Location.load(time_zone) if time_zone + @custom_data = setting?(Hash(String, JSON::Any::Type), :custom_data) || {} of String => JSON::Any::Type + end + + def create_guest( + event_start : Int64, + attendee_email : String, + attendee_name : String, + company_name : String? = nil, # Mandatory but driver will extract from email if not passed + phone_number : String = "0123456789", # Mandatory, use a fake value as default + sms_service_provider : String? = nil, # Use this param to override the setting + guest_type : String? = nil, # Mandatory but use this param to override the setting + portal_id : String? = nil # Mandatory but use this param to override the setting + ) + # Determine the name of the attendee for ISE + guest_names = attendee_name.split + first_name_index_end = guest_names.size > 1 ? -2 : -1 + first_name = guest_names[0..first_name_index_end].join(' ') + last_name = guest_names[-1] + username = genererate_username(first_name, last_name) + + return {"username" => username, "password" => UUID.random.to_s[0..3]}.merge(@custom_data) if setting?(Bool, :test) + + sms_service_provider ||= @sms_service_provider + guest_type ||= @guest_type + portal_id ||= @portal_id + + time_object = Time.unix(event_start).in(@timezone) + from_date = time_object.at_beginning_of_day.to_s(TIME_FORMAT) + to_date = time_object.at_end_of_day.to_s(TIME_FORMAT) + + # If company_name isn't passed + # Hackily grab a company name from the attendee's email (we may be able to grab this from the signal if possible) + company_name ||= attendee_email.split('@')[1].split('.')[0].capitalize + + # Now generate our XML body + xml_string = %( + ) + + # customFields is required for ISE API v2.2 + # since location is also required for 2.2, we can check if location is present + xml_string += %( + ) if @location + + xml_string += %( + + #{from_date}) + + xml_string += %( + #{@location}) if @location + + xml_string += %( + #{to_date} + 1 + + + #{company_name} + #{attendee_email} + #{first_name} + #{last_name} + English + #{phone_number}) + + xml_string += %( + #{sms_service_provider}) if sms_service_provider + + xml_string += %( + #{username} + + #{guest_type} + #{portal_id} + ) + + response = post("/guestuser/", body: xml_string, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success? + + guest_id = response.headers["Location"].split('/').last + guest_crendentials(guest_id).merge(@custom_data) + end + + # Will be 9 characters in length until 2081-08-05 10:16:46.208000000 UTC + # when it will increase to 10 + private def genererate_username(firstname, lastname) + "#{firstname[0].downcase}#{lastname[0].downcase}#{Time.utc.to_unix_ms.to_s(62)}" + end + + def guest_crendentials(id : String) + response = get("/guestuser/#{id}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + parsed_body = XML.parse(response.body) + guest_user = parsed_body.first_element_child.not_nil! + guest_info = guest_user.children.find { |c| c.name == "guestInfo" }.not_nil! + { + "username" => guest_info.children.find { |c| c.name == "userName" }.not_nil!.content, + "password" => guest_info.children.find { |c| c.name == "password" }.not_nil!.content, + } + end + + def test_xml(xml_string : String) + response = post("/guestuser/", body: XML.parse(xml_string).to_s, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success? + end + + def test2 + xml_string = %( + + +08/06/2014 23:22 +08/07/2014 23:22 +1 + + +New Company +john@example.com +John +Doe +English +9999998877 +Global Default +autoguestuser1 + +Daily +sponsor +portal101 +interview +) + test_xml(xml_string) + end + + def test_json(json : String) + response = post("/guestuser/", body: json, headers: { + "Accept" => "application/json", + "Content-Type" => "application/json", + "Authorization" => @basic_auth, + }) + raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success? + end +end diff --git a/drivers/cisco/ise/guest_users_spec.cr b/drivers/cisco/ise/guest_users_spec.cr new file mode 100644 index 00000000000..ac109bd570e --- /dev/null +++ b/drivers/cisco/ise/guest_users_spec.cr @@ -0,0 +1,62 @@ +require "placeos-driver/spec" +require "xml" + +TIME_FORMAT = "%m/%d/%Y %H:%M" + +DriverSpecs.mock_driver "Cisco::Ise::Guests" do + portal = "portal101" + phone = "0123456789" + type = "Contractor" + lo = "New York" + + settings({ + portal_id: portal, + guest_type: type, + location: lo, + }) + + start_time = Time.local(Time::Location.load("Australia/Sydney")) + start_date = start_time.at_beginning_of_day.to_s(TIME_FORMAT) + end_date = start_time.at_end_of_day.to_s(TIME_FORMAT) + attendee_email = "attendee@test.com" + company_name = "PlaceOS" + + sms = "Global Default" + exec(:create_guest, start_time.to_unix, attendee_email, "First Last", company_name, phone, sms, "Daily") + + # POST to /guestuser/ + expect_http_request do |request, response| + parsed_body = XML.parse(request.body.not_nil!) + guest_user = parsed_body.first_element_child.not_nil! + + guest_access_info = guest_user.children.find { |c| c.name == "guestAccessInfo" }.not_nil! + from_date = guest_access_info.children.find { |c| c.name == "fromDate" }.not_nil!.content + from_date.should eq start_date + to_date = guest_access_info.children.find { |c| c.name == "toDate" }.not_nil!.content + to_date.should eq end_date + + guest_info = guest_user.children.find { |c| c.name == "guestInfo" }.not_nil! + company = guest_info.children.find { |c| c.name == "company" }.not_nil!.content + company.should eq company_name + email_address = guest_info.children.find { |c| c.name == "emailAddress" }.not_nil!.content + email_address.should eq attendee_email + first_name = guest_info.children.find { |c| c.name == "firstName" }.not_nil!.content + first_name.should eq "First" + last_name = guest_info.children.find { |c| c.name == "lastName" }.not_nil!.content + last_name.should eq "Last" + phone_number = guest_info.children.find { |c| c.name == "phoneNumber" }.not_nil!.content + phone_number.should eq phone + sms_service_provider = guest_info.children.find { |c| c.name == "smsServiceProvider" }.not_nil!.content + sms_service_provider.should eq sms + + portal_id = guest_user.children.find { |c| c.name == "portalId" }.not_nil!.content + portal_id.should eq portal + + guest_type = guest_user.children.find { |c| c.name == "guestType" }.not_nil!.content + guest_type.should eq "Daily" + + response.status_code = 201 + response.headers["Location"] = "https://ise-pan:9060/ers/config/guestuser/e1bb8290-6ccb-11e3-8cdf-000c29c56fc7" + response.headers["Content-Type"] = "application/xml" + end +end diff --git a/drivers/cisco/ise/models/internal_user.cr b/drivers/cisco/ise/models/internal_user.cr new file mode 100644 index 00000000000..332c4364a70 --- /dev/null +++ b/drivers/cisco/ise/models/internal_user.cr @@ -0,0 +1,41 @@ +require "json" + +class Cisco::Ise::Models::InternalUser + include JSON::Serializable + + @[JSON::Field(key: "name")] + property name : String + + @[JSON::Field(key: "id")] + property id : String? + + @[JSON::Field(key: "identityGroups")] + property identity_groups : String? + + @[JSON::Field(key: "description")] + property description : String? + + @[JSON::Field(key: "changePassword")] + property change_password : Bool = false + + @[JSON::Field(key: "email")] + property email : String? + + @[JSON::Field(key: "enabled")] + property enabled : Bool = true + + @[JSON::Field(key: "customAttributes")] + property custom_attributes : Hash(String, String) = {} of String => String + + @[JSON::Field(key: "firstName")] + property first_name : String? + + @[JSON::Field(key: "lastName")] + property last_name : String? + + @[JSON::Field(key: "password")] + property password : String? + + @[JSON::Field(key: "passwordIDStore")] + property password_store : String = "Internal Users" +end diff --git a/drivers/cisco/ise/network_access.cr b/drivers/cisco/ise/network_access.cr new file mode 100644 index 00000000000..9200c63f747 --- /dev/null +++ b/drivers/cisco/ise/network_access.cr @@ -0,0 +1,293 @@ +require "placeos-driver" +require "./models/internal_user" +require "uuid" + +require "../../place/password_generator_helper" + +# Tested with Cisco ISE API v3.1 +# https://developer.cisco.com/docs/identity-services-engine/v1/#!internaluser + +class Cisco::Ise::NetworkAccess < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco ISE REST API" + generic_name :NetworkAccess + uri_base "https://ise-pan:9060/ers/config" + + default_settings({ + username: "user", + password: "pass", + portal_id: "Required for Guest Users, ask cisco ISE admins", + timezone: "UTC", + guest_type: "Required for Guest Users, ask cisco ISE admins for valid subset of values", # e.g. Contractor + custom_data: {} of String => String, + password_length: DEFAULT_PASSWORD_LENGTH, + password_exclude: DEFAULT_PASSWORD_EXCLUDE, + password_minimum_lowercase: DEFAULT_PASSWORD_MINIMUM_LOWERCASE, + password_minimum_uppercase: DEFAULT_PASSWORD_MINIMUM_UPPERCASE, + password_minimum_numbers: DEFAULT_PASSWORD_MINIMUM_NUMBERS, + password_minimum_symbols: DEFAULT_PASSWORD_MINIMUM_SYMBOLS, + debug: false, + test_mode: false, + }) + + @basic_auth : String = "" + @portal_id : String = "" + @sms_service_provider : String? = nil + @guest_type : String = "default_guest_type" + @password_length : Int32 = DEFAULT_PASSWORD_LENGTH + @password_exclude : String = DEFAULT_PASSWORD_EXCLUDE + @password_minimum_lowercase : Int32 = DEFAULT_PASSWORD_MINIMUM_LOWERCASE + @password_minimum_uppercase : Int32 = DEFAULT_PASSWORD_MINIMUM_UPPERCASE + @password_minimum_numbers : Int32 = DEFAULT_PASSWORD_MINIMUM_NUMBERS + @password_minimum_symbols : Int32 = DEFAULT_PASSWORD_MINIMUM_SYMBOLS + @timezone : Time::Location = Time::Location.load("Australia/Sydney") + @custom_data = {} of String => String + + TYPE_HEADER = "application/json" + TIME_FORMAT = "%m/%d/%Y %H:%M" + + def on_update + username = setting?(String, :username) + password = setting?(String, :password) + + @basic_auth = ["Basic", Base64.strict_encode([username, password].join(":"))].join(" ") + + @debug = setting?(Bool, :debug) || false + @test_mode = setting?(Bool, :test) || false + + @portal_id = setting?(String, :portal_id) || "portal101" + @guest_type = setting?(String, :guest_type) || "default_guest_type" + @sms_service_provider = setting?(String, :sms_service_provider) + @password_length = setting?(Int32, :password_length) || DEFAULT_PASSWORD_LENGTH + @password_exclude = setting?(String, :password_exclude) || DEFAULT_PASSWORD_EXCLUDE + @password_minimum_lowercase = setting?(Int32, :password_minimum_lowercase) || DEFAULT_PASSWORD_MINIMUM_LOWERCASE + @password_minimum_uppercase = setting?(Int32, :password_minimum_uppercase) || DEFAULT_PASSWORD_MINIMUM_UPPERCASE + @password_minimum_numbers = setting?(Int32, :password_minimum_numbers) || DEFAULT_PASSWORD_MINIMUM_NUMBERS + @password_minimum_symbols = setting?(Int32, :password_minimum_symbols) || DEFAULT_PASSWORD_MINIMUM_SYMBOLS + + time_zone = setting?(String, :timezone).presence + @timezone = Time::Location.load(time_zone) if time_zone + @custom_data = setting?(Hash(String, String), :custom_data) || {} of String => String + + logger.debug { "Basic auth details: #{@basic_auth}" } if @debug + end + + def create_internal_user( + email : String, + name : String? = nil, + first_name : String? = nil, + last_name : String? = nil, + description : String? = nil, + password : String? = nil, + identity_groups : Array(String) = [] of String + ) + name ||= email + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + + internal_user = Models::InternalUser.from_json( + { + name: name, + email: email, + password: password, + firstName: first_name, + lastName: last_name, + description: description, # custom_attributes: custom_attributes + identityGroups: identity_groups.join(","), + }.to_json) + + logger.debug { "Creating Internal User: #{internal_user.to_json}" } if @debug + + response = post("/internaluser/", body: {"InternalUser" => internal_user}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "Failed to create internal user, code #{response.status_code}\n#{response.body}" unless response.success? + + user = get_internal_user_by_name(name) + user.password = password + user + end + + def get_internal_user_by_id(id : String) + response = get("/internaluser/#{id}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by id, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + internal_user + end + + def get_internal_user_by_name(name : String) + response = get("/internaluser/name/#{name}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by name, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + internal_user + end + + def get_internal_user_by_email(email : String) + response = get("/internaluser/?filter=email.CONTAINS.#{email}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + + resources = parsed_body["SearchResult"].as_h.["resources"].as_a + + raise "returned body has no resources" if resources.empty? + + get_internal_user_by_id(resources.first.as_h.["id"].to_s) + end + + def update_internal_user_password_by_id(id : String, password : String? = nil) + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + + response = put("/internaluser/#{id}", body: {"InternalUser" => {"password" => password}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed: #{response.status_code}: #{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_password_by_name(name : String, password : String? = nil) + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + + response = put("/internaluser/name/#{name}", body: {"InternalUser" => {"password" => password}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed: #{response.status_code}: #{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_password_by_email(email : String, password : String? = nil) + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + internal_user = get_internal_user_by_email(email) + + update_internal_user_password_by_id(internal_user.id.to_s, password) + end + + def update_internal_user_identity_groups_by_id(id : String, identity_groups : Array(String)) + internal_user = get_internal_user_by_id(id) + + response = put("/internaluser/#{internal_user.id}", body: {"InternalUser" => {"identityGroups" => identity_groups.join(",")}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_identity_groups_by_name(name : String, identity_groups : Array(String)) + response = put("/internaluser/name/#{name}", body: {"InternalUser" => {"identityGroups" => identity_groups.join(",")}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed: #{response.status_code}: #{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_identity_groups_by_email(email : String, identity_groups : Array(String)) + internal_user = get_internal_user_by_email(email) + + update_internal_user_identity_groups_by_id(internal_user.id.to_s, identity_groups) + end + + # Todo, when ISE doesn't return 401 for Guest related api calls + # def create_guest (...) + # # sms_service_provider ||= @sms_service_provider + # # guest_type ||= @guest_type + # # portal_id ||= @portal_id + + # # time_object = Time.unix(event_start).in(@timezone) + # # from_date = time_object.at_beginning_of_day.to_s(TIME_FORMAT) + # # to_date = time_object.at_end_of_day.to_s(TIME_FORMAT) + + # # If company_name isn't passed + # # Hackily grab a company name from the attendee's email (we may be able to grab this from the signal if possible) + # # company_name ||= attendee_email.split('@')[1].split('.')[0].capitalize + + # These custom attributes and any custom attribute needs to be predefined + # in the ISE GUI. + # custom_attributes = { + # "fromDate" => from_date, + # "toDate" => to_date, + # "location" => @location.to_s, + # "companyName" => company_name, + # "phoneNumber" => phone_number, + # "smsServiceProvider" => sms_service_provider.to_s, + # "guestType" => guest_type, + # "portalId" => portal_id, + # } of String => String + + # custom_attributes.merge!(@custom_data) + # end +end diff --git a/drivers/cisco/ise/network_access_spec.cr b/drivers/cisco/ise/network_access_spec.cr new file mode 100644 index 00000000000..5f0b76aa78d --- /dev/null +++ b/drivers/cisco/ise/network_access_spec.cr @@ -0,0 +1,39 @@ +require "placeos-driver" +require "./network_access" +require "./models/internal_user" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Ise::NetworkAccess" do + # Mock data for GUEST Users only + # portal = "portal101" + # phone = "0123456789" + # type = "Contractor" + # lo = "New York" + # settings({ + # portal_id: portal, + # guest_type: type, + # location: lo, + # }) + # start_time = Time.local(Time::Location.load("Australia/Sydney")) + # start_date = start_time.at_beginning_of_day.to_s(Cisco::Ise::NetworkAccess::TIME_FORMAT) + # end_date = start_time.at_end_of_day.to_s(Cisco::Ise::NetworkAccess::TIME_FORMAT) + + # Test INTERNAL User creation + attendee_email = "attendee@test.com" + exec(:create_internal, email: attendee_email, name: attendee_email) # The attendee name must be unique, and in most real-world use cases, the clients prefer that to be the email address + # POST to /internaluser/ + expect_http_request do |request, response| + parsed_body = JSON.parse(request.body.not_nil!) + internal_user = Cisco::Ise::Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + email_address = internal_user.email + email_address.should eq attendee_email + + name = internal_user.name + name.should eq attendee_email + + response.status_code = 201 + response.headers["Location"] = "https://ise-pan:9060/ers/config/internaluser/e1bb8290-6ccb-11e3-8cdf-000c29c56fc7" + response.headers["Content-Type"] = "application/xml" + end +end diff --git a/drivers/cisco/meraki/captive_portal.cr b/drivers/cisco/meraki/captive_portal.cr new file mode 100644 index 00000000000..acf9d316f7f --- /dev/null +++ b/drivers/cisco/meraki/captive_portal.cr @@ -0,0 +1,138 @@ +require "json" +require "openssl" +require "placeos-driver" + +class Cisco::Meraki::CaptivePortal < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Meraki Captive Portal" + generic_name :CaptivePortal + description %( + for more information visit: https://meraki.cisco.com/lib/pdf/meraki_whitepaper_captive_portal.pdf + ) + + default_settings({ + wifi_secret: "anything really", + default_timezone: "Australia/Sydney", + date_format: "%Y%m%d", + # duration of access in hours + access_duration: 12, + # Length of the clients wifi code + code_length: 4, + success_url: "https://company.com/welcome", + }) + + @wifi_secret : String = "" + @date_format : String = "%Y%m%d" + @success_url : String = "https://place.technology/" + @default_timezone : Time::Location = Time::Location.load("Australia/Sydney") + @access_duration : Time::Span = 12.hours + @code_length : Int32 = 4 + + @denied : UInt64 = 0_u64 + @granted : UInt64 = 0_u64 + @errors : UInt64 = 0_u64 + + @guests : Hash(String, ChallengePayload) = {} of String => ChallengePayload + + def on_update + @wifi_secret = setting?(String, :wifi_secret) || "anything really" + @date_format = setting?(String, :date_format) || "%Y%m%d" + @success_url = setting?(String, :success_url) || "https://place.technology/" + @access_duration = (setting?(Int32, :access_duration) || 12).hours + @code_length = setting?(Int32, :code_length) || 4 + + time_zone = setting?(String, :default_timezone).presence + @default_timezone = Time::Location.load(time_zone) if time_zone + end + + @[Security(Level::Support)] + def guests + @guests + end + + @[Security(Level::Support)] + def lookup(mac : String) + @guests[format_mac(mac)] + end + + def generate_guest_data(email : String, time : Int64, time_zone : String? = nil) + time_zone = time_zone.presence ? Time::Location.load(time_zone.not_nil!) : @default_timezone + date = Time.unix(time).in(time_zone).to_s(@date_format) + guest_string = "#{email.downcase}-#{date}-#{@wifi_secret}" + + OpenSSL::Digest.new("SHA256").update(guest_string).final.hexstring + end + + # Splits the SHA256 into code length and then randomly selects one + def generate_guest_token(email : String, time : Int64, time_zone : String? = nil) + generate_guest_data(email, time, time_zone).scan(/.{#{@code_length}}/).sample(1)[0][0] + end + + class ChallengePayload + include JSON::Serializable + + property ap_mac : String + property client_ip : String + property client_mac : String + property base_grant_url : String + property user_continue : String? + + # key they were provided in their invite email + property code : String + property email : String + property timezone : String? + + property expires : Time? = nil + end + + EMPTY_HEADERS = {} of String => String + JSON_HEADERS = { + "Content-Type" => "application/json", + } + + # Webhook for providing guest access + def challenge(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "guest access attempt: #{method},\nheaders #{headers},\nbody #{body}" } + + challenge = ChallengePayload.from_json(body) + + check_code = challenge.code + guest_codes = generate_guest_data(challenge.email, Time.utc.to_unix, challenge.timezone) + matched = guest_codes.scan(/.{#{@code_length}}/).count { |code| code[0] == check_code } > 0 + + if matched + challenge.expires = @access_duration.from_now + @guests[format_mac(challenge.client_mac)] = challenge + @granted += 1_u64 + self[:granted_access] = @granted + + redirect_url = "#{challenge.base_grant_url}?duration=#{@access_duration.to_i}&continue_url=#{challenge.user_continue || @success_url}" + response = { + redirect_to: redirect_url, + }.to_json + + logger.debug { "successful joined network #{challenge.inspect}" } + + # Redirect to the success URL + {HTTP::Status::OK, JSON_HEADERS, response} + else + @denied += 1_u64 + self[:denied_access] = @denied + + logger.debug { "failed wifi access attempt by #{challenge.inspect}" } + + {HTTP::Status::NOT_ACCEPTABLE, JSON_HEADERS, "{}"} + end + rescue error + @errors += 1_u64 + self[:errors] = @errors + last_error = error.inspect_with_backtrace + self[:last_error] = last_error + logger.error { "failed to parse wifi challenge payload\n#{error}" } + {HTTP::Status::INTERNAL_SERVER_ERROR, EMPTY_HEADERS, nil} + end + + protected def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end +end diff --git a/drivers/cisco/meraki/captive_portal_spec.cr b/drivers/cisco/meraki/captive_portal_spec.cr new file mode 100644 index 00000000000..11b01cf020b --- /dev/null +++ b/drivers/cisco/meraki/captive_portal_spec.cr @@ -0,0 +1,16 @@ +require "openssl" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Meraki::CaptivePortal" do + date = Time.unix(1599477274).in(Time::Location.load("Australia/Sydney")).to_s("%Y%m%d") + hexdigest = OpenSSL::Digest.new("SHA256").update("guest@email.com-#{date}-anything really").final.hexstring + + # Check the hex codes match + retval = exec(:generate_guest_data, "guest@email.com", 1599477274, "Australia/Sydney") + retval.get.should eq hexdigest + + # check it matches on of the codes + codes = hexdigest.scan(/.{4}/).map { |code| code[0] } + retval = exec(:generate_guest_token, "guest@email.com", 1599477274, "Australia/Sydney") + codes.includes?(retval.get.not_nil!.as_s).should eq true +end diff --git a/drivers/cisco/meraki/dashboard.cr b/drivers/cisco/meraki/dashboard.cr new file mode 100644 index 00000000000..9e5c8b2edf7 --- /dev/null +++ b/drivers/cisco/meraki/dashboard.cr @@ -0,0 +1,252 @@ +require "uri" +require "json" +require "link-header" +require "placeos-driver" +require "./scanning_api" + +class Cisco::Meraki::Dashboard < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Meraki Dashboard" + generic_name :Dashboard + uri_base "https://api.meraki.com" + description %( + for more information visit: + * Dashboard API: https://documentation.meraki.com/zGeneral_Administration/Other_Topics/The_Cisco_Meraki_Dashboard_API + * Scanning API: https://developer.cisco.com/meraki/scanning-api/#!introduction/scanning-api + + NOTE:: API Call volume is rate limited to 5 calls per second per organization. + ) + + default_settings({ + meraki_validator: "configure if scanning API is enabled", + meraki_secret: "configure if scanning API is enabled", + meraki_api_key: "configure for the dashboard API", + + # Max requests a second made to the dashboard + rate_limit: 4, + debug_payload: false, + + # filter message type + scanning_api_filter: "WiFi", + }) + + def on_load + spawn { rate_limiter } + on_update + end + + def on_unload + @channel.close + end + + @scanning_validator : String = "" + @scanning_secret : String = "" + @api_key : String = "" + @scanning_api_filter : MessageType = MessageType::WiFi + + @rate_limit : Int32 = 4 + @channel : Channel(Nil) = Channel(Nil).new(1) + @queue_lock : Mutex = Mutex.new + @queue_size = 0 + @wait_time : Time::Span = 300.milliseconds + + @debug_payload : Bool = false + + def on_update + @scanning_validator = setting?(String, :meraki_validator) || "" + @scanning_secret = setting?(String, :meraki_secret) || "" + @api_key = setting?(String, :meraki_api_key) || "" + @scanning_api_filter = setting?(MessageType, :scanning_api_filter) || MessageType::WiFi + + @rate_limit = setting?(Int32, :rate_limit) || 4 + @wait_time = 1.second / @rate_limit + + @debug_payload = setting?(Bool, :debug_payload) || false + end + + # Perform fetch with the required API request limits in place + @[Security(PlaceOS::Driver::Level::Support)] + def fetch(location : String) + req(location, &.body) + end + + @[Security(PlaceOS::Driver::Level::Support)] + def fetch_all(location : String) + responses = [] of String + req_all_pages(location) { |response| responses << response.body } + responses + end + + protected def req(location : String) + if (@wait_time * @queue_size) > 10.seconds + raise "wait time would be exceeded for API request, #{@queue_size} requests already queued" + end + + @queue_lock.synchronize { @queue_size += 1 } + @channel.receive + @queue_lock.synchronize { @queue_size -= 1 } + + headers = HTTP::Headers{ + "X-Cisco-Meraki-API-Key" => @api_key, + "Content-Type" => "application/json", + "Accept" => "application/json", + "User-Agent" => "PlaceOS/2.0 PlaceTechnology", + } + + uri = URI.parse(location) + response = if uri.host.nil? + get(location, headers: headers) + else + HTTP::Client.get(location, headers: headers) + end + + if response.success? + yield response + elsif response.status.found? + # Meraki might return a `302` on GET requests + response = HTTP::Client.get(response.headers["Location"], headers: headers) + if response.success? + yield response + else + raise "request #{location} failed with status: #{response.status_code}" + end + else + raise "request #{location} failed with status: #{response.status_code}" + end + end + + protected def req_all_pages(location : String) : Nil + next_page = location + + loop do + break unless next_page + + next_page = req(next_page) do |response| + yield response + LinkHeader.new(response)["next"]? + end + end + end + + EMPTY_HEADERS = {} of String => String + SUCCESS_RESPONSE = {HTTP::Status::OK.to_i, EMPTY_HEADERS, nil} + + @[Security(PlaceOS::Driver::Level::Support)] + def organizations + req("/api/v1/organizations?perPage=1000") do |response| + Array(Organization).from_json(response.body) + end + end + + @[Security(PlaceOS::Driver::Level::Support)] + def networks(organization_id : String) + nets = [] of Network + req_all_pages("/api/v1/organizations/#{organization_id}/networks?perPage=1000") do |response| + nets.concat Array(Network).from_json(response.body) + end + nets + end + + @[Security(PlaceOS::Driver::Level::Support)] + def poll_clients( + network_id : String? = nil, + timespan : UInt32 = 900_u32, + connection : ConnectionType? = nil, + device_serial : String? = nil, + statuses : String = "Online" + ) + params = URI::Params.build do |form| + form.add "perPage", "1000" + form.add "timespan", timespan.to_s + form.add "statuses[]", statuses + form.add "recentDeviceConnections[]", connection.to_s if connection + end + + clients = [] of Client + req_all_pages "/api/v1/networks/#{network_id}/clients?#{params}" do |response| + clients.concat Array(Client).from_json(response.body) + end + + if device_serial + clients.select! { |client| client.recent_device_serial == device_serial }.sort! { |a, b| b.last_seen <=> a.last_seen } + else + clients.sort! { |a, b| b.last_seen <=> a.last_seen } + end + end + + def ports_statuses(device_serial : String) + req("/api/v1/devices/#{device_serial}/switch/ports/statuses") do |response| + Array(PortStatusResponse).from_json(response.body) + end + end + + def get_zones(camera_serial : String) + req("/api/v1/devices/#{camera_serial}/camera/analytics/zones") do |response| + Array(CameraZone).from_json(response.body) + end + end + + # Webhook endpoint for scanning API, expects version 3 + def scanning_api(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "scanning API received: #{method},\nheaders #{headers},\nbody size #{body.size}" } + logger.debug { body } if @debug_payload + + # Return the scanning API validator code on a GET request + return {HTTP::Status::OK.to_i, EMPTY_HEADERS, @scanning_validator} if method == "GET" + + # Check the version matches + if !body.starts_with?(%({"version":"3.0")) + logger.warn { "unknown scanning API message received:\n#{body[0..96]}" } + return SUCCESS_RESPONSE + end + + # Parse the data posted + begin + seen = DevicesSeen.from_json(body) + logger.debug { "parsed meraki payload" } + + # filter out observations we're not interested in + if !@scanning_api_filter.none? && seen.message_type != @scanning_api_filter + logger.debug { "ignoring message type: #{seen.message_type}" } + return SUCCESS_RESPONSE + end + + # Check the secret matches + raise "secret mismatch, sent: #{seen.secret}" unless seen.secret == @scanning_secret + + self[seen.data.network_id] = seen.data.observations + rescue e + logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" } + logger.debug { "failed payload body was\n#{body}" } + end + + # Return a 200 response + SUCCESS_RESPONSE + end + + # a webhook for obtaining changes in port status + def port_status(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "Webhook Alert received: #{method},\nheaders #{headers},\nbody #{body}" } + + self[:port_update] = WebhookAlert.from_json(body) + + # Return a 200 response + SUCCESS_RESPONSE + end + + protected def rate_limiter + loop do + break if @channel.closed? + begin + @channel.send(nil) + rescue error + logger.error(exception: error) { "issue with rate limiter" } + ensure + sleep @wait_time + end + end + rescue + # Possible error with logging exception, restart rate limiter silently + spawn { rate_limiter } unless @channel.closed? + end +end diff --git a/drivers/cisco/meraki/dashboard_spec.cr b/drivers/cisco/meraki/dashboard_spec.cr new file mode 100644 index 00000000000..b5ca78a6486 --- /dev/null +++ b/drivers/cisco/meraki/dashboard_spec.cr @@ -0,0 +1,21 @@ +require "./scanning_api" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Meraki::Dashboard" do + # Send the request + retval = exec(:fetch, "/api/v0/organizations") + + # The dashboard should send a HTTP request with the API key + expect_http_request do |request, response| + headers = request.headers + if headers["X-Cisco-Meraki-API-Key"]? == "configure for the dashboard API" + response.status_code = 202 + response << %([{"id":"org id","name":"place tech"}]) + else + response.status_code = 401 + end + end + + # Should return the payload + retval.get.should eq %([{"id":"org id","name":"place tech"}]) +end diff --git a/drivers/cisco/meraki/geo.cr b/drivers/cisco/meraki/geo.cr new file mode 100644 index 00000000000..cf2c419721c --- /dev/null +++ b/drivers/cisco/meraki/geo.cr @@ -0,0 +1,73 @@ +require "math" +require "json" + +module Cisco; end + +module Cisco::Meraki; end + +module Cisco::Meraki::Geo + struct Point + include JSON::Serializable + + def initialize(@lat, @lng) + end + + property lat : Float64 + property lng : Float64 + end + + struct Distance + include JSON::Serializable + + def initialize(@x, @y) + end + + property x : Float64 + property y : Float64 + end + + def self.calculate_xy(top_left : Point, bottom_left : Point, bottom_right : Point, position, distance : Distance) + y_base = geo_distance(top_left, bottom_left) + a = geo_distance(top_left, position) + c = geo_distance(bottom_left, position) + x_raw = triangle_height(a, y_base, c) + + x_base = geo_distance(bottom_left, bottom_right) + a = geo_distance(bottom_left, position) + c = geo_distance(bottom_right, position) + y_raw = triangle_height(a, x_base, c) + + # find the percentage distance from the origin + percentage_height = y_raw / y_base + percentage_width = x_raw / x_base + + # adjust into range provided by the original distances + Distance.new(distance.x * percentage_width, distance.y * percentage_height) + end + + # radius in meters, approx as we're using a perfect sphere the same volume as the earth + EarthRadiusApprox = 6371000.7900_f64 + Radians = Math::PI / 180_f64 + + # https://www.movable-type.co.uk/scripts/latlong.html + # returns the distance in meters + def self.geo_distance(start : Point, ending) + lat_diff = (ending.lat - start.lat) * Radians + lng_diff = (ending.lng - start.lng) * Radians + start_lat_radian = start.lat * Radians + end_lng_radian = ending.lng * Radians + + a = Math.sin(lat_diff / 2_f64) * Math.sin(lat_diff / 2_f64) + + Math.cos(start_lat_radian) * Math.cos(end_lng_radian) * + Math.sin(lng_diff / 2_f64) * Math.sin(lng_diff / 2_f64) + + c = 2_f64 * Math.atan2(Math.sqrt(a), Math.sqrt(1_f64 - a)) + + EarthRadiusApprox * c + end + + # https://www.omnicalculator.com/math/triangle-height + def self.triangle_height(a : Float64, base : Float64, c : Float64) + 0.5_f64 * Math.sqrt((a + base + c) * (base + c - a) * (a - base + c) * (a + base - c)) / base + end +end diff --git a/drivers/cisco/meraki/meraki_locations.cr b/drivers/cisco/meraki/meraki_locations.cr new file mode 100644 index 00000000000..11347efa2a0 --- /dev/null +++ b/drivers/cisco/meraki/meraki_locations.cr @@ -0,0 +1,1337 @@ +require "placeos-driver" +require "json" +require "s2_cells" +require "./mqtt_models" +require "./scanning_api" +require "../../place/area_polygon" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" + +class Cisco::Meraki::Locations < PlaceOS::Driver + include Interface::Locatable + include Interface::Sensor + + # Discovery Information + descriptive_name "Meraki Location Service" + generic_name :MerakiLocations + + description %(requires meraki dashboard driver for API calls) + + accessor dashboard : Dashboard_1 + accessor staff_api : StaffAPI_1 + accessor area_manager : AreaManagement_1 + + default_settings({ + # We will always accept a reading with a confidence lower than this + acceptable_confidence: 5.0, + + # Max Uncertainty in meters - we don't accept positions that are less certain + maximum_uncertainty: 25.0, + + # For confident yet inaccurate location data/maps. If a location's variance is below this threshold, increase it to this value. + # 0.0 disables the override + override_min_variance: 0.0, + + # Optionally only store locations for devices whose "os" property matches this regex string. + regex_filter_device_os: nil, + + # can we use the meraki dashboard API for user lookups + default_network_id: "network_id", + + # Area index each point on a floor lands on + # 21 == ~4 meters squared, which given wifi variance is good enough for tracing + # S2 cell levels: https://s2geometry.io/resources/s2cell_statistics.html + s2_level: 21, + debug_payload: false, + debug_webhook: false, + + # Level mappings, level name for human readability + floorplan_mappings: { + "g_727894289773756672" => { + "building": "zone-12345", + "level": "zone-123456", + "level_name": "BUILDING - L1", + }, + }, + + # Time before a user location is considered probably too old + max_location_age: 10, + + # Ignore certain usernames from the dashboard + ignore_usernames: ["host/"], + + # Enable / Disable dashboard username lookup completely + disable_username_lookup: false, + + # Where desks have no occupancy + return_empty_spaces: true, + + # wired desks mappings + wired_desks: [ + { + serial: "switch-serial-number", + level_id: "zone-1234", + ports: { + 11 => "desk-1234", + 12 => "desk-5678", + }, + }, + ], + }) + + alias WiredDesks = Hash(String, Hash(Int32, String)) + + def on_load + # We want to store our user => mac_address mappings in redis + @user_mac_mappings = PlaceOS::Driver::RedisStorage.new(module_id, "user_macs") + on_update + end + + @acceptable_confidence : Float64 = 5.0 + @maximum_uncertainty : Float64 = 25.0 + @override_min_variance : Float64 = 0.0 + @regex_filter_device_os : String? = nil + getter building_zone : String = "searching..." + + @time_multiplier : Float64 = 0.0 + @confidence_multiplier : Float64 = 0.0 + @max_location_age : Time::Span = 6.minutes + @drift_location_age : Time::Span = 4.minutes + @confidence_time : Time::Span = 2.minutes + + @storage_lock : Mutex = Mutex.new + @user_mac_mappings : PlaceOS::Driver::RedisStorage? = nil + @default_network : String = "" + @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new + @floorplan_sizes = {} of String => FloorPlan + @network_devices = {} of String => NetworkDevice + + @s2_level : Int32 = 21 + @ignore_usernames : Array(String) = [] of String + @return_empty_spaces : Bool = true + + @debug_payload : Bool = false + @debug_webhook : Bool = false + + def on_update + @default_network = setting?(String, :default_network_id) || "" + @return_empty_spaces = setting?(Bool, :return_empty_spaces) || false + + @acceptable_confidence = setting?(Float64, :acceptable_confidence) || 5.0 + @maximum_uncertainty = setting?(Float64, :maximum_uncertainty) || 25.0 + @override_min_variance = setting?(Float64, :override_min_variance) || 0.0 + @regex_filter_device_os = setting?(String, :regex_filter_device_os) + + @max_location_age = (setting?(UInt32, :max_location_age) || 6).minutes + # Age we keep a confident value (without drifting towards less confidence) + @confidence_time = @max_location_age / 3 + # Age at which we discard a drifting value (accepting a less confident value) + @drift_location_age = @max_location_age - @confidence_time + + # How much confidence do we have in this new value, relative to an old confident value + @time_multiplier = 1.0_f64 / (@drift_location_age.to_i - @confidence_time.to_i).to_f64 + @confidence_multiplier = 1.0_f64 / (@maximum_uncertainty.to_i - @acceptable_confidence.to_i).to_f64 + + @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings + + @s2_level = setting?(Int32, :s2_level) || 21 + @debug_payload = setting?(Bool, :debug_payload) || false + @debug_webhook = setting?(Bool, :debug_webhook) || false + @ignore_usernames = setting?(Array(String), :ignore_usernames) || [] of String + disable_username_lookup = setting?(Bool, :disable_username_lookup) || false + + schedule.clear + + # Wired desk data + init_wired_port_mappings + + if @default_network.presence + schedule.every(59.seconds) { update_sensor_cache } + schedule.every(2.minutes) { map_users_to_macs } unless disable_username_lookup + schedule.every(29.minutes) { sync_floorplan_sizes } + + schedule.in(30.milliseconds) do + sync_floorplan_sizes + update_sensor_cache + end + end + schedule.every(30.minutes) { cleanup_caches } + + subscriptions.clear + if @default_network.presence + dashboard.subscribe(@default_network) do |_subscription, new_value| + # values are always raw JSON strings + parse_new_locations(new_value) + end + end + + zones = config.control_system.not_nil!.zones + spawn { find_building(zones) } + + # Grab desk data from the MQTT connection + if system.exists? :MerakiMQTT + mqtt_module = system[:MerakiMQTT] + mqtt_module.subscribe(:floor_lookup) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + @floor_lookup = Hash(String, FloorMapping).from_json(new_value) + update_desk_mappings unless @zone_lookup.empty? + end + mqtt_module.subscribe(:zone_lookup) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + @zone_lookup = Hash(String, Array(String)).from_json(new_value) + update_desk_mappings unless @floor_lookup.empty? + end + schedule.every(10.minutes) { update_desk_mappings } + mqtt_module.subscribe(:camera_updated) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + _time, camera_serial = Tuple(Int64, String).from_json(new_value) + + if @desk_mappings.has_key? camera_serial + check_camera_status(mqtt_module, camera_serial) + end + end + end + end + + # grab building zone from the current system + protected def find_building(zones : Array(String)) : Nil + zones.each do |zone_id| + zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json + if zone.tags.includes?("building") + @building_zone = zone.id + break + end + end + raise "no building zone found in System" unless @building_zone + rescue error + logger.warn(exception: error) { "error looking up building zone" } + schedule.in(5.seconds) { find_building(zones) } + end + + protected def check_camera_status(mqtt_module, camera_serial) + detected_desks = mqtt_module.status(DetectedDesks, "camera_#{camera_serial}_desks") + desk_details[camera_serial] = detected_desks + average_results(camera_serial, detected_desks) + if lux_level = mqtt_module.status?(Float64, "camera_#{camera_serial}_lux") + lux[camera_serial] = lux_level + end + end + + # serial => desks detected + getter desk_details : Hash(String, DetectedDesks) = {} of String => DetectedDesks + + # serial => lux + getter lux : Hash(String, Float64) = {} of String => Float64 + + protected def user_mac_mappings + @storage_lock.synchronize { + yield @user_mac_mappings.not_nil! + } + end + + protected def req(location : String) + response = dashboard.fetch(location).get.as_s + begin + yield response + rescue error + logger.debug(exception: error) { "processing failed for #{location} with response: #{response}" } + raise error + end + end + + protected def req_all(location : String) + dashboard.fetch_all(location).get.as_a.each { |resp| yield resp.as_s } + end + + struct Lookup + include JSON::Serializable + + property time : Time + property mac : String + + def initialize(@time, @mac) + end + end + + # MAC Address => Location + @locations : Hash(String, DeviceLocation) = {} of String => DeviceLocation + @ip_lookup : Hash(String, Lookup) = {} of String => Lookup + + def lookup_ip(address : String) + @ip_lookup[address.downcase]? + end + + def locate_mac(address : String) + @locations[format_mac(address)]? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_foorplans + @floorplan_sizes + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_network_devices + @network_devices + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_state + logger.debug { + "IP Mappings: #{@ip_lookup.keys}\n\nMAC Locations: #{@locations.keys}\n\nClient Details: #{@client_details.keys}" + } + {ip_mappings: @ip_lookup.size, tracking: @locations.size, client_details: @client_details.size} + end + + # Returns the list of users who can be located + @[Security(PlaceOS::Driver::Level::Support)] + def locateable + too_old = @max_location_age.ago + @client_details.compact_map do |mac, client| + location = @locations[mac]? + client.user if location && ((location.time > too_old) || (client.time_added > too_old)) + end + end + + @[Security(PlaceOS::Driver::Level::Support)] + def poll_clients( + network_id : String? = nil, + timespan : UInt32 = 900_u32, + connection : ConnectionType? = nil, + device_serial : String? = nil + ) + network_id = network_id.presence || @default_network + Array(Client).from_json dashboard.poll_clients(network_id, timespan, connection, device_serial).get.to_json + end + + @client_details : Hash(String, Client) = {} of String => Client + + @[Security(PlaceOS::Driver::Level::Support)] + def map_users_to_macs(network_id : String? = nil) + network_id = network_id.presence || @default_network + + logger.debug { "mapping users to device MACs" } + clients = poll_clients(network_id) + + new_devices = 0 + updated_dev = 0 + now = Time.utc + + logger.debug { "mapping found #{clients.size} devices" } + + user_mac_mappings do |storage| + clients.each do |client| + # So we can merge additional details into device location responses + user_mac = format_mac(client.mac) + client.time_added = now + + # Store the mac to hostname lookups to help with learning + if hostname = client.description + @mac_hostnames[user_mac] = hostname + end + + user_id = client.user + + if user_id + @ignore_usernames.each do |name| + if user_id.starts_with?(name) + client.user = user_id = nil + break + end + end + end + + # Attempt to lookup username via learning + if user_id.nil? + if known_id = storage[user_mac]? + client.user = known_id + end + end + + @client_details[user_mac] = client + next unless user_id + + was_update, was_new = map_user_mac(user_mac, user_id, storage) + updated_dev += 1 if was_update + new_devices += 1 if was_new + end + end + + logger.debug { "mapping assigned #{new_devices} new devices, #{updated_dev} user updated" } + nil + end + + protected def map_user_mac(user_mac, user_id, storage) + updated_dev = false + new_devices = false + user_id = format_username(user_id) + + # Check if mac mapping already exists + existing_user = storage[user_mac]? + return {false, false} if existing_user == user_id + + # Remove any pervious mappings + if existing_user + updated_dev = true + if user_macs = storage[existing_user]? + macs = Array(String).from_json(user_macs) + macs.delete(user_mac) + storage[existing_user] = macs.to_json + end + else + new_devices = true + end + + # Update the user mappings + storage[user_mac] = user_id + macs = if user_macs = storage[user_id]? + tmp_macs = Array(String).from_json(user_macs) + tmp_macs.unshift(user_mac) + tmp_macs.uniq! + tmp_macs[0...9] + else + [user_mac] + end + storage[user_id] = macs + + {updated_dev, new_devices} + end + + def format_username(user : String) + if user.includes? "@" + user = user.split("@")[0] + elsif user.includes? "\\" + user = user.split("\\")[1] + end + user.downcase + end + + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + username = format_username(username.presence || email.presence.not_nil!) + if macs = user_mac_mappings(&.[username]?) + Array(String).from_json(macs) + else + [] of String + end + end + + def check_ownership_of(mac_address : String) : OwnershipMAC? + lookup = format_mac(mac_address) + if user = user_mac_mappings(&.[lookup]?) + { + location: "wireless", + assigned_to: user, + mac_address: lookup, + } + end + end + + # returns locations based on most recently seen + # versus most accurate location + def locate_user(email : String? = nil, username : String? = nil) + username = format_username(username.presence || email.presence.not_nil!) + + if macs = user_mac_mappings(&.[username]?) + location_max_age = @max_location_age.ago + + Array(String).from_json(macs).compact_map { |mac| + if location = locate_mac(mac) + client = @client_details[mac]? + + # If a filter is set, then ignore this device unless it matches + if @regex_filter_device_os + if client && client.os + unless /#{@regex_filter_device_os}/.match(client.os.not_nil!) + logger.debug { "[#{username}] IGNORING #{mac} as OS does not match regex filter" } + next + end + else + logger.debug { "[#{username}] IGNORING #{mac} as OS is UNKNOWN" } + next + end + end + + # We set these here to speed up processing + location.client = client + location.mac = mac + + if client && client.time_added > location_max_age + location + elsif location.time > location_max_age + location + end + end + }.sort! { |a, b| + b.time <=> a.time + }.map { |location| + lat = location.lat + lon = location.lng + + loc = { + "location" => "wireless", + "coordinates_from" => "bottom-left", + "x" => location.x, + "y" => location.y, + "lon" => lon, + "lat" => lat, + "s2_cell_id" => lat ? S2Cells.at(lat.not_nil!, lon.not_nil!).parent(@s2_level).to_token : nil, + "mac" => location.mac, + "variance" => location.variance, + "last_seen" => location.time.to_unix, + "meraki_floor_id" => location.floor_plan_id, + "meraki_floor_name" => location.floor_plan_name, + } + + # Add our zone IDs to the response + if level_data = @floorplan_mappings[location.floor_plan_id]? + level_data.each { |k, v| loc[k] = v } + end + + # Add meraki map information to the response + if map_size = @floorplan_sizes[location.floor_plan_id]? + loc["map_width"] = map_size.width + loc["map_height"] = map_size.height + end + + # Add additional client information if it's available + if client = location.client + loc["manufacturer"] = client.manufacturer if client.manufacturer + loc["os"] = client.os if client.os + loc["ssid"] = client.ssid if client.ssid + end + + loc + } + else + [] of Nil + end + end + + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "looking up device locations in #{zone_id}" } + case location.presence + when "wireless" + wireless_locations(zone_id) + when "desk" + desk_locs = wired_desk_locations(zone_id) + cam_locs = desk_locations(zone_id) + combind = Array(typeof(cam_locs[0]) | typeof(desk_locs[0])).new(cam_locs.size + desk_locs.size) + combind.concat(desk_locs) + combind.concat(cam_locs) + when nil + wireless_locs = wireless_locations(zone_id) + desk_locs = wired_desk_locations(zone_id) + cam_locs = desk_locations(zone_id) + combind = Array(typeof(wireless_locs[0]) | typeof(cam_locs[0]) | typeof(desk_locs[0])).new(wireless_locs.size + cam_locs.size + desk_locs.size) + combind.concat(wireless_locs) + combind.concat(desk_locs) + combind.concat(cam_locs) + else + [] of String + end + end + + def wireless_locations(zone_id : String) + # Find the floors associated with the provided zone id + floors = [] of String + @floorplan_mappings.each do |floor_id, data| + floors << floor_id if data.values.includes?(zone_id) + end + logger.debug { "found matching meraki floors: #{floors}" } + return [] of String if floors.empty? + + checking_count = @locations.size + wrong_floor = 0 + too_old = 0 + + # Find the devices that are on the matching floors + oldest_location = @max_location_age.ago + matching = @locations.compact_map do |mac, loc| + # We set this here to speed up processing + client = @client_details[mac]? + loc.client = client + + if loc.time < oldest_location + if client + if client.time_added < oldest_location + too_old += 1 + next + end + else + too_old += 1 + next + end + end + if !floors.includes?(loc.floor_plan_id) + wrong_floor += 1 + next + end + # ensure the formatted mac is being used + loc.mac = mac + loc + end + + logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" } + + # Build the payload on the matching locations + matching.group_by(&.floor_plan_id).flat_map { |floor_id, locations| + map_width = -1.0 + map_height = -1.0 + + if map_size = @floorplan_sizes[floor_id]? + map_width = map_size.width + map_height = map_size.height + elsif mappings = @floorplan_mappings[floor_id]? + map_width = (mappings["width"]? || map_width).as(Float64) + map_height = (mappings["height"]? || map_width).as(Float64) + end + + locations.compact_map do |loc| + lat = loc.lat + lon = loc.lng + + # Add additional client information if it's available + if client = @client_details[loc.mac]? + manufacturer = client.manufacturer + os = client.os + ssid = client.ssid + end + + # Skip payloads with invalid coordinates + if (x = loc.x) && (y = loc.y) + if x.is_a?(Float64) && y.is_a?(Float64) + if loc.x.as(Float64).nan? || loc.y.as(Float64).nan? + logger.warn { "ignoring bad location for #{loc.mac}, NaN" } + next + end + else + logger.warn { "ignoring bad location for #{loc.mac}, unexpected value #{loc.x.inspect}" } + next + end + else + logger.warn { "ignoring bad location for #{loc.mac}, no coordinates provided" } + next + end + + { + location: :wireless, + coordinates_from: "bottom-left", + x: loc.x, + y: loc.y, + lon: lon, + lat: lat, + s2_cell_id: lat ? S2Cells.at(lat.not_nil!, lon.not_nil!).parent(@s2_level).to_token : nil, + mac: loc.mac, + variance: loc.variance, + last_seen: loc.time.to_unix, + map_width: map_width, + map_height: map_height, + manufacturer: manufacturer, + os: os, + ssid: ssid, + } + end + } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def cleanup_caches : Nil + logger.debug { "removing IP and location data that is over 30 minutes old" } + + # IP => MAC mappings + old = 30.minutes.ago + remove_keys = [] of String + @ip_lookup.each { |ip, lookup| remove_keys << ip if lookup.time < old } + remove_keys.each { |ip| @ip_lookup.delete(ip) } + logger.debug { "removed #{remove_keys.size} IP => MAC mappings" } + + # IP => Username mappings + remove_keys.clear + @ip_usernames.each { |ip, lookup| remove_keys << ip if lookup.time < old } + remove_keys.each { |ip| @ip_usernames.delete(ip) } + logger.debug { "removed #{remove_keys.size} IP => Username mappings" } + + # Client details + remove_keys.clear + @client_details.each { |mac, client| remove_keys << mac if client.time_added < old } + remove_keys.each { |mac| @client_details.delete(mac) } + logger.debug { "removed #{remove_keys.size} client details" } + + # MACs + remove_keys.clear + @locations.each do |mac, location| + if location.time < old + if client = @client_details[mac]? + remove_keys << mac if client.time_added < old + else + remove_keys << mac + end + end + end + remove_keys.each { |mac| @locations.delete(mac) } + logger.debug { "removed #{remove_keys.size} MACs" } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def sync_floorplan_sizes(network_id : String? = nil) + network_id = network_id.presence || @default_network + logger.debug { "syncing floor plan sizes for network #{network_id}" } + + floor_plans = {} of String => FloorPlan + + req_all("/api/v1/networks/#{network_id}/floorPlans?perPage=1000") { |response| + Array(FloorPlan).from_json(response).each do |plan| + floor_plans[plan.id] = plan + end + nil + } + + @floorplan_sizes = floor_plans + + # mac address => device location + network_devices = {} of String => NetworkDevice + cameras = [] of NetworkDevice + + req_all("/api/v1/networks/#{network_id}/devices?perPage=1000") { |response| + Array(NetworkDevice).from_json(response).each do |device| + cameras << device if device.firmware.starts_with?("cam") + next unless device.floor_plan_id + network_devices[format_mac(device.mac)] = device + end + nil + } + + @network_devices = network_devices + @cameras = cameras + + {floor_plans, network_devices} + end + + @[Security(PlaceOS::Driver::Level::Support)] + def camera_analytics(serial : String) + req("/api/v1/devices/#{serial}/camera/analytics/live") do |response| + CameraAnalytics.from_json(response) + end + end + + alias CamAnalytics = NamedTuple( + camera: NetworkDevice, + details: CameraAnalytics, + building: String?, + level: String?) + + @camera_analytics = {} of String => CamAnalytics + @cameras = [] of NetworkDevice + + getter cameras + + def update_sensor_cache + analytics = {} of String => CamAnalytics + cameras.each do |cam| + begin + mappings = @floorplan_mappings[cam.floor_plan_id]? + counts = camera_analytics(cam.serial) + mac = format_mac(cam.mac) + if mappings + analytics[mac] = { + camera: cam, + details: counts, + building: mappings["building"]?.as(String?), + level: mappings["level"]?.as(String?), + } + else + analytics[mac] = { + camera: cam, + details: counts, + building: nil.as(String?), + level: nil.as(String?), + } + end + + counts.zones.each do |area_id, count| + self["people-#{mac}-#{area_id}"] = count.person + self["presence-#{mac}-#{area_id}"] = count.person > 0 + end + rescue error + logger.debug(exception: error) { "failed to obtain analytics for #{cam.name} (serial: #{cam.serial})" } + end + end + @camera_analytics = analytics + end + + # Webhook endpoint for scanning API, expects version 3 + def parse_new_locations(payload : String) : Nil + logger.debug { payload } if @debug_payload + + locations_updated = 0 + + # Parse the data posted + begin + observations = Array(Observation).from_json(payload) + logger.debug { "parsed meraki payload" } + + ignore_older = @max_location_age.ago.in Time::Location::UTC + drift_older = @drift_location_age.ago.in Time::Location::UTC + current_time = Time.utc + + observations.each do |observation| + client_mac = format_mac(observation.client_mac) + existing = @locations[client_mac]? + logger.debug { "parsing new observation for #{client_mac}" } if @debug_webhook + + # If a filter is set, then ignore this device unless it matches + if @regex_filter_device_os + # client.os has more accurate data (observation.os is usually nil for iPhones) + client = @client_details[format_mac(observation.client_mac)]? + if client.nil? || /#{@regex_filter_device_os}/.match(client.os || "").nil? + logger.debug { "FILTERED OUT #{client_mac}: OS \"#{observation.os}\" did not match \"#{@regex_filter_device_os}\"" } if @debug_webhook + next + end + end + location = parse(existing, ignore_older, drift_older, observation) + if location + @locations[client_mac] = location + locations_updated += 1 + end + update_ipv4(observation.ipv4, client_mac, current_time) + update_ipv6(observation.ipv6.try(&.downcase), client_mac, current_time) + end + rescue e + logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" } + logger.debug { "failed payload body was\n#{payload}" } + end + + logger.debug { "updated #{locations_updated} locations" } + end + + protected def parse(existing, ignore_older, drift_older, observation) : DeviceLocation? + locations_raw = observation.locations + + # We'll attempt to return a location based on the nearest WAP + if locations_raw.empty? + last_seen = observation.latest_record + if wap_device = @network_devices[format_mac(last_seen.nearest_ap_mac)]? + return wap_device.location unless wap_device.location.nil? + + if floor_plan = @floorplan_sizes[wap_device.floor_plan_id.not_nil!]? + return wap_device.location = DeviceLocation.calculate_location(floor_plan, wap_device, last_seen.time) + end + end + return nil + end + + # existing.time is our ajusted time + if existing_time = existing.try &.time + existing = nil if existing_time < ignore_older + end + + # remove locations that don't have an x,y or very uncertain or very old + locations = locations_raw.reject do |loc| + loc.get_x.nil? || loc.variance > @maximum_uncertainty + end + + if locations.empty? + logger.debug { + if locations_raw.empty? + "ignored as no location data provided" + else + "ignored as no location in observation met minimum requirements, had coordinates: #{!!locations_raw[0].get_x}, uncertainty: #{locations_raw[0].variance}" + end + } if @debug_webhook + return existing + end + + # ensure oldest -> newest (we adjusted these already) + locations = locations.sort { |a, b| a.time <=> b.time } + + # estimate the location given the current observations + location = existing || locations.shift + locations.each do |new_loc| + next unless new_loc.time >= location.time + + # If acceptable then this is newer + if new_loc.variance < @acceptable_confidence + location = new_loc + next + end + + # if more accurate and newer then we'll take this + if new_loc.variance < location.variance + location = new_loc + location.variance = @override_min_variance if location.variance < @override_min_variance + next + end + + # should we drift the older location towards a less accurate newer location + if location.time < drift_older + # has the floor changed, we should probably accept the newer less accurate location + if location.floor_plan_id != new_loc.floor_plan_id + location = new_loc + next + end + + new_uncertainty = new_loc.variance + old_uncertainty = location.variance + + confidence_factor = 1.0 - (@confidence_multiplier * (new_uncertainty - @acceptable_confidence)) + confidence_factor = 0.0 if confidence_factor < 0 + + time_diff = new_loc.time.to_unix - location.time.to_unix + time_factor = @time_multiplier * (time_diff - @confidence_time.to_i).to_f + time_factor = 0.0 if time_factor < 0 + + # Average of the confidence factors + average_multiplier = (confidence_factor + time_factor) / 2.0 + + new_x = new_loc.x! + new_y = new_loc.y! + old_x = location.x! + old_y = location.y! + + # 7.5 = 5 + (( 10 - 5 ) * 0.5) + new_x = old_x + ((new_x - old_x) * average_multiplier) + new_y = old_y + ((new_y - old_y) * average_multiplier) + new_uncertainty = old_uncertainty + ((new_uncertainty - old_uncertainty) * average_multiplier) + + new_loc.x = new_x + new_loc.y = new_y + new_loc.variance = new_uncertainty < @override_min_variance ? @override_min_variance : new_uncertainty + + location = new_loc + end + end + + location + end + + protected def update_ipv4(ipv4, client_mac, current_time) + return unless ipv4 + + lookup = @ip_lookup[ipv4]? || Lookup.new(current_time, client_mac) + lookup.time = current_time + lookup.mac = client_mac + @ip_lookup[ipv4] = lookup + + if lookup = @ip_usernames[ipv4]? + username = lookup.mac + user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) } + end + end + + protected def update_ipv6(ipv6, client_mac, current_time) + return unless ipv6 + + lookup = @ip_lookup[ipv6]? || Lookup.new(current_time, client_mac) + lookup.time = current_time + lookup.mac = client_mac + @ip_lookup[ipv6] = lookup + + if lookup = @ip_usernames[ipv6]? + username = lookup.mac + user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) } + end + end + + def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + # ip => {username, time} + @ip_usernames : Hash(String, Lookup) = {} of String => Lookup + + @[Security(PlaceOS::Driver::Level::Administrator)] + def ip_username_mappings(ip_map : Array(Tuple(String, String, String, String?))) : Nil + now = Time.utc + user_mac_mappings do |storage| + ip_map.each do |(ip, username, domain, hostname)| + username = format_username(username) + @ip_usernames[ip] = Lookup.new(now, username) + + if lookup = @ip_lookup[ip]? + map_user_mac(lookup.mac, username, storage) + end + end + end + end + + @[Security(PlaceOS::Driver::Level::Administrator)] + def mac_address_mappings(username : String, macs : Array(String), domain : String = "") + username = format_username(username) + user_mac_mappings do |storage| + macs.each { |mac| map_user_mac(format_mac(mac), username, storage) } + end + end + + # ========================== + # Wired Port Sensing / desks + # ========================== + + bind Dashboard_1, :port_update, :port_updated + + # Serial => level + port mappings + @wired_desks : Hash(String, DeskMappings) = {} of String => DeskMappings + + # level_id => [switch serial numbers] + @level_serials : Hash(String, Array(String)) = {} of String => Array(String) + + # serial => port => status + mac address? + @port_status : Hash(String, Hash(Int32, PortStatusResponse)) = {} of String => Hash(Int32, PortStatusResponse) + + # User lookup helpers using device hostnames + getter mac_hostnames : Hash(String, String) = {} of String => String + + protected def init_wired_port_mappings + @port_status = Hash(String, Hash(Int32, PortStatusResponse)).new { |h, k| h[k] = {} of Int32 => PortStatusResponse } + + wired_desks = setting?(Array(DeskMappings), :wired_desks) || [] of DeskMappings + level_serials = Hash(String, Array(String)).new { |h, k| h[k] = [] of String } + desk_mappings = {} of String => DeskMappings + wired_desks.each do |switch| + level_serials[switch.level_id] << switch.serial + desk_mappings[switch.serial] = switch + end + @wired_desks = desk_mappings + @level_serials = level_serials + + spawn { get_port_status(@wired_desks.keys) } + + if (serials = @wired_desks.keys) && !serials.empty? + schedule.in(5.seconds) { get_port_status(serials) } + schedule.every(5.minutes) { get_port_status(serials) } + end + rescue error + logger.warn(exception: error) { "error initializing wired port mappings" } + end + + def hostname_ownership(hostname : String, username : String?) : Nil + macs = @mac_hostnames.compact_map { |(mac, host)| host == hostname ? mac : nil } + + if username && username.presence + user_mac_mappings { |storage| macs.each { |mac| map_user_mac(mac, username, storage) } } + else + # just in case the client doesn't show up again, we don't need to perform lookups + macs.each { |mac| @mac_hostnames.delete mac } + end + end + + # grab all the client data we have (applies to all ports) + # grab the port information + # check we have a desk mapping for the port + # see if we can find the client information for the port. + protected def get_port_status(devices : Iterable(String)) + all_clients = poll_clients(@default_network, connection: :wired) + devices.each do |serial| + begin + ports = @port_status[serial] + mappings = @wired_desks[serial] + + Array(PortStatusResponse).from_json(dashboard.ports_statuses(serial).get.to_json).each do |port| + # ensure the port has a desk mapping + desk_id = mappings.ports[port.port]? + next unless desk_id + + port.desk_id = desk_id + port.level_id = mappings.level_id + port.switch_serial = serial + + if port.status.connected? && (client = all_clients.find { |c| c.recent_device_serial == serial && c.switch_port == port.port }) + port.mac = client.mac + end + + ports[port.port] = port + end + rescue error + logger.warn(exception: error) { "error querying port statuses for #{serial}" } + end + end + end + + private def port_updated(_subscription, new_value) + details = WebhookAlert.from_json(new_value) + logger.debug { "switch #{details.device_serial}, port #{details.port_num} = #{details.alert_type}" } + + serial = details.device_serial + if mappings = @wired_desks[serial]? + # query the switch for the port status + get_port_status({serial}) + area_manager.update_available({mappings.level_id}) + end + rescue error + logger.warn(exception: error) { "failed to parse port update\n#{new_value.inspect}" } + end + + # grabs the wired desk data for a level + def wired_desk_locations(zone_id : String) + return_empty_spaces = @return_empty_spaces + + serials = if zone_id == @building_zone + @level_serials.values.flatten + else + @level_serials[zone_id]? || [] of String + end + + serials.compact_map { |serial| + ports = @port_status[serial]? + next unless ports + + ports.map do |(port_num, port)| + occupied = port.status.connected? ? 1 : 0 + + # Do we want to return empty desks (depends on the frontend) + next if !return_empty_spaces && occupied == 0 + + { + location: "desk", + at_location: occupied, + map_id: port.desk_id, + level: port.level_id, + building: @building_zone, + capacity: 1, + mac: port.mac, + port: port_num, + switch: port.switch_serial, + } + end + }.flatten + end + + # ====================== + # Sensor interface: + # ====================== + + protected def to_sensors(zone_id, filter, camera, details, building, level) + sensors = [] of Interface::Sensor::Detail + return sensors if zone_id && (building || level) && !zone_id.in?({building, level}) + + formatted_mac = format_mac(camera.mac) + + {SensorType::PeopleCount, SensorType::Presence}.each do |type| + next if filter && filter != type + + time = details.ts.to_unix + type_indicator = type.to_s.underscore.split('_', 2)[0] + + details.zones.each do |area_id, count| + value = case type + when SensorType::PeopleCount + count.person.to_f + when SensorType::Presence + count.person > 0 ? 1.0 : 0.0 + else + # Will never make it here + raise "unknown sensor" + end + + sensor = Interface::Sensor::Detail.new( + type: type, + value: value, + last_seen: time, + mac: camera.mac, + id: "#{area_id}-#{type_indicator}", + name: "#{camera.name} Presence: #{camera.model} (#{camera.serial})", + + module_id: module_id, + binding: "#{type_indicator}-#{formatted_mac}-#{area_id}" + ) + + sensor.building = building + sensor.level = level + sensors << sensor + end + end + + sensors + end + + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if type && !type.in?({"Presence", "PeopleCount"}) + filter = type ? SensorType.parse(type) : nil + + if mac + cam_state = @camera_analytics[format_mac(mac)]? + return NO_MATCH unless cam_state + return to_sensors(zone_id, filter, **cam_state) + end + + @camera_analytics.values.flat_map { |cam_data| to_sensors(zone_id, filter, **cam_data) } + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + + return nil unless id + cam_state = @camera_analytics[format_mac(mac)]? + return nil unless cam_state + + # https://crystal-lang.org/api/1.1.0/String.html#rpartition(search:Char%7CString):Tuple(String,String,String)-instance-method + area_str, _, sensor_type = id.rpartition('-') + + filter = case sensor_type + when "people" + SensorType::PeopleCount + when "presence" + SensorType::Presence + else + return nil + end + + area_id = area_str.to_i64? + return nil unless area_id + + zone_count = cam_state[:details].zones[area_id]?.try &.person + return nil unless zone_count + + to_sensors(nil, filter, **cam_state).find { |sensor| sensor.id == id } + end + + # ================= + # Camera Desk data: + # ================= + # desk_id => [{time, occupied}] + getter desk_occupancy : Hash(String, Array(Tuple(Int64, Bool))) + + @desk_occupancy : Hash(String, Array(Tuple(Int64, Bool))) = Hash(String, Array(Tuple(Int64, Bool))).new do |hash, key| + hash[key] = Array(Tuple(Int64, Bool)).new(4) + end + + protected def average_results(serial, detected) + desks = @desk_mappings[serial]? + return unless desks + + time = Time.utc.to_unix + past = desk_data_expiry_time + + # id => Array({distance, occupied}) + results = Hash(String, Array(Tuple(Float64, Bool))).new { |h, k| h[k] = [] of Tuple(Float64, Bool) } + + # we store the closest desk point to the line, + # as this detected desk might be the occupancy we care about + detected.desks.each do |(lx, ly, cx, cy, rx, ry, occupancy)| + desks.each { |desk| desk.distance = calculate_distance(lx, ly, cx, cy, rx, ry, desk) } + if desk = desks.sort! { |a, b| a.distance <=> b.distance }.first? + results[desk.label] << {desk.distance, !occupancy.zero?} + end + end + + # then for each desk id, we take the closest detected desk and use that + # occupancy value + results.each do |desk_id, distances| + _distance, occupancy = distances.sort! { |a, b| a[0] <=> b[0] }.first + desk_occupation = @desk_occupancy[desk_id] + desk_occupation << {time, occupancy} + cleanup_old_data(desk_occupation, past) + end + end + + # We want to find the line closest to the offical desk point + protected def calculate_distance(lx, ly, cx, cy, rx, ry, desk) + desk = Point.new(desk.x, desk.y) + { + Point.new(lx, ly).distance_to(desk), + Point.new(rx, ry).distance_to(desk), + Point.new(cx, cy).distance_to(desk), + }.sum + end + + protected def desk_data_expiry_time + 90.seconds.ago.to_unix + end + + protected def cleanup_old_data(desk_occupation, expiry_time) + desk_occupation.reject! { |(time, _occupancy)| time < expiry_time } + end + + # ============================= + # Camera Desk location service: + # ============================= + # zone_id => array of camera serials + @zone_lookup : Hash(String, Array(String)) = {} of String => Array(String) + + # camera serial => level + building + @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping + + # Camera serial => [desk location] + getter desk_mappings : Hash(String, Array(CameraZone)) = {} of String => Array(CameraZone) + + def desk_locations(zone_id : String) + serials = @zone_lookup[zone_id]? || [] of String + return_empty_spaces = @return_empty_spaces + expiry_time = desk_data_expiry_time + + serials.compact_map { |serial| + desks = @desk_mappings[serial]? + next unless desks + + # does data exist for the desks? + next unless desk_details[serial]? + + floor = @floor_lookup[serial] + illumination = lux[serial]? + + desks.compact_map do |desk| + desk_id = desk.label + occupied = is_occupied?(desk_id, expiry_time) + + # Do we want to return empty desks (depends on the frontend) + next if !return_empty_spaces && occupied == 0 + + { + location: "desk", + at_location: occupied, + map_id: desk_id, + level: floor.level_id, + building: floor.building_id, + capacity: 1, + + area_lux: illumination, + merakimv: serial, + } + end + }.flatten + end + + def update_desk_mappings + desk_mappings = Hash(String, Array(CameraZone)).new + @floor_lookup.keys.each do |serial| + begin + desk_mappings[serial] = Array(CameraZone).from_json(dashboard.get_zones(serial).get.to_json).reject!(&.id.==("0")) + rescue error + logger.warn(exception: error) { "fetching zones for camera: #{serial}" } + end + end + + @desk_mappings = desk_mappings + + mqtt_module = system[:MerakiMQTT] + desk_mappings.keys.each { |camera_serial| check_camera_status(mqtt_module, camera_serial) } + end + + protected def desk_data_expiry_time + 90.seconds.ago.to_unix + end + + protected def is_occupied?(desk_id, expiry_time) + desk_occupation = @desk_occupancy[desk_id]? + return 0 unless desk_occupation + + occupied = 0 + desk_occupation.reject! do |(time, occupancy)| + if time < expiry_time + next true + elsif occupancy + occupied += 1 + end + false + end + + size = desk_occupation.size + return 0 if size.zero? + + # We care if the desk basically had signs of life + (occupied / size) > 0.3 ? 1 : 0 + end +end diff --git a/drivers/cisco/meraki/meraki_locations_spec.cr b/drivers/cisco/meraki/meraki_locations_spec.cr new file mode 100644 index 00000000000..ada209e876f --- /dev/null +++ b/drivers/cisco/meraki/meraki_locations_spec.cr @@ -0,0 +1,199 @@ +require "./scanning_api" +require "placeos-driver/spec" + +# :nodoc: +class DashboardMock < DriverSpecs::MockDriver + def fetch(location : String) + logger.info { "fetching: #{location}" } + case location + when "/api/v1/networks/network_id/floorPlans" + %([{"floorPlanId":"floor-123","name":"Level 1","width":30.5,"height":20,"topLeftCorner":{"lat":0,"lng":0},"bottomLeftCorner":{"lat":0,"lng":0},"bottomRightCorner":{"lat":0,"lng":0}}]) + when "/api/v1/networks/network_id/devices" + %([]) + when "/api/v1/devices/Q2HV-KAM-ETSG/camera/analytics/live" + %({ + "ts": "2021-08-09T23:56:52.236Z", + "zones": { + "582653201791058186": { + "person": 0 + }, + "582653201791058185": { + "person": 0 + }, + "0": { + "person": 0 + } + } + }) + else + %([]) + end + end + + def fetch_all(location : String) + [fetch(location)] + end + + def get_zones(serial : String) + logger.info { "ZONE REQ: request made for camera '#{serial}'" } + [{ + zoneId: "ignored", + type: "something", + label: "desk-1234", + regionOfInterest: { + x0: "0.44", + y0: "0.56", + x1: "0.44", + y1: "0.56", + }, + }] + end +end + +# :nodoc: +class MQTTMock < DriverSpecs::MockDriver + def on_load + self["camera_camera_serial_desks"] = { + "_v" => 2, + "time" => "2022-01-20 02:14:00", + "desks" => [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0], + ], + } + + self["camera_updated"] = {0, "camera_serial"} + + self["floor_lookup"] = { + "camera_serial" => { + camera_serials: ["camera_serial"], + level_id: "zone-123", + building_id: "zone-456", + }, + } + + self["zone_lookup"] = { + "zone-456" => {"camera_serial"}, + } + end + + def trigger_update + self["camera_updated"] = {1, "camera_serial"} + end +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver + def zone(id : String) + { + id: "zone-building", + tags: ["building"], + name: "building zone", + } + end +end + +DriverSpecs.mock_driver "Cisco::Meraki::Locations" do + system({ + Dashboard: {DashboardMock}, + MerakiMQTT: {MQTTMock}, + StaffAPI: {StaffAPIMock}, + }) + + sleep 0.5 + + # Should standardise the format of MAC addresses + exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b) + + floors_raw = %({"g_727894289773756676": { + "floorPlanId": "g_727894289773756676", + "width": 84.73653902424, + "height": 55.321510873304, + "topLeftCorner": { + "lat": 25.20105494120424, + "lng": 55.27527794417147 + }, + "bottomLeftCorner": { + "lat": 25.20128402691947, + "lng": 55.27478983574903 + }, + "bottomRightCorner": { + "lat": 25.200607564298647, + "lng": 55.27440203743774 + }, + "name": "BUILDING - L3" + }, + "g_727894289773756679": { + "floorPlanId": "g_727894289773756679", + "width": 82.037895885132, + "height": 48.035263155936, + "topLeftCorner": { + "lat": 25.201070920997147, + "lng": 55.27523029269689 + }, + "bottomLeftCorner": { + "lat": 25.20126383588677, + "lng": 55.274803104166594 + }, + "bottomRightCorner": { + "lat": 25.200603702563107, + "lng": 55.27443896882145 + }, + "name": "Building - GF" + }}) + floors = Hash(String, Cisco::Meraki::FloorPlan).from_json(floors_raw) + + macs_raw = %({"683a1e545b0c": { + "floorPlanId": "g_727894289773756676", + "lat": 25.2011012305148, + "lng": 55.2749184519053, + "mac": "68:3a:1e:54:5b:0c", + "name": "1F-07", + "model": "MV22", + "firmware": "camera-4-13", + "serial": "Q2HV-KAM-ETSG" + }, + "683a1e5474ed": { + "floorPlanId": "g_727894289773756679", + "lat": 25.2008175846893, + "lng": 55.2746475487948, + "mac": "68:3a:1e:54:74:ed", + "name": "GF-29", + "model": "MV22", + "firmware": "camera-4-13", + "serial": "Q2HV-KAM-ETSG" + }}) + macs = Hash(String, Cisco::Meraki::NetworkDevice).from_json(macs_raw) + + macs.each do |_mac, wap_device| + floor_plan = floors[wap_device.floor_plan_id] + # do some unit testing + loc = Cisco::Meraki::DeviceLocation.calculate_location(floor_plan, wap_device, Time.utc) + loc.to_json + end + + exec(:camera_analytics, "Q2HV-KAM-ETSG").get.should eq({ + "ts" => "2021-08-09T23:56:52.236+0000", + "zones" => { + "582653201791058186" => {"person" => 0}, + "582653201791058185" => {"person" => 0}, + "0" => {"person" => 0}, + }, + }) + + exec(:device_locations, "zone-456").get.should eq([{ + "location" => "desk", + "at_location" => 0, + "map_id" => "desk-1234", + "level" => "zone-123", + "building" => "zone-456", + "capacity" => 1, + "area_lux" => nil, + "merakimv" => "camera_serial", + }]) +end diff --git a/drivers/cisco/meraki/mqtt.cr b/drivers/cisco/meraki/mqtt.cr new file mode 100644 index 00000000000..0271cfaf737 --- /dev/null +++ b/drivers/cisco/meraki/mqtt.cr @@ -0,0 +1,421 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" +require "../../place/mqtt_transport_adaptor" +require "./mqtt_models" + +# documentation: https://developer.cisco.com/meraki/mv-sense/#!mqtt +# Use https://www.desmos.com/calculator for plotting points (sample code for copy and paste) +# data = [[1,2,3,4,5,6, 0]] +# data.each do |d| +# puts "(#{d[0]}, #{d[1]}),(#{d[2]}, #{d[3]}),(#{d[4]}, #{d[5]})" +# end + +class Cisco::Meraki::MQTT < PlaceOS::Driver + include Interface::Sensor + + descriptive_name "Meraki MQTT" + generic_name :MerakiMQTT + + tcp_port 1883 + description %(subscribes to Meraki MV Sense camera data) + + default_settings({ + username: "user", + password: "pass", + keep_alive: 60, + client_id: "placeos", + + floor_mappings: [ + { + camera_serials: ["1234", "camera_serial"], + level_id: "zone-123", + building_id: "zone-456", + }, + ], + + line_crossing_combined: { + area_name: ["camera_serial1", "camera_serial2"], + }, + + timezone: "America/New_York", + disable_line_crossing_reset: false, + }) + + SUBS = { + # Meraki desk occupancy (coords and occupancy are floats) + # {ts: unix_time, desks: [[lx, ly, rx, ry, cx, cy, occupancy], [...]]} + "/merakimv/+/net.meraki.detector", + + # lux levels on a camera + # {lux: float} + "/merakimv/+/light", + + # Number of entrances in the camera’s complete field of view + # {ts: unix_time, counts: {person: number, vehicle: number}} + "/merakimv/+/0", + + # meraki entry and exist monitoring + "/merakimv/+/crossing/+", + } + + @keep_alive : Int32 = 60 + @username : String? = nil + @password : String? = nil + @client_id : String = "placeos" + + @mqtt : ::MQTT::V3::Client? = nil + @subs : Array(String) = [] of String + @transport : Place::TransportAdaptor? = nil + @sub_proc : Proc(String, Bytes, Nil) = Proc(String, Bytes, Nil).new { |_key, _payload| nil } + + @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping + + # area name => array of serials + @line_crossing : Hash(String, Array(String)) = {} of String => Array(String) + + # serial => area name + @crossing_lookup : Hash(String, String) = {} of String => String + + def on_load + @sub_proc = Proc(String, Bytes, Nil).new { |key, payload| on_message(key, payload) } + on_update + end + + def on_unload + end + + def on_update + @username = setting?(String, :username) + @password = setting?(String, :password) + @keep_alive = setting?(Int32, :keep_alive) || 60 + @client_id = setting?(String, :client_id) || ::MQTT.generate_client_id("placeos_") + + # zone_id => camera serial + zone_lookup = Hash(String, Array(String)).new { |h, k| h[k] = [] of String } + # camera serial => level + building + floor_lookup = {} of String => FloorMapping + floor_mappings = setting?(Array(FloorMapping), :floor_mappings) || [] of FloorMapping + floor_mappings.each do |mapping| + mapping.camera_serials.each do |serial| + zone_lookup[mapping.level_id] << serial + zone_lookup[mapping.building_id.not_nil!] << serial if mapping.building_id + floor_lookup[serial] = mapping + end + end + self[:floor_lookup] = @floor_lookup = floor_lookup + self[:zone_lookup] = zone_lookup + + existing = @subs + @subs = SUBS.to_a + + @line_crossing = line_crossing_combined = setting?(Hash(String, Array(String)), :line_crossing_combined) || {} of String => Array(String) + line_crossing_mapping = {} of String => String + line_crossing_combined.each do |name, serials| + serials.each { |serial| line_crossing_mapping[serial] = name } + end + @crossing_lookup = line_crossing_mapping + + schedule.clear + schedule.every((@keep_alive // 3).seconds) { ping } + + if !setting?(Bool, :disable_line_crossing_reset) + time_zone = setting?(String, :timezone).presence || "America/New_York" + tz = Time::Location.load(time_zone) + schedule.cron("30 3 * * *", tz) do + crossing_people.each_key { |key| self["camera_mvx-#{key}_person"] = 0 } + crossing_people.clear + crossing_vehicle.each_key { |key| self["camera_mvx-#{key}_vehicle"] = 0 } + crossing_vehicle.clear + end + end + + if client = @mqtt + unsub = existing - @subs + newsub = @subs - existing + + unsub.each do |sub| + logger.debug { "unsubscribing to #{sub}" } + client.unsubscribe(sub) + end + + newsub.each do |sub| + logger.debug { "subscribing to #{sub}" } + client.subscribe(sub, &@sub_proc) + end + end + end + + def connected + transp = Place::TransportAdaptor.new(transport, queue) + client = ::MQTT::V3::Client.new(transp) + @transport = transp + @mqtt = client + + logger.debug { "sending connect message" } + client.connect(@username, @password, @keep_alive, @client_id) + @subs.each do |sub| + logger.debug { "subscribing to #{sub}" } + client.subscribe(sub, &@sub_proc) + end + end + + def disconnected + @transport = nil + @mqtt = nil + end + + def ping + logger.debug { "sending ping" } + @mqtt.not_nil!.ping + end + + def received(data, task) + logger.debug { "received #{data.size} bytes: 0x#{data.hexstring}" } + @transport.try &.process(data) + task.try &.success + end + + getter people_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do + Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key| + hash[key] = {} of String => Tuple(Float64, Int64) + end + end + + getter vehicle_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do + Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key| + hash[key] = {} of String => Tuple(Float64, Int64) + end + end + + # Serial => count + getter crossing_people : Hash(String, Tuple(Int32, Int64)) do + Hash(String, Tuple(Int32, Int64)).new { |hash, key| hash[key] = {0, 0_i64} } + end + + getter crossing_vehicle : Hash(String, Tuple(Int32, Int64)) do + Hash(String, Tuple(Int32, Int64)).new { |hash, key| hash[key] = {0, 0_i64} } + end + + getter lux : Hash(String, Tuple(Float64, Int64)) = {} of String => Tuple(Float64, Int64) + + # this is where we do all of the MQTT message processing + protected def on_message(key : String, playload : Bytes) : Nil + json_message = String.new(playload) + key = key[1..-1] if key.starts_with?("/") + + logger.debug { "new message: #{key} = #{json_message}" } + _merakimv, serial_no, status = key.split("/") + + case status + when "net.meraki.detector" + # we assume version 3 of the API here for sanity reasons + detected_desks = DetectedDesks.from_json(json_message) + self["camera_#{serial_no}_desks"] = detected_desks + self["camera_updated"] = {Time.utc.to_unix, serial_no} + when "light" + light = LuxLevel.from_json(json_message) + lux[serial_no] = {light.lux, light.timestamp} + self["camera_#{serial_no}_lux"] = light.lux + when "crossing" + crossing = Crossing.from_json(json_message) + count_hash = crossing.type.person? ? crossing_people : crossing_vehicle + lookup_name = @crossing_lookup[serial_no]? || serial_no + current_count, _timestamp = count_hash[lookup_name] + case crossing.event + when .crossing_in? + current_count += 1 + when .crossing_out? + current_count -= 1 + end + current_count = 0 if current_count < 0 + count_hash[lookup_name] = {current_count, crossing.timestamp} + self["camera_mvx-#{serial_no}_#{crossing.type.to_s.downcase}"] = current_count + else + # Everything else is a zone count + entry = Entrances.from_json json_message + case entry.count_type + in CountType::People + people_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix} + in CountType::Vehicles + vehicle_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix} + in CountType::Unknown + # ignore + end + self["camera_#{serial_no}_zone#{status}_#{entry.count_type.to_s.downcase}"] = entry.count + end + end + + # ---------------- + # Sensor Interface + # ---------------- + + # return the specified sensor details + def sensor(mac : String, id : String? = nil) : Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + + if id == "lux" + add_lux_values([] of Detail, mac).first? + elsif id.starts_with? "zone" + zone, count_type = id.split('_', 2) + zone = zone[4..-1] # remove the word "zone" + + sensor_type = SensorType::PeopleCount + lookup = case count_type + when "people" + people_counts + when "vehicles" + sensor_type = SensorType::Counter + vehicle_counts + end + + if lookup + if counts = lookup[mac]? + if count = counts[zone]? + to_sensor(sensor_type, mac, "zone#{zone}_#{count_type}", count[0], count[1]) + end + end + end + else + nil + end + end + + NO_MATCH = [] of Interface::Sensor::Detail + LUX_ID = "lux" + + # return an array of sensor details + # zone_id can be ignored if location is unknown by the sensor provider + # mac_address can be used to grab data from a single device (basic grouping) + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + serial_filter = nil + if zone_id && !@floor_lookup.empty? + serial_filter = [] of String + @floor_lookup.each do |serial, floor| + serial_filter << serial if {floor.level_id, floor.building_id}.includes?(zone_id) + end + end + + sensors = [] of Detail + filter = type ? Interface::Sensor::SensorType.parse?(type) : nil + + case filter + when nil + add_lux_values(sensors, mac, serial_filter) + add_people_counts(sensors, mac, serial_filter) + add_vehicle_counts(sensors, mac, serial_filter) + add_people_crossing(sensors, mac, serial_filter) + add_vehicle_crossing(sensors, mac, serial_filter) + when .people_count? + add_people_counts(sensors, mac, serial_filter) + add_people_crossing(sensors, mac, serial_filter) + when .counter? + add_vehicle_counts(sensors, mac, serial_filter) + add_vehicle_crossing(sensors, mac, serial_filter) + when .illuminance? + add_lux_values(sensors, mac, serial_filter) + else + sensors + end + rescue error + logger.warn(exception: error) { "searching for sensors" } + NO_MATCH + end + + protected def add_people_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + people_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, mac, "zone#{zone_name}_people", count, time) } + else + people_counts.each do |serial, zones| + next if serial_filter && !serial_filter.includes?(serial) + zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, serial, "zone#{zone_name}_people", count, time) } + end + end + sensors + end + + protected def add_vehicle_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + vehicle_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, mac, "zone#{zone_name}_vehicles", count, time) } + else + vehicle_counts.each do |serial, zones| + next if serial_filter && !serial_filter.includes?(serial) + zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, serial, "zone#{zone_name}_vehicles", count, time) } + end + end + sensors + end + + protected def add_people_crossing(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors unless mac.starts_with?("mvx-") + mac = mac[4..-1] + + if data = crossing_people[mac]? + count, time = data + sensors << to_sensor(SensorType::PeopleCount, "mvx-#{mac}", "person", count, time) + end + else + crossing_people.each do |mac, (count, time)| + serial = @line_crossing[mac]?.try(&.first?) || mac + next if serial_filter && !serial_filter.includes?(serial) + sensors << to_sensor(SensorType::PeopleCount, "mvx-#{mac}", "person", count, time) + end + end + sensors + end + + protected def add_vehicle_crossing(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors unless mac.starts_with?("mvx-") + mac = mac[4..-1] + + if data = crossing_vehicle[mac]? + count, time = data + sensors << to_sensor(SensorType::Counter, "mvx-#{mac}", "vehicle", count, time) + end + else + crossing_vehicle.each do |mac, (count, time)| + serial = @line_crossing[mac]?.try(&.first?) || mac + next if serial_filter && !serial_filter.includes?(serial) + sensors << to_sensor(SensorType::Counter, "mvx-#{mac}", "vehicle", count, time) + end + end + sensors + end + + protected def add_lux_values(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + if lux_val = lux[mac]? + level, time = lux_val + sensors << to_sensor(SensorType::Illuminance, mac, LUX_ID, level, time) + end + else + lux.each do |serial, (level, time)| + next if serial_filter && !serial_filter.includes?(serial) + sensors << to_sensor(SensorType::Illuminance, serial, LUX_ID, level, time) + end + end + sensors + end + + protected def to_sensor(sensor_type, serial, id, value, timestamp) : Interface::Sensor::Detail + Interface::Sensor::Detail.new( + type: sensor_type, + value: value, + last_seen: timestamp, + mac: serial, + id: id, + name: "Meraki Camera #{serial}: #{id}", + module_id: module_id, + binding: "camera_#{serial}_#{id}", + unit: sensor_type.illuminance? ? "lx" : nil + ) + end +end diff --git a/drivers/cisco/meraki/mqtt_models.cr b/drivers/cisco/meraki/mqtt_models.cr new file mode 100644 index 00000000000..395e322f71f --- /dev/null +++ b/drivers/cisco/meraki/mqtt_models.cr @@ -0,0 +1,98 @@ +require "json" + +# Meraki MQTT Data Models +module Cisco::Meraki + class FloorMapping + include JSON::Serializable + + getter camera_serials : Array(String) + getter level_id : String + getter building_id : String? + end + + class DetectedDesks + include JSON::Serializable + + @[JSON::Field(key: "_v")] + getter api_version : Int32 + + # Time in milliseconds v3, + @[JSON::Field(key: "ts")] + getter time_unix : Int64? + + @[JSON::Field(key: "time")] + getter time_string : String? + + getter desks : Array(Tuple(Float64, Float64, # left +Float64, Float64, # center +Float64, Float64, # right +Float64 # occupancy +)) + end + + class LuxLevel + include JSON::Serializable + + # Not actually provided for this message, but here for testing + @[JSON::Field(key: "ts")] + getter timestamp : Int64 { Time.utc.to_unix } + + getter lux : Float64 + end + + enum CountType + People + Vehicles + Unknown + end + + class Entrances + include JSON::Serializable + + @[JSON::Field(key: "ts")] + getter timestamp : Int64 + + getter counts : NamedTuple( + person: Int32?, + vehicle: Int32?, + ) + + @[JSON::Field(ignore: true)] + getter count_type : CountType do + if counts[:person] + CountType::People + elsif counts[:vehicle] + CountType::Vehicles + else + CountType::Unknown + end + end + + @[JSON::Field(ignore: true)] + getter count : Int32 { counts[:person] || counts[:vehicle] || 0 } + end + + enum CrossingObject + Person + Vehicle + Unknown + end + + enum CrossingEvent + CrossingIn + CrossingOut + Expired + Appeared + end + + struct Crossing + include JSON::Serializable + + @[JSON::Field(key: "ts")] + getter timestamp : Int64 + # getter object_id : Int64 + getter label : String? + getter event : CrossingEvent + getter type : CrossingObject + end +end diff --git a/drivers/cisco/meraki/mqtt_spec.cr b/drivers/cisco/meraki/mqtt_spec.cr new file mode 100644 index 00000000000..0f8bd3d439a --- /dev/null +++ b/drivers/cisco/meraki/mqtt_spec.cr @@ -0,0 +1,179 @@ +require "placeos-driver/spec" +require "mqtt" + +DriverSpecs.mock_driver "Place::MQTT" do + # ============================ + # CONNECTION + # ============================ + puts "===== CONNECTION NEGOTIATION =====" + connect = MQTT::V3::Connect.new + connect.id = MQTT::RequestType::Connect + connect.keep_alive_seconds = 60_u16 + connect.client_id = "placeos" + connect.clean_start = true + connect.username = "user" + connect.password = "pass" + connect.packet_length = connect.calculate_length + should_send(connect.to_slice) + + connack = MQTT::V3::Connack.new + connack.id = MQTT::RequestType::Connack + connack.packet_length = connack.calculate_length + responds(connack.to_slice) + + # ============================ + # SUBSCRIPTIONS + # ============================ + puts "===== CHECKING DESKS SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 2_u16 + packet.topic = "/merakimv/+/net.meraki.detector" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 2_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + puts "===== CHECKING LUX SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 4_u16 + packet.topic = "/merakimv/+/light" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 4_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + puts "===== CHECKING COUNTS SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 6_u16 + packet.topic = "/merakimv/+/0" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 6_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + # ============================ + # REMOTE PUBLISH + # ============================ + puts "===== REMOTE PUBLISH =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/1234/light" + publish.payload = %({"lux":33.2,"ts":1642564552}) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_1234_lux"].should eq(33.2) + + # ============================ + # CHECK SENSOR INTERFACE + # ============================ + lux_sensor = { + "status" => "normal", + "type" => "illuminance", + "value" => 33.2, + "last_seen" => 1642564552, + "mac" => "1234", + "id" => "lux", + "name" => "Meraki Camera 1234: lux", + "module_id" => "spec_runner", + "binding" => "camera_1234_lux", + "unit" => "lx", + "location" => "sensor", + } + exec(:sensors).get.should eq([lux_sensor]) + exec(:sensor, "1234", "lux").get.should eq(lux_sensor) + + # ============================ + # CHECK LOCATABLE INTERFACE + # ============================ + puts "===== CHECKING LOCATABLE INTERFACE =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/camera_serial/net.meraki.detector" + publish.payload = %({ + "_v": 2, + "time": "2022-01-20 02:14:00", + "coords":[], + "desks": [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0] + ] + }) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_1234_lux"].should eq(33.2) + status["camera_camera_serial_desks"].should eq({ + "_v" => 2, + "time" => "2022-01-20 02:14:00", + "desks" => [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0], + ], + }) + + # ============================ + # Check Line Crossing + # ============================ + puts "===== REMOTE PUBLISH =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/56789/crossing/uuid" + publish.payload = %({"label":"testing","event":"crossing_in","type":"person","ts":1642564558,"object_id":2}) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_mvx-56789_person"].should eq(1) + + exec(:sensors, "people_count", "mvx-56789").get.should eq([ + { + "status" => "normal", + "type" => "people_count", + "value" => 1.0, + "last_seen" => 1642564558, + "mac" => "mvx-56789", + "id" => "person", + "name" => "Meraki Camera mvx-56789: person", + "module_id" => "spec_runner", + "binding" => "camera_mvx-56789_person", + "location" => "sensor", + }, + ]) +end diff --git a/drivers/cisco/meraki/scanning_api.cr b/drivers/cisco/meraki/scanning_api.cr new file mode 100644 index 00000000000..63d8ae7a2c3 --- /dev/null +++ b/drivers/cisco/meraki/scanning_api.cr @@ -0,0 +1,433 @@ +require "json" +require "./geo" + +module Cisco::Meraki + ISO8601 = "%FT%T%z" + + class Organization + include JSON::Serializable + + property id : String + property name : String + property url : String + property api : NamedTuple(enabled: Bool) + end + + class Network + include JSON::Serializable + + property id : String + + @[JSON::Field(key: "organizationId")] + property organization_id : String + + property name : String + + @[JSON::Field(key: "productTypes")] + property product_types : Array(String) + + @[JSON::Field(key: "timeZone")] + property time_zone : String + property tags : Array(String) + property url : String + + @[JSON::Field(key: "enrollmentString")] + property enrollment_string : String? + property notes : String? + end + + class CameraAnalytics + include JSON::Serializable + ISO8601_MS = "%FT%T.%3N%z" + + class PeopleCount + include JSON::Serializable + + property person : Int32 + end + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::CameraAnalytics::ISO8601_MS))] + property ts : Time + property zones : Hash(Int64, PeopleCount) + end + + class FloorPlan + include JSON::Serializable + + @[JSON::Field(key: "floorPlanId")] + property id : String + property width : Float64 + property height : Float64 + + @[JSON::Field(key: "topLeftCorner")] + property top_left : Geo::Point + + @[JSON::Field(key: "bottomLeftCorner")] + property bottom_left : Geo::Point + + @[JSON::Field(key: "bottomRightCorner")] + property bottom_right : Geo::Point + + # This is useful for when we have to map meraki IDs to our zones + property name : String? + + def to_distance + Geo::Distance.new(width, height) + end + end + + class FloorPlanLocation + include JSON::Serializable + + property id : String + property name : String + property x : Float64 + property y : Float64 + end + + class NetworkDevice + include JSON::Serializable + + # Used for caching the location calculated for this device + # where an observation doesn't have location values but has a closest WAP + @[JSON::Field(ignore: true)] + property location : DeviceLocation? + + @[JSON::Field(key: "floorPlanId")] + property floor_plan_id : String? + + property lat : Float64 + property lng : Float64 + property mac : String + + property serial : String + property model : String + property firmware : String + + # This is useful for when we have to map meraki IDs to our zones + property name : String? + end + + class Client + include JSON::Serializable + + property id : String + property mac : String + property description : String? + + property ip : String? + property ip6 : String? + + @[JSON::Field(key: "ip6Local")] + property ip6_local : String? + + property user : String? + + # 2020-09-29T07:53:08Z + @[JSON::Field(key: "firstSeen")] + property first_seen : String + + @[JSON::Field(key: "lastSeen")] + property last_seen : Time + + property manufacturer : String? + property os : String? + + @[JSON::Field(key: "recentDeviceSerial")] + property recent_device_serial : String? + + @[JSON::Field(key: "recentDeviceMac")] + property recent_device_mac : String? + property ssid : String? + property vlan : String? + property switchport : String? + property status : String + property notes : String? + + @[JSON::Field(ignore: true)] + property! time_added : Time + + @[JSON::Field(ignore: true)] + getter switch_port : Int32 { @switchport.as(String).to_i } + end + + class RSSI + include JSON::Serializable + + @[JSON::Field(key: "apMac")] + property access_point_mac : String + property rssi : Int32 + end + + class DeviceLocation + include JSON::Serializable + + def initialize(@x, @y, @lng, @lat, @variance, floor_plan_id, floor_plan_name, @time) + @wifi_floor_plan_name = floor_plan_name + @wifi_floor_plan_id = floor_plan_id + @mac = nil + @client = nil + @rssi_records = [] of RSSI + end + + def self.calculate_location(floor : FloorPlan, device : NetworkDevice, time : Time) : DeviceLocation + distance = Geo.calculate_xy(floor.top_left, floor.bottom_left, floor.bottom_right, device, floor.to_distance) + DeviceLocation.new(distance.x, distance.y, device.lng, device.lat, 25_f64, floor.id, floor.name, time) + end + + # NOTE:: This is not part of the location response, + # it is here to simplify processing + @[JSON::Field(ignore: true)] + property mac : String? + + # NOTE:: this is not part of the location response, + # it is here to speed up processing + @[JSON::Field(ignore: true)] + property client : Client? = nil + + # Multiple types as the location when parsed might include javascript `"NaN"` + property x : Float64 | String | Nil + property y : Float64 | String | Nil + property lng : Float64? + property lat : Float64? + property variance : Float64 + + @[JSON::Field(key: "floorPlanId")] + property wifi_floor_plan_id : String? + + @[JSON::Field(key: "floorPlanName")] + property wifi_floor_plan_name : String? + + @[JSON::Field(key: "floorPlan")] + property floor_plan : FloorPlanLocation? + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))] + property time : Time + + @[JSON::Field(key: "nearestApTags")] + property nearest_ap_tags : Array(String) { [] of String } + + @[JSON::Field(key: "rssiRecords")] + property rssi_records : Array(RSSI) + + def x! + get_x.not_nil! + end + + def y! + get_y.not_nil! + end + + def get_x : Float64? + if tmp = x || floor_plan.try(&.x) + if tmp.is_a?(Float64) + tmp + end + end + end + + def get_y : Float64? + if tmp = y || floor_plan.try(&.y) + if tmp.is_a?(Float64) + tmp + end + end + end + + def floor_plan_id + wifi_floor_plan_id || floor_plan.try(&.id) + end + + def floor_plan_name + wifi_floor_plan_name || floor_plan.try(&.name) + end + end + + class LatestRecord + include JSON::Serializable + + @[JSON::Field(key: "nearestApMac")] + property nearest_ap_mac : String + + @[JSON::Field(key: "nearestApRssi")] + property nearest_ap_rssi : Int32 + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))] + property time : Time + end + + class Observation + include JSON::Serializable + + @[JSON::Field(key: "clientMac")] + property client_mac : String + + property manufacturer : String? + property ipv4 : String? + property ipv6 : String? + property ssid : String? + property os : String? + + @[JSON::Field(key: "latestRecord")] + property latest_record : LatestRecord + property locations : Array(DeviceLocation) + end + + class Data + include JSON::Serializable + + @[JSON::Field(key: "networkId")] + property network_id : String + property observations : Array(Observation) + end + + enum MessageType + None + WiFi + Bluetooth + end + + class DevicesSeen + include JSON::Serializable + + property version : String + property secret : String + + @[JSON::Field(key: "type")] + property message_type : MessageType + + property data : Data + end + + struct CameraZone + include JSON::Serializable + + struct Region + include JSON::Serializable + + getter x0 : String + getter y0 : String + getter x1 : String + getter y1 : String + end + + @[JSON::Field(key: "zoneId")] + getter id : String + getter type : String + getter label : String + + @[JSON::Field(key: "regionOfInterest")] + getter region : Region + + @[JSON::Field(ignore: true)] + property distance : Float64 = 0.0 + + def mid_point + mid_x = (region.x0.to_f64 + region.x1.to_f64) / 2.0 + mid_y = (region.y0.to_f64 + region.y1.to_f64) / 2.0 + {mid_x, mid_y} + end + + getter x : Float64 do + xpos, @y = mid_point + xpos + end + + getter y : Float64 do + @x, ypos = mid_point + ypos + end + end + + struct DeskMappings + include JSON::Serializable + + getter serial : String + getter level_id : String + + # port_id => desk_id + getter ports : Hash(Int32, String) + end + + struct ZoneDetails + include JSON::Serializable + + property id : String + property name : String + property tags : Array(String) + end + + enum ConnectionType + Wired + Wireless + end + + enum AlertType + PortConnected + PortDisconnected + end + + struct WebhookAlert + include JSON::Serializable + + struct PortData + include JSON::Serializable + + @[JSON::Field(key: "portNum")] + getter port_num : Int32 + end + + @[JSON::Field(key: "networkId")] + getter network_id : String + + @[JSON::Field(key: "alertTypeId")] + getter alert_type : AlertType + + @[JSON::Field(key: "alertData")] + getter alert_data : PortData + + @[JSON::Field(key: "deviceSerial")] + getter device_serial : String + + @[JSON::Field(key: "sharedSecret")] + getter shared_secret : String + + def port_num : Int32 + alert_data.port_num + end + end + + enum PortState + Connected + Disconnected + Disabled + end + + class PortStatusResponse + include JSON::Serializable + + @[JSON::Field(key: "portId")] + getter port_id : String + + @[JSON::Field(ignore: true)] + getter port : Int32 { port_id.to_i } + + getter? enabled : Bool + getter status : PortState + + @[JSON::Field(key: "isUplink")] + getter? is_uplink : Bool + + @[JSON::Field(ignore: true)] + property! switch_serial : String + + @[JSON::Field(ignore: true)] + property mac : String? = nil + + @[JSON::Field(ignore: true)] + property! desk_id : String + + @[JSON::Field(ignore: true)] + property! level_id : String + end +end diff --git a/drivers/cisco/room_kit.cr b/drivers/cisco/room_kit.cr new file mode 100644 index 00000000000..e4b574c9166 --- /dev/null +++ b/drivers/cisco/room_kit.cr @@ -0,0 +1,385 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "promise" +require "uuid" + +require "./collaboration_endpoint" +require "./collaboration_endpoint/ui_extensions" +require "./collaboration_endpoint/presentation" +require "./collaboration_endpoint/powerable" +require "./collaboration_endpoint/cameras" + +class Cisco::RoomKit < PlaceOS::Driver + include Interface::Sensor + + # Discovery Information + descriptive_name "Cisco Room Kit" + generic_name :VidConf + tcp_port 22 + + description <<-DESC + Control of Cisco SX20 devices + + API access requires a local user with the "admin" role to be + created on the codec + DESC + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + peripheral_id: "uuid", + configuration: { + "RoomAnalytics" => { + "PeopleCountOutOfCall" => "On", + "PeoplePresenceDetector" => "On", + "WakeupOnMotionDetection" => "On", + }, + }, + presets: { + "Front Lecturn": 1, + }, + }) + + include Cisco::CollaborationEndpoint + include Cisco::CollaborationEndpoint::UIExtensions + include Cisco::CollaborationEndpoint::Presentation + include Cisco::CollaborationEndpoint::Powerable + include Cisco::CollaborationEndpoint::Cameras + + enum PresentationMode + None + Local + Remote + end + + @presentation_mode : PresentationMode = PresentationMode::None + @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new + + def connected + super + schedule.in(40.seconds) { disconnect if self["calls"]?.nil? } + end + + protected def connection_ready + subscriptions.clear + subscribe("presentation") do |_sub, state| + if state != "null" + # presentation is typically false or "Sending" + if state == "false" + self[:presentation_mode] = @presentation_mode + else + self[:presentation_mode] = PresentationMode::Remote + end + end + end + + register_feedback "/Event/PresentationPreviewStarted" do + self[:presentation_mode] = PresentationMode::Local + end + register_feedback "/Event/PresentationPreviewStopped" do + @presentation_mode = PresentationMode::None + self[:presentation_mode] = @presentation_mode if self[:presentation]? == false + end + + @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key| + hash[key] = {} of String => Enumerable::JSONComplex + end + self[:calls] = @calls + register_feedback "/Status/Call" do |value_path, value| + if value.is_a? Hash(String, Enumerable::JSONComplex) + if value["Status"]? == "Idle" || value["ghost"]? == "True" + @calls.delete value_path + else + @calls[value_path].merge! value + end + self[:calls] = @calls + else + logger.debug { "unexpected call status value #{value}" } + end + end + end + + map_status mic_mute: "Audio Microphones Mute" + map_status volume: "Audio Volume" + map_status speaker_track: "Cameras SpeakerTrack" + map_status presence_detected: "RoomAnalytics PeoplePresence" + map_status people_count: "RoomAnalytics PeopleCount Current" + map_status do_not_disturb: "Conference DoNotDisturb" + map_status presentation: "Conference Presentation Mode" + map_status peripherals: "Peripherals ConnectedDevice" + # selfview == camera pip + map_status selfview: "Video Selfview Mode" + map_status selfview_fullscreen: "Video Selfview FullScreenMode" + map_status video_input: "Video Input" + map_status video_output: "Video Output" + map_status video_layout: "Video Layout LayoutFamily Local" + map_status standby: "Standby State" + + command({"Audio Microphones Mute" => :mic_mute_on}) + command({"Audio Microphones Unmute" => :mic_mute_off}) + command({"Audio Microphones ToggleMute" => :mic_mute_toggle}) + + def mic_mute(state : Bool = true) + state ? mic_mute_on : mic_mute_off + end + + enum Toogle + On + Off + end + + enum Sound + Alert + Bump + Busy + CallDisconnect + CallInitiate + CallWaiting + Dial + KeyInput + KeyInputDelete + KeyTone + Nav + NavBack + Notification + OK + PresentationConnect + Ringing + SignIn + SpecialInfo + TelephoneCall + VideoCall + VolumeAdjust + WakeUp + end + + command({"Audio Sound Play" => :play_sound}, + sound: Sound, + loop_: Toogle) + command({"Audio Sound Stop" => :stop_sound}) + + command({"Bookings List" => :bookings}, + days_: 1..365, + day_offset_: 0..365, + limit_: Int32, + offset_: Int32) + + command({"Call Accept" => :call_accept}, call_id_: Int32) + command({"Call Reject" => :call_reject}, call_id_: Int32) + command({"Call Disconnect" => :hangup}, call_id_: Int32) + command({"Call Hold" => :call_place_on_hold}, call_id_: Int32) + command({"Call Resume" => :call_resume}, call_id_: Int32) + + command({"Call DTMFSend" => :dtmf_send}, + d_t_m_f_string: String, + call_id_: 0..65534) + + enum DialProtocol + H320 + H323 + Sip + Spark + end + + enum CallType + Audio + Video + end + + command({"Dial" => :dial}, + number: String, + protocol_: DialProtocol, + call_rate_: 64..6000, + call_type_: CallType) + + enum VideoLayout + Equal + PIP + end + + command({"Video Input SetMainVideoSource" => :camera_select}, + connector_id_: 1..3, # Source can either be specified as the + layout_: VideoLayout, # physical connector... + source_id_: 1..3) # ...or the logical source ID + + enum LayoutFamily + Auto + Equal + Overlay + Prominent + Single + end + + enum LayoutTarget + Local + Remote + end + + command({"Video Layout LayoutFamily Set" => :video_layout}, + layout_family: LayoutFamily, + target_: LayoutTarget) + + enum PiPPosition + CenterLeft + CenterRight + LowerLeft + LowerRight + UpperCenter + UpperLeft + UpperRight + end + + enum MonitorRole + First + Second + Third + Fourth + end + + command({"Video Selfview Set" => :selfview}, + mode_: Toogle, + full_screen_mode_: Toogle, + p_i_p_position_: PiPPosition, + on_monitor_role_: MonitorRole) + + @[Security(Level::Support)] + command({"Cameras AutoFocus Diagnostics Start" => :autofocus_diagnostics_start}, + camera_id: 1..1) + + @[Security(Level::Support)] + command({"Cameras AutoFocus Diagnostics Stop" => :autofocus_diagnostics_stop}, + camera_id: 1..1) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Diagnostics Start" => :speaker_track_diagnostics_start}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Diagnostics Stop" => :speaker_track_diagnostics_stop}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Activate" => :speaker_track_activate}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Deactivate" => :speaker_track_deactivate}) + + def speaker_track(state : Bool = true) + state ? speaker_track_activate : speaker_track_deactivate + end + + enum PhonebookType + Corporate + Local + end + + command({"Phonebook Search" => :phonebook_search}, + search_string: String, + phonebook_type_: PhonebookType, + limit_: Int32, + offset_: Int32) + + command({"UserInterface WebView Display" => :webview_display}, + url: String) + + command({"UserInterface WebView Clear" => :webview_clear}) + + @[Security(Level::Support)] + command({"SystemUnit Boot" => :reboot}, action_: PowerOff) + + # Helper methods + # ============== + + def show_camera_pip(visible : Bool) + mode = visible ? Toogle::On : Toogle::Off + selfview mode: mode + end + + def mic_mute(state : Bool = true) + state ? mic_mute_on : mic_mute_off + end + + def presentation_mode(value : PresentationMode) + case value + in .remote? + presentation_start sending_mode: :LocalRemote + in .local? + @presentation_mode = PresentationMode::Local + presentation_start sending_mode: :LocalOnly + in .none? + @presentation_mode = PresentationMode::None + presentation_stop + end + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if mac && mac != config.ip + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + + if sensor_type + sensor = build_sensor_details(sensor_type) + return NO_MATCH unless sensor + [sensor] + else + space_sensors + end + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + return nil unless mac == config.ip + + case id + when "people" + build_sensor_details(:people_count) + when "presence" + build_sensor_details(:presence) + end + end + + protected def build_sensor_details(sensor : SensorType) : Detail? + id = "people_count" + + value = case sensor + when .people_count? + self[:people_count].as_i.to_f64 + when .presence? + id = "presence_detected" + self[:presence_detected] == "No" ? 0.0 : 1.0 + else + raise "sensor type unavailable: #{sensor}" + end + return nil unless value + + Detail.new( + type: sensor, + value: value, + last_seen: Time.utc.to_unix, + mac: config.ip.as(String), + id: id, + name: "Cisco Room Kit (#{config.ip})", + module_id: module_id, + binding: id + ) + end + + protected def space_sensors + [ + build_sensor_details(:people_count), + build_sensor_details(:presence), + ].compact + end +end diff --git a/drivers/cisco/room_os.cr b/drivers/cisco/room_os.cr new file mode 100644 index 00000000000..ec3af9fa146 --- /dev/null +++ b/drivers/cisco/room_os.cr @@ -0,0 +1,42 @@ +require "placeos-driver" +require "promise" +require "uuid" + +require "./collaboration_endpoint" +require "./collaboration_endpoint/ui_extensions" + +class Cisco::RoomOS < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Room OS" + generic_name :RoomOS + tcp_port 22 + + description <<-DESC + Low level driver for any Cisco Room OS device. This may be used + if direct access is required to the device API, or a required feature + is not provided by the device specific implementation. + + Where possible use the implementation for room device in use + i.e. SX80, Room Kit etc. + DESC + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + peripheral_id: "uuid", + configuration: { + "Audio Microphones Mute" => {"Enabled" => "False"}, + "Audio Input Line 1 VideoAssociation" => { + "MuteOnInactiveVideo" => "On", + "VideoInputSource" => 2, + }, + }, + }) + + include Cisco::CollaborationEndpoint + include Cisco::CollaborationEndpoint::UIExtensions + + map_status volume: "Audio Volume" +end diff --git a/drivers/cisco/room_os_spec.cr b/drivers/cisco/room_os_spec.cr new file mode 100644 index 00000000000..9e15843da94 --- /dev/null +++ b/drivers/cisco/room_os_spec.cr @@ -0,0 +1,608 @@ +require "placeos-driver/spec" +require "./collaboration_endpoint/xapi" + +DriverSpecs.mock_driver "Cisco::RoomOS" do + # Test command generation helpers + action = Cisco::CollaborationEndpoint::XAPI.xcommand( + "Camera PositionSet", + camera_id: 1, + lens: "Wide", + optional: nil + ) + action.should eq(%(xCommand Camera PositionSet CameraId: 1 Lens: "Wide")) + + action = Cisco::CollaborationEndpoint::XAPI.xcommand( + "Audio Volume Decrease" + ) + action.should eq(%(xCommand Audio Volume Decrease)) + + # Test the response processing helpers + response = JSON.parse(%({ + "Configuration":{ + "Audio":{ + "DefaultVolume":{ + "valueSpaceRef":"/Valuespace/INT_0_100", + "Value":"50" + }, + "Input":{ + "Line":[ + { + "id":"1", + "VideoAssociation":{ + "MuteOnInactiveVideo":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "VideoInputSource":{ + "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2", + "Value":"2" + } + } + } + ], + "Microphone":[ + { + "id":"AAA", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + { + "id":"2", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + } + ] + }, + "Microphones":{ + "Mute":{ + "Enabled":{ + "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled", + "Value":"True" + } + } + } + } + } + })).as_h.flatten_xapi_json + response.should eq({ + "Configuration/Audio/DefaultVolume" => 50, + "Configuration/Audio/Input/Line/1" => { + "VideoAssociation/MuteOnInactiveVideo" => true, + "VideoAssociation/VideoInputSource" => 2, + }, + "Configuration/Audio/Input/Microphone/AAA" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "Configuration/Audio/Input/Microphone/2" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "Configuration/Audio/Microphones/Mute/Enabled" => true, + }) + + transmit "welcome\n*r Login successful\r\n" + + # ==== + # Connection setup + puts "\nCONNECTION SETUP:\n==============" + should_send "xPreferences OutputMode JSON\n" + should_send "xPreferences OutputMode JSON\n" + + # ==== + # System registration + puts "\nSYSTEM REGISTRATION:\n==============" + + data = String.new expect_send + data.starts_with?(%(xCommand Peripherals Connect ID: "uuid" Name: "PlaceOS" Type: ControlSystem | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "PeripheralsConnectResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + + # ==== + # Config push + puts "\nCONFIG PUSH:\n==============" + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Microphones Mute Enabled: "False" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation MuteOnInactiveVideo: "On" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation VideoInputSource: 2 | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + # MAPS Status ==== + data = String.new expect_send + data.starts_with?(%(xFeedback Register /Configuration | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + should_send "xConfiguration *\n" + responds %({ + "Configuration":{ + "Audio":{ + "DefaultVolume":{ + "valueSpaceRef":"/Valuespace/INT_0_100", + "Value":"50" + }, + "Input":{ + "Line":[ + { + "id":"1", + "VideoAssociation":{ + "MuteOnInactiveVideo":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "VideoInputSource":{ + "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2", + "Value":"2" + } + } + } + ], + "Microphone":[ + { + "id":"1", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + { + "id":"2", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + } + ] + }, + "Microphones":{ + "Mute":{ + "Enabled":{ + "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled", + "Value":"True" + } + } + } + } + } + }) + + status[:configuration].should eq({ + "/Audio/DefaultVolume" => 50, + "/Audio/Input/Line/1" => { + "VideoAssociation/MuteOnInactiveVideo" => true, + "VideoAssociation/VideoInputSource" => 2, + }, + "/Audio/Input/Microphone/1" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "/Audio/Input/Microphone/2" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "/Audio/Microphones/Mute/Enabled" => true, + }) + + data = String.new expect_send + puts "GOT: #{data}" + data.starts_with?(%(xFeedback Register /Status/Audio/Volume | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + puts "GOT: #{data}" + data.starts_with?(%(xStatus Audio Volume | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "Audio":{ + "Volume":{ + "Value":"50" + } + } + }, + "ResultId": "#{id}" + }) + + # Finish mapping status + status[:volume].should eq(50) + + # ==== + # Audio Status + resp = exec(:xstatus, "Audio") + data = String.new expect_send + data.starts_with?(%(xStatus Audio | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "Status":{ + "Audio":{ + "Input":{ + "Connectors":{ + "Microphone":[ + { + "id":"1", + "ConnectionStatus":{ + "Value":"Connected" + } + }, + { + "id":"2", + "ConnectionStatus":{ + "Value":"NotConnected" + } + } + ] + } + }, + "Microphones":{ + "Mute":{ + "Value":"On" + } + }, + "Output":{ + "Connectors":{ + "Line":[ + { + "id":"1", + "DelayMs":{ + "Value":"0" + } + } + ] + } + }, + "Volume":{ + "Value":"50" + } + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq({ + "Status/Audio/Input/Connectors/Microphone/1" => { + "ConnectionStatus" => "Connected", + }, + "Status/Audio/Input/Connectors/Microphone/2" => { + "ConnectionStatus" => "NotConnected", + }, + "Status/Audio/Microphones/Mute" => true, + "Status/Audio/Output/Connectors/Line/1" => { + "DelayMs" => 0, + }, + "Status/Audio/Volume" => 50, + }) + + # ==== + # Time Status + resp = exec(:xstatus, "Time") + data = String.new expect_send + data.starts_with?(%(xStatus Time | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "Time":{ + "SystemTime":{ + "Value":"2017-11-27T15:14:25+1000" + } + } + }, + "ResultId": "#{id}" + }) + + resp.get.should eq({ + "Status/Time/SystemTime" => "2017-11-27T15:14:25+1000", + }) + + # ==== + # Time Status fail + resp = exec(:xstatus, "Wrong") + data = String.new expect_send + data.starts_with?(%(xStatus Wrong | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "status":"Error", + "Reason":{ + "Value":"No match on address expression." + }, + "XPath":{ + "Value":"Status/Wrong" + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Basic command + resp = exec(:xcommand, "Standby Deactivate") + data = String.new expect_send + data.starts_with?(%(xCommand Standby Deactivate | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "StandbyDeactivateResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq "OK" + + # Command with arguments + resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, Layout: :PIP}) + data = String.new expect_send + data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 Layout: "PIP" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq "OK" + + # Return device argument errors + resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, SourceId: 1}) + data = String.new expect_send + data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 SourceId: 1 | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"Error", + "Reason":{ + "Value":"Must supply either SourceId or ConnectorId (but not both.)" + } + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Return error from invalid / inaccessable xCommands + resp = exec(:xcommand, "Not A Real Command") + data = String.new expect_send + data.starts_with?(%(xCommand Not A Real Command | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "Result":{ + "status":"Error", + "Reason":{ + "Value":"Unknown command" + } + }, + "XPath":{ + "Value":"/Not/A/Real/Command" + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Multiline commands + resp = exec(:xcommand, "SystemUnit SignInBanner Set", "Hello\nWorld!") + data = String.new expect_send + data.starts_with?(%(xCommand SystemUnit SignInBanner Set | resultId=")).should be_true + data.ends_with?(%(Hello\nWorld!\n.\n)).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "SignInBannerSetResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + + resp.get.should eq "OK" + + # Multuple settings return a unit :success when all ok + resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Name: "Borris", Quality: :Motion}) + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Name: "Borris" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + resp.get.should eq true + + # Multiple settings with failure with return a command failure + resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Foo: "Bar", Quality: :Motion}) + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Foo: "Bar" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "CommandResponse":{ + "Configuration":{ + "status":"Error", + "Reason":{ + "Value":"No match on address expression." + }, + "XPath":{ + "Value":"Configuration/Video/Input/Connector[1]/Foo" + } + } + }, + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Out of order send + responds %({ + "Status":{ + "Audio":{ + "Volume":{ + "Value":"52" + } + } + } + }) + + # Finish mapping status + status[:volume].should eq(52) +end diff --git a/drivers/cisco/spaces_room.cr b/drivers/cisco/spaces_room.cr new file mode 100644 index 00000000000..8f5fc49873b --- /dev/null +++ b/drivers/cisco/spaces_room.cr @@ -0,0 +1,59 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" + +class Cisco::SpacesRoom < PlaceOS::Driver + include Interface::Sensor + + descriptive_name "Cisco Spaces Room Sensors" + generic_name :SpacesRoomSensors + description "exposes sensor information to the room" + + default_settings({ + _cisco_spaces_system: "sys-12345", + _cisco_spaces_module: "Cisco_Spaces", + + space_room_id: "a410b6d676", + }) + + getter system_id : String = "" + getter module_name : String = "" + getter room_id : String = "" + + def on_update + @system_id = setting?(String, :cisco_spaces_system).presence || config.control_system.not_nil!.id + @module_name = setting?(String, :cisco_spaces_module).presence || "Cisco_Spaces" + @room_id = setting(String, :space_room_id) + end + + private def cisco_spaces + system(system_id)[module_name] + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence, SensorType::Humidity, SensorType::Temperature, SensorType::AirQuality, SensorType::SoundPressure} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if mac && mac != @room_id + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + return NO_MATCH if zone_id && !system.zones.includes?(zone_id) + + Array(Interface::Sensor::Detail).from_json cisco_spaces.sensors(type, @room_id, zone_id).get.to_json + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + return nil unless mac == @room_id + + Interface::Sensor::Detail?.from_json(cisco_spaces.sensors(@room_id, id).get.to_json) + end +end diff --git a/drivers/cisco/spaces_room_spec.cr b/drivers/cisco/spaces_room_spec.cr new file mode 100644 index 00000000000..88118d8df83 --- /dev/null +++ b/drivers/cisco/spaces_room_spec.cr @@ -0,0 +1,4 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::SpacesRoom" do +end diff --git a/drivers/cisco/switch/snooping_catalyst.cr b/drivers/cisco/switch/snooping_catalyst.cr new file mode 100644 index 00000000000..9680d0b5ece --- /dev/null +++ b/drivers/cisco/switch/snooping_catalyst.cr @@ -0,0 +1,264 @@ +require "placeos-driver" +require "set" + +class Cisco::Switch::SnoopingCatalyst < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Catalyst Switch IP Snooping" + generic_name :Snooping + tcp_port 22 + + # Communication settings + # tokenize delimiter: /\n|-- / + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + building: "building_code", + ignore_macs: { + "Cisco Phone Dock" => "7001b5", + }, + }) + + # Interfaces that indicate they have a device connected + @check_interface = ::Set(String).new + + # MAC, IP, Interface + @snooping = [] of Tuple(String, String, String) + + # interface to MAC address mappings + @interface_macs = {} of String => String + @devices = {} of String => NamedTuple(mac: String, ip: String) + + @hostname : String? = nil + @switch_name : String? = nil + @ignore_macs = ::Set(String).new + + def on_load + # "--More--" is sent without a newline + transport.tokenizer = Tokenizer.new("\n", "--More--") + + on_update + end + + def on_update + @ignore_macs = ::Set.new((setting?(Hash(String, String), :ignore_macs) || {} of String => String).values) + + self[:name] = @switch_name = setting?(String, :switch_name) + self[:ip_address] = config.ip.not_nil!.downcase + self[:building] = setting?(String, :building) + self[:level] = setting?(String, :level) + self[:last_successful_query] ||= 0 + end + + def connected + schedule.in(1.second) { query_connected_devices } + schedule.every(1.minute) { query_connected_devices } + end + + def disconnected + schedule.clear + queue.clear + end + + # Don't want the every day user using this method + @[Security(Level::Administrator)] + def run(command : String) + do_send command + end + + def query_interface_status + do_send "show interfaces status" + end + + def query_mac_addresses + @interface_macs.clear + do_send "show mac address-table" + end + + def query_snooping_bindings + @snooping.clear + do_send "show ip dhcp snooping binding" + end + + @querying_devices : Bool = false + + def query_connected_devices + return if @querying_devices + @querying_devices = true + + logger.debug { "Querying for connected devices" } + + query_interface_status.get + sleep 3.seconds + + query_mac_addresses.get + sleep 3.seconds + + query_snooping_bindings.get + sleep 2.seconds + + nil + ensure + @querying_devices = false + end + + def received(data, task) + data = String.new(data) + logger.debug { "Switch sent: #{data}" } + + # determine the hostname + if @hostname.nil? + parts = data.split(">") + if parts.size == 2 + self[:hostname] = @hostname = parts[0] + + # Exit early as this line is not a response + return task.try &.success + end + end + + case data + when /More/ + # Detect more data available + # ==> --More-- + send(" ", priority: 99, retries: 0) + return task.try &.success + when /STATIC|DYNAMIC/ + # Interface MAC Address detection + # 33 e4b9.7aa5.aa7f STATIC Gi3/0/8 + # 10 f4db.e618.10a4 DYNAMIC Te2/0/40 + parts = data.split(/\s+/).reject(&.empty?) + mac = format(parts[1]) + interface = normalise(parts[-1]) + + @interface_macs[interface] = mac if mac && interface + + return :success + when /%LINK/ + # Interface change detection + # 07-Aug-2014 17:28:26 %LINK-I-Up: gi2 + # 07-Aug-2014 17:28:31 %STP-W-PORTSTATUS: gi2: STP status Forwarding + # 07-Aug-2014 17:44:43 %LINK-I-Up: gi2, aggregated (1) + # 07-Aug-2014 17:44:47 %STP-W-PORTSTATUS: gi2: STP status Forwarding, aggregated (1) + # 07-Aug-2014 17:45:24 %LINK-W-Down: gi2, aggregated (2) + interface = normalise(data.split(",")[0].split(/\s/)[-1]) + + if data =~ /Up:/ + logger.debug { "Notify Up: #{interface}" } + @check_interface << interface + + # Delay here is to give the PC some time to negotiate an IP address + # schedule.in(3000) { query_snooping_bindings } + elsif data =~ /Down:/ + logger.debug { "Notify Down: #{interface}" } + # We are no longer interested in this interface + @check_interface.delete(interface) + end + + self[:interfaces] = @check_interface + + return task.try &.success + when .starts_with?("Total number") + logger.debug { "Processing #{@snooping.size} bindings" } + checked = Set(String).new + devices = {} of String => NamedTuple(mac: String, ip: String) + state_changed = false + + @snooping.each do |mac, ip, interface| + next unless @check_interface.includes?(interface) + next unless @interface_macs[interface]? == mac + next if checked.includes?(interface) + + checked << interface + iface = @devices[interface]? || {mac: "", ip: ""} + + if iface[:ip] != ip || iface[:mac] != mac + logger.debug { "New connection on #{interface} with #{ip}: #{mac}" } + devices[interface] = {mac: mac, ip: ip} + state_changed = true + else + devices[interface] = iface + end + end + + # did an interface change state + if state_changed + @devices = devices + self[:devices] = devices + end + + # As a link up or down might have modified this list + if @check_interface != checked + @check_interface = checked + self[:interfaces] = checked + end + + self[:last_successful_query] = Time.utc.to_unix + + return task.try &.success + end + + # Grab the parts of the response + entries = data.split(/\s+/).reject(&.empty?) + + # show interfaces status + # Port Name Status Vlan Duplex Speed Type + # Gi1/1 notconnect 1 auto auto No Gbic + # Fa6/1 connected 1 a-full a-100 10/100BaseTX + case entries + when .includes?("connected") + interface = entries[0].downcase + unless @check_interface.includes? interface + logger.debug { "Interface Up: #{interface}" } + @check_interface << interface + end + when .includes?("notconnect") + interface = entries[0].downcase + if @check_interface.includes? interface + # Delete the lookup records + logger.debug { "Interface Down: #{interface}" } + @check_interface.delete(interface) + end + else + if entries.size > 2 + # We are looking for MAC to IP address mappings + # ============================================= + # MacAddress IpAddress Lease(sec) Type VLAN Interface + # ------------------ --------------- ---------- ------------- ---- -------------------- + # 00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/43 + # Total number of bindings: 3 + interface = normalise(entries[-1]) + + # We only want entries that are currently active + if @check_interface.includes? interface + # Ensure the data is valid + mac = entries[0] + if mac =~ /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/ + mac = format(mac) + ip = entries[1] + + @snooping << {mac, ip, interface} unless @ignore_macs.includes?(mac[0..5]) + end + end + end + end + + task.try &.success + end + + protected def do_send(cmd, **options) + logger.debug { "requesting: #{cmd}" } + send("#{cmd}\n", **options) + end + + protected def format(mac) + mac.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + protected def normalise(interface) + # Port-channel == po + interface.downcase.gsub("tengigabitethernet", "te").gsub("twogigabitethernet", "tw").gsub("gigabitethernet", "gi").gsub("fastethernet", "fa") + end +end diff --git a/drivers/cisco/switch/snooping_catalyst_spec.cr b/drivers/cisco/switch/snooping_catalyst_spec.cr new file mode 100644 index 00000000000..b3c8cdee782 --- /dev/null +++ b/drivers/cisco/switch/snooping_catalyst_spec.cr @@ -0,0 +1,63 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Switch::SnoopingCatalyst" do + transmit "SG-MARWFA61301>" + sleep 1.5.seconds + + should_send "show interfaces status\n" + transmit "show interfaces status\n" + status[:hostname].should eq("SG-MARWFA61301") + + transmit %(Port Name Status Vlan Duplex Speed Type +Gi1/0/1 notconnect 113 auto auto 10/100/1000BaseTX +Gi1/0/2 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/11 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/12 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/13 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/14 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/15 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/16 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/17 notconnect 113 auto auto 10/100/1000BaseTX +Gi3/0/8 connected 33 auto auto 10/100/1000BaseTX + --More--) + + should_send " " + transmit %( +Gi4/0/48 notconnect 113 auto auto 10/100/1000BaseTX +Gi4/1/1 notconnect 1 auto auto unknown +Gi4/1/2 notconnect 1 auto auto unknown +Te4/1/4 connected trunk full 10G SFP-10GBase-SR +Po1 connected trunk a-full a-10G +) + + sleep 3.1.seconds + + should_send "show mac address-table\n" + transmit "show mac address-table\n" + + transmit %(Vlan MAC Type Port +33 e4b9.7aa5.aa7f STATIC Gi3/0/8 +10 f4db.e618.10a4 DYNAMIC Te2/0/40 +) + + sleep 3.1.seconds + + should_send "show ip dhcp snooping binding\n" + transmit %(MacAddress IpAddress Lease(sec) Type VLAN Interface +------------------ --------------- ---------- ------------- ---- -------------------- +38:C9:86:17:A2:07 192.168.1.15 19868 dhcp-snooping 113 tenGigabitEthernet4/1/4 +E4:B9:7A:A5:AA:7F 10.151.128.150 16532 dhcp-snooping 33 GigabitEthernet3/0/8 +00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/34 +Total number of bindings: 3 + +) + + status["devices"].should eq({ + "gi3/0/8" => { + "mac" => "e4b97aa5aa7f", + "ip" => "10.151.128.150", + }, + }) + + status["interfaces"].should eq(["gi3/0/8"]) +end diff --git a/drivers/cisco/ui_extender.cr b/drivers/cisco/ui_extender.cr new file mode 100644 index 00000000000..bd7783dd3a9 --- /dev/null +++ b/drivers/cisco/ui_extender.cr @@ -0,0 +1,428 @@ +require "promise" +require "placeos-driver" +require "./collaboration_endpoint/response" + +class Cisco::UIExtender < PlaceOS::Driver + descriptive_name "Cisco UI Extender" + generic_name :CiscoUI + description "Cisco Touch 10 UI extensions" + + default_settings({ + codec: "VidConf_1", + cisco_ui_layout: "XML Config", + cisco_ui_bindings: { + "id" => "VidConf_1.binding", + }, + }) + + @event_handlers : Hash(Tuple(String, String), Proc(JSON::Any, Nil)) = {} of Tuple(String, String) => Proc(JSON::Any, Nil) + + # ------------------------------ + # Module callbacks + + def on_load + on_update(true) + end + + def on_unload + clear_extensions + unbind + end + + alias Binding = String | Hash(String, String | Hash(String, String | Hash(String, Array(String)))) + + # id => binding + alias Bindings = Hash(String, Binding) + + def on_update(loading = false) + # we don't want a failure here to prevent loading new settings + unless loading + begin + clear_events + rescue + end + end + + codec_mod = setting?(String, :codec) || "VidConf_1" + unless system.exists? codec_mod + logger.warn { "could not find codec #{codec_mod}" } + return + end + + ui_layout = setting?(String, :cisco_ui_layout) + bindings = setting?(Bindings, :cisco_ui_bindings) || {} of String => Binding + + bind(codec_mod) do + deploy_extensions "PlaceOS", ui_layout if ui_layout + bindings.each { |id, config| link_widget id, config } + end + end + + # ------------------------------ + # Deployment + + # Push a UI definition build with the in-room control editor to the device. + def deploy_extensions(id : String, xml_def : String) + codec.xcommand "UserInterface Extensions Set", xml_def, {"config_id" => id} + end + + # Retrieve the extensions currently loaded. + def list_extensions + codec.xcommand "UserInterface Extensions List" + end + + # Clear any deployed UI extensions. + def clear_extensions + codec.xcommand "UserInterface Extensions Clear" + end + + # ------------------------------ + # Panel interaction + + def close_panel + codec.xcommand "UserInterface Extensions Panel Close" + end + + protected def on_extensions_panel_clicked(event) : Nil + id = event["/Event/UserInterface/Extensions/Panel/Clicked/PanelId"]?.try &.as_s + return unless id + logger.debug { "#{id} opened" } + self[:__active_panel] = id + end + + # ------------------------------ + # Element interaction + + protected def set_actual(id : String, value : String) + logger.debug { "setting #{id} to #{value}" } + update = codec.xcommand "UserInterface Extensions Widget SetValue", + hash_args: {WidgetId: id, Value: value} + + # The device does not raise an event when a widget state is changed via + # the API. In these cases, ensure locally tracked state remains valid. + Promise.defer do + update.get + self[id] = Cisco::CollaborationEndpoint::XAPI.value_convert(value) + value.as(String | Nil) + end + end + + protected def set_actual(id : String, value : Nil) + unset id + end + + protected def set_actual(id : String, value : Bool) + switch(id, value).catch { highlight(id, value).get } + end + + # Set the value of a widget. + def set(id : String, value : String | Bool | Nil) + set_actual(id, value) + end + + # Clear the value associated with a widget. + def unset(id : String) + logger.debug { "clearing #{id}" } + + update = codec.xcommand "UserInterface Extensions Widget UnsetValue", + hash_args: {WidgetId: id} + + Promise.defer do + update.get + self[id] = nil + nil.as(String | Nil) + end + end + + # Set the state of a switch widget. + def switch(id : String, state : Bool? = nil) + state = !status?(Bool, id) if state.nil? + value = state ? "on" : "off" + set id, value + end + + # Set the highlight state for a button widget. + def highlight(id : String, state : Bool = true, momentary : Bool = false, time : Int32 = 500) + value = state ? "active" : "inactive" + schedule.in(time.milliseconds) { highlight(id, !state); nil } if momentary + set id, value + end + + # Set the text label used on text or spinner widget. + def label(id : String, value : String | Bool | Nil) + set_actual(id, value) + end + + # Callback for changes to widget state. + @action_merged : Hash(String, JSON::Any) = {} of String => JSON::Any + + def on_extensions_widget_action(event : Hash(String, JSON::Any)) + logger.debug { "received widget action update #{event}" } + current_key = event.keys.first + case current_key + when "/Event/UserInterface/Extensions/Widget/Action/WidgetId" + @action_merged["WidgetId"] = event[current_key] + when "/Event/UserInterface/Extensions/Widget/Action", "/Event/UserInterface/Extensions/Widget/Action/Value" + @action_merged["Value"] = event[current_key] + when "/Event/UserInterface/Extensions/Widget/Action/Type" + @action_merged["Type"] = event[current_key] + else + logger.debug { "ignoring key #{current_key} processing widget_action event" } + end + logger.debug { "current action state: #{@action_merged}" } + return unless @action_merged.size == 3 + id, value, type = @action_merged.values_at "WidgetId", "Value", "Type" + @action_merged = {} of String => JSON::Any + + logger.debug { "#{id} #{type} = #{value}" } + + id = id.as_s + type = type.as_s + + # Track values of stateful widgets + self[id] = value unless ["", "increment", "decrement"].includes?(value.raw) + + # Trigger any bindings defined for the widget action + begin + handler = @event_handlers.fetch [id, type], nil + handler.try &.call(value) + rescue e + logger.error(exception: e) { "error in binding for #{id}.#{type}" } + end + + # Provide an event stream for other modules to subscribe to + self[:__event_stream] = {id: id, type: type, value: value} + end + + # ------------------------------ + # Popup messages + + def alert(text : String, title : String = "", duration : Int32 = 0) + codec.xcommand( + "UserInterface Message Alert Display", + hash_args: { + Text: text, + Title: title, + Duration: duration, + } + ) + end + + def clear_alert + codec.xcommand "UserInterface Message Alert Clear" + end + + # ------------------------------ + # Internals + + @codec_mod : String = "" + @subscriptions : Array(PlaceOS::Driver::Subscriptions::Subscription) = [] of PlaceOS::Driver::Subscriptions::Subscription + + protected def clear_subscriptions + logger.debug { "clearing subscriptions!" } + @subscriptions.each { |sub| subscriptions.unsubscribe(sub) } + @subscriptions.clear + end + + # Bind to a Cisco CE device module. + protected def bind(mod : String, &bind_cb : Proc(Nil)) + logger.debug { "binding to #{mod}" } + + @codec_mod = mod + subscriptions.clear + @subscriptions.clear + system.subscribe(@codec_mod, :ready) do |_sub, value| + logger.debug { "codec ready: #{value}" } + next unless value == "true" + clear_subscriptions + subscribe_events + bind_cb.call + sync_widget_state + end + @codec_mod + end + + # Unbind from the device module. + protected def unbind + logger.debug { "unbinding" } + clear_events async: true + @codec_mod = "" + end + + protected def bound? + !@codec_mod.empty? + end + + protected def codec + raise "not currently bound to a codec module" unless bound? + system[@codec_mod] + end + + # Push the current module state to the device. + def sync_widget_state + @__status__.each do |key, value| + next if key == "connected" + + # Non-widget related status prefixed with `__` + next if key =~ /^__.*/ + case value + when .starts_with?("\"") + set key, String.from_json(value) + when "true", "false" + set key, value == "true" + end + end + end + + # Build a list of device XPath -> callback mappings. + protected def event_mappings + ui_callbacks.map do |(function_name, callback)| + path = "/Event/UserInterface/#{function_name[3..-1].split("_").map(&.capitalize).join("/")}" + {path, function_name, callback} + end + end + + protected def each_mapping(async : Bool) + device_mod = codec + event_mappings.each { |(path, function, callback)| yield path, function, callback, device_mod } + end + + # Perform an action for each event -> callback mapping. + protected def each_mapping + device_mod = codec + interactions = event_mappings.map do |(path, function, callback)| + future = yield path, function, callback, device_mod + Promise.defer { future.get } + end + Promise.all(interactions).get + end + + protected def subscribe_events(**opts) + mod_id = module_id + each_mapping(**opts) do |path, function, callback, codec| + logger.debug { "monitoring #{mod_id}/#{function}" } + @subscriptions << monitor("#{mod_id}/#{function}") do |_sub, event_json| + logger.debug { "#{function} received #{event_json}" } + spawn do + begin + callback.call(Hash(String, JSON::Any).from_json(event_json)) + rescue error + logger.error(exception: error) { "processing panel event" } + end + end + end + codec.on_event path, mod_id, function + end + end + + protected def clear_events(**opts) + clear_subscriptions + each_mapping(**opts) do |path, _function, _callback, _codec| + future = codec.clear_event(path) + future.get + future + end + end + + # Wire up a widget based on a binding target. + def link_widget(id : String, bindings : Binding) + logger.debug { "setting up bindings for #{id}" } + + binding = case bindings + in String + %w(clicked changed status).product([bindings]).to_h + in Hash(String, Hash(String, Hash(String, Array(String)) | String) | String) + bindings + end + + binding.each do |type, target| + # Status / feedback state binding + if type == "status" + # String | Hash(String, String | Hash(String, String)) + case target + in String + # "mod.status" + mod, state = target.split "." + link_feedback id, mod, state + in Hash(String, String | Hash(String, Array(String))) + # mod => status (provided for compatability with event bindings) + mod, state = target.first + link_feedback id, mod, state.as(String) + end + + # Event binding + else + handler = build_handler target + if handler + @event_handlers[{id, type}] = handler + else + logger.warn { "invalid #{type} binding for #{id}" } + end + end + end + end + + # Bind a widget to another modules status var for feedback. + protected def link_feedback(id : String, mod : String, state : String) + logger.debug { "linking #{id} state to #{mod}.#{state}" } + + system[mod].subscribe(state) do |_sub, value| + spawn do + begin + logger.debug { "#{mod}.#{state} changed to #{value}, updating #{id}" } + payload = value.presence ? JSON.parse(value).raw.as(String | Bool | Nil) : nil + set id, payload + rescue error + logger.error(exception: error) { "module status update" } + end + end + end + end + + # Given the action for a binding, construct the executable event handler. + protected def build_handler(action) + case action + # Implicit arguments + in String + # "mod.method" + raise "action expected to be in format Module_1.binding not: #{action.inspect}" unless action.includes?(".") + mod, method = action.split "." + ->(value : JSON::Any) { + logger.debug { "proxying event to #{mod}.#{method}" } + proxy = system[mod] + args = proxy.__metadata__.arity(method).zero? ? nil : {value} + proxy.__send__ method, args + nil + } + + # Explicit / static arguments + # mod => { method => [params] } + in Hash(String, String | Hash(String, Array(String))) + mod, command = action.first + method, args = command.as(Hash(String, Array(String))).first + ->(_value : JSON::Any) { + logger.debug { "proxying event to #{mod}.#{method}" } + system[mod].__send__ method, args + nil + } + end + end + + # Build a list of all callback methods that have been defined. + # + # Callback methods are denoted being single arity and beginning with `on_`. + IGNORE_METHODS = %w(on_load on_unload on_update) + + {% begin %} + protected def ui_callbacks + [ + {% for method in @type.methods %} + {% method_name = method.name.stringify %} + {% if method.args.size == 1 && !IGNORE_METHODS.includes?(method_name) && method_name[0..2] == "on_" %} + { {{method_name}}, ->(event : Hash(String, JSON::Any)) { {{method_name.id}}(event); nil } }, + {% end %} + {% end %} + ] + end + {% end %} +end diff --git a/drivers/cisco/ui_extender_spec.cr b/drivers/cisco/ui_extender_spec.cr new file mode 100644 index 00000000000..5bd779fac6f --- /dev/null +++ b/drivers/cisco/ui_extender_spec.cr @@ -0,0 +1,53 @@ +require "placeos-driver/spec" +# require "./collaboration_endpoint" + +DriverSpecs.mock_driver "Cisco::UIExtender" do + system({ + VidConf: {VidConfMock}, + }) + sleep 1 + + resp = exec(:set, "something", true).get + puts resp.inspect + sleep 1 + status[:something].should eq(true) + + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action/WidgetId" => "something", + }.to_json) + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action" => false, + }.to_json) + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action/Type" => "changed", + }.to_json) + sleep 1 + status[:something].should eq(false) + sleep 1 +end + +# :nodoc: +class VidConfMock < DriverSpecs::MockDriver + def on_load + spawn { + sleep 0.5 + self[:ready] = self[:connected] = true + } + end + + def xcommand( + command : String, + multiline_body : String? = nil, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type + ) + puts "Running command: #{command} #{hash_args} + body #{multiline_body.try(&.size) || 0}" + end + + def on_event(path : String, mod_id : String, channel : String) + puts "Registering callback for #{path} to #{mod_id}/#{channel}" + end + + def clear_event(path : String) + puts "Clearing event subscription for #{path}" + end +end diff --git a/drivers/cisco/webex/api/messages.cr b/drivers/cisco/webex/api/messages.cr new file mode 100644 index 00000000000..24bfa2edc2c --- /dev/null +++ b/drivers/cisco/webex/api/messages.cr @@ -0,0 +1,41 @@ +module Cisco + module Webex + module Api + class Messages + def initialize(@session : Session) + end + + def list(room_id : String, parent_id : String = "", mentioned_people : String = "", before : String = "", before_message : String = "", max : Int32 = 50) : Array(Models::Message) + params = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, mentionedPeople: mentioned_people, before: before, beforeMessage: before_message, max: max) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def list_direct(person_id : String = "", person_email : String = "", parent_id : String = "") : Array(Models::Message) + params = Utils.hash_from_items_with_values(personId: person_id, personEmail: person_email, parentId: parent_id) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def create(room_id : String = "", parent_id : String = "", to_person_id : String = "", to_person_email : String = "", text : String = "", markdown : String = "") : Models::Message + json = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, toPersonId: to_person_id, toPersonEmail: to_person_email, text: text, markdown: markdown) + response = @session.post([Constants::MESSAGES_ENDPOINT, "/"].join(""), json: json) + Models::Message.from_json(response.body) + end + + def get(message_id : String) : Models::Message + response = @session.get([Constants::MESSAGES_ENDPOINT, "/", message_id].join("")) + Models::Message.from_json(response.body) + end + end + end + end +end diff --git a/drivers/cisco/webex/api/people.cr b/drivers/cisco/webex/api/people.cr new file mode 100644 index 00000000000..500cd455c05 --- /dev/null +++ b/drivers/cisco/webex/api/people.cr @@ -0,0 +1,15 @@ +module Cisco + module Webex + module Api + class People + def initialize(@session : Session) + end + + def me : Models::Person + response = @session.get([Constants::PEOPLE_ENDPOINT, "/", "me"].join("")) + Models::Person.from_json(response.body) + end + end + end + end +end diff --git a/drivers/cisco/webex/api/rooms.cr b/drivers/cisco/webex/api/rooms.cr new file mode 100644 index 00000000000..bf80133d252 --- /dev/null +++ b/drivers/cisco/webex/api/rooms.cr @@ -0,0 +1,41 @@ +module Cisco + module Webex + module Api + class Rooms + def initialize(@session : Session) + end + + def list(room_id : String, parent_id : String = "", mentioned_people : String = "", before : String = "", before_message : String = "", max : Int32 = 50) : Array(Models::Message) + params = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, mentionedPeople: mentioned_people, before: before, beforeMessage: before_message, max: max) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def list_direct(person_id : String = "", person_email : String = "", parent_id : String = "") : Array(Models::Message) + params = Utils.hash_from_items_with_values(personId: person_id, personEmail: person_email, parentId: parent_id) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def create(room_id : String = "", parent_id : String = "", to_person_id : String = "", to_person_email : String = "", text : String = "", markdown : String = "") : Models::Message + json = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, toPersonId: to_person_id, toPersonEmail: to_person_email, text: text, markdown: markdown) + response = @session.post([Constants::MESSAGES_ENDPOINT, "/"].join(""), json: json) + Models::Message.from_json(response.body) + end + + def get(message_id : String) : Models::Message + response = @session.get([Constants::MESSAGES_ENDPOINT, "/", message_id].join("")) + Models::Message.from_json(response.body) + end + end + end + end +end diff --git a/drivers/cisco/webex/client.cr b/drivers/cisco/webex/client.cr new file mode 100644 index 00000000000..8d5dc1aed32 --- /dev/null +++ b/drivers/cisco/webex/client.cr @@ -0,0 +1,153 @@ +module Cisco + module Webex + class Client + Log = ::Log.for(self) + + property id : String + property keywords : Hash(String, Command) + property socket : HTTP::WebSocket? + + def initialize(@name : String, @access_token : String, @emails : String, @session : Session, @commands : Array(Command)) + @rooms = Api::Rooms.new(@session) + @people = Api::People.new(@session) + @messages = Api::Messages.new(@session) + + @keywords = + @commands + .flat_map { |command| command.keywords.map { |keyword| {"#{keyword}" => command} } } + .reduce { |acc, i| acc.try(&.merge(i.not_nil!)) } + + @id = @people.me.id + end + + def rooms + @rooms + end + + def people + @people + end + + def messages + @messages + end + + private def device(check_existing : Bool = true) : Models::Device + if check_existing + response = @session.get([Constants::DEFAULT_DEVICE_URL, "/", "devices"].join("")) + data = JSON.parse(response.body) + + devices = data.["devices"].as_a.map do |item| + Models::Device.from_json(item.to_json) + end + + devices.each do |device| + if device.name == nil + next + end + + if device.name == Constants::DEVICE["name"] + return device + end + end + end + + response = @session.post([Constants::DEFAULT_DEVICE_URL, "/", "devices"].join(""), json: Constants::DEVICE) + Models::Device.from_json(response.body) + end + + private def message_id(activity) : String + # In order to geo-locate the correct DC to fetch the message from, you need to use the base64 Id of the message. + id = activity.id + target_url = activity.target.url + target_id = activity.target.id + + verb = activity.verb == "post" ? "messages" : "attachment/actions" + + message_url = target_url.gsub(["conversations", "/", target_id].join(""), [verb, "/", id].join("")) + response = Halite.get(message_url, headers: {"Authorization" => ["Bearer", @access_token].join(" ")}) + + message = JSON.parse(response.body) + message["id"].to_s + end + + private def process_incoming_websocket_message(socket, message) + peek = Models::Peek.from_json(message) + return if peek.data.event_type == "status.start_typing" + + begin + event = Models::Event.from_json(message) + + if event.data.event_type == "conversation.activity" + activity = event.data.activity + Log.debug { "Activity verb is: #{activity.verb}" } + + if activity.verb == "post" + id = message_id(activity) + message = self.messages.get(id) + + if message.person_id != @id + # Ack that this message has been processed. This will prevent the message coming again. + socket.send({"type" => "ack", "messageId" => id}.to_json) + + if message.text.starts_with?(@name) + message.text = message.text.sub(@name, "").strip + end + + return if @emails.none?(activity.actor.email) + + keyword = message.text.split.first.downcase + + if @keywords[keyword]? + message.text = message.text.sub(keyword, "").strip + message = @keywords[keyword].execute(event, keyword, message) + + room_id = message["id"]? || "" + parent_id = message["parent_id"]? || "" + to_person_id = message["to_person_id"]? || "" + to_person_email = message["to_person_email"]? || "" + text = message["text"]? || "" + markdown = message["markdown"]? || "" + + self.messages.create(room_id, parent_id, to_person_id, to_person_email, text, markdown) + else + end + end + else + end + end + rescue e : Exception + Log.debug(exception: e) { } + end + end + + def run : Void + device = device() + @socket = socket = HTTP::WebSocket.new(URI.parse(device.websocket_url)) + + socket.on_message do |message| + process_incoming_websocket_message(socket, message) + end + + socket.on_binary do |binary| + process_incoming_websocket_message(socket, String.new(binary)) + end + + message = { + "id" => UUID.random.to_s, + "type" => "authorization", + "trackingId" => ["webex", "-", UUID.random.to_s].join(""), + "data" => { + "token" => ["Bearer", @access_token].join(" "), + }, + } + socket.send(message.to_json) + socket.run + end + + def stop : Void + @socket.close + end + end + end +end diff --git a/drivers/cisco/webex/cloud_xapi.cr b/drivers/cisco/webex/cloud_xapi.cr new file mode 100644 index 00000000000..29997d660e1 --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi.cr @@ -0,0 +1,258 @@ +require "placeos-driver" +require "./cloud_xapi/ui_extensions" + +class Cisco::Webex::Cloud < PlaceOS::Driver + include CloudXAPI::UIExtensions + + # Discovery Information + descriptive_name "Webex Cloud xAPI" + generic_name :CloudXAPI + + uri_base "https://webexapis.com" + + default_settings({ + cisco_client_id: "", + cisco_client_secret: "", + cisco_target_orgid: "", + cisco_app_id: "", + cisco_personal_token: "", + debug_payload: false, + }) + + getter! device_token : DeviceToken + + @cisco_client_id : String = "" + @cisco_client_secret : String = "" + @cisco_target_orgid : String = "" + @cisco_app_id : String = "" + @cisco_personal_token : String = "" + @debug_payload : Bool = false + + def on_load + on_update + schedule.every(1.minute) { keep_token_refreshed } + end + + def on_update + @cisco_client_id = setting(String, :cisco_client_id) + @cisco_client_secret = setting(String, :cisco_client_secret) + @cisco_target_orgid = setting(String, :cisco_target_orgid) + @cisco_app_id = setting(String, :cisco_app_id) + @cisco_personal_token = setting(String, :cisco_personal_token) + @debug_payload = setting?(Bool, :debug_payload) || false + + @device_token = setting?(DeviceToken, :cisco_token_pair) || @device_token + end + + def led_mode?(device_id : String) + config?(device_id, "UserInterface.LedControl.Mode") + end + + def led_mode(device_id : String, value : String) + value = value.downcase.capitalize + config("UserInterface.LedControl.Mode", device_id, value) + end + + def led_colour?(device_id : String) + status(device_id, "UserInterface.LedControl.Color") + end + + command({"UserInterface LedControl Color Set" => :led_colour}, color: Colour) + + def list_workspaces(org_id : String? = nil, location_id : String? = nil, workspace_location_id : String? = nil, floor_id : String? = nil, + display_name : String? = nil, capacity : Int32? = nil, workspace_type : String? = nil, start : Int32? = nil, max : Int32? = nil, + calling : String? = nil, supported_devices : String? = nil, calendar : String? = nil, device_hosted_meetings_enabled : Bool? = nil, + device_platform : String? = nil, health_level : String? = nil) + params = URI::Params.build do |form| + form.add("orgId", org_id.to_s) if org_id + form.add("locationId", location_id.to_s) if location_id + form.add("workspaceLocationId", workspace_location_id.to_s) if workspace_location_id + form.add("floorId", floor_id.to_s) if floor_id + form.add("displayName", display_name.to_s) if display_name + form.add("capacity", capacity.to_s) if capacity + form.add("type", workspace_type.to_s) if workspace_type + form.add("start", start.to_s) if start + form.add("max", max.to_s) if max + form.add("calling", calling.to_s) if calling + form.add("supportedDevices", supported_devices.to_s) if supported_devices + form.add("calendar", calendar.to_s) if calendar + form.add("deviceHostedMeetingsEnabled", device_hosted_meetings_enabled.to_s) if device_hosted_meetings_enabled + form.add("devicePlatform", device_platform.to_s) if device_platform + form.add("healthLevel", health_level.to_s) if health_level + end + + query = params.empty? ? nil : params.to_s + api_get("/v1/workspaces", query) + end + + def workspace_details(workspace_id : String) + api_get("/v1/workspaces/#{workspace_id}") + end + + def list_devices(max : Int32? = nil, start : Int32? = nil, display_name : String? = nil, person_id : String? = nil, workspace_id : String? = nil, + org_id : String? = nil, connection_status : String? = nil, product : String? = nil, device_type : String? = nil, serial : String? = nil, + tag : String? = nil, software : String? = nil, upgrade_channel : String? = nil, error_code : String? = nil, capability : String? = nil, + permission : String? = nil, location_id : String? = nil, workspace_location_id : String? = nil, mac : String? = nil, device_platform : String? = nil) + params = URI::Params.build do |form| + form.add("max", max.to_s) if max + form.add("start", start.to_s) if start + form.add("displayName", display_name.to_s) if display_name + + form.add("personId", person_id.to_s) if person_id + form.add("workspaceId", workspace_id.to_s) if workspace_id + form.add("orgId", org_id.to_s) if org_id + form.add("connectionStatus", connection_status.to_s) if connection_status + form.add("product", product.to_s) if product + form.add("type", device_type.to_s) if device_type + form.add("tag", tag.to_s) if tag + form.add("serial", serial.to_s) if serial + form.add("software", software.to_s) if software + form.add("upgradeChannel", upgrade_channel.to_s) if upgrade_channel + form.add("errorCode", error_code.to_s) if error_code + form.add("capability", capability.to_s) if capability + form.add("permission", permission.to_s) if permission + form.add("locationId", location_id.to_s) if location_id + form.add("workspaceLocationId", workspace_location_id.to_s) if workspace_location_id + form.add("mac", mac.to_s) if mac + form.add("devicePlatform", device_platform.to_s) if device_platform + end + query = params.empty? ? nil : params.to_s + api_get("/v1/devices", query) + end + + def device_details(device_id : String, org_id : String? = nil) + params = URI::Params.build do |form| + form.add("orgId", org_id.to_s) if org_id + end + + query = params.empty? ? nil : params.to_s + api_get("/v1/devices/#{device_id}", query) + end + + def status(device_id : String, name : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + form.add("name", name) + end + + headers = get_headers + logger.debug { {msg: "Status HTTP Data:", headers: headers.to_json, query: query.to_s} } if @debug_payload + + response = get("/v1/xapi/status?#{query}", headers: headers) + raise "failed to query status for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def command(name : String, payload : String) + headers = get_headers + logger.debug { {msg: "Command HTTP Data:", headers: headers.to_json, command: name, payload: payload} } if @debug_payload + + response = post("/v1/xapi/command/#{name}", headers: headers, body: payload) + raise "failed to execute command #{name}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def api_get(resource : String, query : String? = nil) + headers = get_headers + logger.debug { {msg: "GET #{resource}:", headers: headers.to_json, query: query.to_s} } if @debug_payload + uri = query.presence ? resource + "?#{query}" : resource + response = get(uri, headers: headers) + raise "failed to get #{resource}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def config?(device_id : String, name : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + form.add("key", name) + end + + headers = get_headers + logger.debug { {msg: "Status HTTP Data:", headers: headers.to_json, query: query.to_s} } if @debug_payload + + response = get("/v1/deviceConfigurations?#{query}", headers: headers) + raise "failed to query configuration for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def config(name : String, device_id : String, value : String) + body = { + "op" => "replace", + "path" => "#{name}/sources/configured/value", + "value" => value, + } + + config(device_id, body.to_json) + end + + def config(device_id : String, payload : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + end + + headers = get_headers("application/json-patch+json") + logger.debug { {msg: "Config HTTP Data:", headers: headers.to_json, query: query, payload: payload} } if @debug_payload + + response = patch("/v1/deviceConfigurations?#{query}", headers: headers, body: payload) + raise "failed to patch config on device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + protected def get_access_token + if device_token? + logger.debug { {msg: "Access Token expiry", expiry: device_token.expiry} } if @debug_payload + return device_token.auth_token if 1.minute.from_now <= device_token.expiry + logger.debug { {msg: "Access Token expiring, refreshing token", token_expiry: device_token.expiry, refresh_expiry: device_token.refresh_expiry} } if @debug_payload + return refresh_token if 1.minute.from_now <= device_token.refresh_expiry + end + + body = { + "clientId": @cisco_client_id, + "clientSecret": @cisco_client_secret, + "targetOrgId": @cisco_target_orgid, + }.to_json + + headers = HTTP::Headers{ + "Authorization" => "Bearer #{@cisco_personal_token}", + "Content-Type" => "application/json", + "Accept" => "application/json", + } + response = post("/v1/applications/#{@cisco_app_id}/token", headers: headers, body: body) + raise "failed to retriee access token for client-id #{@cisco_client_id}, code #{response.status_code}, body #{response.body}" unless response.success? + @device_token = DeviceToken.from_json(response.body) + define_setting(:cisco_token_pair, device_token) + device_token.auth_token + end + + protected def refresh_token + body = URI::Params.build do |form| + form.add("grant_type", "refresh_token") + form.add("client_id", @cisco_client_id) + form.add("client_secret", @cisco_client_secret) + form.add("refresh_token", device_token.refresh_token) + end + + headers = HTTP::Headers{ + "Content-Type" => "application/x-www-form-urlencoded", + "Accept" => "application/json", + } + response = post("/v1/access_token", headers: headers, body: body) + raise "failed to refresh access token for client-id #{@cisco_client_id}, code #{response.status_code}, body #{response.body}" unless response.success? + @device_token = DeviceToken.from_json(response.body) + define_setting(:cisco_token_pair, device_token) + device_token.auth_token + end + + protected def keep_token_refreshed : Nil + return if @device_token.nil? + refresh_token if 1.minute.from_now >= device_token.refresh_expiry + end + + private def get_headers(content_type : String = "application/json") + HTTP::Headers{ + "Authorization" => get_access_token, + "Content-Type" => content_type, + "Accept" => "application/json", + } + end +end diff --git a/drivers/cisco/webex/cloud_xapi/models.cr b/drivers/cisco/webex/cloud_xapi/models.cr new file mode 100644 index 00000000000..7e5f6a53531 --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi/models.cr @@ -0,0 +1,105 @@ +module CloudXAPI::Models + enum Colour + Green + Yellow + Red + Purple + Blue + Orange + Orchid + Aquamarine + Fuchsia + Violet + Magenta + Scarlet + Gold + Lime + Turquoise + Cyan + Off + + def to_json(json : JSON::Builder) + json.string(to_s) + end + end + + record DeviceToken, expires_in : Int64, token_type : String, refresh_token : String, refresh_token_expires_in : Int64, + access_token : String do + include JSON::Serializable + + @[JSON::Field(ignore: true)] + getter! expiry : Time + + @[JSON::Field(ignore: true)] + getter! refresh_expiry : Time + + def after_initialize + @expiry = Time.utc + expires_in.seconds + @refresh_expiry = Time.utc + refresh_token_expires_in.seconds + end + + def auth_token + "#{token_type} #{access_token}" + end + end + + enum TextInputType + SingleLine + Numeric + Password + PIN + end + + enum TextKeyboardState + Open + Closed + end + + macro command(cmd_name, **params) + {% for cmd, name in cmd_name %} + def {{name.id}}(device_id : String, + {% for param, klass in params %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + + {% if klass.is_a?(RangeLiteral) %} + {{param.id}} : Int32{% if optional %}? = nil{% end %}, + {% else %} + {{param.id}} : {{klass}}{% if optional %}? = nil{% end %}, + {% end %} + {% end %} + ) + {% for param, klass in params %} + {% if klass.is_a?(RangeLiteral) %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + {% if optional %} if {{param.id}}{% end %} + raise ArgumentError.new("#{ {{param.stringify}} } must be within #{ {{klass}} }, was #{ {{param.id}} }") unless ({{klass}}).includes?({{param.id}}) + {% if optional %}end{% end %} + {% end %} + {% end %} + + command({{cmd.split(" ").join(".")}},{ + "deviceId" => JSON::Any.new(device_id), + {% if params.size > 0 %} + "arguments" => { + {% for param, klass in params %} + {% if param.stringify.ends_with?("_") %} + {% param = param.stringify[0..-2] %} + {% end %} + "{{param.id.capitalize}}" => JSON.parse({{param.id}}.to_json), + {% end %} + } of String => JSON::Any + {% end %} + }.to_json + ) + end + {% end %} + end +end diff --git a/drivers/cisco/webex/cloud_xapi/ui_extensions.cr b/drivers/cisco/webex/cloud_xapi/ui_extensions.cr new file mode 100644 index 00000000000..57a294a52b3 --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi/ui_extensions.cr @@ -0,0 +1,58 @@ +require "./models" + +module CloudXAPI::UIExtensions + include CloudXAPI::Models + command({"UserInterface Message Alert Clear" => :msg_alert_clear}) + command({"UserInterface Message Alert Display" => :msg_alert}, + text: String, + title_: String, + duration_: 0..3600) + + command({"UserInterface Message Prompt Clear" => :msg_prompt_clear}) + + def msg_prompt(device_id : String, text : String, options : Array(JSON::Any::Type), title : String? = nil, feedback_id : String? = nil, duration : Int64? = nil) + option_map = {} of String => JSON::Any::Type + ("Option.1".."Option.5").each_with_index do |key, i| + break if i >= options.size + option_map[key] = options[i] + end + + command "UserInterface.Message.Prompt.Display", + { + "deviceId" => device_id, + "arguments" => { + "text" => text, + "title" => title, + "feedback_id" => feedback_id, + "duration" => duration, + }.merge(option_map), + }.to_json + end + + command({"UserInterface Message TextInput Clear" => :msg_text_clear}) + command({"UserInterface Message TextInput Display" => :msg_text}, + text: String, + feedback_id: String, + title_: String, + duration_: 0..3600, + input_type_: TextInputType, + keyboard_state_: TextKeyboardState, + place_holder_: String, + submit_text_: String) + + def ui_set_value(device_id : String, widget : String, value : JSON::Any::Type? = nil) + cmd = (value.nil? ? "UserInterface Extensions Widget UnsetValue" : "UserInterface Extensions Widget SetValue").tap { |v| break v.split(" ").join(".") } + payload = { + "deviceId" => JSON::Any.new(device_id), + "arguments" => JSON::Any.new({ + "widget_id" => JSON::Any.new(widget), + }), + } of String => JSON::Any + payload["arguments"].as_h["value"] = JSON::Any.new(value) unless value.nil? + command(cmd, payload.to_json) + end + + command({"UserInterface Extensions Set" => :ui_extensions_deploy}, id: String, xml_def: String) + command({"UserInterface Extensions List" => :ui_extensions_list}) + command({"UserInterface Extensions Clear" => :ui_extensions_clear}) +end diff --git a/drivers/cisco/webex/cloud_xapi_spec.cr b/drivers/cisco/webex/cloud_xapi_spec.cr new file mode 100644 index 00000000000..1141a73a1fd --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi_spec.cr @@ -0,0 +1,172 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Webex::Cloud" do + settings({ + cisco_client_id: "client-id", + cisco_client_secret: "client-secret", + cisco_target_orgid: "target-org", + cisco_app_id: "my-app", + cisco_personal_token: "my-personal-token", + debug_payload: true, + }) + + ret_val = exec(:led_colour?, "device1-id") + + expect_http_request(2.seconds) do |request, response| + if request.path == "/v1/applications/my-app/token" + response.status_code = 200 + response << device_resp_json.to_json + else + response.status_code = 401 + end + end + + expect_http_request(2.seconds) do |request, response| + if request.headers["Authorization"]? == "Bearer generated-access-token" + response.status_code = 200 + response << color_resp(request.query_params["deviceId"]).to_json + else + response.status_code = 401 + end + end + + ret_val.get.should eq(color_resp("device1-id")) + + ret_val = exec(:led_colour, "device1-id", :green) + + # invoking another endpoint request should use previously obtained access token + + expect_http_request do |request, response| + headers = request.headers + io = request.body + if io + data = io.gets_to_end + request = JSON.parse(data) + if request["deviceId"] == "device1-id" && request["arguments"]["Color"] == "Green" && headers["Authorization"] == "Bearer generated-access-token" + response.status_code = 202 + response << color_set_resp.to_json + else + response.status_code = 401 + end + else + raise "expected request to include excute command body params #{request.inspect}" + end + end + + ret_val.get.should eq(color_set_resp) + + ret_val = exec(:led_mode?, "device1-id") + expect_http_request do |request, response| + response.status_code = 200 + response << %({"mode": "Auto"}) + end + + ret_val.get.try &.as_h["mode"].should eq "Auto" + + ret_val = exec(:msg_prompt, "device1-id", "text", [JSON::Any.new("one")], "title", "feedback_id", 32) + expect_http_request do |request, response| + response.status_code = 200 + response << %({"status": "OK"}) + end + + ret_val.get.try &.as_h["status"].should eq "OK" + + ret_val = exec(:list_workspaces) + + expect_http_request(2.seconds) do |request, response| + if request.path == "/v1/workspaces" && request.query_params.empty? + response.status_code = 200 + response << workspace_resp.to_json + else + response.status_code = 401 + end + end + + ret_val.get.try &.as_h["items"].as_a.size.should eq 1 + + ret_val = exec(:workspace_details, "some-workspace-id") + + expect_http_request(2.seconds) do |request, response| + if request.path == "/v1/workspaces/some-workspace-id" && request.query_params.empty? + response.status_code = 200 + response << workspace_resp[:items][0].to_json + else + response.status_code = 401 + end + end + + ret_val.get.try &.as_h["capacity"].as_i.should eq 5 +end + +def color_resp(device_id : String) + {"deviceId" => device_id, "result" => {"LedControl" => {"Color" => "Green"}}} +end + +def color_set_resp + {"deviceId" => "device1-id", "arguments" => {"Color" => "Green"}} +end + +def device_resp_json + { + "expires_in": 64799, + "token_type": "Bearer", + "refresh_token": "MjZmMzcyZWUtMzI2MS00MmE4LTgyZWMtYTVlMWIxYzBjZjhiODJmYzViOTItMGFi_PF84_1eb65fdf-9643-417f-9974-ad72cae0e10f", + "access_token": "generated-access-token", + "refresh_token_expires_in": 7697037, + } +end + +def workspace_resp + { + "items": [ + { + "id": "Y2lzY29zcGFyazovL3VzL1BMQUNFUy81MTAxQjA3Qi00RjhGLTRFRjctQjU2NS1EQjE5QzdCNzIzRjc", + "orgId": "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8xZWI2NWZkZi05NjQzLTQxN2YtOTk3NC1hZDcyY2FlMGUxMGY", + "locationId": "Y2lzY29...", + "workspaceLocationId": "YL34GrT...", + "floorId": "Y2lzY29z...", + "displayName": "SFO-12 Capanina", + "capacity": 5, + "type": "notSet", + "sipAddress": "test_workspace_1@trialorg.room.ciscospark.com", + "created": "2016-04-21T17:00:00.000Z", + "calling": { + "type": "hybridCalling", + "hybridCalling": { + "emailAddress": "workspace@example.com", + }, + "webexCalling": { + "licenses": [ + "Y2lzY29g4...", + ], + }, + }, + "notes": "this is a note", + "hotdeskingStatus": "on", + "supportedDevices": "collaborationDevices", + "calendar": { + "type": "microsoft", + "emailAddress": "workspace@example.com", + }, + "deviceHostedMeetings": { + "enabled": true, + "siteUrl": "'example.webex.com'", + }, + "devicePlatform": "cisco", + "health": { + "level": "error", + "issues": [ + { + "id": "", + "createdAt": "", + "title": "", + "description": "", + "recommendedAction": "", + "level": "", + }, + ], + }, + }, + ], + } +end diff --git a/drivers/cisco/webex/command.cr b/drivers/cisco/webex/command.cr new file mode 100644 index 00000000000..4fdac03b1d3 --- /dev/null +++ b/drivers/cisco/webex/command.cr @@ -0,0 +1,9 @@ +module Cisco + module Webex + abstract class Command + abstract def keywords : Array(String) + abstract def description : String + abstract def execute(event, keyword, message) + end + end +end diff --git a/drivers/cisco/webex/commands/echo.cr b/drivers/cisco/webex/commands/echo.cr new file mode 100644 index 00000000000..64c4bcdad3b --- /dev/null +++ b/drivers/cisco/webex/commands/echo.cr @@ -0,0 +1,19 @@ +module Cisco + module Webex + module Commands + class Echo < Command + def keywords : Array(String) + ["echo"] + end + + def description : String + "This command simply replies your message!" + end + + def execute(event, keyword, message) + {"id" => message.room_id, "text" => message.text} + end + end + end + end +end diff --git a/drivers/cisco/webex/commands/greeting.cr b/drivers/cisco/webex/commands/greeting.cr new file mode 100644 index 00000000000..dc1a396a1be --- /dev/null +++ b/drivers/cisco/webex/commands/greeting.cr @@ -0,0 +1,19 @@ +module Cisco + module Webex + module Commands + class Greeting < Command + def keywords : Array(String) + ["hello", "hi"] + end + + def description : String + "This command simply responds to hello, hi, how are you, etc." + end + + def execute(event, keyword, message) + {"id" => message.room_id, "text" => "👋"} + end + end + end + end +end diff --git a/drivers/cisco/webex/constants.cr b/drivers/cisco/webex/constants.cr new file mode 100644 index 00000000000..ef5605e0096 --- /dev/null +++ b/drivers/cisco/webex/constants.cr @@ -0,0 +1,45 @@ +module Cisco + module Webex + module Constants + VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify.downcase }} + + STATUS_CODES = { + 200 => "Successful request with body content.", + 204 => "Successful request without body content.", + 400 => "The request was invalid or cannot be otherwise served.", + 401 => "Authentication credentials were missing or incorrect.", + 403 => "The request is understood, but it has been refused or access is not allowed.", + 404 => "The URI requested is invalid or the resource requested, such as a user, does not exist. Also returned when the requested format is not supported by the requested method.", + 405 => "The request was made to a resource using an HTTP request method that is not supported.", + 409 => "The request could not be processed because it conflicts with some established rule of the system. For example, a person may not be added to a room more than once.", + 410 => "The requested resource is no longer available.", + 415 => "The request was made to a resource without specifying a media type or used a media type that is not supported.", + 423 => "The requested resource is temporarily unavailable. A `Retry-After` header may be present that specifies how many seconds you need to wait before attempting the request again.", + 429 => "Too many requests have been sent in a given amount of time and the request has been rate limited. A `Retry-After` header should be present that specifies how many seconds you need to wait before a successful request can be made.", + 500 => "Something went wrong on the server. If the issue persists, feel free to contact the Webex Developer Support team (https://developer.webex.com/support).", + 502 => "The server received an invalid response from an upstream server while processing the request. Try again later.", + 503 => "Server is overloaded with requests. Try again later.", + } + + DEFAULT_BASE_URL = "https://webexapis.com/v1/" + DEFAULT_DEVICE_URL = "https://wdm-a.wbx2.com/wdm/api/v1/" + DEFAULT_SINGLE_REQUEST_TIMEOUT = 60 + DEFAULT_WAIT_ON_RATE_LIMIT = true + + DEVICE = { + "deviceType" => "DESKTOP", + "localizedModel" => "crystal", + "model" => "crystal", + "name" => UUID.random.to_s, + "systemName" => "webex-bot-client", + "systemVersion" => VERSION, + } + + ROOMS_ENDPOINT = "rooms" + PEOPLE_ENDPOINT = "people" + MESSAGES_ENDPOINT = "messages" + + WEBEX_TEAMS_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + end + end +end diff --git a/drivers/cisco/webex/exceptions/argument.cr b/drivers/cisco/webex/exceptions/argument.cr new file mode 100644 index 00000000000..4885bbce93f --- /dev/null +++ b/drivers/cisco/webex/exceptions/argument.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class Argument < Exception + end + end + end +end diff --git a/drivers/cisco/webex/exceptions/method.cr b/drivers/cisco/webex/exceptions/method.cr new file mode 100644 index 00000000000..3daec0b5636 --- /dev/null +++ b/drivers/cisco/webex/exceptions/method.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class Method < Exception + end + end + end +end diff --git a/drivers/cisco/webex/exceptions/rate_limit.cr b/drivers/cisco/webex/exceptions/rate_limit.cr new file mode 100644 index 00000000000..af22d61182f --- /dev/null +++ b/drivers/cisco/webex/exceptions/rate_limit.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class RateLimit < Exception + end + end + end +end diff --git a/drivers/cisco/webex/exceptions/status_code.cr b/drivers/cisco/webex/exceptions/status_code.cr new file mode 100644 index 00000000000..de113fad804 --- /dev/null +++ b/drivers/cisco/webex/exceptions/status_code.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class StatusCode < Exception + end + end + end +end diff --git a/drivers/cisco/webex/extensions/chainable.cr b/drivers/cisco/webex/extensions/chainable.cr new file mode 100644 index 00000000000..2707afeb345 --- /dev/null +++ b/drivers/cisco/webex/extensions/chainable.cr @@ -0,0 +1,536 @@ +require "base64" + +module Halite + module Chainable + {% for verb in %w(get head) %} + # {{ verb.id.capitalize }} a resource + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", params: { + # first_name: "foo", + # last_name: "bar" + # }) + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response + request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls) + end + + # {{ verb.id.capitalize }} a streaming resource + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil, + &block : Halite::Response ->) + request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls, &block) + end + {% end %} + + {% for verb in %w(put post patch delete options) %} + # {{ verb.id.capitalize }} a resource + # + # ### Request with form data + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", form: { + # first_name: "foo", + # last_name: "bar" + # }) + # ``` + # + # ### Request with json data + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", json: { + # first_name: "foo", + # last_name: "bar" + # }) + # ``` + # + # ### Request with raw string + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", raw: "name=Peter+Lee&address=%23123+Happy+Ave&Language=C%2B%2B") + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response + request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls) + end + + # {{ verb.id.capitalize }} a streaming resource + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil, + &block : Halite::Response ->) + request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls, &block) + end + {% end %} + + # Adds a endpoint to the request. + # + # + # ``` + # Halite.endpoint("https://httpbin.org") + # .get("/get") + # ``` + def endpoint(endpoint : String | URI) : Halite::Client + branch(default_options.with_endpoint(endpoint)) + end + + # Make a request with the given Basic authorization header + # + # ``` + # Halite.basic_auth("icyleaf", "p@ssw0rd") + # .get("http://httpbin.org/get") + # ``` + # + # See Also: [http://tools.ietf.org/html/rfc2617](http://tools.ietf.org/html/rfc2617) + def basic_auth(user : String, pass : String) : Halite::Client + auth("Basic " + Base64.strict_encode(user + ":" + pass)) + end + + # Make a request with the given Authorization header + # + # ``` + # Halite.auth("private-token", "6abaef100b77808ceb7fe26a3bcff1d0") + # .get("http://httpbin.org/get") + # ``` + def auth(value : String) : Halite::Client + headers({"Authorization" => value}) + end + + # Accept the given MIME type + # + # ``` + # Halite.accept("application/json") + # .get("http://httpbin.org/get") + # ``` + def accept(value : String) : Halite::Client + headers({"Accept" => value}) + end + + # Set requests user agent + # + # ``` + # Halite.user_agent("Custom User Agent") + # .get("http://httpbin.org/get") + # ``` + def user_agent(value : String) : Halite::Client + headers({"User-Agent" => value}) + end + + # Make a request with the given headers + # + # ``` + # Halite.headers({"Content-Type", "application/json", "Connection": "keep-alive"}) + # .get("http://httpbin.org/get") + # # Or + # Halite.headers({content_type: "application/json", connection: "keep-alive"}) + # .get("http://httpbin.org/get") + # ``` + def headers(headers : Hash(String, _) | NamedTuple) : Halite::Client + branch(default_options.with_headers(headers)) + end + + # Make a request with the given headers + # + # ``` + # Halite.headers(content_type: "application/json", connection: "keep-alive") + # .get("http://httpbin.org/get") + # ``` + def headers(**kargs) : Halite::Client + branch(default_options.with_headers(kargs)) + end + + # Make a request with the given cookies + # + # ``` + # Halite.cookies({"private-token", "6abaef100b77808ceb7fe26a3bcff1d0"}) + # .get("http://httpbin.org/get") + # # Or + # Halite.cookies({private-token: "6abaef100b77808ceb7fe26a3bcff1d0"}) + # .get("http://httpbin.org/get") + # ``` + def cookies(cookies : Hash(String, _) | NamedTuple) : Halite::Client + branch(default_options.with_cookies(cookies)) + end + + # Make a request with the given cookies + # + # ``` + # Halite.cookies(name: "icyleaf", "gender": "male") + # .get("http://httpbin.org/get") + # ``` + def cookies(**kargs) : Halite::Client + branch(default_options.with_cookies(kargs)) + end + + # Make a request with the given cookies + # + # ``` + # cookies = HTTP::Cookies.from_client_headers(headers) + # Halite.cookies(cookies) + # .get("http://httpbin.org/get") + # ``` + def cookies(cookies : HTTP::Cookies) : Halite::Client + branch(default_options.with_cookies(cookies)) + end + + # Adds a timeout to the request. + # + # How long to wait for the server to send data before giving up, as a int, float or time span. + # The timeout value will be applied to both the connect and the read timeouts. + # + # Set `nil` to timeout to ignore timeout. + # + # ``` + # Halite.timeout(5.5).get("http://httpbin.org/get") + # # Or + # Halite.timeout(2.minutes) + # .post("http://httpbin.org/post", form: {file: "file.txt"}) + # ``` + def timeout(timeout : (Int32 | Float64 | Time::Span)?) + timeout ? timeout(timeout, timeout, timeout) : branch + end + + # Adds a timeout to the request. + # + # How long to wait for the server to send data before giving up, as a int, float or time span. + # The timeout value will be applied to both the connect and the read timeouts. + # + # ``` + # Halite.timeout(3, 3.minutes, 5) + # .post("http://httpbin.org/post", form: {file: "file.txt"}) + # # Or + # Halite.timeout(3.04, 64, 10.0) + # .get("http://httpbin.org/get") + # ``` + def timeout(connect : (Int32 | Float64 | Time::Span)? = nil, + read : (Int32 | Float64 | Time::Span)? = nil, + write : (Int32 | Float64 | Time::Span)? = nil) + branch(default_options.with_timeout(connect, read, write)) + end + + # Returns `Options` self with automatically following redirects. + # + # ``` + # # Automatically following redirects. + # Halite.follow + # .get("http://httpbin.org/relative-redirect/5") + # + # # Always redirect with any request methods + # Halite.follow(strict: false) + # .get("http://httpbin.org/get") + # ``` + def follow(strict = Halite::Options::Follow::STRICT) : Halite::Client + branch(default_options.with_follow(strict: strict)) + end + + # Returns `Options` self with given max hops of redirect times. + # + # ``` + # # Max hops 3 times + # Halite.follow(3) + # .get("http://httpbin.org/relative-redirect/3") + # + # # Always redirect with any request methods + # Halite.follow(4, strict: false) + # .get("http://httpbin.org/relative-redirect/4") + # ``` + def follow(hops : Int32, strict = Halite::Options::Follow::STRICT) : Halite::Client + branch(default_options.with_follow(hops, strict)) + end + + # Returns `Options` self with enable or disable logging. + # + # #### Enable logging + # + # Same as call `logging` method without any argument. + # + # ``` + # Halite.logging.get("http://httpbin.org/get") + # ``` + # + # #### Disable logging + # + # ``` + # Halite.logging(false).get("http://httpbin.org/get") + # ``` + def logging(enable : Bool = true) + options = default_options + options.logging = enable + branch(options) + end + + # Returns `Options` self with given the logging which it integration from `Halite::Logging`. + # + # #### Simple logging + # + # ``` + # Halite.logging + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post + # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json + # { ... } + # ``` + # + # #### Logger configuration + # + # By default, Halite will logging all outgoing HTTP requests and their responses(without binary stream) to `STDOUT` on DEBUG level. + # You can configuring the following options: + # + # - `skip_request_body`: By default is `false`. + # - `skip_response_body`: By default is `false`. + # - `skip_benchmark`: Display elapsed time, by default is `false`. + # - `colorize`: Enable colorize in terminal, only apply in `common` format, by default is `true`. + # + # ``` + # Halite.logging(skip_request_body: true, skip_response_body: true) + # .post("http://httpbin.org/get", form: {image: File.open("halite-logo.png")}) + # + # # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post + # # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json + # ``` + # + # #### Use custom logging + # + # Creating the custom logging by integration `Halite::Logging::Abstract` abstract class. + # Here has two methods must be implement: `#request` and `#response`. + # + # ``` + # class CustomLogger < Halite::Logging::Abstract + # def request(request) + # @logger.info "| >> | %s | %s %s" % [request.verb, request.uri, request.body] + # end + # + # def response(response) + # @logger.info "| << | %s | %s %s" % [response.status_code, response.uri, response.content_type] + # end + # end + # + # # Add to adapter list (optional) + # Halite::Logging.register_adapter "custom", CustomLogger.new + # + # Halite.logging(logging: CustomLogger.new) + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # We can also call it use format name if you added it. + # Halite.logging(format: "custom") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # => 2017-12-13 16:40:13 +08:00 | >> | GET | http://httpbin.org/get?name=foobar + # # => 2017-12-13 16:40:15 +08:00 | << | 200 | http://httpbin.org/get?name=foobar application/json + # ``` + def logging(logging : Halite::Logging::Abstract = Halite::Logging::Common.new) + branch(default_options.with_logging(logging)) + end + + # Returns `Options` self with given the file with the path. + # + # #### JSON-formatted logging + # + # ``` + # Halite.logging(format: "json") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + # + # #### create a http request and log to file + # + # ``` + # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a"))) + # Halite.logging(for: "halite.file") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + # + # #### Always create new log file and store data to JSON formatted + # + # ``` + # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "w")) + # Halite.logging(for: "halite.file", format: "json") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + # + # Check the log file content: **/tmp/halite.log** + def logging(format : String = "common", *, for : String = "halite", + skip_request_body = false, skip_response_body = false, + skip_benchmark = false, colorize = true) + opts = { + for: for, + skip_request_body: skip_request_body, + skip_response_body: skip_response_body, + skip_benchmark: skip_benchmark, + colorize: colorize, + } + branch(default_options.with_logging(format, **opts)) + end + + # Turn on given features and its options. + # + # Available features to review all subclasses of `Halite::Feature`. + # + # #### Use JSON logging + # + # ``` + # Halite.use("logging", format: "json") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # => { ... } + # ``` + # + # #### Use common format logging and skip response body + # ``` + # Halite.use("logging", format: "common", skip_response_body: true) + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # => 2018-08-28 14:58:26 +08:00 | request | GET | http://httpbin.org/get + # # => 2018-08-28 14:58:27 +08:00 | response | 200 | http://httpbin.org/get | 615.8ms | application/json + # ``` + def use(feature : String, **opts) + branch(default_options.with_features(feature, **opts)) + end + + # Turn on given the name of features. + # + # Available features to review all subclasses of `Halite::Feature`. + # + # ``` + # Halite.use("logging", "your-custom-feature-name") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + def use(*features) + branch(default_options.with_features(*features)) + end + + # Make an HTTP request with the given verb + # + # ``` + # Halite.request("get", "http://httpbin.org/get", { + # "headers" = { "user_agent" => "halite" }, + # "params" => { "nickname" => "foo" }, + # "form" => { "username" => "bar" }, + # }) + # ``` + def request(verb : String, uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response + request(verb, uri, options_with(headers, params, form, json, raw, tls)) + end + + # Make an HTTP request with the given verb and options + # + # > This method will be executed with oneshot request. + # + # ``` + # Halite.request("get", "http://httpbin.org/stream/3", headers: {"user-agent" => "halite"}) do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def request(verb : String, uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil, + &block : Halite::Response ->) + request(verb, uri, options_with(headers, params, form, json, raw, tls), &block) + end + + # Make an HTTP request with the given verb and options + # + # > This method will be executed with oneshot request. + # + # ``` + # Halite.request("get", "http://httpbin.org/get", Halite::Options.new( + # "headers" = { "user_agent" => "halite" }, + # "params" => { "nickname" => "foo" }, + # "form" => { "username" => "bar" }, + # ) + # ``` + def request(verb : String, uri : String, options : Halite::Options? = nil) : Halite::Response + branch(options).request(verb, uri) + end + + # Make an HTTP request with the given verb and options + # + # > This method will be executed with oneshot request. + # + # ``` + # Halite.request("get", "http://httpbin.org/stream/3") do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def request(verb : String, uri : String, options : Halite::Options? = nil, &block : Halite::Response ->) + branch(options).request(verb, uri, &block) + end + + private def branch(options : Halite::Options? = nil) : Halite::Client + options ||= default_options + Halite::Client.new(options) + end + + private def default_options + {% if @type.superclass %} + @default_options + {% else %} + DEFAULT_OPTIONS.clear! + {% end %} + end + + private def options_with(headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) + options = Halite::Options.new(headers: headers, params: params, form: form, json: json, raw: raw, tls: tls) + default_options.merge!(options) + end + end +end diff --git a/drivers/cisco/webex/instant_connect.cr b/drivers/cisco/webex/instant_connect.cr new file mode 100644 index 00000000000..09032909dbb --- /dev/null +++ b/drivers/cisco/webex/instant_connect.cr @@ -0,0 +1,118 @@ +require "placeos-driver" +require "base64" +require "jwt" + +class Cisco::Webex::InstantConnect < PlaceOS::Driver + # Discovery Information + generic_name :InstantConnect + descriptive_name "Webex InstantConnect" + uri_base "https://mtg-broker-a.wbx2.com" + + default_settings({ + bot_access_token: "token", + jwt_audience: "a4d886b0-979f-4e2c-a958-3e8c14605e51", + webex_guest_issuer: "a4d886b0", + webex_guest_secret: "a958-3e8c14605e51", + }) + + @jwt_audience : String = "a4d886b0-979f-4e2c-a958-3e8c14605e51" + @bot_access_token : String = "" + @webex_guest_issuer : String = "" + @webex_guest_secret : String = "" + + def on_update + @webex_guest_issuer = setting?(String, :webex_guest_issuer) || "" + @webex_guest_secret = setting?(String, :webex_guest_secret) || "" + + @audience_setting = setting?(String, :jwt_audience) || "a4d886b0-979f-4e2c-a958-3e8c14605e51" + @bot_access_token = setting(String, :bot_access_token) + end + + # Cisco docs on the subject: + # * Guest JWT: https://developer.webex.com/docs/guest-issuer + # * Testing site: https://webexsamples.github.io/browser-sdk-samples/browser-auth-jwt/ + def create_guest_bearer(user_id : String, display_name : String, expiry : Int64? = nil) + expires_at = expiry || 12.hours.from_now.to_unix + JWT.encode({ + "sub": user_id, + "name": display_name, + "iss": @webex_guest_issuer, + "iat": 3.minutes.ago.to_unix, + "exp": expires_at, + }, Base64.decode_string(@webex_guest_secret), :hs256) + end + + def create_meeting(room_id : String) + expiry = 24.hours.from_now.to_unix + request = { + aud: @jwt_audience, + provideShortUrls: true, + jwt: { + # the encounter id, should be unique for each patient encounter + sub: room_id, + exp: expiry, + }, + }.to_json + + get_meeting_details get_hash(request) + end + + protected def get_meeting_details(meeting_keys) + host_details = meeting_keys.host.first + guest_details = meeting_keys.guest.first + + response = get("/api/v1/space/?int=jose&data=#{host_details.cipher}") + logger.debug { "host config returned:\n#{response.body}" } + raise "host token request failed with #{response.status_code}" if response_failed?(response) + meeting_config = Hash(String, JSON::Any).from_json(response.body) + + response = get("/api/v1/space/?int=jose&data=#{guest_details.cipher}") + logger.debug { "guest config returned:\n#{response.body}" } + raise "guest token request failed with #{response.status_code}" if response_failed?(response) + guest_token = String.from_json(response.body, root: "token") + + { + # space_id seems to be an internal id for the meeting room + space_id: meeting_config["spaceId"].as_s, + host_token: meeting_config["token"].as_s, + guest_token: guest_token, + host_url: "#{meeting_keys.base_url}#{host_details.short}", + guest_url: "#{meeting_keys.base_url}#{guest_details.short}", + } + end + + struct JoseEncryptResponse + include JSON::Serializable + + getter host : Array(MeetingDetails) + getter guest : Array(MeetingDetails) + + @[JSON::Field(key: "baseUrl")] + getter base_url : String + end + + struct MeetingDetails + include JSON::Serializable + + getter cipher : String + getter short : String + end + + protected def get_hash(request : String) + response = post("/api/v2/joseencrypt", body: request, headers: HTTP::Headers{ + "Accept" => "application/json", + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@bot_access_token}", + }) + + logger.debug { "get_hash returned:\n#{response.body}" } + raise "request failed with #{response.status_code}" if response_failed?(response) + + JoseEncryptResponse.from_json(response.body) + end + + protected def response_failed?(response) + logger.warn { "instant connect response failure\ncode: #{response.status_code}, status: #{response.status}\nbody:\n#{response.body.inspect}" } unless response.success? + response.status_code != 200 || response.body.nil? + end +end diff --git a/drivers/cisco/webex/instant_connect_spec.cr b/drivers/cisco/webex/instant_connect_spec.cr new file mode 100644 index 00000000000..f747868df63 --- /dev/null +++ b/drivers/cisco/webex/instant_connect_spec.cr @@ -0,0 +1,94 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Webex::InstantConnect" do + # Send the request + retval = exec(:create_meeting, + room_id: "1" + ) + + # HTTP request to get host/guest hash + expect_http_request do |request, response| + headers = request.headers + io = request.body + if io + data = io.gets_to_end + request = JSON.parse(data) + if request.to_s.includes?(%("aud" => "a4d886b0-979f-4e2c-a958-3e8c14605e51")) && headers["Authorization"].includes?(%(Bearer)) + response.status_code = 200 + response << RAW_HASH_RESPONSE + else + response.status_code = 401 + end + else + raise "expected request to include aud & sub details #{request.inspect}" + end + end + + # HTTP request to get token/spaceId using host JWT + expect_http_request do |request, response| + headers = request.headers + if request.resource.includes?("api/v1/space/?int=jose&data=") + response.status_code = 200 + response << RAW_HOST_RESPONSE + else + response.status_code = 401 + end + end + + # HTTP request to get token using guest JWT + expect_http_request do |request, response| + headers = request.headers + if request.resource.includes?("api/v1/space/?int=jose&data=") + response.status_code = 200 + response << RAW_GUEST_RESPONSE + else + response.status_code = 401 + end + end + + retval.get.should eq(JSON.parse(RETVAL)) +end + +RAW_HOST_RESPONSE = %({ + "userIdentifier": "Host", + "isLoggedIn": false, + "isHost": true, + "organizationId": "16917798-5582-49a7-92d0-4410f6964000", + "orgName": "PlaceOS", + "token": "NmFmZGQwODYtZmIzNi00OTlmLWE3N2QtNzUyNzk2MDk4NDU5MjZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "spaceId": "Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1", + "visitId": "1", + "integrationType": "jose" +}) + +RAW_GUEST_RESPONSE = %({ + "userIdentifier": "Guest", + "isLoggedIn": false, + "isHost": false, + "organizationId": "16917798-5582-49a7-92d0-4410f6964000", + "orgName": "PlaceOS", + "token": "NmFmZGQwODYtZmIzNi05OTlmLWE3N2QtMzUyNzk2MDk4NDU5MeZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "spaceId": "Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1", + "visitId": "1", + "integrationType": "jose" +}) + +RAW_HASH_RESPONSE = %({ + "host": [{ + "cipher": "eyJwMnMiOiJCWXpoYmV4W", + "short": "abc1234" + }], + "guest": [{ + "cipher": "eyJwMnMiOiJaVVJsejNsb1", + "short": "def1234" + }], + "baseUrl": "https://somedomain.com/chat/" +}) + +RETVAL = %({ + "space_id":"Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1", + "host_token":"NmFmZGQwODYtZmIzNi00OTlmLWE3N2QtNzUyNzk2MDk4NDU5MjZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "guest_token":"NmFmZGQwODYtZmIzNi05OTlmLWE3N2QtMzUyNzk2MDk4NDU5MeZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "host_url": "https://somedomain.com/chat/abc1234", + "guest_url": "https://somedomain.com/chat/def1234" +}) diff --git a/drivers/cisco/webex/models/device.cr b/drivers/cisco/webex/models/device.cr new file mode 100644 index 00000000000..3ccad2ccce3 --- /dev/null +++ b/drivers/cisco/webex/models/device.cr @@ -0,0 +1,15 @@ +module Cisco + module Webex + module Models + class Device + include JSON::Serializable + + @[JSON::Field(key: "webSocketUrl")] + property websocket_url : String + + @[JSON::Field(key: "name")] + property name : String? + end + end + end +end diff --git a/drivers/cisco/webex/models/event.cr b/drivers/cisco/webex/models/event.cr new file mode 100644 index 00000000000..d0ff44cd00b --- /dev/null +++ b/drivers/cisco/webex/models/event.cr @@ -0,0 +1,27 @@ +module Cisco + module Webex + module Models + class Event + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "data")] + property data : Events::Data + + @[JSON::Field(key: "timestamp")] + property timestamp : Int64 + + @[JSON::Field(key: "trackingId")] + property tracking_id : String + + @[JSON::Field(key: "sequenceNumber")] + property sequence_number : Int64 + + @[JSON::Field(key: "filterMessage")] + property filter_message : Bool + end + end + end +end diff --git a/drivers/cisco/webex/models/events/activity.cr b/drivers/cisco/webex/models/events/activity.cr new file mode 100644 index 00000000000..504b2b87c60 --- /dev/null +++ b/drivers/cisco/webex/models/events/activity.cr @@ -0,0 +1,35 @@ +module Cisco + module Webex + module Models + module Events + class Activity + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "url")] + property url : String + + @[JSON::Field(key: "published")] + property published : String + + @[JSON::Field(key: "verb")] + property verb : String + + @[JSON::Field(key: "actor")] + property actor : Actor + + @[JSON::Field(key: "target")] + property target : Target + + @[JSON::Field(key: "clientTempId")] + property client_temp_id : String? + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/actor.cr b/drivers/cisco/webex/models/events/actor.cr new file mode 100644 index 00000000000..01c3b9f8007 --- /dev/null +++ b/drivers/cisco/webex/models/events/actor.cr @@ -0,0 +1,32 @@ +module Cisco + module Webex + module Models + module Events + class Actor + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "displayName")] + property display_name : String + + @[JSON::Field(key: "orgId")] + property organisation_id : String + + @[JSON::Field(key: "emailAddress")] + property email : String + + @[JSON::Field(key: "entryUUID")] + property entry_uuid : String + + @[JSON::Field(key: "type")] + property type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/data.cr b/drivers/cisco/webex/models/events/data.cr new file mode 100644 index 00000000000..6cb51913264 --- /dev/null +++ b/drivers/cisco/webex/models/events/data.cr @@ -0,0 +1,17 @@ +module Cisco + module Webex + module Models + module Events + class Data + include JSON::Serializable + + @[JSON::Field(key: "activity")] + property activity : Activity + + @[JSON::Field(key: "eventType")] + property event_type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/target.cr b/drivers/cisco/webex/models/events/target.cr new file mode 100644 index 00000000000..4c5b77d196c --- /dev/null +++ b/drivers/cisco/webex/models/events/target.cr @@ -0,0 +1,23 @@ +module Cisco + module Webex + module Models + module Events + class Target + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "url")] + property url : String + + @[JSON::Field(key: "published")] + property published : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/type.cr b/drivers/cisco/webex/models/events/type.cr new file mode 100644 index 00000000000..f4a6f84f360 --- /dev/null +++ b/drivers/cisco/webex/models/events/type.cr @@ -0,0 +1,14 @@ +module Cisco + module Webex + module Models + module Events + class Type + include JSON::Serializable + + @[JSON::Field(key: "eventType")] + property event_type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/message.cr b/drivers/cisco/webex/models/message.cr new file mode 100644 index 00000000000..a2136fcc694 --- /dev/null +++ b/drivers/cisco/webex/models/message.cr @@ -0,0 +1,77 @@ +module Cisco + module Webex + module Models + class Message + include JSON::Serializable + + # The unique identifier for the message. + @[JSON::Field(key: "id")] + property id : String + + # The unique identifier for the parent message. + @[JSON::Field(key: "parentId")] + property parent_id : String? + + # The room ID of the message. + @[JSON::Field(key: "roomId")] + property room_id : String + + # The type of room. + @[JSON::Field(key: "roomType")] + property room_type : String + + # The person ID of the recipient when sending a 1:1 message. + @[JSON::Field(key: "toPersonId")] + property to_person_id : String? + + # The email address of the recipient when sending a 1:1 message. + @[JSON::Field(key: "toPersonEmail")] + property to_person_email : String? + + # The message, in plain text. + @[JSON::Field(key: "text")] + property text : String + + # The message, in Markdown format. + @[JSON::Field(key: "markdown")] + property markdown : String? + + # The text content of the message, in HTML format. This read-only property is used by the Webex Teams clients. + @[JSON::Field(key: "html")] + property html : String? + + # Public URLs for files attached to the message. + @[JSON::Field(key: "files")] + property files : Array(String)? + + # The person ID of the message author. + @[JSON::Field(key: "personId")] + property person_id : String + + # The email address of the message author. + @[JSON::Field(key: "personEmail")] + property person_email : String + + # People IDs for anyone mentioned in the message. + @[JSON::Field(key: "mentionedPeople")] + property mentioned_people : Array(String)? + + # Group names for the groups mentioned in the message. + @[JSON::Field(key: "mentionedGroups")] + property mentioned_groups : Array(String)? + + # Message content attachments attached to the message. + @[JSON::Field(key: "attachments")] + property attachments : Array(String)? + + # The date and time the message was created. + @[JSON::Field(key: "created")] + property created : String + + # The date and time the message was created. + @[JSON::Field(key: "updated")] + property updated : String? + end + end + end +end diff --git a/drivers/cisco/webex/models/peek.cr b/drivers/cisco/webex/models/peek.cr new file mode 100644 index 00000000000..2198be1757f --- /dev/null +++ b/drivers/cisco/webex/models/peek.cr @@ -0,0 +1,15 @@ +module Cisco + module Webex + module Models + class Peek + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "data")] + property data : Events::Type + end + end + end +end diff --git a/drivers/cisco/webex/models/person.cr b/drivers/cisco/webex/models/person.cr new file mode 100644 index 00000000000..0d74284904b --- /dev/null +++ b/drivers/cisco/webex/models/person.cr @@ -0,0 +1,12 @@ +module Cisco + module Webex + module Models + class Person + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + end + end + end +end diff --git a/drivers/cisco/webex/models/room.cr b/drivers/cisco/webex/models/room.cr new file mode 100644 index 00000000000..d674cda6f9c --- /dev/null +++ b/drivers/cisco/webex/models/room.cr @@ -0,0 +1,45 @@ +module Cisco + module Webex + module Models + class Room + include JSON::Serializable + + # A unique identifier for the room. + @[JSON::Field(key: "id")] + property id : String + + # The name of the room. + @[JSON::Field(key: "title")] + property title : String + + # The room type. + @[JSON::Field(key: "type")] + property type : String + + # Whether the room is moderated (locked) or not. + @[JSON::Field(key: "isLocked")] + property is_locked : Bool + + # The ID for the team with which this room is associated.. + @[JSON::Field(key: "teamId")] + property team_id : String? + + # The date and time of the room"s last activity.. + @[JSON::Field(key: "lastActivity")] + property last_activity : String + + # The ID of the person who created this room. + @[JSON::Field(key: "creatorId")] + property creator_id : String + + # The date and time the room was created. + @[JSON::Field(key: "created")] + property created : String + + # The ID of the organization which owns this room. + @[JSON::Field(key: "ownerId")] + property owner_id : String + end + end + end +end diff --git a/drivers/cisco/webex/session.cr b/drivers/cisco/webex/session.cr new file mode 100644 index 00000000000..f67597a687c --- /dev/null +++ b/drivers/cisco/webex/session.cr @@ -0,0 +1,82 @@ +module Cisco + module Webex + class Session + Log = ::Log.for(self) + + property base_url : String = Constants::DEFAULT_BASE_URL + property single_request_timeout : Int32 = Constants::DEFAULT_SINGLE_REQUEST_TIMEOUT + property user_agent : String = ["Tepha", Constants::VERSION].join(" ") + property wait_on_rate_limit : Bool = Constants::DEFAULT_WAIT_ON_RATE_LIMIT + + private property client : Halite::Client = Halite::Client.new + + def initialize(@access_token : String) + end + + def request(method : String, url : String, **kwargs) : Halite::Response + # Abstract base method for making requests to the Webex Teams APIs. + # This base method: + # * Expands the API endpoint URL to an absolute URL + # * Makes the actual HTTP request to the API endpoint + # * Provides support for Webex Teams rate-limiting + # * Inspects response codes and raises exceptions as appropriate + + absolute_url = URI.parse(base_url).resolve(url).to_s + + @client.headers({"Authorization" => ["Bearer", @access_token].join(" ")}) + @client.headers({"Content-Type" => "application/json;charset=utf-8"}) + @client.timeout single_request_timeout + + loop do + case method + when "GET" + response = @client.get absolute_url, **kwargs + when "POST" + response = @client.post absolute_url, **kwargs + when "PUT" + response = @client.put absolute_url, **kwargs + when "DELETE" + response = @client.delete absolute_url, **kwargs + else + raise Exceptions::Method.new("The request-method type is invalid.") + end + + begin + status_code = StatusCode.new(response.status_code) + raise Exceptions::RateLimit.new(status_code.message) if response.status_code == 429 + raise Exceptions::StatusCode.new(status_code.message) if !status_code.valid? + + return response + rescue e : Exceptions::StatusCode + Log.error(exception: e) { } + rescue e : Exceptions::RateLimit + Log.error(exception: e) { } + + retry_after = (response.headers["Retry-After"]? || "15").to_i * 1000 + sleep(retry_after) + end + end + end + + def get(url : String, **kwargs) : Halite::Response + # Sends a GET request. + request("GET", url, **kwargs) + end + + def post(url : String, **kwargs) : Halite::Response + # Sends a POST request. + request("POST", url, **kwargs) + end + + def put(url : String, **kwargs) : Halite::Response + # Sends a PUT request. + request("PUT", url, **kwargs) + end + + def delete(url : String, **kwargs) : Halite::Response + # Sends a DELETE request. + request("DELETE", url, **kwargs) + end + end + end +end diff --git a/drivers/cisco/webex/status_code.cr b/drivers/cisco/webex/status_code.cr new file mode 100644 index 00000000000..4e48f8d80ba --- /dev/null +++ b/drivers/cisco/webex/status_code.cr @@ -0,0 +1,23 @@ +module Cisco + module Webex + class StatusCode + private property code : Int32 + + def initialize(@code : Int32) + end + + def valid? : Bool + case @code + when 200, 204 + true + else + false + end + end + + def message : String + Constants::STATUS_CODES[@code] + end + end + end +end diff --git a/drivers/cisco/webex/utils.cr b/drivers/cisco/webex/utils.cr new file mode 100644 index 00000000000..1dc7c015cc1 --- /dev/null +++ b/drivers/cisco/webex/utils.cr @@ -0,0 +1,23 @@ +module Cisco + module Webex + module Utils + def self.hash_from_items_with_values(**kwargs) + kwargs = kwargs.map { |k, v| + if v != nil && v != "" + {"#{k}" => v} + end + } + + kwargs.reject!(nil) + kwargs = kwargs.reduce { |acc, i| acc.try(&.merge(i.not_nil!)) } + + kwargs + end + + def self.named_tuple_from_hash(hash) + named_tuple = NamedTuple.new(roomId: String, text: String) + named_tuple.from(hash) + end + end + end +end diff --git a/drivers/cisco/webex/workspace/actions.cr b/drivers/cisco/webex/workspace/actions.cr new file mode 100644 index 00000000000..0a436916ab5 --- /dev/null +++ b/drivers/cisco/webex/workspace/actions.cr @@ -0,0 +1,107 @@ +require "json" +require "./integration" +require "connect-proxy" + +module WebxWorkspace + abstract struct Action + include JSON::Serializable + + @[JSON::Field(key: "sub")] + getter org_id : String + + @[JSON::Field(key: "appId")] + getter app_id : String + + getter action : String + + use_json_discriminator "action", {"updateApproved": UpdateApproved, "deprovision": Deprovisioning, "healthCheck": HealthCheckRequest, + "provision": Provisioning, "update": Updated} + + def initialize(@app_id, @org_id, @action) + end + end + + struct Deprovisioning < Action + def initialize(@app_id, @org_id, @action) + super + end + end + + struct HealthCheckRequest < Action + def initialize(@app_id, @org_id, @action) + super + end + end + + struct Updated < Action + @[JSON::Field(key: "manifestVersion")] + getter manifest_version : String + + def initialize(@app_id, @org_id, @action, @manifest_version) + super + end + end + + struct UpdateApproved < Action + @[JSON::Field(key: "refreshToken")] + getter refresh_token : String + + def initialize(@app_id, @org_id, @action, @refresh_token) + super + end + end + + struct Provisioning < Action + @[JSON::Field(key: "oauthUrl")] + getter oauth_url : String + + @[JSON::Field(key: "orgName")] + getter org_name : String + + @[JSON::Field(key: "appUrl")] + getter app_url : String + + @[JSON::Field(key: "userId")] + getter user_id : String + + @[JSON::Field(key: "manifestUrl")] + getter manifest_url : String + + @[JSON::Field(key: "expiryTime")] + getter expiry_time : Time + + @[JSON::Field(key: "webexapisBaseUrl")] + getter webexapis_base_url : String + + getter scopes : String + getter region : String + + @[JSON::Field(key: "iat", converter: Time::EpochConverter)] + getter issued_at : Time + + getter jti : String + + @[JSON::Field(key: "refreshToken")] + getter refresh_token : String + + @[JSON::Field(key: "xapiAccess")] + getter xapi_access : XapiAccessKeys + + def initialize(@app_id, @org_id, @action, @oauth_url, @action_url, @org_name, @app_url, @user_id, @manifest_url, @expiry_time, @webexapis_base_url, + @scopes, @region, @issued_at, @jti, @refresh_token, @xapi_access) + super(@app_id, @org_id) + end + + def oauth_uri : URI + URI.parse(oauth_url) + end + + def app_uri : URI + URI.parse(app_url) + end + + def refresh_token=(new_token) : Nil + @refresh_token = new_token unless @refresh_token == new_token + end + end +end diff --git a/drivers/cisco/webex/workspace/integration.cr b/drivers/cisco/webex/workspace/integration.cr new file mode 100644 index 00000000000..1b472541cbf --- /dev/null +++ b/drivers/cisco/webex/workspace/integration.cr @@ -0,0 +1,99 @@ +require "json" + +module WebxWorkspace + record XapiAccessKeys, commands : Array(String)?, statuses : Array(String)?, events : Array(String)? do + include JSON::Serializable + end + + record CustomerDetails, id : String, name : String? do + include JSON::Serializable + end + + struct Queue + include JSON::Serializable + + getter state : QueueState + + @[JSON::Field(key: "pollUrl")] + getter poll_url : String? + + def initialize(@state, @poll_url = nil) + end + + def self.enabled + new(QueueState::Enabled) + end + end + + enum QueueState + Enabled + Disabled + Remove + Uknown + end + + enum ProvisioningState + In_Progress + Error + Completed + Uknown + end + + enum OperationalState + Operational + Impaired + Outage + Token_Invalid + Not_Applicable + Uknown + end + + struct Integration + include JSON::Serializable + JSON::Serializable::Unmapped + + getter id : String + + @[JSON::Field(key: "manifestVersion")] + getter manifest_version : Int32 + + getter scopes : Array(String) + getter roles : Array(String) + + @[JSON::Field(key: "xapiAccessKeys")] + getter xapi_access_keys : XapiAccessKeys? + + @[JSON::Field(key: "createdAt")] + getter created_at : Time + + @[JSON::Field(key: "updatedAt")] + getter updated_at : Time + + @[JSON::Field(key: "provisioningState")] + getter provisioning_state : ProvisioningState + + @[JSON::Field(key: "actionsUrl")] + getter action_url : String? + + @[JSON::Field(key: "operationalState")] + getter operational_state : OperationalState? + + getter customer : CustomerDetails? + getter queue : Queue? + end + + struct IntegrationUpdate + include JSON::Serializable + + @[JSON::Field(key: "actionsUrl")] + getter action_url : String? + + @[JSON::Field(key: "provisioningState")] + getter provisioning_state : ProvisioningState? + getter customer : CustomerDetails? + getter queue : Queue? + + def initialize(@provisioning_state, @queue, @action_url = nil, @customer = nil) + end + end +end diff --git a/drivers/cisco/webex/workspace/jws.cr b/drivers/cisco/webex/workspace/jws.cr new file mode 100644 index 00000000000..80925efa6e8 --- /dev/null +++ b/drivers/cisco/webex/workspace/jws.cr @@ -0,0 +1,135 @@ +require "json" +require "base64" +require "uri" +require "connect-proxy" +require "simple_retry" +require "./lib" +require "jwt" +require "./actions" + +module WebxWorkspace + enum KeyType + EC + end + + record ECKey, kid : String, kty : KeyType, use : String, alg : String, crv : String, x : String, y : String do + include JSON::Serializable + include JSON::Serializable::Unmapped + + def pub_key : String + raise "Workspace integration requires Key type to be EC only" unless kty == KeyType::EC + + curve_nid = curve_to_nid(crv) + x_bin = base64url_decode(x) + y_bin = base64url_decode(y) + + group = LibCrypto.ec_group_new_by_curve_name(curve_nid) + point = LibCrypto.ec_point_new(group) + + x_bn = OpenSSL::BN.from_bin(x_bin) + y_bn = OpenSSL::BN.from_bin(y_bin) + + LibCrypto.ec_point_set_affine_coordinates(group, point, x_bn, y_bn, nil) + ec_key = LibCrypto.ec_key_new + LibCrypto.ec_key_set_group(ec_key, group) + LibCrypto.ec_key_set_public_key(ec_key, point) + + io = IO::Memory.new + bio = OpenSSL::BIO.new(io) + LibCrypto.pem_write_bio_ec_pubkey(bio, ec_key) + pem = io.to_s + + LibCrypto.ec_point_free(point) + LibCrypto.ec_group_free(group) + LibCrypto.ec_key_free(ec_key) + pem + end + + private def curve_to_nid(curve : String) : Int32 + case curve + when "P-256" then LibCrypto::NID_X9_62_prime256v1 + when "P-384" then LibCrypto::NID_secp384r1 + when "P-521" then LibCrypto::NID_secp521r1 + else raise "Unsupported curve: #{curve}" + end + end + + private def base64url_decode(input : String) : Bytes + normalized = input.gsub('-', '+').gsub('_', '/') + padding = (4 - normalized.size % 4) % 4 + normalized += "=" * padding + Base64.decode(normalized) + end + end + + class KeySet + getter! keys : Array(ECKey) + getter url : URI + @client : ConnectProxy::HTTPClient + + def initialize(@url, proxy_config = nil) + @client = WebxWorkspace.new_client(url, proxy_config) + end + + private def load + SimpleRetry.try_to( + max_attempts: 3, + retry_on: Exception, + base_interval: 2.milliseconds, + ) do |_| + resp = @client.get(url.request_target) + raise "unable to retrieve key set from url #{url}" unless resp.success? + @keys = Array(ECKey).from_json(resp.body, "keys") + end + end + + def [](kid : String) : ECKey + load unless keys? + found = keys.select { |key| key.kid == kid } + raise "invalid key id '#{kid}', not found in server retrieved keyset" if found.empty? + found.first + end + end + + class JWTDecoder + REGIONAL_KEY_SET_URLS = { + "us-west-2_r" => URI.parse("https://xapi-r.wbx2.com/jwks"), + "us-east-2_a" => URI.parse("https://xapi-a.wbx2.com/jwks"), + "eu-central-1_k" => URI.parse("https://xapi-k.wbx2.com/jwks"), + "us-east-1_int13" => URI.parse("https://xapi-intb.wbx2.com/jwks"), + "us-gov-west-1_a1" => URI.parse("https://xapi.gov.ciscospark.com/jwks"), + } of String => URI + + property default_region : String = "us-east-2_a" + @verification_keys : KeySet? + @proxy_config : WebxWorkspace::ProxyConfig? + + def initialize(@proxy_config = nil) + end + + def decode_action(jwt : String) : Action + verified = verify_jws(jwt) + Action.from_json(verified.to_json) + end + + private def key_set(region) : KeySet + url = REGIONAL_KEY_SET_URLS[region]? || REGIONAL_KEY_SET_URLS[default_region] + @verification_keys ||= KeySet.new(url, @proxy_config) + end + + private def verify_jws(jwt : String) + claims, header = JWT.decode(token: jwt, verify: false, validate: false) + type = header["typ"].as_s + algo = JWT::Algorithm.parse(header["alg"].as_s) + kid = header["kid"].as_s + raise "invalid token type '#{type}', expected token of type JWT" unless type == "JWT" + raise "invalid jwt algorithm '#{algo}', workspace integration required algorithm is ES256" unless algo == JWT::Algorithm::ES256 + + region = claims.as_h["region"]?.try &.as_s? || default_region + + ec_pubkey = key_set(region)[kid].pub_key + payload, _ = JWT.decode(token: jwt, key: ec_pubkey, algorithm: algo, verify: true, validate: true) + payload + end + end +end diff --git a/drivers/cisco/webex/workspace/lib.cr b/drivers/cisco/webex/workspace/lib.cr new file mode 100644 index 00000000000..bc01267567d --- /dev/null +++ b/drivers/cisco/webex/workspace/lib.cr @@ -0,0 +1,11 @@ +require "openssl_ext" + +lib LibCrypto + NID_secp384r1 = 715 + NID_secp521r1 = 716 + + fun ec_group_new_by_curve_name = EC_GROUP_new_by_curve_name(nid : Int32) : EC_GROUP + fun ec_key_set_private_key = EC_KEY_set_private_key(key : EC_KEY, priv_key : Bignum*) : Int32 + fun ec_key_set_public_key = EC_KEY_set_public_key(key : EC_KEY, pub : EcPoint*) : Int32 + fun ec_point_set_affine_coordinates = EC_POINT_set_affine_coordinates(group : EC_GROUP, p : EcPoint*, x : Bignum*, y : Bignum*, ctx : Void*) : Int32 +end diff --git a/drivers/cisco/webex/workspace/messages.cr b/drivers/cisco/webex/workspace/messages.cr new file mode 100644 index 00000000000..8e4546c4d9d --- /dev/null +++ b/drivers/cisco/webex/workspace/messages.cr @@ -0,0 +1,83 @@ +require "json" + +module WebxWorkspace + record Event, key : String, value : String, timestamp : Time do + include JSON::Serializable + end + + record StatusChanges, updated : Hash(String, JSON::Any)?, removed : Array(String)? do + include JSON::Serializable + end + + module ChangeStatus + @[JSON::Field(key: "appId")] + getter app_id : String + + @[JSON::Field(key: "deviceId")] + getter device_id : String + + @[JSON::Field(key: "workspaceId")] + getter workspace_id : String + + @[JSON::Field(key: "orgId")] + getter org_id : String + + getter timestamp : Time + end + + abstract struct Message + include JSON::Serializable + + getter type : String + + use_json_discriminator "type", {"status": StatusMessage, "events": EventsMessage, "healthCheck": HealthCheckMessage, "action": ActionMessage} + + def initialize(@type) + end + end + + struct ActionMessage < Message + getter jwt : String + + def initialize(@jwt, @type) + super + end + end + + struct StatusMessage < Message + include ChangeStatus + + @[JSON::Field(key: "isFullSync")] + getter? full_sync : Bool + + getter changes : StatusChanges + + def initialize(@type, @app_id, @device_id, @workspace_id, @org_id, @timestamp, @full_sync, @changes) + super + end + end + + struct EventsMessage < Message + include ChangeStatus + + getter events : Array(Event) + + def initialize(@type, @app_id, @device_id, @workspace_id, @org_id, @timestamp, @events) + super + end + end + + struct HealthCheckMessage < Message + @[JSON::Field(key: "orgId")] + getter org_id : String? + + @[JSON::Field(key: "appId")] + getter app_id : String? + + getter timestamp : Time + + def initialize(@type, @timestamp, @org_id = nil, @app_id = nil) + super + end + end +end diff --git a/drivers/cisco/webex/workspace/queue_poller.cr b/drivers/cisco/webex/workspace/queue_poller.cr new file mode 100644 index 00000000000..16b810fd1a6 --- /dev/null +++ b/drivers/cisco/webex/workspace/queue_poller.cr @@ -0,0 +1,54 @@ +require "uri" +require "json" +require "log" +require "connect-proxy" +require "./jws" +require "./messages" + +module WebxWorkspace + class QueuePoller + alias HeaderTokens = Proc(HTTP::Headers) + Log = ::Log.for(self) + getter url : URI + getter token_headers : HeaderTokens + getter decoder : JWTDecoder + getter consumer : (Array(Message)) -> + @running : Atomic(Bool) + @client : ConnectProxy::HTTPClient + + def initialize(@url, @decoder, proxy_config : WebxWorkspace::ProxyConfig?, @token_headers, @consumer) + @running = Atomic(Bool).new(false) + @client = WebxWorkspace.new_client(@url, proxy_config) + end + + def start + return if @running.get + @running.set(true) + spawn do + loop do + break unless @running.get + headers = token_headers.call + response = @client.get(url.request_target, headers: headers) + raise "failed to patch integration endpoint, code #{response.status_code}, body #{response.body}" unless response.success? + messages = Array(Message).from_json(response.body, "messages") + consume(messages) + rescue ex : Exception + Log.error(exception: ex) { "encountered error in polling, sleeping 10 seconds" } + sleep(10.seconds) + end + end + end + + def stop + @running.set(false) + end + + private def consume(messages : Array(Message)) + begin + consumer.call(messages) + rescue ex : Exception + Log.error(exception: ex) { "unexpected exception raised in message consumer" } + end + end + end +end diff --git a/drivers/cisco/webex/workspace/workspace.cr b/drivers/cisco/webex/workspace/workspace.cr new file mode 100644 index 00000000000..e41c80a423c --- /dev/null +++ b/drivers/cisco/webex/workspace/workspace.cr @@ -0,0 +1,135 @@ +require "json" +require "connect-proxy" +require "./jws" +require "./integration" +require "./queue_poller" +require "../cloud_xapi/models" + +module WebxWorkspace + alias ProxyConfig = NamedTuple(host: String, port: Int32, auth: NamedTuple(username: String, password: String)?) + + def self.new_client(url : URI, proxy_config : ProxyConfig? = nil) + client = ConnectProxy::HTTPClient.new(url) + return client unless proxy_config + if proxy_conf = proxy_config + if proxy_conf[:host].presence + proxy = ConnectProxy.new(**proxy_conf) + client.before_request { client.set_proxy(proxy) } + end + elsif ConnectProxy.behind_proxy? + begin + proxy = ConnectProxy.new(*ConnectProxy.parse_proxy_url) + client.before_request { client.set_proxy(proxy) } + rescue error + end + end + client + end + + class WorkspaceIntegration + getter client_id : String + getter client_secret : String + getter jwt_decoder : JWTDecoder + getter! queue_url : String + + getter! provisioning : Provisioning + getter! oauth_tokens : CloudXAPI::Models::DeviceToken + getter! poller : QueuePoller + getter! proxy_config : ProxyConfig + + def initialize(@client_id, @client_secret, @proxy_config = nil) + @jwt_decoder = JWTDecoder.new(@proxy_config) + end + + def initialized? : Bool + !!(oauth_tokens?) + end + + def update_auth_tokens(updated : CloudXAPI::Models::DeviceToken) + if oauth_tokens?.nil? + @oauth_tokens = updated + return + end + + @oauth_tokens = updated if updated.refresh_expiry > oauth_tokens.refresh_expiry || updated.expiry > oauth_tokens.expiry + end + + def queue_url=(url : String) + @queue_url = url unless @queue_url == url + end + + def init_with_queue(activation_jwt : String) + update = IntegrationUpdate.new(ProvisioningState::Completed, Queue.enabled) + init(activation_jwt, update) + end + + def init(activation_jwt : String, initial_update : IntegrationUpdate) + @provisioning = jwt_decoder.decode_action(activation_jwt).as(Provisioning) + init_tokens + post_update(initial_update) + oauth_tokens + end + + def init_tokens + if provisioning? + refresh_token = provisioning.refresh_token + oauth_uri = provisioning.oauth_uri + elsif oauth_tokens? + refresh_token = oauth_tokens.refresh_token + oauth_uri = URI.parse("https://webexapis.com/v1/access_token") + else + raise "Invalid state: neither provisioning nor auth tokens are valid." + end + + body = URI::Params.build do |form| + form.add("grant_type", "refresh_token") + form.add("client_id", client_id) + form.add("client_secret", client_secret) + form.add("refresh_token", refresh_token) + end + + headers = HTTP::Headers{ + "Content-Type" => "application/x-www-form-urlencoded", + "Accept" => "application/json", + } + + client = WebxWorkspace.new_client(oauth_uri, @proxy_config) + response = client.post(oauth_uri.request_target, headers: headers, body: body) + raise "failed to refresh access token for client-id #{client_id}, code #{response.status_code}, body #{response.body}" unless response.success? + + @oauth_tokens = CloudXAPI::Models::DeviceToken.from_json(response.body) + provisioning.refresh_token = oauth_tokens.refresh_token if provisioning? + oauth_tokens + end + + def queue_poller(&block : Array(Message) ->) + return poller if poller? + raise "The queue url has not been initialized. Make sure to init with an update that enables a queue" unless queue_url? + @poller = QueuePoller.new(URI.parse(queue_url), jwt_decoder, @proxy_config, ->headers, block) + poller + end + + def headers : HTTP::Headers + HTTP::Headers{ + "Authorization" => oauth_tokens.auth_token, + "Content-Type" => "application/json", + "Accept" => "application/json", + } + end + + def keep_token_refreshed + return nil unless oauth_tokens? + init_tokens if 1.minute.from_now < oauth_tokens.expiry || 1.minute.from_now < oauth_tokens.refresh_expiry + end + + private def post_update(update : IntegrationUpdate) + client = WebxWorkspace.new_client(provisioning.app_uri, @proxy_config) + response = client.patch(provisioning.app_uri.request_target, headers: headers, body: update.to_json) + raise "failed to patch integration endpoint, code #{response.status_code}, body #{response.body}" unless response.success? + + integration = Integration.from_json(response.body) + @queue_url = integration.queue.try &.poll_url + integration + end + end +end diff --git a/drivers/cisco/webex/workspace_xapi.cr b/drivers/cisco/webex/workspace_xapi.cr new file mode 100644 index 00000000000..75251ae3df6 --- /dev/null +++ b/drivers/cisco/webex/workspace_xapi.cr @@ -0,0 +1,151 @@ +require "placeos-driver" +require "./cloud_xapi/ui_extensions" +require "./workspace/workspace" + +class Cisco::Webex::WorkspaceXApi < PlaceOS::Driver + include CloudXAPI::UIExtensions + + # Discovery Information + descriptive_name "Webex Cloud xAPI via Workspace Integration" + generic_name :WebxWorkspaceXApi + + uri_base "https://webexapis.com" + + default_settings({ + cisco_client_id: "", + cisco_client_secret: "", + cisco_provisional_token: "", + debug_payload: false, + }) + + getter! workspace_integration : WebxWorkspace::WorkspaceIntegration + + alias ProxyConfig = NamedTuple(host: String, port: Int32, auth: NamedTuple(username: String, password: String)?) + @cisco_client_id : String = "" + @cisco_client_secret : String = "" + @cisco_provisional_token : String = "" + @proxy_config : ProxyConfig? = nil + @debug_payload : Bool = false + + def on_load + on_update + schedule.every(1.minute) { keep_token_refreshed } + end + + def on_update + @cisco_client_id = setting(String, :cisco_client_id) + @cisco_client_secret = setting(String, :cisco_client_secret) + @cisco_provisional_token = setting(String, :cisco_provisional_token) + @proxy_config = setting?(ProxyConfig, :proxy) + @debug_payload = setting?(Bool, :debug_payload) || false + + if !workspace_integration? && !@cisco_client_id.blank? && !@cisco_client_secret.blank? && !@cisco_provisional_token.blank? + @workspace_integration = WebxWorkspace::WorkspaceIntegration.new(@cisco_client_id, @cisco_client_secret, @proxy_config) + end + + if (workspace_integration? && !workspace_integration.initialized?) && (device_token = setting?(DeviceToken, :cisco_token_pair)) + workspace_integration.update_auth_tokens(device_token) + queue_url = setting(String, :cisco_queue_poll_url) + workspace_integration.queue_url = queue_url + end + end + + def led_mode?(device_id : String) + config?(device_id, "UserInterface.LedControl.Mode") + end + + def led_mode(device_id : String, value : String) + value = value.downcase.capitalize + config("UserInterface.LedControl.Mode", device_id, value) + end + + def led_colour?(device_id : String) + status(device_id, "UserInterface.LedControl.Color") + end + + command({"UserInterface LedControl Color Set" => :led_colour}, color: Colour) + + def status(device_id : String, name : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + form.add("name", name) + end + hdrs = self.headers + logger.debug { {msg: "Status HTTP Data:", headers: hdrs.to_json, query: query.to_s} } if @debug_payload + + response = get("/v1/xapi/status?#{query}", headers: hdrs) + raise "failed to query status for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def command(name : String, payload : String) + hdrs = self.headers + logger.debug { {msg: "Command HTTP Data:", headers: hdrs.to_json, command: name, payload: payload} } if @debug_payload + + response = post("/v1/xapi/command/#{name}", headers: hdrs, body: payload) + raise "failed to execute command #{name}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def config?(device_id : String, name : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + form.add("key", name) + end + + hdrs = self.headers + logger.debug { {msg: "Status HTTP Data:", headers: hdrs.to_json, query: query.to_s} } if @debug_payload + + response = get("/v1/deviceConfigurations?#{query}", headers: hdrs) + raise "failed to query configuration for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def config(name : String, device_id : String, value : String) + body = { + "op" => "replace", + "path" => "#{name}/sources/configured/value", + "value" => value, + } + + config(device_id, body.to_json) + end + + def config(device_id : String, payload : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + end + hdrs = self.headers + logger.debug { {msg: "Config HTTP Data:", headers: hdrs.to_json, query: query, payload: payload} } if @debug_payload + + response = patch("/v1/deviceConfigurations?#{query}", headers: hdrs, body: payload) + raise "failed to patch config on device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + protected def consume_messages(messages : Array(WebxWorkspace::Message)) + messages.select(WebxWorkspace::StatusMessage).select(WebxWorkspace::EventsMessage).map do |message| + logger.debug { {message: "Polled #{message.type} message", polled: message.to_json} } + end + end + + protected def headers + @workspace_integration = WebxWorkspace::WorkspaceIntegration.new(@cisco_client_id, @cisco_client_secret) unless workspace_integration? + unless workspace_integration.initialized? + workspace_integration.init_with_queue(@cisco_provisional_token) + define_setting(:cisco_token_pair, workspace_integration.oauth_tokens) + define_setting(:cisco_queue_poll_url, workspace_integration.queue_url) + queue = workspace_integration.queue_poller(&->consume_messages(Array(WebxWorkspace::Message))) + queue.start + end + workspace_integration.headers + end + + protected def keep_token_refreshed : Nil + return unless workspace_integration.initialized? + + if device_token = workspace_integration.keep_token_refreshed + define_setting(:cisco_token_pair, device_token) + end + end +end diff --git a/drivers/clipsal/c_bus.cr b/drivers/clipsal/c_bus.cr new file mode 100644 index 00000000000..e93e2d92b1f --- /dev/null +++ b/drivers/clipsal/c_bus.cr @@ -0,0 +1,252 @@ +require "placeos-driver" +require "placeos-driver/interface/lighting" + +# Documentation: https://aca.im/driver_docs/Clipsal/CBUS-Lighting-Application.pdf +# and https://aca.im/driver_docs/Clipsal/CBUS-Trigger-Control-Application.pdf + +class Clipsal::CBus < PlaceOS::Driver + include Interface::Lighting::Scene + include Interface::Lighting::Level + alias Area = Interface::Lighting::Area + + # Discovery Information + descriptive_name "Clipsal CBus Lighting" + generic_name :Lighting + tcp_port 10001 + + default_settings({ + trigger_groups: [0xCA], + }) + + @trigger_groups : Array(UInt8) = [0xCA_u8] + + def on_load + queue.wait = false + queue.delay = 100.milliseconds + transport.tokenizer = Tokenizer.new("\r") + + on_update + end + + def on_update + @trigger_groups = setting?(Array(UInt8), :trigger_groups) || [0xCA_u8] + end + + def disconnected + schedule.clear + end + + def connected + # Ensure we are in smart mode + send("|||\r", priority: 99) + + # maintain the connection + schedule.every(1.minute) do + logger.debug { "maintaining connection" } + send("|||\r", priority: 0) + end + end + + def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32) + application, group = get_application_group(area, 0xCA) + action = scene & 0xFF + command = Bytes[0x05, application, 0x00, 0x02, group, action.to_u8] + + self[area] = action + + do_send(command) + end + + def lighting_scene?(area : Area? = nil) + _application, group = get_application_group(area, 0xCA) + self[Area.new(group.to_u32)]? + end + + RAMP_RATES = { + (...2_000_u32) => 0b0000_u8, # instant + (2_000_u32...6_000_u32) => 0b0001_u8, # 4s + (6_000_u32...10_000_u32) => 0b0010_u8, # 8s + (10_000_u32...15_000_u32) => 0b0011_u8, # 12s + (15_000_u32...25_000_u32) => 0b0100_u8, # 20s + (25_000_u32...35_000_u32) => 0b0101_u8, # 30s + (35_000_u32...50_000_u32) => 0b0110_u8, # 40s + (50_000_u32...75_000_u32) => 0b0111_u8, # 1m + (75_000_u32...105_000_u32) => 0b1000_u8, # 1m 30s + (105_000_u32...150_000_u32) => 0b1001_u8, # 2m + (150_000_u32...240_000_u32) => 0b1010_u8, # 3m + (240_000_u32...360_000_u32) => 0b1011_u8, # 5m + (360_000_u32...510_000_u32) => 0b1100_u8, # 7m + (510_000_u32...720_000_u32) => 0b1101_u8, # 10m + (720_000_u32...960_000_u32) => 0b1110_u8, # 15m + (960_000_u32...) => 0b1111_u8, # 17m + } + + def lookup_ramp_rate(fade_time : UInt32) : UInt8 + range = RAMP_RATES.keys.find(&.includes?(fade_time)) + rate = RAMP_RATES[range] + + # The command is structured as: 0b0xxxx010 where xxxx == rate + ((rate & 0x0F_u8) << 3) | 0b010_u8 + end + + LEVEL_PERCENTAGE = 0xFF / 100 + + def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32) + application, group = get_application_group(area, 0x38) + + level = level.clamp(0.0, 100.0) + level_byte = (level * LEVEL_PERCENTAGE).to_u8 + group = (group & 0xFF).to_u8 + rate = lookup_ramp_rate(fade_time) + + # stop_fading(group) + stop_f = cmd_string(Bytes[0x05, 0x38, 0x00, 0x09, group]) + command = stop_f + cmd_string(Bytes[0x05, application, 0x00, rate, group, level_byte]) + + self["#{area}_level"] = level + + send(command, name: "level_#{application}_#{group}") + end + + def stop_fading(group : UInt8) + do_send(Bytes[0x05, 0x38, 0x00, 0x09, group]) + end + + # return the current level + def lighting_level?(area : Area? = nil) + _application, group = get_application_group(area, 0x38) + self[Area.new(group.to_u32, append: "level")]? + end + + def received(data, task) + # extract the hex string encoded bytes + payload = String.new(data) + logger.debug { "CBus sent: #{payload}" } + data = payload[1..-2].hexbytes + + if !check_checksum(data) + return task.try(&.abort("CBus checksum failed")) + end + + # We are only looking at Point -> MultiPoint commands + # 0x03 == Point -> Point -> MultiPoint + # 0x06 == Point -> Point + if data[0] != 0x05 + logger.debug { "was not a Point -> MultiPoint response: type 0x#{data[0].to_s(16)}" } + return + end + + application = data[1] # The application being referenced + commands = data[3..-2].to_a # Remove the header + checksum + + while commands.size > 0 + current = commands.shift + + case application + when .in?(@trigger_groups) # Trigger group + area = if application == 0xCA_u8 + Area.new(commands.shift.to_u32) + else + Area.new(commands.shift.to_u32, channel: application.to_u32) + end + + case current + when 0x02 # Trigger Event (ex: 0504CA00 020101 29) + self[area] = commands.shift # Action selector + when 0x01 # Trigger Min + self[area] = 0 + when 0x79 # Trigger Max + self[area] = 0xFF + when 0x09 # Indicator Kill (ex: 0504CA00 0901 23) + logger.debug { "trigger kill request: grp 0x#{commands[0].to_s(16)}" } + # Group (turns off indicators of all scenes triggered by this group) + else + logger.debug { "unknown trigger group request 0x#{current.to_s(16)}" } + break # We don't know what data is here + end + when 0x30..0x5F # Lighting group + case current + when 0x01 # Group off (ex: 05043800 0101 0102 0103 0104 7905 33) + self[Area.new(commands.shift.to_u32, append: "level")] = 0.0 + when 0x79 # Group on (ex: 05013800 7905 44) + self[Area.new(commands.shift.to_u32, append: "level")] = 100.0 + when 0x02 # Blinds up or stop + # Example: 05083000022FFF93 + group = commands.shift + value = commands.shift + area = Area.new(group.to_u32, append: "blind") + + if value == 0xFF + self[area] = :up + elsif value == 5 + self[area] = :stopped + end + when 0x1A # Blinds down + # Example: 050830001A2F007A + group = commands.shift + value = commands.shift + self[Area.new(group.to_u32, append: "blind")] = :down if value == 0x00 + when 0x09 # Terminate Ramp + logger.debug { "terminate ramp request: grp 0x#{commands[0].to_s(16)}" } + commands.shift # Group address + else + # Ramp to level (ex: 05013800 0205FF BC) + # Header cmd cksum + if ((current & 0b10000101) == 0) && commands.size > 1 + logger.debug { "ramp request: grp 0x#{commands[0].to_s(16)} - level 0x#{commands[1].to_s(16)}" } + commands.shift(2) # Group address, level + else + logger.debug { "unknown lighting request 0x#{current.to_s(16)}" } + break # We don't know what data is here + end + end + else + logger.debug { "unknown application request app 0x#{application.to_s(16)}" } + break # We haven't programmed this application + end + end + + task.try &.success + end + + protected def get_application_group(area : Area?, app_default = 0x38) + group = area.try &.id + raise "area (cbus group) id required" unless group + application = (area.try(&.channel) || app_default).to_u8 + + {application, group.to_u8 & 0xFF_u8} + end + + protected def checksum(data : Bytes) : Bytes + check = 0 + data.each do |byte| + check += byte + end + check = check % 0x100 + check = ((check ^ 0xFF) + 1) & 0xFF + Bytes[check.to_u8] + end + + protected def check_checksum(data : Bytes) : Bool + check = 0 + data.each do |byte| + check += byte + end + (check % 0x100) == 0x00 + end + + protected def cmd_string(command : Bytes) : String + String.build do |str| + str << "\\" + str << command.hexstring.upcase + str << checksum(command).hexstring.upcase + str << "\r" + end + end + + protected def do_send(command : Bytes, **options) + cmd = cmd_string(command) + logger.debug { "Requesting CBus: #{cmd}" } + send(cmd, **options) + end +end diff --git a/drivers/clipsal/c_bus_spec.cr b/drivers/clipsal/c_bus_spec.cr new file mode 100644 index 00000000000..bf3eb128707 --- /dev/null +++ b/drivers/clipsal/c_bus_spec.cr @@ -0,0 +1,12 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Clipsal::CBus" do + should_send("|||\r") + + transmit "\\05CA0002250109\r" + status["area37"]?.should eq 1 + + exec :set_lighting_scene, 2, {id: 37} + should_send "\\05CA0002250208\r" + status["area37"]?.should eq 2 +end diff --git a/drivers/comm_box/v3x_v4.cr b/drivers/comm_box/v3x_v4.cr new file mode 100644 index 00000000000..ba001a56a4b --- /dev/null +++ b/drivers/comm_box/v3x_v4.cr @@ -0,0 +1,172 @@ +require "placeos-driver" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" + +class CommBox::V3X_V4 < PlaceOS::Driver + include Interface::Powerable + include Interface::Muteable + + enum Input + Vga = 111 # pc in manual + Dvi = 221 + Hdmi = 211 + Hdmi2 = 212 + Hdmi3 = 213 + Hdmi4 = 214 + DisplayPort = 231 + Dtv = 250 + Media = 310 + end + include Interface::InputSelection(Input) + + # Discovery Information + tcp_port 4660 + descriptive_name "CommBox V3, V3X and V4 Display " + generic_name :Display + + DELIMITER = "\r" + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds) { power? } + end + + def disconnected + schedule.clear + end + + def switch_to(input : Input, **options) + send "!200INPT #{input.value}\r", name: "input" + end + + def volume(value : Int32 | Float64, **options) + data = value.to_f.clamp(0.0, 100.0).round_away.to_i + send "!200VOLM #{data}\r", name: "volume" + end + + # Mutes both audio/video + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + mute_audio(state) if layer.audio? || layer.audio_video? + end + + # Emulate audio mute + def mute_audio(state : Bool = true) + send "!200MUTE #{state ? 1 : 0}\r", name: "mute" + end + + def power? + send "!200POWR ?\r" + end + + def input? + send "!200INPT ?\r" + end + + def volume? + send "!200VOLM ?\r" + end + + def toggle_mute + send "!200MUTE 2\r", name: "toggle_mute" + end + + def freeze_screen + send "!200FREZ 1\r", name: "freeze_on" + end + + def toggle_freeze_screen + send "!200FREZ 2\r", name: "togge_freeze" + end + + def freeze_screen? + send "!200FREZ ?\r" + end + + def power(state : Bool) + if state + send "!200POWR 1\r", name: "power" + else + send "!200POWR 0\r", name: "power" + end + end + + def age_mode(state : Bool) + if state + send "!200AGEM 1\r", name: "age_mode" + else + send "!200AGEMR 0\r", name: "age_mode" + end + end + + def toggle_age_mode + send "!200AGEM 2\r", name: "age_mode" + end + + def age_mode? + send "!200AGEM ?\r" + end + + @[Security(Level::Administrator)] + def custom_send(raw : String) + send "#{raw}\r" + end + + # Command is in format of header, version, ID, command name, separator, parameters, terminator + # A common received function for handling responses + def received(data, task) + data = String.new(data).strip + logger.debug { "received data: #{data}" } + + cmd, value = data.split("=", 2) + return task.try &.abort if error_check(cmd, value) + + case cmd + when "!201VOLM" + self[:volume_level] = value.to_i + when "!201MUTE" + self[:mute] = value == "1" + when "!201INPT" + self[:input] = Input.from_value(value.to_i) + when "!201POWR" + self[:power] = value == "1" + when "!201FREZ" + self[:freeze] = value + when "!201AGEM" + self[:age_mode] = value + else + logger.debug { "Unknown Output: #{cmd} with value #{value}" } + end + + task.try &.success + end + + def error_check(data : String, value : String) : Bool + return false unless value.starts_with?("ERR") + + case value + when "ERR1" + logger.warn { "ERR1 - The command is invalid" } + true + when "ERR2" + logger.warn { "ERR2 - The parameter is out of range or not supported." } + true + when "ERR3" + logger.warn { "ERR3 - The command is unavailable at this time." } + true + when "ERR4" + logger.warn { "ERR4 - General failure - all other errors." } + true + else + logger.warn { "Unknown Error : #{value}" } + true + end + end +end diff --git a/drivers/comm_box/v3x_v4_spec.cr b/drivers/comm_box/v3x_v4_spec.cr new file mode 100644 index 00000000000..27b083d2cbe --- /dev/null +++ b/drivers/comm_box/v3x_v4_spec.cr @@ -0,0 +1,34 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "CommBox::V3X_V4" do + exec(:power, true) + should_send("!200POWR 1\r") + responds("!201POWR=1\r") + status[:power].should eq(true) + + exec(:power?) + should_send("!200POWR ?\r") + responds("!201POWR=1\r") + + exec(:volume, 24) + should_send("!200VOLM 24\r") + responds("!201VOLM=24\r") + + exec(:volume, 6) + should_send("!200VOLM 6\r") + responds("!201VOLM=6\r") + + exec(:mute, true) + # Audio mute + should_send("!200MUTE 1\r") + responds("!201MUTE=1\r") + + exec(:mute, false) + # Audio mute + should_send("!200MUTE 0\r") + responds("!201MUTE=0\r") + + exec(:switch_to, "hdmi") + should_send("!200INPT 211\r") + responds("!201INPT=211\r") +end diff --git a/drivers/company_3m/displays/wall_display.cr b/drivers/company_3m/displays/wall_display.cr new file mode 100644 index 00000000000..5f31f6e37dc --- /dev/null +++ b/drivers/company_3m/displays/wall_display.cr @@ -0,0 +1,314 @@ +require "placeos-driver" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "bindata" + +# Protocol: https://aca.im/driver_docs/X3M/RS-232%20Instructions.pdf + +class Company3M::Displays::WallDisplay < PlaceOS::Driver + include Interface::Powerable + include Interface::AudioMuteable + + enum Input + VGA = 0 + DVI = 1 + HDMI = 2 + DisplayPort = 3 + end + + include Interface::InputSelection(Input) + + # Discovery Information + descriptive_name "3M Wall Display" + generic_name :Display + description <<-DESC + Display control is via RS-232 only. Ensure IP -> RS-232 converter has + been configured to provide comms at 9600,N,8,1. + DESC + + # Global Cache Port + tcp_port 4999 + + default_settings({ + # 0 == all monitors + monitor_id: "all", + }) + + @monitor_id : MonitorID = MonitorID::All + @power_target : Bool? = nil + + def on_load + transport.tokenizer = Tokenizer.new("\r") + on_update + end + + def on_update + @monitor_id = setting?(MonitorID, :monitor_id) || MonitorID::All + end + + def connected + schedule.every(15.seconds) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + logger.debug { "Polling device for connectivity heartbeat" } + + # The device does not provide any query only methods for interaction. + # Re-apply the current known power state to provide a comms heartbeat + # if we can do it safely. + target = @power_target + power(target, priority: 0) unless target.nil? + end + + # =================== + # Powerable Interface + # =================== + + def power(state : Bool, **options) + if state != @power_target + # Define setting for polling + self[:power_target] = @power_target = state + end + set :power, state, **options + end + + # ==================== + # Audio Mute Interface + # ==================== + + def mute_audio(state : Bool = true, index : Int32 | String = 0) + set :audio_mute, state + end + + # ========================= + # Input selection Interface + # ========================= + + def switch_to(input : Input) + set :input, input + end + + # ============== + # End Interfaces + # ============== + + protected def in_range(level : Int32 | Float64) : Int32 + level = level.to_f.clamp(0.0, 100.0) + level.round.to_i + end + + def volume(level : Int32 | Float64) + percentage = in_range(level) / 100.0 + adjusted = (percentage * 30.0).round_away.to_i + set :volume, adjusted + end + + def brightness(value : Int32 | Float64) + value = in_range value + set :brightness, value + end + + def contrast(value : Int32 | Float64) + value = in_range value + set :contrast, value + end + + def sharpness(value : Int32 | Float64) + value = in_range value + set :sharpness, value + end + + enum ColourTemp + K9300 = 0 + K6500 = 1 + User = 2 + end + + def colour_temp(value : ColourTemp) + set :colour_temp, value + end + + protected def set(command : Command, param, **opts) + logger.debug { "Setting #{command} -> #{param}" } + + request = new_request command, param + packet = build_packet request + send packet, **opts, name: command.to_s + end + + def received(bytes, task) + response = begin + parse_response bytes + rescue parse_error + logger.warn(exception: parse_error) { "failed to parse 3M packet" } + return task.try &.abort + end + + unless response.success? + logger.warn { "Device error: #{response.inspect}" } + return task.try &.abort + end + + logger.debug { "Device response received: #{response.inspect}" } + + self[response.command.to_s.underscore] = response.value + task.try &.success + end + + enum MonitorID : UInt8 + All = 0x2a + A = 0x41 + B = 0x42 + C = 0x43 + D = 0x44 + E = 0x45 + F = 0x46 + G = 0x47 + H = 0x48 + I = 0x49 + end + + enum MessageSender + PC = 0x30 + end + + enum MessageType : UInt8 + Command = 0x45 + Reply = 0x46 + end + + enum Command : UInt16 + Brightness = 0x0110 + Contrast = 0x0112 + Sharpness = 0x018c + ColourTemp = 0x0254 + Volume = 0x0062 + AudioMute = 0x008d + Input = 0x02cb + Power = 0x0003 + end + + enum ResultCode : UInt16 + Success = 0x3030 + Unsupported = 0x3031 + end + + class RequestPacket < BinData + endian big + + uint8 :header_start, value: ->{ 0x01_u8 } + uint8 :reserved, value: ->{ 0x30_u8 } + enum_field UInt8, monitor_id : MonitorID = MonitorID::All + enum_field UInt8, sender : MessageSender = MessageSender::PC + enum_field UInt8, message_type : MessageType = MessageType::Command + string :message_length, value: ->{ 10.to_s(16).upcase.rjust(2, '0') }, length: ->{ 2 } + + uint8 :message_start, value: ->{ 0x02_u8 } + string :op_code_page, length: ->{ 2 } + string :op_code, length: ->{ 2 } + string :set_value, length: ->{ 4 } + uint8 :message_end, value: ->{ 0x03_u8 } + + def command=(command : Command) + code = command.value.to_s(16).upcase.rjust(4, '0') + self.op_code_page = code[0..1] + self.op_code = code[2..3] + command + end + + def value=(val : Int32) + self.set_value = val.to_s(16).upcase.rjust(4, '0') + end + end + + class ResponsePacket < BinData + endian big + + uint8 :header_start + uint8 :reserved + enum_field UInt8, receiver : MessageSender = MessageSender::PC + enum_field UInt8, monitor_id : MonitorID = MonitorID::All + enum_field UInt8, message_type : MessageType = MessageType::Reply + string :message_length, length: ->{ 2 } + + uint8 :message_start, value: ->{ 0x02_u8 } + enum_field UInt16, result_code : ResultCode = ResultCode::Success + string :op_code_page, length: ->{ 2 } + string :op_code, length: ->{ 2 } + string :reply_type, length: ->{ 2 } + string :max_value, length: ->{ 4 } + string :current_value, length: ->{ 4 } + uint8 :message_end + uint8 :bcc + uint8 :delimiter + + getter command : Command do + Command.from_value "#{op_code_page}#{op_code}".to_i(16) + end + + def success? + self.result_code.success? + end + + def value + raw_val = self.current_value.to_i(16) + case self.command + in .brightness?, .contrast?, .sharpness? + raw_val + in .volume? + # adjust back into 0-100 range + (raw_val / 30.0) * 100.0 + in .audio_mute?, .power? + raw_val == 1 + in .colour_temp? + ColourTemp.from_value raw_val + in .input? + Input.from_value raw_val + end + end + end + + # Map a symbolic command and parameter value to an [op_code, value] + protected def new_request(command : Command, param : Bool | Enum | Int32) + value = case param + in Bool + param ? 1 : 0 + in Enum + param.to_i + in Int32 + param + end + + request = RequestPacket.new + request.command = command + request.value = value + request + end + + # Build a "set_parameter_command" packet ready for transmission + protected def build_packet(request : RequestPacket) : Bytes + request.monitor_id = @monitor_id + io = IO::Memory.new + io.write_bytes(request) + + bytes = io.to_slice + io.write_byte(bytes[1..-1].reduce { |acc, i| acc ^ i }) + io << "\r" + io.to_slice + end + + protected def parse_response(packet : Bytes) : ResponsePacket + io = IO::Memory.new(packet) + response = io.read_bytes(ResponsePacket) + + bcc = packet[1..-3].reduce { |acc, i| acc ^ i } + raise "invalid checksum" if bcc != response.bcc + + response + end +end diff --git a/drivers/company_3m/displays/wall_display_spec.cr b/drivers/company_3m/displays/wall_display_spec.cr new file mode 100644 index 00000000000..9eb2f66a4a3 --- /dev/null +++ b/drivers/company_3m/displays/wall_display_spec.cr @@ -0,0 +1,13 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Company3M::Displays::WallDisplay" do + exec(:power, true) + should_send("\x010*0E0A\x0200030001\x03\x1d\r") + responds("\x0100*F12\x020000030000010001\x03\x6d\r") + status[:power].should be_true + + exec(:power, false) + should_send("\x010*0E0A\x0200030000\x03\x1c\r") + responds("\x0100*F12\x020000030000010000\x03\x6c\r") + status[:power].should be_false +end diff --git a/drivers/crestron/cres_next.cr b/drivers/crestron/cres_next.cr new file mode 100644 index 00000000000..75bd0cc26ac --- /dev/null +++ b/drivers/crestron/cres_next.cr @@ -0,0 +1,127 @@ +require "placeos-driver" +require "json" +require "path" +require "uri" +require "./nvx_models" +require "./cres_next_auth" + +# Documentation: https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Prerequisites-Assumptions.htm +# inspecting request - response packets from the device webui is also useful + +# Parent module for Crestron DM NVX devices. +abstract class Crestron::CresNext < PlaceOS::Driver + include Crestron::CresNextAuth + + def websocket_headers + authenticate + + headers = HTTP::Headers.new + transport.cookies.add_request_headers(headers) + headers["CREST-XSRF-TOKEN"] = @xsrf_token unless @xsrf_token.empty? + headers["User-Agent"] = "advanced-rest-client" + headers + end + + def connected + schedule.every(10.minutes) { maintain_session } + end + + def disconnected + schedule.clear + end + + def tokenize(path : String) + path.split('/').reject(&.empty?) + end + + # ============================================ + # websocket for state changes and get requests + # ============================================ + protected def query(path : String, **options, &block : (JSON::Any, ::PlaceOS::Driver::Task) -> Nil) + request_path = Path["/Device"].join(path).to_s + tokens = tokenize(request_path) + parts = tokens.map { |part| %("#{part}":) } + + send(request_path, **options) do |data, task| + raw_json = String.new(data) + logger.debug { "Crestron sent: #{raw_json}" } + + # check if the response path is included + unless parts.map(&.in?(raw_json)).includes?(false) + # Just grab the relevant data as the response is deeply nested + json = JSON.parse(raw_json) + tokens.each { |key| json = json[key] } + block.call json, task + task.success json + end + end + end + + protected def ws_update(path : String, value, **options) + request_path = Path["/Device"].join(path).to_s + + # expands into object that we need to post + components = tokenize(request_path).map { |part| %({"#{part}") } + payload = %(#{components.join(':')}:#{value.to_json}#{"}" * components.size}) + + apply_ws_changes(payload, **options) + end + + private def apply_ws_changes(payload : String, **options) + logger.debug { "Sending WebSocket update: #{payload}" } + send(payload, **options) do |data, task| + raw_json = String.new(data) + logger.debug { "Crestron sent: #{raw_json}" } + + if raw_json.includes? %("Results":) + task.success JSON.parse(raw_json) + end + end + end + + @[PlaceOS::Driver::Security(Level::Support)] + def manual_send(payload : JSON::Any) + data = payload.to_json + logger.debug { "Sending: #{data}" } + send data, wait: false + end + + def received(data, task) + raw_json = String.new data + logger.debug { "Crestron sent: #{raw_json}" } + end + + # ======================================== + # HTTP for updates and session maintenance + # ======================================== + def maintain_session + response = get("/Device/DeviceInfo") + return logout unless response.success? + logger.debug { "Maintaining Session:\n#{response.body}" } + end + + # payload is expected to be a hash or named tuple + protected def update(path : String, value, **options) + request_path = Path["/Device"].join(path).to_s + + # expands into object that we need to post + components = tokenize(request_path).map { |part| %({"#{part}") } + payload = %(#{components.join(':')}:#{value.to_json}#{"}" * components.size}) + + apply_http_changes(request_path, payload, **options) + end + + private def apply_http_changes(request_path : String, payload : String, **options) + queue(**options) do |task| + response = post request_path, body: payload, headers: HTTP::Headers{"CREST-XSRF-TOKEN" => @xsrf_token} + logger.debug { "updated requested for #{request_path}, response was #{response.body}" } + + # no real need to parse the responses as the changes will be sent down the websocket + if response.success? + task.success JSON.parse(response.body) + else + task.abort "crestron failed to apply changes to: #{request_path}\n#{response.body}" + end + end + end +end diff --git a/drivers/crestron/cres_next_auth.cr b/drivers/crestron/cres_next_auth.cr new file mode 100644 index 00000000000..9005ee84348 --- /dev/null +++ b/drivers/crestron/cres_next_auth.cr @@ -0,0 +1,74 @@ +require "uri" + +module Crestron::CresNextAuth + protected getter xsrf_token : String = "" + + getter? authenticated : Bool = false + + def authenticate : Nil + logger.debug { "Authenticating" } + was_authenticated = @authenticated + @authenticated = false + + # 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"]? || "" + @authenticated = true + begin + queue.set_connected(true) unless was_authenticated + rescue + end + logger.debug { "Authenticated" } + else + error = "Device did not return all auth information, cookies returned: #{response.cookies.to_h.keys}, redirect: #{response.headers["Location"]?}" + end + when 403 + error = "Invalid credentials" + else + error = "Unexpected response (HTTP #{response.status})" + end + + self[:authenticated] = @authenticated + self[:auth_error] = error + + if error + logger.error { error } + queue.set_connected(false) + raise error + end + end + + def logout + response = post "/logout" + + case response.status + when 302 + logger.debug { "Logout successful" } + @authenticated = false + true + else + logger.warn { "Unexpected response (HTTP #{response.status})" } + false + end + ensure + @xsrf_token = "" + transport.cookies.clear + schedule.clear + disconnect + end +end diff --git a/drivers/crestron/fusion.cr b/drivers/crestron/fusion.cr new file mode 100644 index 00000000000..8d69bb81d64 --- /dev/null +++ b/drivers/crestron/fusion.cr @@ -0,0 +1,183 @@ +require "placeos-driver" +require "xml" +require "json" +require "uri" + +# TODO: add handling of security level 2 +# TODO: parse returend results into models +# +# Documentation: https://sdkcon78221.crestron.com/sdk/Fusion_APIs/Content/Topics/Default.htm +class Crestron::Fusion < PlaceOS::Driver + descriptive_name "Crestron Fusion" + generic_name :CrestronFusion + description <<-DESC + Crestron Fusion + DESC + + uri_base "https://fusion.myorg.com/fusion/apiservice/" + + default_settings({ + # Security level: 0 (No Security), 1 (Clear Text), 2 (Encrypted) + security_level: 1, + + user_id: "FUSION_USER_ID", + + # Should be the same as set in the Fusion configuration client + api_pass_code: "FUSION_API_PASS_CODE", + + # xml or json + content_type: "json", + + # uses old ciphers + https_insecure: true, + }) + + @security_level : Int32 = 1 + @user_id : String = "" + @api_pass_code : String = "" + @content_type : String = "" + + def on_update + @security_level = setting(Int32, :security_level) + @user_id = setting(String, :user_id) + @api_pass_code = setting(String, :api_pass_code) + @content_type = "application/" + setting(String, :content_type) + end + + ########### + # Actions # + ########### + + def get_actions(name : String?, room_id : String? = nil, page : Int32? = nil) + params = URI::Params.new + params["search"] = name if name + params["room"] = room_id if room_id + params["page"] = page.to_s if page + + response = perform_request("GET", "/actions", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_action(action_id : String) + response = perform_request("GET", "/actions/#{action_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def send_action(action_id : String?, room_id : String? = nil, node_id : String? = nil) + params = URI::Params.new + params["room"] = room_id if room_id + params["node"] = node_id if node_id + + path = if (id = action_id) && !id.empty? + "/actions/#{id}" + else + "/actions" + end + + response = perform_request("POST", path, params) + JSON.parse(response.body) + end + + ########## + # Alerts # + ########## + + # Severity should be in the range 1-4 + def get_alerts(node_ids : Array(String)? = nil, room_ids : Array(String)? = nil, start_time : String? = nil, end_time : String? = nil, severity : Int32? = nil, active_alerts : Bool = true) + params = URI::Params.new + params["nodes"] = node_ids.join(',') if node_ids + params["rooms"] = room_ids.join(',') if room_ids + params["start"] = start_time if start_time + params["end"] = end_time if end_time + params["severity"] = severity.to_s if severity + params["activeAlerts"] = active_alerts.to_s if active_alerts + + response = perform_request("GET", "/rooms", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ######### + # Rooms # + ######### + + def post_room(room_xml_or_json : String) + response = perform_request("POST", "/rooms", body: room_xml_or_json) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_rooms(name : String?, node_id : String? = nil, page : Int32? = nil) + params = URI::Params.new + params["search"] = name if name + params["node"] = node_id if node_id + params["page"] = page.to_s if page + + response = perform_request("GET", "/rooms", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_room(room_id : String) + response = perform_request("GET", "/rooms/#{room_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def put_room(room_id : String, room_xml_or_json : String) + response = perform_request("PUT", "/rooms/#{room_id}", body: room_xml_or_json) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def delete_room(room_id : String) + response = perform_request("DELETE", "/rooms/#{room_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ################# + # Signal Values # + ################# + + def get_signal_values(symbol_id : String) + response = perform_request("GET", "/signalvalues/#{symbol_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_signal_value(symbol_id : String, attribute_id : String) + response = perform_request("GET", "/signalvalues/#{symbol_id}/#{attribute_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def put_signal_value(symbol_id : String, attribute_id : String, value : String) + params = URI::Params.new + params["value"] = value + + response = perform_request("PUT", "/signalvalues/#{symbol_id}/#{attribute_id}", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ########### + # Helpers # + ########### + + private def perform_request(method : String, path : String, params : URI::Params = URI::Params.new, body : String? = nil) + if @security_level == 1 + params["auth"] = "#{@api_pass_code} #{@user_id}" + elsif @security_level == 2 + params["auth"] = encrypted_token + end + + headers = HTTP::Headers.new + headers["Content-Type"] = @content_type + headers["Accept"] = @content_type + + response = http(method, path, body, params, headers) + if response.status_code == 200 + response + else + raise "Fusion API request failed. Status code: #{response.status_code}" + end + end + + private def encrypted_token + # TODO: encrypt this + # "#{Time.utc.to_rfc3339} #{@user_id}" + raise "Fusion API security level 2 not supported" + end +end diff --git a/drivers/crestron/fusion_spec.cr b/drivers/crestron/fusion_spec.cr new file mode 100644 index 00000000000..230f29a90bb --- /dev/null +++ b/drivers/crestron/fusion_spec.cr @@ -0,0 +1,75 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::Fusion" do + settings({ + security_level: 0, + user_id: "spec-user-id", + api_pass_code: "spec-api-pass-code", + service_url: "/RoomViewSE/APIService/", + content_type: "xml", + }) + + resp = exec(:get_rooms, "Meeting Room A") + expect_http_request do |_request, response| + response.status_code = 200 + response << rooms.to_json + end + resp.get + + resp = exec(:get_room, "room-id") + expect_http_request do |_request, response| + response.status_code = 200 + response << room.to_json + end + resp.get + + resp = exec(:get_signal_value, "symbol-id", "attribute-id") + expect_http_request do |_request, response| + response.status_code = 200 + response << signal_value.to_json + end + resp.get + + resp = exec(:put_signal_value, "symbol-id", "attribute-id", "start") + expect_http_request do |_request, response| + response.status_code = 200 + response << put_signal_value_response.to_json + end + resp.get +end + +########### +# Helpers # +########### + +private def rooms + { + "rooms" => [ + room, + ], + } +end + +private def room + { + "RoomName" => "Meeting Room A", + } +end + +private def signal_value + { + "API_Signals" => [{ + "AttributeID" => "attribute-id", + "AttributeName" => "PlaceOS_Enabled", + "RawValue" => "False", + "SymbolID" => "symbol-id", + }], + "Status" => "Success", + } +end + +private def put_signal_value_response + { + "Status" => "Success", + } +end diff --git a/drivers/crestron/nvx_address_manager.cr b/drivers/crestron/nvx_address_manager.cr new file mode 100644 index 00000000000..0a31eb96a7e --- /dev/null +++ b/drivers/crestron/nvx_address_manager.cr @@ -0,0 +1,72 @@ +require "placeos-driver" +require "./nvx_models" + +class Crestron::NvxAddressManager < PlaceOS::Driver + descriptive_name "Crestron NVX Address Manager" + generic_name :NvxAddressManager + + description <<-DESC + Simplified management of NVX encoder multicast addressing. + + Allows a subnet to be assigned with sequential, blocked address + allocation to all NVX encoders appearing alongside instances of this + module. + + This is intended to be instantiated in systems containing all NVX + encoders that share a multicast subnet. + DESC + + default_settings({ + base_address: "239.8.0.2", + block_size: 8, + }) + + # https://github.com/Sija/ipaddress.cr + MULTICAST_ADDRESSES = ::IPAddress::IPv4.new("224.0.0.0/4") + + @base_address : UInt32 = 0_u32 + @block_size : Int32 = 8 + + def on_update + addr = setting(String, :base_address) + base_addr = ::IPAddress::IPv4.new addr + @base_address = base_addr.to_u32 + logger.warn { "#{addr} is not a valid multicast address" } unless MULTICAST_ADDRESSES.includes? base_addr + @block_size = setting(Int32, :block_size) + end + + def readdress_streams + logger.debug { "readdressing devices" } + + address_pairs = encoders.zip(addresses) + + interactions = address_pairs.map_with_index(1) do |(mod, ip_u32), idx| + ip = ::IPAddress::IPv4.parse_u32(ip_u32) + logger.debug { "setting encoder #{idx} to #{ip}" } + mod.multicast_address ip.to_s + end + + failed = 0 + interactions.each do |request| + begin + request.get + rescue error + failed += 1 + logger.warn(exception: error) { "addressing NVX devices" } + end + end + + raise "#{failed} failed to set stream address" unless failed == 0 + interactions.size + end + + protected def encoders + system.implementing(Crestron::Transmitter) + end + + # returns an iterator of IPv4 addresses represented as 32bit numbers + protected def addresses + address_range = (@base_address..MULTICAST_ADDRESSES.last.to_u32) + address_range.step by: @block_size + end +end diff --git a/drivers/crestron/nvx_address_manager_spec.cr b/drivers/crestron/nvx_address_manager_spec.cr new file mode 100644 index 00000000000..bc92e0b00a7 --- /dev/null +++ b/drivers/crestron/nvx_address_manager_spec.cr @@ -0,0 +1,22 @@ +require "placeos-driver/spec" +require "./nvx_models" + +DriverSpecs.mock_driver "Crestron::NvxAddressManager" do + system({ + Encoder: {NvxEncoderMock, NvxEncoderMock}, + }) + + exec(:readdress_streams).get.should eq 2 + + system(:Encoder_1)[:address].should eq "239.8.0.2" + system(:Encoder_2)[:address].should eq "239.8.0.10" +end + +# :nodoc: +class NvxEncoderMock < DriverSpecs::MockDriver + include Crestron::Transmitter + + def multicast_address(address : String) + self[:address] = address + end +end diff --git a/drivers/crestron/nvx_models.cr b/drivers/crestron/nvx_models.cr new file mode 100644 index 00000000000..15ba209b7aa --- /dev/null +++ b/drivers/crestron/nvx_models.cr @@ -0,0 +1,61 @@ +require "json" + +module Crestron + # Interface for enumerating devices + module Transmitter + end + + module Receiver + end + + enum AspectRatio + MaintainAspectRatio + StretchToFit + end + + enum SourceType + Audio + Video + end + + enum Location + CenterLeft + CenterRight + Custom + LowerLeft + LowerRight + UpperLeft + UpperRight + end + + struct OSD + include JSON::Serializable + + @[JSON::Field(key: "IsEnabled")] + property is_enabled : Bool? + + @[JSON::Field(key: "Location")] + property location : String? + + @[JSON::Field(key: "XPosition")] + property x_position : Int32? = 0 + + @[JSON::Field(key: "YPosition")] + property y_position : Int32? = 0 + + @[JSON::Field(key: "Text")] + property text : String? + + @[JSON::Field(key: "FontColor")] + property font_color : String? + + @[JSON::Field(key: "BackgroundTransparency")] + property background_transparency : String? + + @[JSON::Field(key: "Version")] + property version : String? = "2.0.0" + + def initialize(@text, @is_enabled, @location, @background_transparency) + end + end +end diff --git a/drivers/crestron/nvx_rx.cr b/drivers/crestron/nvx_rx.cr new file mode 100644 index 00000000000..2e16741f7c2 --- /dev/null +++ b/drivers/crestron/nvx_rx.cr @@ -0,0 +1,295 @@ +require "./cres_next" +require "placeos-driver/interface/switchable" + +class Crestron::NvxRx < Crestron::CresNext # < PlaceOS::Driver + alias Input = String + alias Output = Int32 + include Interface::Switchable(Input, Output) + include Interface::InputSelection(Input) + include Crestron::Receiver + + descriptive_name "Crestron NVX Receiver" + generic_name :Decoder + description <<-DESC + Crestron NVX network media decoder. + DESC + + uri_base "wss://192.168.0.5/websockify" + + default_settings({ + username: "admin", + password: "admin", + }) + + @subscriptions : Hash(String, JSON::Any) = {} of String => JSON::Any + @audio_follows_video : Bool = true + + def connected + super + audio_follows_video = setting?(Bool, :audio_follows_video) + @audio_follows_video = audio_follows_video.nil? ? true : audio_follows_video + + # NVX hardware can be confiured a either a RX or TX unit - check this + # device is in the correct mode. + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/DeviceSpecific.htm?Highlight=DeviceMode + query("/DeviceSpecific/DeviceMode") do |mode| + # "DeviceMode":"Transmitter|Receiver", + next if mode == "Receiver" + logger.warn { "device configured as a #{mode}" } + self[:WARN] = "device configured as a #{mode}. Expecting Receiver" + end + + # Get the registered subscriptions for index based switching. + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + query("/XioSubscription/Subscriptions") do |subs| + self[:subscriptions] = @subscriptions = subs.as_h + end + + # Background poll for subscription changes. + schedule.every(1.hour) do + query("/XioSubscription/Subscriptions", priority: 5) do |subs| + self[:subscriptions] = @subscriptions = subs.as_h + end + end + + # Background poll to remain in sync with any external routing changes + schedule.every(5.minutes, immediate: true) { update_source_info } + end + + def switch_to(input : Input) + switch_layer input + end + + protected def switch_layer(input : Input, layer : SwitchLayer? = nil) + layer ||= SwitchLayer::All + + do_switch = case input.downcase + when "none", "break", "clear", "blank", "black" + blank layer + when "input1", "hdmi", "hdmi1" + switch_local "Input1", layer + when "input2", "hdmi2" + switch_local "Input2", layer + when "input3", "usbc1" + switch_local "USB-C1", layer + when "input4", "usbc2" + switch_local "USB-C2", layer + else + switch_stream input, layer + end + + do_switch.try &.get + update_source_info + end + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + switch_layer map.keys.first, layer + end + + def output(state : Bool) + logger.debug { "#{state ? "enabling" : "disabling"} output sync" } + + ws_update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + Hdmi: {IsOutputDisabled: !state}, + }], + }], + name: :output + ) + end + + def output_with_index(state : Bool, output_index : Int32, port_index : Int32?) + logger.debug { "#{state ? "enabling" : "disabling"} output sync for output #{output_index}#{port_index ? " port #{port_index}" : ""}" } + # /Device/AvioV2/Outputs/Output1/OutputInfo/Ports/Port1/Digital/IsOutputDisabled https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects-HD-PS/AvioV2.htm + ws_update( + "/AvioV2/Outputs/Output#{output_index}/OutputInfo/Ports/Port#{port_index}/Digital/IsOutputDisabled", + !state, + name: :output + ) + end + + # aspect ratio defined in nvx_rx_models + def aspect_ratio(mode : AspectRatio) + logger.debug { "setting output aspect ratio mode: #{mode}" } + + ws_update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + AspectRatioMode: mode, + }], + }], + name: :aspect_ratio + ) + end + + protected def query_device_name + query("/Localization/Name", name: "device_name") do |name| + self["device_name"] = name + end + end + + def query_osd_text + query("/Osd/Text", name: "osd_text") do |text| + self[:osd_text] = text + end + end + + def set_osd_text(text : String, enabled : Bool = true) + ws_update "/Osd/Text", text, name: :set_osd_text + ws_update "/Osd/IsEnabled", enabled, name: :set_osd_enabled + end + + protected def switch_stream(stream_reference : String | Int32, layer : SwitchLayer) + uuid = uuid_for stream_reference + + logger.debug do + subscription = @subscriptions[uuid].as_h + id, name = subscription.values_at "Position", "SessionName" + "switching to Stream#{id} (#{name}) on layer #{layer}" + end + + if layer.all? || layer.video? + ws_update "/DeviceSpecific/VideoSource", "Stream", name: :input_video + resp = ws_update "/AvRouting/Routes", { {VideoSource: uuid} }, name: :switch_video + end + + if @audio_follows_video + ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: uuid} }, name: :switch_audio + elsif layer.all? || layer.audio? + ws_update "/DeviceSpecific/AudioSource", "Stream", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: uuid} }, name: :switch_audio + end + + resp + end + + protected def switch_local(input, layer : SwitchLayer) + logger.debug { "switching to #{input}" } + + if layer.all? || layer.video? + resp = ws_update "/DeviceSpecific/VideoSource", input, name: :input_video + end + + if @audio_follows_video + resp = ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio + elsif layer.all? || layer.audio? + resp = ws_update "/DeviceSpecific/AudioSource", input, name: :input_audio + end + + resp + end + + protected def blank(layer : SwitchLayer) + logger.debug { "blanking output" } + + if layer.all? || layer.video? + ws_update "/DeviceSpecific/VideoSource", "None", name: :input_video + resp = ws_update "/AvRouting/Routes", { {VideoSource: ""} }, name: :switch_video + end + + if @audio_follows_video + ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: ""} }, name: :switch_audio + elsif layer.all? || layer.audio? + ws_update "/DeviceSpecific/AudioSource", "None", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: ""} }, name: :switch_audio + end + + resp + end + + # Decoders must first subscribe to encoders they need to receive signals + # from. Switching is then based on device UUID's. + # + # The deivce web UI's (and presumbly XIO director) show these as selectable + # 'inputs' - this mapping allows sources to either be specified as a UUID, + # or their 'input number' as displayed with Crestron tooling. + # + # Alternatively, if a string is provided the list of search props will be + # searched for a match. + protected def uuid_for(reference : String) + if /Stream(\d+)/i =~ reference + # grab the matching data https://crystal-lang.org/api/latest/Regex.html + id = $~[1].to_i + return uuid_for id + end + + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + subscriptions = @subscriptions + + if subscriptions.has_key? reference + uuid = reference + else + {"MulticastAddress", "SessionName"}.each do |prop| + if result = subscriptions.find { |_, x| x.as_h[prop]? == reference } + uuid = result[0] + end + break if uuid + end + end + + raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil? + + uuid + end + + protected def uuid_for(reference : Int32) + subscriptions = @subscriptions + + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + if result = subscriptions.find { |_, x| x.as_h["Position"]? == reference } + uuid = result[0] + end + + raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil? + + uuid + end + + enum SourceType + Audio + Video + end + + # Build friendly source names based on a device state. + # + # Maps all streams into `Stream1`...`StreamN` style names based on + # subscriptions. Local inputs (`Input1`, `Input2`, `AnalogueAudio` etc) are + # left untouched. + protected def query_source_name_for(type : SourceType) + type_downcase = type.to_s.downcase + + # "ActiveAudioSource":"Input1|Input2|Analog|PrimaryAudio|SecondaryAudio", + # "ActiveVideoSource":"None|Input1|Input2|Stream", + query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source", priority: 0) do |source_name| + if source_name.as_s.includes? "Stream" + # "Routes": [{ + # "AudioSource": "07147488-9e0b-11e7-abc4-cec278b6b50a", + # "AutomaticStreamRoutingEnabled": false, + # "Name": "PrimaryStream", + # "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c", + # "VideoSource": "07147488-9e0b-11e7-abc4-cec278b6b50a" + # }] + query("/AvRouting/Routes", name: :routes, priority: 1) do |routes| + uuid = routes.dig?(0, "#{type}Source").try &.as_s? + # FIXME: provide 'Stream1..n' rather than uuids + self["#{type_downcase}_source"] = uuid.presence ? "Stream-#{uuid}" : "None" + end + else + self["#{type_downcase}_source"] = source_name + end + end + end + + # Query the device for the current source state and update status vars. + protected def update_source_info + query_source_name_for(:video) + query_source_name_for(:audio) + query_device_name + query_osd_text + end +end diff --git a/drivers/crestron/nvx_rx_spec.cr b/drivers/crestron/nvx_rx_spec.cr new file mode 100644 index 00000000000..adcf3a2868e --- /dev/null +++ b/drivers/crestron/nvx_rx_spec.cr @@ -0,0 +1,90 @@ +require "placeos-driver/spec" +require "uri" + +DriverSpecs.mock_driver "Crestron::NvxRx" do + # Connected callback makes some queries + should_send "/Device/DeviceSpecific/DeviceMode" + responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Receiver"}}}) + + should_send "/Device/XioSubscription/Subscriptions" + responds %({"Device": {"XioSubscription": {"Subscriptions": { + "00000000-0000-4002-0054-018a0089fd1c": { + "Address": "https://10.254.47.133/onvif/services", + "AudioChannels": 0, + "AudioFormat": "No Audio", + "Bitrate": 750, + "Encryption": true, + "Fps": 0, + "MulticastAddress": "228.228.228.224", + "Position": 2, + "Resolution": "0x0", + "RtspUri": "rtsp://10.254.47.133:554/live.sdp", + "SessionName": "DM-NVX-E30-DEADBEEF1234", + "SnapshotUrl": "", + "Transport": "TS/RTP", + "UniqueId": "00000000-0000-4002-0054-018a0089fd1c", + "VideoFormat": "Pixel", + "IsSyncDetected": false, + "Status": "SUBSCRIBED" + } + }}}}) + + should_send "/Device/Localization/Name" + responds %({"Device": {"Localization": {"Name": "projector"}}}) + + should_send "/Device/Osd/Text" + responds %({"Device": {"Osd": {"Text": "Hearing Loop"}}}) + + should_send "/Device/DeviceSpecific/ActiveVideoSource" + responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Stream"}}}) + + should_send "/Device/AvRouting/Routes" + responds %({"Device": {"AvRouting": {"Routes": [ + { + "Name": "Routing0", + "AudioSource": "00000000-0000-4002-0054-018a0089fd1c", + "VideoSource": "00000000-0000-4002-0054-018a0089fd1c", + "UsbSource": "00000000-0000-4002-0054-018a0089fd1c", + "AutomaticStreamRoutingEnabled": false, + "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c" + } + ]}}}) + + should_send "/Device/DeviceSpecific/ActiveAudioSource" + responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}}) + + status[:video_source].should eq("Stream-00000000-0000-4002-0054-018a0089fd1c") + status[:audio_source].should eq("Input1") + status[:device_name].should eq("projector") + status[:osd_text].should eq("Hearing Loop") + + # we call this manually as the driver isn't loaded in websocket mode + exec :authenticate + + # We expect the first thing it to do is authenticate + auth = URI::Params.build { |form| + form.add("login", "admin") + form.add("passwd", "admin") + } + + expect_http_request do |request, response| + io = request.body + if io + request_body = io.gets_to_end + if request_body == auth + response.status_code = 200 + response.headers["CREST-XSRF-TOKEN"] = "1234" + cookies = response.cookies + cookies["AuthByPasswd"] = "true" + cookies["iv"] = "true" + cookies["tag"] = "true" + cookies["userid"] = "admin" + cookies["userstr"] = "admin" + else + response.status_code = 401 + end + else + raise "expected request to include login form #{request.inspect}" + end + end +end diff --git a/drivers/crestron/nvx_scaler_control.cr b/drivers/crestron/nvx_scaler_control.cr new file mode 100644 index 00000000000..2e34b62734f --- /dev/null +++ b/drivers/crestron/nvx_scaler_control.cr @@ -0,0 +1,67 @@ +require "placeos-driver" +require "./nvx_models" + +class Crestron::NvxScalerControl < PlaceOS::Driver + descriptive_name "Crestron NVX Scaler Control" + generic_name :NvxAddressManager + + description <<-DESC + Synconisation tool for managing scaling settings on NVX decoders based + on window aspect ratios of a videowall processor. + + To enable flexible / user selectable distribution of both 16:9 and 21:9 + signals, aspect ratio control across both the videowall processor and + NVX decoders is exploited to keep things looking nice. + + In the case a decoder is being displayed on a 16:9 window it is set to + scale-to-fit, enabling ultrawide signals to be letterboxed. When a + signal is being send to an ultrawide window it is instead set to + scale-to-fill (stretch) on the NVX, then a second level of distortion + is applied on the videowall processor to convert this back to it's + original aspect. + + This approach keeps all components of the signal chain at 1080p / 4K and + enables live switching all all sources without EDID re-negotation. + DESC + + default_settings({ + # Mapping of { : } + link_scalers: { + window_1: "Decoder_1", + window_2: "Decoder_2", + }, + }) + + @links : Hash(String, String) = {} of String => String + + # Window of aspect ratio's to detect as 16:9 - allows for +/-5% for + # slightly off-shape windows + SCALE_TO_FIT_BOUNDS = (16 / 9 * 0.95)..(16 / 9 * 1.05) + + def on_update + @links = setting?(Hash(String, String), :link_scalers) || {} of String => String + end + + bind VideoWall_1, :windows, :videowall_windows_changed + + private def videowall_windows_changed(_subscription, new_value) + windows = Hash(String, NamedTuple(canwidth: Float64, canheight: Float64)).from_json new_value + windows.each do |id, props| + next unless @links.has_key? id + + nvx = system.get @links[id] + + aspect_ratio = props[:canwidth] / props[:canheight] + + if aspect_ratio.nan? + logger.debug { "#{id} not positioned on canvas, skipping" } + elsif SCALE_TO_FIT_BOUNDS.includes? aspect_ratio + logger.debug { "detected #{id} as 16:9, maintaining aspect" } + nvx.aspect_ratio AspectRatio::MaintainAspectRatio + else + logger.debug { "detected #{id} as ultrawide, filling window" } + nvx.aspect_ratio AspectRatio::StretchToFit + end + end + end +end diff --git a/drivers/crestron/nvx_scaler_control_spec.cr b/drivers/crestron/nvx_scaler_control_spec.cr new file mode 100644 index 00000000000..06e402ae452 --- /dev/null +++ b/drivers/crestron/nvx_scaler_control_spec.cr @@ -0,0 +1,37 @@ +require "placeos-driver/spec" +require "./nvx_models" + +DriverSpecs.mock_driver "Crestron::NvxScalerControl" do + system({ + Decoder: {NvxDecoderMock, NvxDecoderMock}, + VideoWall: {VideoWallMock}, + }) + + sleep 1 + + system(:Decoder_1)[:aspect_ratio].should eq "MaintainAspectRatio" + system(:Decoder_2)[:aspect_ratio].should eq "StretchToFit" +end + +# :nodoc: +class NvxDecoderMock < DriverSpecs::MockDriver + def aspect_ratio(mode : Crestron::AspectRatio) + self[:aspect_ratio] = mode + end +end + +# :nodoc: +class VideoWallMock < DriverSpecs::MockDriver + def on_load + self[:windows] = { + "window_1" => { + canwidth: 1920, + canheight: 1080, + }, + "window_2" => { + canwidth: 1080, + canheight: 1080, + }, + } + end +end diff --git a/drivers/crestron/nvx_tx.cr b/drivers/crestron/nvx_tx.cr new file mode 100644 index 00000000000..3ae11c1de3b --- /dev/null +++ b/drivers/crestron/nvx_tx.cr @@ -0,0 +1,150 @@ +require "./cres_next" +require "placeos-driver/interface/switchable" + +class Crestron::NvxTx < Crestron::CresNext # < PlaceOS::Driver + enum Input + None + Input1 + Input2 + end + include PlaceOS::Driver::Interface::InputSelection(Input) + include Crestron::Transmitter + + descriptive_name "Crestron NVX Transmitter" + generic_name :Encoder + description <<-DESC + Crestron NVX network media encoder + DESC + + uri_base "wss://192.168.0.5/websockify" + + def connected + super + + # NVX hardware can be confiured a either a RX or TX unit - check this + # device is in the correct mode. + query("/DeviceSpecific/DeviceMode") do |mode| + # "DeviceMode":"Transmitter|Receiver", + next if mode == "Transmitter" + logger.warn { "device configured as a #{mode}" } + self[:WARN] = "device configured as a #{mode}. Expecting Transmitter" + end + + # Background poll to remain in sync with any external routing changes + schedule.every(5.minutes, immediate: true) { update_source_info } + end + + def switch_to(input : Input) + logger.debug { "switching to #{input}" } + update( + "/DeviceSpecific", + {VideoSource: input, AudioSource: "AudioFollowsVideo"}, + name: :switch + ).get + update_source_info + end + + def output(state : Bool) + logger.debug { "#{state ? "enabling" : "disabling"} output sync" } + + update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + Hdmi: {IsOutputDisabled: !state}, + }], + }], + name: :output + ) + end + + def multicast_address(address : String) + logger.debug { "setting multicast address to #{address}" } + update("/StreamTransmit/Streams", [{MulticastAddress: address}], name: :multicast_address) + end + + def emulate_input_sync(state : Bool = true, idx : Int32 = 1) + self["input_#{idx}_sync"] = state + end + + # Build friendly source names based on a device state. + protected def query_source_name_for(type : SourceType) + type_downcase = type.to_s.downcase + query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source") do |source_name| + self["#{type_downcase}_source"] = source_name + end + end + + protected def query_multicast_address + query("/StreamTransmit/Streams", name: "streams") do |streams| + self["multicast_address"] = streams.dig(0, "MulticastAddress") + end + end + + # this is the audio AES67 address + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/NaxAudio.htm + protected def query_nax_address + query("/NaxAudio/NaxTx/NaxTxStreams/Stream01/SessionNameStatus", name: "audio_name") do |stream| + self["nax_address"] = stream + end + end + + protected def query_stream_name + query("/Localization/Name", name: "stream_name") do |name| + self["stream_name"] = name + end + end + + # Query the device for the current source state and update status vars. + protected def update_source_info + query_stream_name + query_nax_address + query_multicast_address + query_source_name_for(:video) + query_source_name_for(:audio) + end + + def received(data, task) + raw_json = String.new data + logger.debug { "Crestron sent: #{raw_json}" } + + return unless raw_json.includes? "AudioVideoInputOutput" + raw_json.lines.each do |line| + next if line.empty? + + begin + payload = JSON.parse(line) + + # we're checking if a device is plugged into a port + # Device/AudioVideoInputOutput/Inputs/0/Ports/0/IsSyncDetected + if av_inputs = payload.dig?("Device", "AudioVideoInputOutput", "Inputs").try &.as_a? + av_inputs.each do |input| + name = input["Name"]?.try(&.as_s) || "" + + # Device returns inputs as "input0", "input1" ... "inputN" within + # long poll responses, but appears to reference these same inputs + # as "input-1", "input-2" ... "input-N" within direct state queries. + idx = case name + when /input(\d+)/ + # increment by 1 + $~[1].to_i.succ + when /input-(\d+)/ + $~[1].to_i + else + # There also appears to be situations where no name is + # returned. As only the first input is in use across all + # encoders, default to input 1 as a nasty hack around + # this craziness. + 1 + end + + sync = input.dig?("Ports", 0, "IsSyncDetected").try &.as_bool? + self["input_#{idx}_sync"] = sync unless sync.nil? + end + end + rescue error + logger.warn(exception: error) { "error parsing JSON:\n#{line}" } + end + end + end +end diff --git a/drivers/crestron/nvx_tx_spec.cr b/drivers/crestron/nvx_tx_spec.cr new file mode 100644 index 00000000000..bbe720e3551 --- /dev/null +++ b/drivers/crestron/nvx_tx_spec.cr @@ -0,0 +1,36 @@ +require "placeos-driver/spec" +require "uri" + +DriverSpecs.mock_driver "Crestron::NvxRx" do + # Connected callback makes some queries + should_send "/Device/DeviceSpecific/DeviceMode" + responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Transmitter"}}}) + + should_send "/Device/Localization/Name" + responds %({"Device": {"Localization": {"Name": "pc-in-rack"}}}) + + should_send "/Device/NaxAudio/NaxTx/NaxTxStreams/Stream01/SessionNameStatus" + responds %({"Device": {"NaxAudio": {"NaxTx": {"NaxTxStreams": {"Stream01": {"SessionNameStatus": "pc-in-rack"}}}}}}) + + should_send "/Device/StreamTransmit/Streams" + responds %({"Device": {"StreamTransmit": {"Streams": [{"MulticastAddress": "192.168.0.2"}]}}}) + + should_send "/Device/DeviceSpecific/ActiveVideoSource" + responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Input1"}}}) + + should_send "/Device/DeviceSpecific/ActiveAudioSource" + responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}}) + + status[:stream_name].should eq("pc-in-rack") + status[:multicast_address].should eq("192.168.0.2") + status[:audio_source].should eq("Input1") + status[:audio_source].should eq("Input1") + + transmit %({"Device": {"AudioVideoInputOutput": {"Inputs": [ + {"Name": "input0", "Ports": [{"IsSyncDetected": true}]}, + {"Name": "input-2", "Ports": [{"IsSyncDetected": false}]} + ]}}}).gsub(/\s/, "") + + status["input_1_sync"].should eq(true) + status["input_2_sync"].should eq(false) +end diff --git a/drivers/crestron/occupancy_sensor.cr b/drivers/crestron/occupancy_sensor.cr new file mode 100644 index 00000000000..192413b3fb3 --- /dev/null +++ b/drivers/crestron/occupancy_sensor.cr @@ -0,0 +1,195 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "./cres_next_auth" + +# This device doesn't seem to support a websocket interface +# and relies on long polling. + +class Crestron::OccupancySensor < PlaceOS::Driver + include Crestron::CresNextAuth + include Interface::Sensor + + descriptive_name "Crestron Occupancy Sensor" + generic_name :Occupancy + + uri_base "https://192.168.0.5" + + default_settings({ + username: "admin", + password: "admin", + + http_keep_alive_seconds: 600, + http_max_requests: 1200, + }) + + @mac : String = "" + @name : String? = nil + @occupied : Bool? = nil + getter last_update : Int64 = 0_i64 + getter poll_counter : UInt64 = 0_u64 + + @sensor_data : Array(Interface::Sensor::Detail) = Array(Interface::Sensor::Detail).new(1) + @monitoring : Bool = false + @lock : Mutex = Mutex.new + + def on_load + # re-authenticate every 10 minutes + schedule.every(10.minutes) { authenticate } + + # sync device state every hour + schedule.every(1.hour) { poll_device_state } + end + + def on_update + authenticate + end + + def connected + if !authenticated? + authenticate + return # connected is called again by the authenticate function + end + + poll_device_state + @lock.synchronize do + if !@monitoring + spawn { event_monitor } + @monitoring = true + end + end + end + + def poll_device_state : Nil + response = get("/Device", concurrent: true) + raise "unexpected response code: #{response.status_code}" unless response.success? + payload = JSON.parse(response.body) + + @last_update = Time.utc.to_unix + self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool + self[:presence] = @occupied ? 1.0 : 0.0 + self[:mac] = @mac = format_mac payload.dig("Device", "DeviceInfo", "MacAddress").as_s + self[:name] = @name = payload.dig("Device", "DeviceInfo", "Name").as_s? + + update_sensor + + # Start long polling once we have state + @poll_counter += 1 + end + + protected def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + def event_monitor + loop do + break if terminated? + if authenticated? + # sleep if long poll failed + logger.debug { "event monitor: performing long poll" } + sleep 1.second unless long_poll + else + # sleep if not authenticated + logger.debug { "event monitor: idling as not authenticated" } + sleep 1.second + end + end + end + + # NOTE:: /Device/Longpoll + # 200 == check data + # when nothing new: {"Device":"Response Timeout"} + # when update: {"Device":{"SystemClock":{"CurrentTime":"2022-10-22T20:29:03Z","CurrentTimeWithOffset":"2022-10-22T20:29:03+09:30"}}} + # 301 == authentication required + # could auth every so often to prevent hitting this too + protected def long_poll : Bool + response = get("/Device/Longpoll") + + # retry after authenticating + if response.status_code == 301 + authenticate + response = get("/Device/Longpoll") + end + raise "unexpected response code: #{response.status_code}" unless response.success? + + raw_json = response.body + logger.debug { "long poll sent: #{raw_json}" } + payload = JSON.parse(raw_json) + + if !raw_json.includes?("IsRoomOccupied") + if !@occupied.nil? && payload["Device"]?.try(&.raw) + @last_update = Time.utc.to_unix + update_sensor + end + return true + end + + @last_update = Time.utc.to_unix + self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool + self[:presence] = @occupied ? 1.0 : 0.0 + update_sensor + + true + rescue timeout : IO::TimeoutError + logger.debug { "timeout waiting for long poll to complete" } + false + rescue error + logger.warn(exception: error) { "during long polling" } + false + end + + @update_lock = Mutex.new + + protected def update_sensor + @update_lock.synchronize do + if sensor = @sensor_data[0]? + sensor.value = @occupied ? 1.0 : 0.0 + sensor.last_seen = authenticated? ? Time.utc.to_unix : @last_update + sensor.mac = @mac + sensor.name = @name + sensor.status = authenticated? ? Status::Normal : Status::Fault + else + @sensor_data << Detail.new( + type: :presence, + value: @occupied ? 1.0 : 0.0, + last_seen: authenticated? ? Time.utc.to_unix : @last_update, + mac: @mac, + id: nil, + name: @name, + module_id: module_id, + binding: "presence", + status: authenticated? ? Status::Normal : Status::Fault, + ) + end + end + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::Presence} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if @occupied.nil? + return NO_MATCH if mac && mac != @mac + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + + @sensor_data + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless @mac == mac && !@occupied.nil? + @sensor_data[0]? + end + + def get_sensor_details + @sensor_data[0]? + end +end diff --git a/drivers/crestron/occupancy_sensor_spec.cr b/drivers/crestron/occupancy_sensor_spec.cr new file mode 100644 index 00000000000..857298c8760 --- /dev/null +++ b/drivers/crestron/occupancy_sensor_spec.cr @@ -0,0 +1,114 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::OccupancySensor" do + full_query = %({ + "Device": { + "DeviceInfo": { + "BuildDate": "May 23 2022 (461338)", + "Category": "Linux Device", + "DeviceId": "@E-00107fec2d72", + "DeviceVersion": "3.0000.00002", + "Devicekey": "No SystemKey Server", + "MacAddress": "00.10.7f.ec.2d.72", + "Manufacturer": "Crestron", + "Model": "CEN-ODT-C-POE", + "Name": "Room1-Sensor", + "PufVersion": "3.0000.00002", + "RebootReason": "poweron", + "SerialNumber": "2027NEJ00064", + "Version": "2.1.0" + }, + "OccupancySensor": { + "ForceOccupied": "GET Not Supported, Write Only Property", + "ForceVacant": "GET Not Supported, Write Only Property", + "IsGraceOccupancyDetected": false, + "IsLedFlashEnabled": true, + "IsRoomOccupied": false, + "IsShortTimeoutEnabled": false, + "IsSingleSensorDeterminingOccupancy": true, + "IsSingleSensorDeterminingVacancy": true, + "Pir": { + "IsSensor1Enabled": true, + "OccupiedSensitivity": "Low", + "VacancySensitivity": "Low" + }, + "RawStates": { + "IsRawEnabled": false, + "RawOccupancy": false, + "RawPir": false, + "RawUltrasonic": false + }, + "TimeoutSeconds": 120, + "Ultrasonic": { + "IsSensor1Enabled": true, + "IsSensor2Enabled": true, + "OccupiedSensitivity": "Medium", + "VacancySensitivity": "Medium" + }, + "Version": "1.0.2" + } + } + }) + + # expect authentication + expect_http_request do |request, response| + data = request.body.try(&.gets_to_end) + if data == "login=admin&passwd=admin" + response.status_code = 200 + response.headers.add("Set-Cookie", [ + "userstr=61646d696e00;Path=/;Secure;HttpOnly;", + "userid=483d71e5ce65e6a6689a0e95adb3e2c5ff75ca5582c2f13d669e9213c0eeb9771a6923bf7c1aa1cef460ebf266f3231d;Path=/;Secure;HttpOnly;", + "iv=6023331f67beb11c89bb515f87580a6a;Path=/;Secure;HttpOnly;", + "tag=3877a0be20e70900c0ffb6b620e70900;Path=/;Secure;HttpOnly;", + "AuthByPasswd=crypt:36c319e11b69d1853c6d7070d3da33ec9f3194c840cbbb578f9690a4e9baf7da;Path=/;Secure;HttpOnly;", + "redirectCookie=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure;HttpOnly;", + ]) + else + response.status_code = 401 + response << "bad password" + end + end + + # expect a complete poll + expect_http_request do |request, response| + if request.path == "/Device" + response.status_code = 200 + response << full_query + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 0.5 + status[:occupied].should be_false + status[:name].should eq "Room1-Sensor" + status[:mac].should eq "00107fec2d72" + + # followed by a series of long polls + expect_http_request do |request, response| + if request.path == "/Device/Longpoll" + response.status_code = 200 + response << %({"Device": {"OccupancySensor": {"IsRoomOccupied": true}}}) + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 0.5 + status[:occupied].should be_true + + resp = exec(:get_sensor_details).get.not_nil! + resp.should eq({ + "status" => "normal", + "type" => "presence", + "value" => 1.0, + "last_seen" => resp["last_seen"].as_i64, + "mac" => "00107fec2d72", + "name" => "Room1-Sensor", + "module_id" => "spec_runner", + "binding" => "presence", + "location" => "sensor", + }) +end diff --git a/drivers/crestron/series4.cr b/drivers/crestron/series4.cr new file mode 100644 index 00000000000..762b1dc0c4f --- /dev/null +++ b/drivers/crestron/series4.cr @@ -0,0 +1,47 @@ +require "placeos-driver" +require "./cres_next_auth" + +class Crestron::Series4 < PlaceOS::Driver + include Crestron::CresNextAuth + + descriptive_name "Crestron Series4 Controller" + generic_name :AVController + + uri_base "https://192.168.0.5" + + default_settings({ + username: "admin", + password: "admin", + + http_keep_alive_seconds: 600, + http_max_requests: 1200, + }) + + getter last_update : Int64 = 0_i64 + getter poll_counter : UInt64 = 0_u64 + + @time_zone : Time::Location = Time::Location.load("UTC") + + def on_update + time_zone = setting?(String, :calendar_time_zone).presence || config.control_system.not_nil!.timezone.presence + @time_zone = Time::Location.load(time_zone) if time_zone + end + + def connected + schedule.every(10.minutes, immediate: true) { authenticate } + schedule.every(1.hour, immediate: true) { get_device_info } + end + + def disconnected + schedule.clear + end + + def get_device_info : Nil + response = get("/Device/DeviceInfo/") + raise "unexpected response code: #{response.status_code}" unless response.success? + + payload = JSON.parse(response.body) + self[:last_updated] = Time.local(@time_zone) + self[:info] = payload.dig("Device", "DeviceInfo") + end +end diff --git a/drivers/crestron/series4_spec.cr b/drivers/crestron/series4_spec.cr new file mode 100644 index 00000000000..8b8444cd7e1 --- /dev/null +++ b/drivers/crestron/series4_spec.cr @@ -0,0 +1,56 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::OccupancySensor" do + full_query = %({ + "Device":{ + "DeviceInfo":{ + "Model":"", + "Category":"", + "Manufacturer":"Crestron", + "DeviceId":"TSID or UUID", + "SerialNumber":"12345", + "Name":"Friendly Name", + "DeviceVersion":"1.2.3", + "PufVersion":"1.3454.00040.001", + "BuildDate":"May 13 2016", + "DeviceKey":"54857", + "MacAddress":"", + "RebootReason": "poweron", + "Version": "2.1.0" + } + } + }) + + # expect authentication + expect_http_request do |request, response| + data = request.body.try(&.gets_to_end) + if data == "login=admin&passwd=admin" + response.status_code = 200 + response.headers.add("Set-Cookie", [ + "userstr=61646d696e00;Path=/;Secure;HttpOnly;", + "userid=483d71e5ce65e6a6689a0e95adb3e2c5ff75ca5582c2f13d669e9213c0eeb9771a6923bf7c1aa1cef460ebf266f3231d;Path=/;Secure;HttpOnly;", + "iv=6023331f67beb11c89bb515f87580a6a;Path=/;Secure;HttpOnly;", + "tag=3877a0be20e70900c0ffb6b620e70900;Path=/;Secure;HttpOnly;", + "AuthByPasswd=crypt:36c319e11b69d1853c6d7070d3da33ec9f3194c840cbbb578f9690a4e9baf7da;Path=/;Secure;HttpOnly;", + "redirectCookie=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure;HttpOnly;", + ]) + else + response.status_code = 401 + response << "bad password" + end + end + + # expect a complete poll + expect_http_request do |request, response| + if request.path == "/Device/DeviceInfo/" + response.status_code = 200 + response << full_query + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 200.milliseconds + status[:info]["Manufacturer"].should eq "Crestron" +end diff --git a/drivers/crestron/virtual_switcher.cr b/drivers/crestron/virtual_switcher.cr new file mode 100644 index 00000000000..5f09ecdcc1e --- /dev/null +++ b/drivers/crestron/virtual_switcher.cr @@ -0,0 +1,196 @@ +require "placeos-driver" +require "placeos-driver/interface/switchable" +require "placeos-driver/interface/muteable" +require "./nvx_models" + +class Crestron::VirtualSwitcher < PlaceOS::Driver + descriptive_name "Crestron Virtual Switcher" + generic_name :Switcher + description <<-DESC + Enumerates the Creston Transmitters and Receivers in a system and provides + a simple interface for switching between avaiable streams + DESC + + include Interface::Switchable(String, Int32 | String) + include Interface::Muteable + + default_settings({ + audio_sink: { + module_id: "Mixer_1", + function_name: "set_string", + arguments: ["aes67_control_id"], + named_args: {} of String => JSON::Any, + }, + }) + + class AudioSink + include JSON::Serializable + + getter module_id : String + getter function_name : String + getter arguments : Array(JSON::Any) { [] of JSON::Any } + getter named_args : Hash(String, JSON::Any) { {} of String => JSON::Any } + end + + @audio : AudioSink? = nil + + def on_update + @audio = setting?(AudioSink, :audio_sink) + end + + # dummy to supress errors in routing + def power(state : Bool) + state + end + + protected def switch_audio_to(address : JSON::Any?) + return unless address + if sink = @audio + args = sink.arguments + [address] + system[sink.module_id].__send__(sink.function_name, args, sink.named_args) + end + end + + def transmitters + system.implementing(Crestron::Transmitter) + end + + def receivers + system.implementing(Crestron::Receiver) + end + + protected def get_streams(input : Input, layer : SwitchLayer = SwitchLayer::All) + if int_input = input.to_i? + if int_input == 0 + {"none", JSON::Any.new("")} # disconnected + else + # Subtract one as Encoder_1 on the system would be encoder[0] here + if tx = transmitters[int_input - 1]? + {tx[:stream_name], tx[:nax_address]?} + else + logger.warn { "could not find Encoder_#{input}" } + nil + end + end + else + return {input, nil} if layer.video? + if tx = transmitters.find { |sender| sender[:stream_name]? == input } + {input, tx[:nax_address]?} + else + {input, nil} + end + end + rescue ex + logger.warn { "could not find Encoder_#{input}, due to '#{ex.message}'" } + {input, nil} + end + + # only support muting the outputs, no unmuting + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo, + ) + return unless state + switch_layer = case layer + in MuteLayer::Audio then SwitchLayer::Audio + in MuteLayer::Video then SwitchLayer::Video + in MuteLayer::AudioVideo then SwitchLayer::All + end + switch({"none" => [index]}, switch_layer) + end + + def switch_to(input : Input) + # lookup the input stream + stream = get_streams(input) + return unless stream + + switch_audio_to stream[1] + receivers.switch_to(stream[0]) + end + + def available_inputs + encoder_name_map.keys + end + + def available_outputs + decoder_name_map.keys + end + + protected def decoder_name_map + name_map = {} of String => PlaceOS::Driver::Proxy::Driver + # map-reduce for speed + Promise.all(receivers.map { |rx| + Promise.defer { name_map[rx["device_name"].as_s] = rx rescue nil } + }).get + name_map + end + + protected def encoder_name_map + name_map = {} of String => PlaceOS::Driver::Proxy::Driver + # map-reduce for speed + Promise.all(transmitters.map { |tx| + Promise.defer { name_map[tx["stream_name"].as_s] = tx rescue nil } + }).get + name_map + end + + DUMMY_OUTPUT = [] of Int32 + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + layer ||= SwitchLayer::All + + return unless layer.all? || layer.video? || layer.audio? + + logger.debug { "switching #{layer}: #{map}" } + + connect(map, layer) do |mod, (video, audio)| + case layer + in .all? + switch_audio_to audio + mod.switch_to(video) + in .audio? + switch_audio_to audio + in .video? + # the NVX RX implements this interface but the output is ignored + inp = case video + in JSON::Any + video.as_s? || video.as_i + in String + video + end + mod.switch({inp => DUMMY_OUTPUT}, layer) + in .data?, .data2? + end + end + end + + private def connect(inouts : Hash(Input, Array(Output)), layer : SwitchLayer, &) + inouts.each do |input, outputs| + stream = get_streams(input, layer) + next unless stream + + outputs = outputs.is_a?(Array) ? outputs : [outputs] + decoders = receivers + device_names = nil + outputs.each do |output| + case output + in Int32 + # Subtract one as Decoder_1 on the system would be decoder[0] here + if decoder = decoders[output - 1]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder_#{output}" } + end + in String + device_names = decoder_name_map unless device_names + if decoder = device_names[output]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder with name: #{output}" } + end + end + end + end + end +end diff --git a/drivers/delta/api.cr b/drivers/delta/api.cr new file mode 100644 index 00000000000..a281299d347 --- /dev/null +++ b/drivers/delta/api.cr @@ -0,0 +1,113 @@ +require "placeos-driver" +require "./models/**" + +class Delta::API < PlaceOS::Driver + descriptive_name "Delta API Gateway" + generic_name :Delta + uri_base "https://example.delta.io" + + default_settings({ + basic_auth: { + username: "srvc_acct", + password: "password!", + }, + user_agent: "PlaceOS", + debug: false, + }) + + @user_agent : String = "PlaceOS" + @debug : Bool = false + + def on_update + @user_agent = setting?(String, :user_agent) || "PlaceOS" + @debug = setting?(Bool, :debug) || false + end + + private def fetch(path : String, skip : Int32 = 0, max_results : Int32 = 1000) + logger.debug { config.uri } if @debug + request = "#{path}?alt=json&skip=#{skip}&max-results=#{max_results}" + logger.debug { request } if @debug + + response = get(request, headers: HTTP::Headers{ + "User-Agent" => @user_agent, + "Accept" => "*/*", + }) + + logger.debug { response.headers } if @debug + logger.debug { response.body } if @debug + + response + end + + # list all sites + def list_sites + response = Models::ListSitesResponse.from_json(fetch("/api/.bacnet").body) + response.json_unmapped.keys + end + + # list devices for site + def list_devices(site_name : String) + skip = 0 + devices = [] of Models::Device + path = URI.encode_path("/api/.bacnet/#{site_name}") + + loop do + response = fetch(path, skip) + + raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? + logger.debug { "response body:\n#{response.body}" } + + # returns this when there are no more results + # {"Collection":""} + + body = Models::ListDevicesBySiteNameResponse.from_json(response.body) + body.json_unmapped.keys.each do |key| + value = body.json_unmapped[key].as_h + devices.push(Models::Device.new(id: key.to_u32, base: value["$base"].to_s, node_type: value["nodeType"].to_s, display_name: value["displayName"].to_s)) + end + + break unless body.next_req.presence + skip += 1000 + end + + devices + end + + # list objects from device resource + def list_device_objects(site_name : String, device_number : String | UInt32) + skip = 0 + objects = [] of Models::Object + path = URI.encode_path("/api/.bacnet/#{site_name}/#{device_number}") + + loop do + response = fetch(path, skip) + + raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? + logger.debug { "response body:\n#{response.body}" } + + body = Models::ListObjectsByDeviceNumber.from_json(response.body) + body.json_unmapped.each do |key, obj| + value = obj.as_h + object_type, instance = key.split(',', 2) + objects.push(Models::Object.new(object_type, instance, base: value["$base"].to_s, display_name: value["displayName"].to_s)) + end + + break unless body.next_req.presence + skip += 1000 + end + + objects + end + + # get value of property from object through instance + def get_object_value(site_name : String, device_number : String | UInt32, object_type : String, instance : String | UInt32) + path = URI.encode_path("/api/.bacnet/#{site_name}/#{device_number}/#{object_type},#{instance}") + + response = fetch(path) + + raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? + logger.debug { "response body:\n#{response.body}" } + + Models::ValueProperty.from_json(response.body) + end +end diff --git a/drivers/delta/api_spec.cr b/drivers/delta/api_spec.cr new file mode 100644 index 00000000000..61e7a8e037a --- /dev/null +++ b/drivers/delta/api_spec.cr @@ -0,0 +1,226 @@ +require "placeos-driver/spec" +require "./models/**" + +DriverSpecs.mock_driver "Delta::API" do + # List sites + + list_sites = exec :list_sites + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Collection", + "nodeType": "PROTOCOL", + "Random Name": { + "$base": "Collection", + "nodeType": "NETWORK", + "truncated": "true" + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + sites = Array(String).from_json(list_sites.get.not_nil!.to_json) + sites.first.should eq "Random Name" + + # List devices by site name + + list_devices_by_site_name = exec(:list_devices, "Random Name") + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet/Random%20Name?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Collection", + "251": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "200": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "253": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "254": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "260": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + devices = Array(Delta::Models::Device).from_json(list_devices_by_site_name.get.not_nil!.to_json) + devices.first.display_name.should eq "Test" + + # List objects by device number + list_objects_by_device_number = exec(:list_device_objects, "Random Name", "200") + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet/Random%20Name/200?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Collection", + "nodeType": "Device", + "analog-input,83": { + "$base": "Object", + "displayName": "CPU Board Temperature", + "truncated": "true" + }, + "analog-input,84": { + "$base": "Object", + "displayName": "APU Board Temperature", + "truncated": "true" + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + devices = Array(Delta::Models::Object).from_json(list_objects_by_device_number.get.not_nil!.to_json) + devices.first.display_name.should eq "CPU Board Temperature" + + # Get value property by object type through instance + get_value_property_by_object_type_through_instance = exec(:get_object_value, "Random Name", "200", "A", 3) + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet/Random%20Name/200/A%2C3?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Object", + "displayName": "DEL1__AI1_85", + "object-identifier": { + "$base": "ObjectIdentifier", + "value": "del,1" + }, + "object-type": { + "$base": "Enumerated", + "value": "data-exchange-local-data" + }, + "object-name": { + "$base": "String", + "value": "DEL1__AI1_85" + }, + "exchange-flags": { + "$base": "BitString", + "value": "" + }, + "exchange-type": { + "$base": "Enumerated", + "value": "optimized-broadcast" + }, + "last-error": { + "$base": "Signed", + "value": 0 + }, + "local-ref": { + "$base": "Sequence", + "type": "0-BACnetDeviceObjectPropertyReference", + "deviceIdentifier": { + "$base": "ObjectIdentifier", + "value": "device,251" + }, + "objectIdentifier": { + "$base": "ObjectIdentifier", + "value": "analog-input,1" + }, + "propertyIdentifier": { + "$base": "Enumerated", + "value": "present-value", + "type": "0-BACnetPropertyIdentifier" + } + }, + "local-flags": { + "$base": "BitString", + "value": "not-commissioned" + }, + "local-value": { + "$base": "Choice", + "real": { + "$base": "Real", + "value": "1" + } + }, + "subscribers": { + "$base": "Array", + "1": { + "$base": "Sequence", + "subscriber": { + "$base": "Choice", + "device": { + "$base": "ObjectIdentifier", + "value": "200" + } + }, + "id": { + "$base": "Unsigned", + "value": 0 + }, + "useConfirmed": { + "$base": "Boolean", + "value": 0 + }, + "flags": { + "$base": "BitString", + "value": "" + }, + "refreshTimer": { + "$base": "Choice", + "refreshTimer": { + "$base": "Unsigned", + "value": "478568103" + } + } + } + }, + "last-sent": { + "$base": "Unsigned", + "value": 0 + }, + "send-frequency": { + "$base": "Unsigned", + "value": 0 + }, + "cov-increment": { + "$base": "Real", + "value": 0 + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + value_property = Delta::Models::ValueProperty.from_json(get_value_property_by_object_type_through_instance.get.not_nil!.to_json) + value_property.display_name.should eq "DEL1__AI1_85" +end diff --git a/drivers/delta/models/device.cr b/drivers/delta/models/device.cr new file mode 100644 index 00000000000..9582479d437 --- /dev/null +++ b/drivers/delta/models/device.cr @@ -0,0 +1,24 @@ +require "json" + +module Delta + module Models + struct Device + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : UInt32 + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "nodeType")] + property node_type : String + + @[JSON::Field(key: "displayName")] + property display_name : String + + def initialize(@id : UInt32, @base : String, @node_type : String, @display_name : String) + end + end + end +end diff --git a/drivers/delta/models/generic_value.cr b/drivers/delta/models/generic_value.cr new file mode 100644 index 00000000000..cd0292ed0c8 --- /dev/null +++ b/drivers/delta/models/generic_value.cr @@ -0,0 +1,15 @@ +require "json" + +module Delta + module Models + struct GenericValue + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "value")] + property value : JSON::Any + end + end +end diff --git a/drivers/delta/models/list_devices_by_site_name_response.cr b/drivers/delta/models/list_devices_by_site_name_response.cr new file mode 100644 index 00000000000..6eabb088393 --- /dev/null +++ b/drivers/delta/models/list_devices_by_site_name_response.cr @@ -0,0 +1,20 @@ +require "json" + +module Delta + module Models + struct ListDevicesBySiteNameResponse + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(key: "$base")] + property base : String? = nil + + # returns this when there are no more results + @[JSON::Field(key: "Collection")] + property collection : String? = nil + + @[JSON::Field(key: "next")] + property next_req : String? = nil + end + end +end diff --git a/drivers/delta/models/list_objects_by_device_number_response.cr b/drivers/delta/models/list_objects_by_device_number_response.cr new file mode 100644 index 00000000000..84c04bbd05b --- /dev/null +++ b/drivers/delta/models/list_objects_by_device_number_response.cr @@ -0,0 +1,19 @@ +require "json" + +module Delta + module Models + struct ListObjectsByDeviceNumber + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "nodeType")] + property node_type : String + + @[JSON::Field(key: "next")] + property next_req : String? = nil + end + end +end diff --git a/drivers/delta/models/list_sites_response.cr b/drivers/delta/models/list_sites_response.cr new file mode 100644 index 00000000000..171a0b942d7 --- /dev/null +++ b/drivers/delta/models/list_sites_response.cr @@ -0,0 +1,16 @@ +require "json" + +module Delta + module Models + struct ListSitesResponse + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "nodeType")] + property node_type : String + end + end +end diff --git a/drivers/delta/models/local_value.cr b/drivers/delta/models/local_value.cr new file mode 100644 index 00000000000..0aaba970812 --- /dev/null +++ b/drivers/delta/models/local_value.cr @@ -0,0 +1,15 @@ +require "json" + +module Delta + module Models + struct LocalValue + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "real")] + property real : GenericValue + end + end +end diff --git a/drivers/delta/models/object.cr b/drivers/delta/models/object.cr new file mode 100644 index 00000000000..65821d5c618 --- /dev/null +++ b/drivers/delta/models/object.cr @@ -0,0 +1,22 @@ +require "json" + +module Delta + module Models + struct Object + include JSON::Serializable + + property object_type : String + property instance : UInt32 + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "displayName")] + property display_name : String + + def initialize(@object_type : String, instance : String, @base : String, @display_name : String) + @instance = instance.to_u32 + end + end + end +end diff --git a/drivers/delta/models/property_identifier.cr b/drivers/delta/models/property_identifier.cr new file mode 100644 index 00000000000..1735720feac --- /dev/null +++ b/drivers/delta/models/property_identifier.cr @@ -0,0 +1,18 @@ +require "json" + +module Delta + module Models + struct PropertyIdentifier + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "value")] + property value : JSON::Any + + @[JSON::Field(key: "type")] + property type : String + end + end +end diff --git a/drivers/delta/models/reference.cr b/drivers/delta/models/reference.cr new file mode 100644 index 00000000000..d4ef5f6c822 --- /dev/null +++ b/drivers/delta/models/reference.cr @@ -0,0 +1,24 @@ +require "json" + +module Delta + module Models + struct Reference + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "type")] + property type : String + + @[JSON::Field(key: "deviceIdentifier")] + property device_identifier : GenericValue + + @[JSON::Field(key: "objectIdentifier")] + property object_identifier : GenericValue + + @[JSON::Field(key: "propertyIdentifier")] + property property_identifier : PropertyIdentifier + end + end +end diff --git a/drivers/delta/models/value_property.cr b/drivers/delta/models/value_property.cr new file mode 100644 index 00000000000..d373ef5ee5d --- /dev/null +++ b/drivers/delta/models/value_property.cr @@ -0,0 +1,79 @@ +require "./**" +require "json" + +module Delta + module Models + struct ValueProperty + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String? + + @[JSON::Field(key: "displayName")] + property display_name : String? + + @[JSON::Field(key: "object-identifier")] + property object_identifier : GenericValue? + + @[JSON::Field(key: "object-type")] + property object_type : GenericValue? + + @[JSON::Field(key: "object-name")] + property object_name : GenericValue? + + @[JSON::Field(key: "exchange-flags")] + property exchange_flags : GenericValue? + + @[JSON::Field(key: "exchange-type")] + property exchange_type : GenericValue? + + @[JSON::Field(key: "last-error")] + property last_error : GenericValue? + + @[JSON::Field(key: "local-ref")] + property local_ref : Reference? + + @[JSON::Field(key: "local-flags")] + property local_flags : GenericValue? + + @[JSON::Field(key: "local-value")] + property local_flags : LocalValue? + + @[JSON::Field(key: "subscribers")] + property subscribers : Hash(String, JSON::Any)? + + @[JSON::Field(key: "last-sent")] + property last_sent : GenericValue? + + @[JSON::Field(key: "send-frequency")] + property send_frequency : GenericValue? + + @[JSON::Field(key: "cov-increment")] + property cov_increment : GenericValue? + + @[JSON::Field(key: "present-value")] + property present_value : GenericValue? + + @[JSON::Field(key: "status-flags")] + property status_flags : GenericValue? + + @[JSON::Field(key: "event-state")] + property event_state : GenericValue? + + @[JSON::Field(key: "out-of-service")] + property out_of_service : GenericValue? + + @[JSON::Field(key: "present-value")] + property present_value : GenericValue? + + @[JSON::Field(key: "units")] + property units : GenericValue? + + @[JSON::Field(key: "description")] + property description : GenericValue? + + @[JSON::Field(key: "reliability")] + property reliability : GenericValue? + end + end +end diff --git a/drivers/delta/uno_next.cr b/drivers/delta/uno_next.cr new file mode 100644 index 00000000000..3506c10936c --- /dev/null +++ b/drivers/delta/uno_next.cr @@ -0,0 +1,196 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "./models/**" + +# documentation: https://isdweb.deltaww.com/resources/files/UNOnext_bacnet_user_guide.pdf + +class Delta::UNOnext < PlaceOS::Driver + include Interface::Sensor + + descriptive_name "Delta UNOnext Indoor Air Monitor" + generic_name :UNOnext + description %(collects sensor data from UNOnext sensors) + + default_settings({ + site_name: "My Office", + manager_mappings: [{ + building_zone: "zone_id_here", + level_zone: "zone_id_here", + managers: [107100, 107300], + }], + # seconds between polling + poll_every: 10, + }) + + accessor delta_api : Delta_1 + + record ManMap, building_zone : String, level_zone : String, managers : Array(UInt32) do + include JSON::Serializable + end + + def on_update + @site_name = setting(String, :site_name) + @manager_mappings = setting(Array(ManMap), :manager_mappings) + + poll_every = setting?(Int32, :poll_every) || 10 + + @cached_data = Hash(String, Array(Detail)).new { |hash, key| hash[key] = [] of Detail } + schedule.clear + schedule.every(poll_every.seconds) { cache_sensor_data } + end + + getter site_name : String = "My Office" + getter manager_mappings : Array(ManMap) = [] of ManMap + getter cached_data : Hash(String, Array(Detail)) = {} of String => Array(Detail) + + # =================================== + # Sensor Interface functions + # =================================== + def sensor(mac : String, id : String? = nil) : Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id && mac.starts_with?("unonext-") + + device_id = mac.lchop("unonext-").to_u32? + index = id.to_u32? + return nil unless device_id && index + + build_sensor_details(device_id, index) + rescue error + logger.warn(exception: error) { "checking for sensor" } + nil + end + + SENSOR_TYPES = { + 0 => SensorType::Temperature, + 1 => SensorType::Humidity, + 2 => SensorType::AirQuality, # PM2.5 (particles smaller than 2.5) + 4 => SensorType::PPM, # CO2 + 5 => SensorType::Illuminance, + # 9 => SensorType::PPM, # O3 + } + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + # skip processing where possible + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.values.includes?(sensor_type) + end + + if mac + return NO_MATCH unless mac.starts_with?("unonext-") + end + + # grab the relevant values + result = if zone_id + cached_data[zone_id]? || [] of Detail + else + manager_mappings.flat_map do |man_map| + cached_data[man_map.level_zone]? || [] of Detail + end + end + + # filter them based on the request + if sensor_type && mac + result.reject! { |details| details.type != sensor_type || details.mac != mac } + elsif sensor_type + result.reject! { |details| details.type != sensor_type } + elsif mac + result.reject! { |details| details.mac != mac } + end + + result + end + + # =================================== + # Helper functions + # =================================== + + protected def build_sensor_details(device_id : UInt32, index : UInt32, building : String? = nil, level : String? = nil) : Detail? + prop = Models::ValueProperty.from_json delta_api.get_object_value(@site_name, device_id, "analog-value", index).get.to_json + return nil if (prop.out_of_service.try(&.value.as_i?) || 1) != 0 + + value = prop.present_value.try do |pv| + if string = pv.value.as_s? + string.to_f? + elsif int = pv.value.as_i? + int.to_f + end + end + return nil unless value + + case prop.units.try &.value + when "°C" + unit = "Cel" + sensor = SensorType::Temperature + when "%RH" + sensor = SensorType::Humidity + when "µg/m³" + sensor = SensorType::AirQuality + when "lx" + unit = "lx" + sensor = SensorType::Illuminance + when "ppm" + modifier = "CO2" + sensor = SensorType::PPM + end + return nil unless sensor + + Detail.new( + modifier: modifier, + type: sensor, + value: value, + last_seen: Time.utc.to_unix, + mac: "unonext-#{device_id}", + id: index.to_s, + name: "UNONext #{device_id}.#{index} #{prop.display_name} #{prop.units.try &.value}", + binding: "#{device_id}.#{index}", + module_id: module_id, + unit: unit, + building: building, + level: level, + ) + rescue error + logger.warn(exception: error) { "error requesting object value from #{device_id}.#{index}" } + nil + end + + NO_OBJECTS = [] of Models::Object + + protected def cache_sensor_data : Nil + logger.debug { "caching sensor data" } + + local_cache = Hash(String, Array(Detail)).new { |hash, key| hash[key] = [] of Detail } + + # grab all the UNONext manager objects + site = site_name + all_objects = manager_mappings.each do |man_map| + man_map.managers.each do |id| + begin + Array(Models::Object).from_json(delta_api.list_device_objects(site, id).get.to_json) + .select(&.display_name.includes?("UnoNext")) + .each do |object| + # skip the data points we don't care about + next if object.display_name.includes?("PM10") + next if object.display_name.includes?("TVOC") + + if details = build_sensor_details(id, object.instance, man_map.building_zone, man_map.level_zone) + self[details.binding] = details + + local_cache[man_map.building_zone] << details + local_cache[man_map.level_zone] << details + end + end + rescue error + logger.warn(exception: error) { "error requesting objects from manager #{id}" } + NO_OBJECTS + end + end + end + + logger.debug { "updating sensor cache" } + @cached_data = local_cache + end +end diff --git a/drivers/delta/uno_next_spec.cr b/drivers/delta/uno_next_spec.cr new file mode 100644 index 00000000000..0670934b069 --- /dev/null +++ b/drivers/delta/uno_next_spec.cr @@ -0,0 +1,5 @@ +require "placeos-driver/spec" +require "./models/**" + +DriverSpecs.mock_driver "Delta::UNOnext" do +end diff --git a/drivers/delta/zen_pir_location.cr b/drivers/delta/zen_pir_location.cr new file mode 100644 index 00000000000..8f05945de81 --- /dev/null +++ b/drivers/delta/zen_pir_location.cr @@ -0,0 +1,157 @@ +require "placeos-driver" +require "placeos-driver/interface/locatable" + +require "./models/**" + +class Delta::ZenPIRLocation < PlaceOS::Driver + include Interface::Locatable + + descriptive_name "Zen PIR Locations" + generic_name :PIR_Locations + description %(maps zen control pir locations to map areas) + + accessor delta_api : Delta_1 + + default_settings({ + site_name: "My Office", + zen_id: 12345, + pir_mappings: [{ + building_zone: "building_zone_id", + level_zone: "level_zone_id", + pirs: [{ + pir: 1234, + map: "area-1234", + }], + }], + # seconds between polling + poll_every: 10, + }) + + record PIR, pir : UInt32, map : String do + include JSON::Serializable + end + + record PIRMap, building_zone : String, level_zone : String, pirs : Array(PIR) do + include JSON::Serializable + end + + def on_update + @site_name = setting(String, :site_name) + @zen_id = setting(UInt32, :zen_id) + @pir_mappings = setting(Array(PIRMap), :pir_mappings) + + poll_every = setting?(Int32, :poll_every) || 10 + + @cached_data = Hash(String, Array(Location)).new { |hash, key| hash[key] = [] of Location } + schedule.clear + schedule.every(poll_every.seconds) { cache_sensor_data } + end + + getter site_name : String = "My Office" + getter zen_id : UInt32 = 1234_u32 + getter pir_mappings : Array(PIRMap) = [] of PIRMap + getter cached_data : Hash(String, Array(Location)) = {} of String => Array(Location) + + # =================================== + # Locatable Interface functions + # =================================== + def locate_user(email : String? = nil, username : String? = nil) + logger.debug { "sensor incapable of locating #{email} or #{username}" } + [] of Nil + end + + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + logger.debug { "sensor incapable of tracking #{email} or #{username}" } + [] of String + end + + def check_ownership_of(mac_address : String) : OwnershipMAC? + logger.debug { "sensor incapable of tracking #{mac_address}" } + nil + end + + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "searching locatable in zone #{zone_id}" } + return [] of Location if location.presence && location != "area" + @cached_data[zone_id]? || [] of Location + end + + # =================================== + # Caching functions + # =================================== + + struct Location + include JSON::Serializable + + getter location : Symbol = :area + property level : String + property map_id : String + property area_id : String + property capacity : Int32 + property at_location : Int32 + + property zen_device_id : UInt32 + property zen_object_id : UInt32 + + def initialize( + @level, @map_id, @area_id, @capacity, @at_location, + @zen_device_id, @zen_object_id + ) + end + end + + protected def cache_sensor_data : Nil + logger.debug { "caching sensor data" } + + # grab all the zen pir objects + site = site_name + device_id = zen_id + cached_count = 0 + cached_data = Hash(String, Array(Location)).new { |hash, key| hash[key] = [] of Location } + + all_objects = pir_mappings.each do |pir_map| + pir_map.pirs.each do |pir| + begin + prop = Models::ValueProperty.from_json delta_api.get_object_value(site, device_id, "binary-value", pir.pir).get.to_json + next if (prop.out_of_service.try(&.value.as_i?) || 1) != 0 + + state = prop.present_value.try do |pv| + if string = pv.value.as_s? + string.downcase + end + end + + next unless state.presence + at_location = case state + when "inactive", "off" + 0 + when "active", "on" + 1 + else + logger.warn { "unexpected PIR value: #{state} for object #{pir.pir}.#{device_id}" } + next + end + + loc = Location.new( + level: pir_map.level_zone, + area_id: pir.map, + map_id: pir.map, + capacity: 1, + at_location: at_location, + zen_device_id: device_id, + zen_object_id: pir.pir + ) + + cached_data[pir_map.building_zone] << loc + cached_data[pir_map.level_zone] << loc + cached_count += 1 + rescue error + logger.warn(exception: error) { "error requesting object #{pir.pir} from zen #{device_id}" } + end + end + end + + @cached_data = cached_data + logger.debug { "cached #{cached_count} PIR objects" } + end +end diff --git a/drivers/delta/zen_pir_location_spec.cr b/drivers/delta/zen_pir_location_spec.cr new file mode 100644 index 00000000000..8b3235e1e41 --- /dev/null +++ b/drivers/delta/zen_pir_location_spec.cr @@ -0,0 +1,4 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Delta::ZenPIRLocation" do +end diff --git a/drivers/denon/amplifier/av_receiver.cr b/drivers/denon/amplifier/av_receiver.cr new file mode 100644 index 00000000000..d2d98469ced --- /dev/null +++ b/drivers/denon/amplifier/av_receiver.cr @@ -0,0 +1,216 @@ +require "digest/md5" +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/switchable" + +# +module Denon; end + +module Denon::Amplifier; end + +# Protocol: https://aca.im/driver_docs/Denon/Denon%20AVR%20PROTOCOL%20V7.5.0.pdf +# +# NOTE:: Denon doesn't respond to commands that request the current state +# (ie if the volume is 100 and you request 100 it will not respond) +# + +class Denon::Amplifier::AvReceiver < PlaceOS::Driver + include PlaceOS::Driver::Interface::Powerable + include PlaceOS::Driver::Utilities::Transcoder + + @channel : Channel(String) = Channel(String).new + @stable_power : Bool = true + + COMMANDS = { + power: :PW, + power_query: :PW?, + mute: :MU, + mute_query: :MU?, + volume: :MV, + volume_query: :MV?, + input: :SI, + input_query: :SI?, + } + COMMANDS.to_h.merge!(COMMANDS.to_h.invert) + + @volume_range = 0..196 + + default_settings({ + max_waits: 10, + timeout: 3000, + }) + # Discovery Information + tcp_port 23 # Telnet + descriptive_name "Denon AVR (Switcher Amplifier)" + generic_name :Switcher + + # Denon requires some breathing room + # delay between_sends: 30 + # delay on_receive: 30 + + def on_load + # transport.tokenizer = Tokenizer.new(Bytes[0x0D]) + transport.tokenizer = Tokenizer.new("\r") + self[:volume_min] = 0 + self[:volume_max] = @volume_range.max # == 98 * 2 - Times by 2 so we can account for the half steps + on_update + end + + def on_update + self[:max_waits] = 10 + self[:timeout] = 3000 + end + + def connected + # + # Get state + # + # power? + # input? + # mute? + + schedule.every(60.seconds) do + logger.info { "-- Polling Denon AVR" } + power? + do_send(:input, priority: 0, name: :input) + end + end + + def disconnected + schedule.clear + end + + def power(state : Bool = false) + # self[:power] is current as we would be informed otherwise + if state && (self[:power] == "OFF" || self[:power] == "STANDBY") # Request to power on if off + do_send(:power, "ON", delay: 3.milliseconds, name: :power) # Manual states delay for 1 second, just to be safe + elsif !state && self[:power] == "ON" # Request to power off if on + do_send(:power, "STANDBY", delay: 3.milliseconds, name: :power) + end + end + + def power? + # def power?(**options) + # options[:emit] = {:power => block} unless block.nil? + do_send(:power_query, priority: 0, name: :power_query) + end + + def mute? + self[:mute] = "OFF" + do_send(:mute_query, priority: 0, name: :mute_query) + end + + def mute(state : Bool = true) + req = state ? "ON" : "OFF" + return if self[:mute] == req + do_send(:mute, req, name: :mute) + end + + def mute_audio(state : Bool = true) + mute state + end + + def unmute + mute false + end + + def unmute_audio + unmute + end + + def volume(level : Float64 | Int32 = 0) + level = level.to_f.clamp(0.0, 100.0) + return if self[:volume] == level + + percentage = level / 100.0 + value = (percentage * @volume_range.end.to_f).round_away.to_i + + # The denon is weird 99 is volume off, + # 99.5 is the minimum volume, + # 0 is the next lowest volume and 985 is the loudest volume + # => So we are treating 99, 995 and 0 as 0 + step = value % 2 + actual = value / 2 + req = actual.to_s.rjust(2, '0') + req += "5" if step != 0 + + do_send(:volume, req, name: :volume) # Name prevents needless queuing of commands + end + + def volume? + do_send(:volume_query, priority: 0, name: :volume_query) + end + + # Just here for documentation (there are many more) + # + # INPUTS = [:cd, :tuner, :dvd, :bd, :tv, :"sat/cbl", :dvr, :game, :game2, :"v.aux", :dock] + def input(input : String = "") + status = input.upcase # .downcase.to_sym + if status != self[:input] + input = input.to_s.upcase + do_send(:input, input, name: :input) + end + end + + def input? + do_send(:input_query, priority: 0, name: :input_query) + end + + def received(data, task) + data = String.new(data) + logger.info { "Denon sent #{data.inspect}" } + + return unless task + + # Process the response + cmd = data[0..1] # first 2 chars are the key / command + val = data[2..-2] # anything following the above and before \r is a response value + + case cmd + when "PW" + self[:power] = val + when "SI" + self[:input] = val + when "MV" + # return :ignore if val.chars.size > 3 # May send 'MVMAX 98' after volume command + # self[:volume] = 0 + # vol = val.to_i32 + # self[:volume] = val unless val.to_i32 > @volume_range.max + vol_percent = ((val.to_f * 2) / @volume_range.end.to_f) * 100.0 + self[:volume] = vol_percent + # return :ignore if param.length > 3 # May send 'MVMAX 98' after volume command + # vol = param[0..1].to_i * 2 + # vol += 1 if param.length == 3 + # vol == 0 if vol > @volume_range.max # this means the volume was 99 or 995 + # self[:volume] = vol + + when "MU" + self[:mute] = val + else + return :ignore + end + + task.try &.success + end + + protected def do_send(command, param = nil, **options) + # prepare the command + cmd = if param.nil? + "#{COMMANDS[command]}" + else + "#{COMMANDS[command]}#{param}" + end + logger.info { "Queing: #{cmd}" } + + # queue the request + queue(**({ + name: command, + }.merge(options))) do + @channel = Channel(String).new + # send the request + logger.info { " Sending: #{cmd}" } + transport.send(cmd) + end + end +end diff --git a/drivers/denon/amplifier/av_receiver_spec.cr b/drivers/denon/amplifier/av_receiver_spec.cr new file mode 100644 index 00000000000..fa81e17bd9e --- /dev/null +++ b/drivers/denon/amplifier/av_receiver_spec.cr @@ -0,0 +1,73 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Denon::Amplifier::AvReceiver" do + #### + # POWER + # + sleep 1.second + # query power + exec(:power?) + should_send("PW?") + responds("PWOFF\r") + status[:power].should eq("OFF") + # turn power on + exec(:power, true) + should_send("PWON") + responds("PWON\r") + status[:power].should eq("ON") + # power off turns amp to STANDBY not actually OFF + exec(:power, false) + should_send("PWSTANDBY") + responds("PWSTANDBY\r") + status[:power].should eq("STANDBY") + + #### + # INPUT + # + sleep 1.second + # query input > DVD + exec(:input?) + should_send("SI?") + responds("SIDVD\r") + status[:input].should eq("DVD") + # chaange input to tuner + exec(:input, "TUNER") + should_send("SITUNER") + responds("SITUNER\r") + status[:input].should eq("TUNER") + + #### + # VOLUME + # + sleep 1.second + # query + exec(:volume?) + should_send("MV?") + responds("MV49\r") + status[:volume].should eq(50.0) + # change volume + exec(:volume, 100) + should_send("MV98.0") + responds("MV98.0\r") + status[:volume].should eq(100.0) + + #### + # MUTE + # + sleep 1.second + # query + exec(:mute?) + should_send("MU?") + responds("MUOFF\r") + status[:mute].should eq("OFF") + # mute on + exec(:mute, true) + should_send("MUON") + responds("MUON\r") + status[:mute].should eq("ON") + # mute off + exec(:mute, false) + should_send("MUOFF") + responds("MUOFF\r") + status[:mute].should eq("OFF") +end diff --git a/drivers/echo360/device_capture.cr b/drivers/echo360/device_capture.cr new file mode 100644 index 00000000000..9b1b6a50ca6 --- /dev/null +++ b/drivers/echo360/device_capture.cr @@ -0,0 +1,167 @@ +require "placeos-driver" +require "oq" + +# Documentation: https://aca.im/driver_docs/Echo360/EchoSystemCaptureAPI_v301.pdf + +class Echo360::DeviceCapture < PlaceOS::Driver + # Discovery Information + generic_name :Capture + descriptive_name "Echo365 Device Capture" + uri_base "https://echo.server" + + default_settings({ + basic_auth: { + username: "srvc_acct", + password: "password!", + }, + }) + + def on_update + schedule.clear + schedule.every(15.seconds) do + logger.debug { "-- Polling Capture" } + system_status + capture_status + end + end + + STATUS_CMDS = { + system_status: :system, + capture_status: :captures, + next: :next_capture, + current: :current_capture, + state: :monitoring, + } + + {% begin %} + {% for function, route in STATUS_CMDS %} + {% path = "/status/#{route.id}" %} + def {{function.id}} + response = get({{path}}) + process_status check(response) + end + {% end %} + {% end %} + + @[Security(PlaceOS::Driver::Level::Support)] + def restart_application + post("/diagnostics/restart_all").success? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def reboot + post("/diagnostics/reboot").success? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def captures + response = get("/diagnostics/recovery/saved-content") + self[:captures] = check(response)["captures"]["capture"] + end + + @[Security(PlaceOS::Driver::Level::Support)] + def upload(id : String) + response = post("/diagnostics/recovery/#{id}/upload") + raise "upload request failed with #{response.status_code}\n#{response.body}" unless response.success? + response.body + end + + # This will auto-start a recording + def capture(name : String, duration : Int32, profile : String? = nil) + profile ||= self[:capture_profiles][0].as_s + response = post("/capture/new_capture", body: URI::Params.build { |form| + form.add("description", name) + form.add("duration", duration.to_s) + form.add("capture_profile_name", profile) + }) + check(response)["ok"]["#text"].as_s + end + + def test_capture(name : String, duration : Int32, profile : String? = nil) + profile ||= self[:capture_profiles][0].as_s + response = post("/capture/confidence_monitor", body: URI::Params.build { |form| + form.add("description", name) + form.add("duration", duration.to_s) + form.add("capture_profile_name", profile) + }) + check(response)["ok"]["#text"].as_s + end + + def extend(duration : Int32) + response = post("/capture/confidence_monitor", body: URI::Params.build { |form| + form.add("duration", duration.to_s) + }) + check(response)["ok"]["#text"].as_s + end + + def pause + response = post("/capture/pause") + check(response)["ok"]["#text"].as_s + end + + def start + response = post("/capture/record") + check(response)["ok"]["#text"].as_s + end + + def resume + start + end + + def record + start + end + + def stop + response = post("/capture/stop") + check(response)["ok"]["#text"].as_s + end + + # Converts the response into the appropriate format and indicates success / failure + protected def check(response) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + + # Convert the XML to JSON for simple parsing + # https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html + input_io = IO::Memory.new response.body + output_io = IO::Memory.new + OQ::Converters::XML.deserialize input_io, output_io + + output_io.rewind + json = JSON.parse(output_io) + logger.debug { "response was\n#{json.pretty_inspect}" } + json + end + + CHECK = {"next", "current"} + + # generic function for processing status and exposing the state + protected def process_status(data) + if results = data["status"]?.try(&.as_h) + results.each do |key, value| + if key.in?(CHECK) && (value.as_s?.try(&.strip.empty?) || value["schedule"]?.try(&.as_s?.try(&.strip.empty?))) + # next / current recordings are not present + self[key] = nil + elsif key[-1] == 's' && (hash = value.as_h?) + # This handles `{"api-versions" => {"api-version" => "3.0"}}` + inner = hash[key[0..-2]]? + if inner + self[key] = inner + else + self[key] = hash + end + elsif str_val = value.as_s?.try(&.strip) + # cleanup whitespace around string values + self[key] = str_val + else + # otherwise we don't manipulate the value and expose it for use + self[key] = value + end + end + results + else + logger.debug { "namespace 'status' not found, ignoring payload" } + data + end + end +end diff --git a/drivers/echo360/device_capture_spec.cr b/drivers/echo360/device_capture_spec.cr new file mode 100644 index 00000000000..aec824dcce2 --- /dev/null +++ b/drivers/echo360/device_capture_spec.cr @@ -0,0 +1,182 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Echo360::DeviceCapture" do + retval = exec(:system_status) + expect_http_request do |request, response| + if request.path == "/status/system" + response.status_code = 200 + response << SYSTEM_STATUS + else + puts "unexpected path #{request.path}" + response.status_code = 404 + end + end + retval.get + + status["api-versions"].should eq "3.0" + + retval = exec(:captures) + expect_http_request do |_request, response| + response.status_code = 200 + response << CAPTURE_STATUS + end + retval.get + status[:captures].as_a.size.should eq(2) +end + +CAPTURE_STATUS = <<-HEREDOC + + + Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014 + 2014-02-12T15:30:00.000Z + 3000 +
Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ + + John Doe + + +
+ + Some other capture + 2014-02-13T15:30:00.000Z + 1500 +
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 + + file + audio.aac + + + + graphics1-archive + + file + display.h264 + + + + graphics2-archive + + file + video.h264 + + + + + +
+
+ + + +
+ HEREDOC diff --git a/drivers/embedia/control_point.cr b/drivers/embedia/control_point.cr new file mode 100644 index 00000000000..52cca92cb07 --- /dev/null +++ b/drivers/embedia/control_point.cr @@ -0,0 +1,107 @@ +require "placeos-driver" + +# Documentation: https://aca.im/driver_docs/Embedia/Embedia%20Control%20Point%20rev2013.pdf +# RS232 Gateway. Baud Rate 9600,8,N,1 + +# this is a good example of communicating with a binary protocol +# although this particular device uses hex encoded streams +class Embedia::ControlPoint < PlaceOS::Driver + # Discovery Information + descriptive_name "Embedia Control Point Blinds" + generic_name :Blinds + description %(simple driver to control embedia blinds, doesn't expose any state) + + # Global Cache Port + tcp_port 4999 + + def on_load + # this device doesn't respond when we make requests to it + queue.wait = false + + # the documentation specifies a delay needs to occur between sends + # to allow the device some time to process requests + queue.delay = 200.milliseconds + + # all messages from the device are terminated so we can tokenize the + # IO stream + transport.tokenizer = Tokenizer.new("\r\n") + end + + def connected + schedule.every(1.minute) do + logger.debug { "Maintaining connection" } + query_sensor 0 + end + end + + def disconnected + schedule.clear + end + + COMMANDS = { + stop: 0x28, + down: 0x4e, # Also extend + up: 0x4b, # Also retract + next_extent_preset: 0x4f, + previous_extent_preset: 0x50, + + close: 0x16, + open: 0x1a, + next_tilt_preset: 0x07, + previous_tilt_preset: 0x04, + + clear_override: 0x4c, + } + + {% begin %} + {% for command, value in COMMANDS %} + def {{command.id}}(address : UInt8, **options) + do_send Bytes[address, 0x06, 0, 1, 0, {{value}}], **options + end + {% end %} + {% end %} + + def extent_preset(address : UInt8, number : UInt8, **options) + num = 0x1D + number.clamp(1, 10) + do_send Bytes[address, 0x06, 0, 1, 0, num], **options, name: "extent_preset#{address}" + end + + def tilt_preset(address : UInt8, number : UInt8, **options) + num = 0x39 + number.clamp(1, 10) + do_send Bytes[address, 0x06, 0, 1, 0, num], **options, name: "tilt_preset#{address}" + end + + def query_sensor(address : UInt8, **options) + do_send Bytes[address, 0x03, 0, 1, 0, 1], **options + end + + protected def do_send(data : Bytes, **options) + sending = data.hexstring.upcase + logger.debug { "sending :#{sending}--" } + send ":#{sending}--\r\n", **options + end + + # as we've configured the tokenizer we know that for every invokation of this function + # will contain exactly one message from the device + def received(bytes, task) + logger.debug { + # remove the newline chars + raw_data = String.new(bytes).strip + + # strip the padding ':' and The LRC checksum + data = raw_data[1..-3].hexbytes + address = data[0] + func = data[1] + + case func + when 3 # Sensor level + "sensor response #{raw_data} on address 0x#{address.to_s(16)}" + else + "sent #{raw_data} on address 0x#{address.to_s(16)}" + end + } + + # as this device is not waiting for responses task will always be nil + task.try &.success + end +end diff --git a/drivers/embedia/control_point_spec.cr b/drivers/embedia/control_point_spec.cr new file mode 100644 index 00000000000..6eef94e4905 --- /dev/null +++ b/drivers/embedia/control_point_spec.cr @@ -0,0 +1,10 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Embedia::ControlPoint" do + exec(:down, 0xFF) + should_send(":FF060001004E--\r\n") + + exec(:query_sensor, 0xFF) + should_send(":FF0300010001--\r\n") + responds(":FF0300010001AA\r\n") +end diff --git a/drivers/epiphan/pearl.cr b/drivers/epiphan/pearl.cr new file mode 100644 index 00000000000..318d8aa44bf --- /dev/null +++ b/drivers/epiphan/pearl.cr @@ -0,0 +1,292 @@ +# Documentation: https://epiphan-video.github.io/pearl_api_swagger_ui/ +# API Reference: Epiphan Pearl REST API for Pearl-2 and Pearl Mini devices +# Device Models: Pearl-2, Pearl Mini +# Protocol: HTTP/HTTPS REST API with Basic Authentication + +require "placeos-driver" +require "./pearl_models" + +class Epiphan::Pearl < PlaceOS::Driver + descriptive_name "Epiphan Pearl Recording Device" + generic_name :Recording + description <<-DESC + Driver for Epiphan Pearl-2 and Pearl Mini recording/streaming devices. + + Requirements: + - Pearl device must be accessible on the network + - Admin credentials required for API access + - REST API v2.0 must be enabled on the device + + Features: + - Recording control (start/stop/pause/resume) + - Streaming control for channels and publishers + - Channel layout switching + - Active recording/streaming monitoring + - Publisher listing and status + + Based on Epiphan Pearl REST API v2.0 + DESC + + uri_base "https://pearl-device.local" + + default_settings({ + basic_auth: { + username: "admin", + password: "admin", + }, + poll_every: 30, + }) + + @poll_every : Int32 = 30 + @recorders = [] of Epiphan::PearlModels::Recorder + + def on_update + @poll_every = setting?(Int32, :poll_every) || 30 + + schedule.clear + schedule.every(@poll_every.seconds) { poll_status } + schedule.in(2.seconds) { poll_status } + end + + def connected + schedule.every(@poll_every.seconds) { poll_status } + schedule.in(2.seconds) { poll_status } + end + + def disconnected + schedule.clear + end + + def list_recorders + response = get("/api/v2.0/recorders") + raise "Failed to get recorders: #{response.status_code}" unless response.success? + + recorders_response = Epiphan::PearlModels::RecordersResponse.from_json(response.body.not_nil!) + raise "API returned error: #{recorders_response.status}" unless recorders_response.status == "ok" + + @recorders = recorders_response.result + self[:recorders] = @recorders + @recorders + end + + def get_recorder_status(recorder_id : String) + response = get("/api/v2.0/recorders/#{recorder_id}/status") + raise "Failed to get recorder status: #{response.status_code}" unless response.success? + + status_response = Epiphan::PearlModels::RecorderStatusResponse.from_json(response.body.not_nil!) + raise "API returned error: #{status_response.status}" unless status_response.status == "ok" + + status = status_response.result + self["recorder_#{recorder_id}_status"] = status + status + end + + def start_recording(recorder_id : String) + response = post("/api/v2.0/recorders/#{recorder_id}/control/start") + raise "Failed to start recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + true + end + + def stop_recording(recorder_id : String) + response = post("/api/v2.0/recorders/#{recorder_id}/control/stop") + raise "Failed to stop recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + true + end + + def list_channels + response = get("/api/v2.0/channels") + raise "Failed to get channels: #{response.status_code}" unless response.success? + + channels_response = Epiphan::PearlModels::ChannelsResponse.from_json(response.body.not_nil!) + raise "API returned error: #{channels_response.status}" unless channels_response.status == "ok" + + channels = channels_response.result + self[:channels] = channels + channels + end + + # Channel status endpoint not clearly defined in API spec - commenting out for now + # def get_channel_status(channel_id : String) + # response = get("/api/v2.0/channels/#{channel_id}/status") + # raise "Failed to get channel status: #{response.status_code}" unless response.success? + # + # status_response = Epiphan::PearlModels::ChannelStatusResponse.from_json(response.body.not_nil!) + # raise "API returned error: #{status_response.status}" unless status_response.status == "ok" + # + # status = status_response.result + # self["channel_#{channel_id}_status"] = status + # status + # end + + def get_channel_layouts(channel_id : String) + response = get("/api/v2.0/channels/#{channel_id}/layouts") + raise "Failed to get layouts: #{response.status_code}" unless response.success? + + layouts_response = Epiphan::PearlModels::LayoutsResponse.from_json(response.body.not_nil!) + raise "API returned error: #{layouts_response.status}" unless layouts_response.status == "ok" + + layouts = layouts_response.result + self["channel_#{channel_id}_layouts"] = layouts + layouts + end + + def start_streaming(channel_id : String, publisher_id : String) + response = post("/api/v2.0/channels/#{channel_id}/publishers/#{publisher_id}/control/start") + raise "Failed to start streaming: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + true + end + + def stop_streaming(channel_id : String, publisher_id : String) + response = post("/api/v2.0/channels/#{channel_id}/publishers/#{publisher_id}/control/stop") + raise "Failed to stop streaming: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + true + end + + def is_recording?(recorder_id : String) + status = get_recorder_status(recorder_id) + status.state == Epiphan::PearlModels::RecorderState::Started + end + + def get_active_recordings + active = [] of String + + @recorders.each do |recorder| + status = get_recorder_status(recorder.id) + if status.state == Epiphan::PearlModels::RecorderState::Started + active << recorder.id + end + end + + self[:number_of_active_recordings] = active.size + self[:active_recordings] = active + active + end + + def stop_all_recordings + results = {} of String => Bool + @recorders.each do |recorder| + if is_recording?(recorder.id) + results[recorder.id] = begin + stop_recording(recorder.id) + rescue + false + end + end + end + results + end + + def pause_recording(recorder_id : String) + response = post("/api/v2.0/recorders/#{recorder_id}/control/pause") + raise "Failed to pause recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + true + end + + def resume_recording(recorder_id : String) + response = post("/api/v2.0/recorders/#{recorder_id}/control/resume") + raise "Failed to resume recording: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_recorder_status(recorder_id) } + true + end + + def get_system_status + response = get("/api/v2.0/system/status") + raise "Failed to get system status: #{response.status_code}" unless response.success? + + status_response = Epiphan::PearlModels::SystemStatusResponse.from_json(response.body.not_nil!) + raise "API returned error: #{status_response.status}" unless status_response.status == "ok" + + status = status_response.result + self[:system_status] = status + status + end + + def list_publishers(channel_id : String) + response = get("/api/v2.0/channels/#{channel_id}/publishers") + raise "Failed to get publishers: #{response.status_code}" unless response.success? + + publishers_response = Epiphan::PearlModels::PublishersResponse.from_json(response.body.not_nil!) + raise "API returned error: #{publishers_response.status}" unless publishers_response.status == "ok" + + publishers = publishers_response.result + self["channel_#{channel_id}_publishers"] = publishers + publishers + end + + def set_channel_layout(channel_id : String, layout_id : String) + body = { + layout_id: layout_id, + }.to_json + + response = put("/api/v2.0/channels/#{channel_id}/set_layout", body: body, headers: {"Content-Type" => "application/json"}) + raise "Failed to set layout: #{response.status_code}" unless response.success? + + control_response = Epiphan::PearlModels::ControlResponse.from_json(response.body.not_nil!) + raise "API returned error: #{control_response.status}" unless control_response.status == "ok" + schedule.in(2.seconds) { get_channel_layouts(channel_id) } + true + end + + def get_active_streamings + active = [] of NamedTuple(channel_id: String, publisher_ids: Array(String)) + + channels = list_channels + channels.each do |channel| + active_publishers = [] of String + publishers = list_publishers(channel.id) + + publishers.each do |publisher| + # Check if this publisher is currently streaming + if publisher.status && publisher.status.try &.state == Epiphan::PearlModels::StreamingState::Started + active_publishers << publisher.id + end + end + + if !active_publishers.empty? + active << {channel_id: channel.id, publisher_ids: active_publishers} + end + end + + self[:active_streamings] = active + active + end + + # Check if a channel has any active streaming publishers + def is_streaming?(channel_id : String) + publishers = list_publishers(channel_id) + publishers.any? { |pub| pub.status && pub.status.try &.state == Epiphan::PearlModels::StreamingState::Started } + end + + private def poll_status + begin + get_system_status + list_recorders + get_active_recordings + list_channels + get_active_streamings if @recorders.size > 0 + rescue error + logger.warn(exception: error) { "Error polling device status" } + end + end +end diff --git a/drivers/epiphan/pearl_models.cr b/drivers/epiphan/pearl_models.cr new file mode 100644 index 00000000000..fcb0a178b19 --- /dev/null +++ b/drivers/epiphan/pearl_models.cr @@ -0,0 +1,119 @@ +require "json" + +module Epiphan::PearlModels + # Enum for recorder states + enum RecorderState + Started + Stopped + Paused + Starting + Stopping + end + + # Enum for publisher/streaming states + enum StreamingState + Started + Stopped + Starting + Stopping + end + + # API Response wrapper - all Pearl API responses use this format + class ApiResponse(T) + include JSON::Serializable + + getter status : String + getter result : T + end + + # Represents a recording channel/recorder + class Recorder + include JSON::Serializable + + getter id : String + getter name : String + getter multisource : Bool + end + + # Represents the status of a recorder + class RecorderStatus + include JSON::Serializable + + getter state : RecorderState + getter duration : Int64? # Duration in seconds (optional) + getter active : String? # Number of active recordings as string (optional) + getter total : String? # Total number of recordings as string (optional) + end + + # Represents a streaming channel + class Channel + include JSON::Serializable + + getter id : String + getter name : String + getter publishers : Array(Publisher)? + getter encoders : Array(JSON::Any)? + getter active_layout : Layout? + end + + # Channel layout information + class Layout + include JSON::Serializable + + class Sources + include JSON::Serializable + + getter video : Array(JSON::Any)? + getter audio : Array(JSON::Any)? + end + + getter id : String + getter name : String + getter sources : Sources? + end + + # Control operation response - simple status response + class ControlResponse + include JSON::Serializable + + getter status : String + end + + # Publisher status within a channel + class PublisherStatus + include JSON::Serializable + + getter state : StreamingState + end + + # Publisher information + class Publisher + include JSON::Serializable + + getter id : String + getter type : String # "rtmp", "rtsp", "srt", etc. + getter name : String + getter status : PublisherStatus? + getter settings : JSON::Any? # PublisherSettings varies by type + end + + # System status information + class SystemStatus + include JSON::Serializable + + getter cpuload : Int32? # CPU load percentage + getter cpuload_high : Bool? # CPU warning + getter cputemp : Int32? # CPU temperature in Celsius + getter cputemp_threshold : Int32? # High CPU temperature threshold + getter date : Time? # Current system time + getter uptime : Int64? # System uptime in seconds + end + + # Response type aliases for specific endpoints + alias RecordersResponse = ApiResponse(Array(Recorder)) + alias ChannelsResponse = ApiResponse(Array(Channel)) + alias RecorderStatusResponse = ApiResponse(RecorderStatus) + alias LayoutsResponse = ApiResponse(Array(Layout)) + alias PublishersResponse = ApiResponse(Array(Publisher)) + alias SystemStatusResponse = ApiResponse(SystemStatus) +end diff --git a/drivers/epiphan/pearl_spec.cr b/drivers/epiphan/pearl_spec.cr new file mode 100644 index 00000000000..b6d26f492d7 --- /dev/null +++ b/drivers/epiphan/pearl_spec.cr @@ -0,0 +1,273 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Epiphan::Pearl" do + settings({ + basic_auth: { + username: "admin", + password: "admin", + }, + poll_every: 30, + }) + + # Test list_recorders functionality with actual API response structure + retval = exec(:list_recorders) + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "1", + "name": "HDMI-A", + "multisource": false + }, + { + "id": "2", + "name": "HDMI-B", + "multisource": false + }, + { + "id": "3", + "name": "USB-A", + "multisource": false + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + recorders = status["recorders"]? + recorders.should_not be_nil + + # Test list_channels functionality with actual API response + retval = exec(:list_channels) + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/channels" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "4", + "name": "CameraTrackingRegie" + }, + { + "id": "5", + "name": "CAM1" + }, + { + "id": "6", + "name": "CAM2" + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + channels = status["channels"]? + channels.should_not be_nil + + # Test get_recorder_status functionality + retval = exec(:get_recorder_status, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/status" + response.status_code = 200 + response << %({ + "status": "ok", + "result": { + "state": "stopped", + "duration": 0, + "active": "0", + "total": "0" + } + }) + else + response.status_code = 401 + end + end + + retval.get + recorder_status = status["recorder_1_status"]? + recorder_status.should_not be_nil + + # Test start_recording functionality + retval = exec(:start_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/start" && request.method == "POST" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test stop_recording functionality + retval = exec(:stop_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/stop" && request.method == "POST" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test get_channel_layouts functionality + retval = exec(:get_channel_layouts, "4") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/channels/4/layouts" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "1", + "name": "Web+Barco+Cams" + }, + { + "id": "2", + "name": "Barco+Cams" + }, + { + "id": "3", + "name": "Cams" + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + layouts = status["channel_4_layouts"]? + layouts.should_not be_nil + + # Test pause_recording functionality + retval = exec(:pause_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/pause" && request.method == "POST" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test resume_recording functionality + retval = exec(:resume_recording, "1") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/recorders/1/control/resume" && request.method == "POST" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test list_publishers functionality + retval = exec(:list_publishers, "4") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/channels/4/publishers" + response.status_code = 200 + response << %({ + "status": "ok", + "result": [ + { + "id": "1", + "type": "rtmp", + "name": "RTMP Stream" + }, + { + "id": "2", + "type": "hls", + "name": "HLS Stream" + } + ] + }) + else + response.status_code = 401 + end + end + + retval.get + publishers = status["channel_4_publishers"]? + publishers.should_not be_nil + + # Test set_channel_layout functionality + retval = exec(:set_channel_layout, "4", "3") + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && + request.path == "/api/v2.0/channels/4/set_layout" && + request.method == "PUT" + response.status_code = 200 + response << %({"status": "ok"}) + else + response.status_code = 401 + end + end + + retval.get.should be_true + + # Test get_system_status functionality + retval = exec(:get_system_status) + + expect_http_request do |request, response| + headers = request.headers + if headers["Authorization"]? == "Basic #{Base64.strict_encode("admin:admin")}" && request.path == "/api/v2.0/system/status" + response.status_code = 200 + response << %({ + "status": "ok", + "result": { + "date": "2025-02-14T08:41:09-05:00", + "uptime": 5490, + "cpuload": 25, + "cpuload_high": false, + "cputemp": 57, + "cputemp_threshold": 70 + } + }) + else + response.status_code = 401 + end + end + + retval.get + system_status = status[:system_status]? + system_status.should_not be_nil +end diff --git a/drivers/epson/projector/esc_vp21.cr b/drivers/epson/projector/esc_vp21.cr new file mode 100644 index 00000000000..298eef0d5f5 --- /dev/null +++ b/drivers/epson/projector/esc_vp21.cr @@ -0,0 +1,341 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/switchable" + +class Epson::Projector::EscVp21 < PlaceOS::Driver + include Interface::Powerable + include Interface::Muteable + + enum Input + HDMI = 0x30 + HDBaseT = 0x80 + end + + include Interface::InputSelection(Input) + + # Discovery Information + tcp_port 3629 + descriptive_name "Epson Projector" + generic_name :Display + + default_settings({ + epson_projectors_poll_video_mute: true, + epson_projectors_poll_volume: true, + epson_projectors_disable_muting: false, + }) + + @poll_video_mute : Bool = true + @poll_volume : Bool = true + + # Mute commands appear to cause significant problems in some Epson models. + # meet.cr appears to send mute commands to outputs of unjoined joinable rooms, causing disturbance to other meetings (especially since the UI currently does not have mute/unmute buttons). + # The below is a workaround until the meeting rm logic driver is fixed (don't mute other (unjoined) rooms on shutdown/unroute) + @muting_disabled : Bool = false + + @ready : Bool = false + + getter power_actual : Bool? = nil # actual power state + getter? power_stable : Bool = true # are we in a stable state? + getter? power_target : Bool = true # what is the target state? + + @unmute_volume : Float64 = 60.0 + + def on_load + transport.tokenizer = Tokenizer.new("\r") + self[:type] = :projector + on_update + end + + def on_update + @poll_video_mute = setting?(Bool, :epson_projectors_poll_video_mute) || true + @poll_volume = setting?(Bool, :epson_projectors_poll_volume) || true + @muting_disabled = setting?(Bool, :epson_projectors_disable_muting) || false + end + + def connected + # Have to init comms + send("ESC/VP.net\x10\x03\x00\x00\x00\x00") + schedule.every(52.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def power(state : Bool) + if state + @power_target = true + logger.debug { "-- epson Proj, requested to power on" } + do_send(:power, "ON", delay: 40.seconds, name: "power", priority: 99) + else + @power_target = false + logger.debug { "-- epson Proj, requested to power off" } + do_send(:power, "OFF", delay: 10.seconds, name: "power", priority: 99) + end + @power_stable = false + self[:power] = state + power? + end + + def power?(**options) : Bool + do_send(:power, **options).get + @power_actual || false + end + + def switch_to(input : Input) + logger.debug { "-- epson Proj, requested to switch to: #{input}" } + do_send(:input, input.value.to_s(16), name: :input) + + # for a responsive UI + self[:input] = input # for a responsive UI + self[:video_mute] = false + input? + end + + def input? + do_send(:input, priority: 0, retries: 0).get + self[:input] + end + + # Volume commands are sent using the inpt command + def volume(vol : Float64 | Int32, **options) + vol = vol.to_f.clamp(0.0, 100.0) + percentage = vol / 100.0 + vol_actual = (percentage * 255.0).round_away.to_i + + @unmute_volume = self[:volume].as_f if (muted = vol == 0.0) && self[:volume]? + do_send(:volume, vol_actual, **options, name: :volume) + + # for a responsive UI + self[:volume] = vol + self[:audio_mute] = muted + volume? unless @muting_disabled # Affected projectors support volume setting, but not volume query + end + + def volume? + return if @muting_disabled + do_send(:volume, priority: 0, retries: 0).get + self[:volume]?.try(&.as_f) + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo, + ) + return if @muting_disabled + case layer + when .audio_video? + do_send(:av_mute, state ? "ON" : "OFF", name: :mute) + do_send(:av_mute, name: :mute?, priority: 0, retries: 0) + when .video? + do_send(:video_mute, state ? "ON" : "OFF", name: :video_mute) + video_mute? + when .audio? + val = state ? 0.0 : @unmute_volume + volume(val) + end + end + + def video_mute? + return if @muting_disabled + do_send(:video_mute, priority: 0).get + !!self[:video_mute]?.try(&.as_bool) + end + + ERRORS = [ + "00: no error", + "01: fan error", + "03: lamp failure at power on", + "04: high internal temperature", + "06: lamp error", + "07: lamp cover door open", + "08: cinema filter error", + "09: capacitor is disconnected", + "0A: auto iris error", + "0B: subsystem error", + "0C: low air flow error", + "0D: air flow sensor error", + "0E: ballast power supply error", + "0F: shutter error", + "10: peltiert cooling error", + "11: pump cooling error", + "12: static iris error", + "13: power supply unit error", + "14: exhaust shutter error", + "15: obstacle detection error", + "16: IF board discernment error", + ] + + def inspect_error + do_send(:error, priority: 0) + end + + COMMAND = { + power: "PWR", + input: "SOURCE", + volume: "VOL", + av_mute: "MUTE", + video_mute: "MSEL", + error: "ERR", + lamp: "LAMP", + } + RESPONSE = COMMAND.to_h.invert + + def received(data, task) + return task.try(&.success) if data.size <= 2 + # Because we see sometimes see responses like ':::PWR' + data = String.new(data[1..-2]).lstrip(':') + logger.debug { "<< Received from Epson Proj: #{data}" } + + # Handle IMEVENT messages + if data.starts_with?("IMEVENT=") + parse_imevent(data) + return task.try(&.success) + end + + 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 + @power_actual = powered = state < 3 + warming = state == 2 + cooling = state == 3 + + if warming || cooling + schedule.in(5.seconds) { power?(priority: 10) } + elsif !@power_stable + if @power_actual == @power_target + @power_stable = true + else + power(@power_target) + end + end + + self[:power] = powered if @power_stable + self[:warming] = warming + self[:cooling] = cooling + + if powered == @power_target + self[:video_mute] = false unless powered + end + when :av_mute + self[:video_mute] = self[:audio_mute] = data[1] == "ON" + self[:volume] = 0.0 + when :video_mute + self[:video_mute] = data[1] == "ON" + when :volume + # convert to a percentage + vol = data[1].to_i + vol_percent = (vol.to_f / 255.0) * 100.0 + self[:volume] = vol_percent + + mute = vol == 0 + self[:audio_mute] = mute if mute + @unmute_volume ||= vol_percent unless mute + when :lamp + self[:lamp_usage] = data[1].split(" ")[0].to_i # split added as we see responses like "LAMP=1633 1633" + when :input + self[:input] = Input.from_value(data[1].to_i(16)) || "unknown" + end + + task.try(&.success) + end + + def do_poll + if power?(priority: 20) && @power_stable + input? + volume? if @poll_volume + end + do_send(:lamp, priority: 20) + end + + private def parse_imevent(data : String) + # IMEVENT format: IMEVENT=0001 03 00000000 00000000 T1 F1 + parts = data.split(' ') + return unless parts.size >= 6 + + begin + # Extract status code from second part + status_code = parts[1].to_i(16) + + # Map status code to power state + power_state = case status_code + when 1 then false # STATE_OFF + when 2 then false # STATE_WARMUP + when 3 then true # STATE_ON + when 4 then false # STATE_COOLDOWN + else + nil + end + + if !power_state.nil? + @power_actual = power_state + + # Determine if warming/cooling based on status code + warming = status_code == 2 + cooling = status_code == 4 + + if warming || cooling + schedule.in(5.seconds) { power?(priority: 10) } + elsif !@power_stable + if @power_actual == @power_target + @power_stable = true + else + power(@power_target) + end + end + + self[:power] = power_state if @power_stable + self[:warming] = warming + self[:cooling] = cooling + + if power_state == @power_target + self[:video_mute] = false unless power_state + end + end + + # Parse warning bits (parts[2]) + warning_bits = parts[2].to_u32(16) + active_warnings = [] of String + warning_map = {0 => "Lamp life", 1 => "No signal", 2 => "Unsupported signal", 3 => "Air filter", 4 => "High temperature"} + warning_map.each do |bit, description| + if (warning_bits >> bit) & 1 == 1 + active_warnings << description + end + end + self[:warnings] = active_warnings + + # Parse alarm bits (parts[3]) + alarm_bits = parts[3].to_u32(16) + active_alarms = [] of String + alarm_map = {0 => "Lamp ON failure", 1 => "Lamp lid", 2 => "Lamp burnout", 3 => "Fan", 4 => "Temperature sensor", 5 => "High temperature", 6 => "Interior (system)"} + alarm_map.each do |bit, description| + if (alarm_bits >> bit) & 1 == 1 + active_alarms << description + end + end + self[:alarms] = active_alarms + + logger.debug { "IMEVENT parsed - Power: #{power_state}, Warnings: #{active_warnings}, Alarms: #{active_alarms}" } + rescue ex + logger.warn(exception: ex) { "Failed to parse IMEVENT: #{data}" } + end + end + + private def do_send(command, param = nil, **options) + command = COMMAND[command] + cmd = param ? "#{command} #{param}\r" : "#{command}?\r" + logger.debug { ">> Sending to Epson Proj: #{command}: #{cmd}" } + send(cmd, **options) + end +end diff --git a/drivers/epson/projector/esc_vp21_spec.cr b/drivers/epson/projector/esc_vp21_spec.cr new file mode 100644 index 00000000000..a87560cae0a --- /dev/null +++ b/drivers/epson/projector/esc_vp21_spec.cr @@ -0,0 +1,82 @@ +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") + # volume? + should_send("VOL?\r") + responds(":VOL=0\r") + status[:volume].should eq(0) + # lamp + should_send("LAMP?\r") + responds(":LAMP=20\r") + status[:lamp_usage].should eq(20) + + # IMEVENT test - projector on + transmit(":IMEVENT=0001 03 00000000 00000000 T1 F1\r") + status[:power].should eq(true) + status[:warming].should eq(false) + status[:cooling].should eq(false) + + 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 153\r") + responds(":\r") + should_send("VOL?\r") + responds(":VOL=255\r") + status[:volume].should eq(100) + status[:audio_mute].should eq(false) + + exec(:volume, 80) + should_send("VOL 204\r") + responds(":\r") + should_send("VOL?\r") + responds(":VOL=204\r") + status[:volume].should eq(80) + status[:audio_mute].should eq(false) + + # Additional IMEVENT tests + # Test warming state + transmit(":IMEVENT=0001 02 00000000 00000000 T1 F1\r") + status[:power].should eq(false) + status[:warming].should eq(true) + status[:cooling].should eq(false) + + # Test cooling state + transmit(":IMEVENT=0001 04 00000000 00000000 T1 F1\r") + status[:power].should eq(false) + status[:warming].should eq(false) + status[:cooling].should eq(true) + + # Test with warnings and alarms + transmit(":IMEVENT=0001 03 00000003 00000007 T1 F1\r") + status[:power].should eq(true) + status[:warnings].should eq(["Lamp life", "No signal"]) + status[:alarms].should eq(["Lamp ON failure", "Lamp lid", "Lamp burnout"]) +end diff --git a/drivers/exterity/avedia_player/m93xx.cr b/drivers/exterity/avedia_player/m93xx.cr new file mode 100644 index 00000000000..6ccc64796eb --- /dev/null +++ b/drivers/exterity/avedia_player/m93xx.cr @@ -0,0 +1,163 @@ +require "telnet" +require "placeos-driver" + +class Exterity::AvediaPlayer::R93xx < PlaceOS::Driver + descriptive_name "Exterity Avedia Player (M93xx)" + generic_name :IPTV + tcp_port 22 + + default_settings({ + ssh: { + username: :ctrl, + password: :labrador, + term: :xterm, + }, + max_waits: 100, + }) + + @ready : Bool = false + + def connected + self[:ready] = @ready = false + + schedule.every(59.seconds) do + logger.debug { "-- Polling Exterity Player" } + current_channel + current_channel_name + end + + schedule.every(1.hour) do + logger.debug { "-- Polling Exterity Player" } + dump + end + end + + def disconnected + self[:ready] = @ready = false + transport.tokenizer = nil + schedule.clear + end + + def channel(number : Int32 | String) + if number.is_a? Number + set :playChannelNumber, number, name: :channel + else + stream number + end + end + + def channel_name(name : String) + set(:currentChannel_name, name, name: :name).get + current_channel_name + end + + def stream(uri : String) : Nil + set(:playChannelUri, uri, name: :channel).get + schedule.in(2.second) do + current_channel + current_channel_name + end + end + + def current_channel + get :currentChannel + end + + def current_channel_name + get :currentChannel_name + end + + def dump + do_send "^dump!\r", name: :dump + end + + def help + do_send "^help!\r", name: :help + end + + def reboot + remote :reboot + end + + def tv_info + get :tv_info + end + + def version + get :SoftwareVersion + end + + @[Security(Level::Support)] + def manual(cmd : String) + do_send cmd + end + + def received(data, task) + data = String.new(data).strip + + logger.debug { "Exterity sent #{data}" } + + if !@ready + if data =~ /Run a Shell/i + logger.info { "-- starting the command interface" } + + # select open shell option + do_send "6\r", wait: false, delay: 2.seconds, priority: 96 + + # launch command processor + do_send "/usr/bin/serialCommandInterface\r", wait: false, delay: 200.milliseconds, priority: 95 + + # we need to disconnect if we don't see the serialCommandInterface after a certain amount of time + schedule.in(20.seconds) do + if !@ready + logger.error { "Exterity connection failed to be ready after 20 seconds." } + disconnect + end + end + elsif data =~ /Terminal Control Interface/i + logger.info { "-- got the control interface message, we're READY now" } + transport.tokenizer = Tokenizer.new("!") + self[:ready] = @ready = true + dump + return + end + end + + # Extract response between the ^ and ! + resp = data.split("^")[1][0..-2] + process_resp resp, task + end + + protected def process_resp(data, task) + logger.debug { "Resp details #{data}" } + + parts = data.split ':', 2 + + case parts[0] + when "error" + message = task ? "Error when requesting: #{task.try &.name}" : "Error response received" + logger.warn { message } + task.try &.abort(message) + else + self[parts[0].underscore] = parts[1] + task.try &.success(parts[1]) + end + end + + protected def do_send(command, **options) + logger.debug { "requesting #{command}" } + send command, **options + end + + protected def set(command, data, **options) + do_send "^set:#{command}:#{data}!\r", **options.merge({wait: false}) + end + + protected def remote(cmd, **options) + do_send "^send:#{cmd}!\r", **options + end + + protected def get(status, **options) + do_send "^get:#{status}!\r", **options + end +end diff --git a/drivers/exterity/avedia_player/m93xx_spec.cr b/drivers/exterity/avedia_player/m93xx_spec.cr new file mode 100644 index 00000000000..d8940064e13 --- /dev/null +++ b/drivers/exterity/avedia_player/m93xx_spec.cr @@ -0,0 +1,31 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Exterity::AvediaPlayer::R92xx" do + # this lets the driver know it's successfully connected + sleep 1 + status[:ready].should eq(false) + responds("Terminal Control Interface\r") + status[:ready].should eq(true) + + should_send("^dump!\r").responds %(^currentChannel:udp://239.193.3.169:5000?hwchan=4! +^currentChannel_name:SBS ONE HD! +^currentChannel_number:30! +^currentAVChannel:udp://239.193.3.169:5000?hwchan=4! +^new_channel:NO VALUE! +^currentChannel:udp://239.193.3.169:5000?hwchan=4!) + + sleep 100.milliseconds + + status[:current_channel].should eq "udp://239.193.3.169:5000?hwchan=4" + status[:current_channel_name].should eq "SBS ONE HD" + + resp = exec(:version) + responds("^SoftwareVersion:123!\r") + resp.get + status[:software_version].should eq("123") + + resp = exec(:tv_info) + responds("^tv_info:a,b,c,d,e,f,g!\r") + resp.get + status[:tv_info].should eq("a,b,c,d,e,f,g") +end diff --git a/drivers/exterity/avedia_player/r92xx.cr b/drivers/exterity/avedia_player/r92xx.cr new file mode 100644 index 00000000000..78bee328f70 --- /dev/null +++ b/drivers/exterity/avedia_player/r92xx.cr @@ -0,0 +1,167 @@ +require "telnet" +require "placeos-driver" + +class Exterity::AvediaPlayer::R92xx < PlaceOS::Driver + descriptive_name "Exterity Avedia Player (R92xx)" + generic_name :IPTV + tcp_port 23 + + default_settings({ + max_waits: 100, + username: "admin", + password: "labrador", + }) + + @ready : Bool = false + @telnet : Telnet? = nil + + def on_load + new_telnet_client + transport.pre_processor { |bytes| @telnet.try &.buffer(bytes) } + end + + def connected + @ready = false + self[:ready] = false + + schedule.every(60.seconds) do + logger.info { "-- Polling Exterity Player" } + tv_info + end + end + + def disconnected + # ensures the buffer is cleared + new_telnet_client + + schedule.clear + end + + def channel(number : Int32 | String) + if number.is_a? Number + set :playChannelNumber, number + else + stream number + end + end + + def stream(uri : String) + set :playChannelUri, uri + end + + def dump + do_send "^dump!", name: :dump + end + + def help + do_send "^help!", name: :help + end + + def reboot + remote :reboot + end + + def tv_info + get :tv_info + end + + def version + get :SoftwareVersion + end + + def manual(cmd : String) + do_send cmd + end + + def received(data, task) + data = String.new(data).strip + + logger.info { "Exterity sent #{data}" } + + if @ready + # Detect if logged out of serialCommandInterface + if data =~ /sh: .* not found/i + # Launch command processor + do_send "/usr/bin/serialCommandInterface", wait: false, delay: 2.seconds, priority: 95 + return :failure + end + + # Extract response + data.split("!").map(&.strip("^")).each do |resp| + process_resp(resp, task) + end + elsif data =~ /Exterity Control Interface| Exit/i + logger.info { "-- got the control interface message, we're READY now" } + @ready = true + self[:ready] = true + version + elsif data =~ /login:/i + logger.info { "-- got the login: prompt" } + transport.tokenizer = Tokenizer.new("\r") + + # login + do_send setting(String, :username), wait: false, delay: 200.milliseconds, priority: 98 + do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97 + + # select open shell option + do_send "6", wait: false, delay: 2.seconds, priority: 96 + + # launch command processor + do_send "/usr/bin/serialCommandInterface", wait: false, delay: 200.milliseconds, priority: 95 + + # we need to disconnect if we don't see the serialCommandInterface after a certain amount of time + schedule.in(20.seconds) do + if !@ready + logger.error { "Exterity connection failed to be ready after 5 seconds. Check username and password." } + disconnect + end + end + elsif logger.info { "Somehow we got here #{data}" } + end + + task.try &.success + end + + protected def process_resp(data, task) + logger.info { "Resp details #{data}" } + + parts = data.split ':' + + case parts[0].to_s + when "error" + if task != nil + logger.warn { "Error when requesting: #{task.try &.name}" } + else + logger.warn { "Error response received" } + end + when "tv_info" + self[:tv_info] = parts[1] + when "SoftwareVersion" + self[:version] = parts[1] + end + end + + protected def new_telnet_client + @telnet = Telnet.new do |data| + transport.send(data) + end + end + + protected def do_send(command, **options) + logger.info { "requesting #{command}" } + send @telnet.not_nil!.prepare(command), **options + end + + protected def set(command, data, **options) + # options[:name] = :"set_#{command}" unless options[:name] + do_send "^set:#{command}:#{data}!", **options + end + + protected def remote(cmd, **options) + do_send "^send:#{cmd}!", **options + end + + protected def get(status, **options) + do_send "^get:#{status}!", **options + end +end diff --git a/drivers/exterity/avedia_player/r92xx_protocol.md b/drivers/exterity/avedia_player/r92xx_protocol.md new file mode 100644 index 00000000000..fdad269cab1 --- /dev/null +++ b/drivers/exterity/avedia_player/r92xx_protocol.md @@ -0,0 +1,415 @@ + +# Exterity AvediaPlayer r9200 Control Protocol. + +NOTE:: All information in this document was obtained via exploration of the R9200 device. +No information here was provided by Exterity during this process + + +## Connecting + +* Telnet Protocol (port 23) +* `telnet 192.168.1.13` +* Default username: `admin` +* Default password: `labrador` +* Select option `6` to run a shell + + +## Shell Navigation + +Once in the shell you can use following tools to read files: + +* `less` for scanning through files +* `cat` for dumping files +* `ps aux` for viewing processes +* `ls` for listing files + +The file system is readonly so moving files to `/usr/local/www` for downloading was not possible. + + +## Applications + +* Application are installed at: `/usr/bin` + * `serialCommandInterface` allows programmatic control of the device + * `irsend` for sending IR commands +* Configuration is at: `/etc` + * `lircd.conf` contains the human readable names of all the IR commands + +``` +begin remote + + name exterity_remote_2 + + bits 16 + flags SPACE_ENC + eps 20 + aeps 200 + + header 8800 4400 + one 550 1650 + zero 550 550 + ptrail 550 + repeat 8800 2200 + pre_data_bits 16 + pre_data 0xB5B7 + gap 38500 + toggle_bit 0 + frequency 38000 + +#! exterity_bit_period 560 +#! exterity_aeps 500 +#! exterity_rmpower_len 66 +#! exterity_rmpower_pattern 16 8 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 1 1 3 1 1 1 1 1 3 1 1 + + begin codes + rm_1 0x45ba + rm_2 0x35ca + rm_3 0x6d92 + rm_4 0xc53a + rm_5 0xb54a + rm_6 0xed12 + rm_7 0x25da + rm_8 0x758a + rm_9 0x1de2 + rm_cancel 0x03fc + rm_0 0xf50a + rm_menu 0xa55a + rm_power 0xad52 + rm_chup 0x0df2 + rm_chdown 0x8d72 + rm_volup 0x5da2 + rm_voldown 0xdd22 + rm_up 0x4db2 + rm_left 0x956a + rm_enter 0xcd32 + rm_right 0xbd42 + rm_down 0x2dd2 + rm_mute 0xa35c + rm_red 0x837c + rm_green 0x43bc + rm_yellow 0xc33c + rm_blue 0x23dc + rm_rewind 0x15ea + rm_play 0x55aa + rm_pause 0xe51a + rm_ff 0x3dc2 + rm_skipback 0x639c + rm_skipfwd 0xe31c + rm_stop 0x7d82 + rm_record 0x659a + rm_exterity 0x13ec + rm_fn_tv 0x936c + rm_fn_home 0x53ac + rm_guide 0xd32c + rm_subtitle 0x857A + rm_info 0x33CC + rm_help 0xB34C + rm_audio 0x9D62 + rm_teletext 0xD52A + rm_av 0xFD02 + end codes + +end remote + +``` + + +## Serial Command Interface + +* All lines start with `^` +* All lines end with `!` + +Dump of the help text: + + +``` +^help! +To display a value: ^get: