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..b013baf51c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,76 @@ +version: "3.7" + +x-build-client-env: &build-client-env + PLACEOS_BUILD_HOST: ${PLACEOS_BUILD_HOST:-build} + PLACEOS_BUILD_PORT: ${PLACEOS_BUILD_PORT:-3000} + +services: + # Driver test harness + drivers: + image: placeos/drivers-spec:latest + restart: always + container_name: placeos-drivers + hostname: drivers + depends_on: + - build + - redis + - install-shards + ports: + - 127.0.0.1:8085:8080 + - 127.0.0.1:4444:4444 + volumes: + - ${PWD}/.logs:/app/report_failures + - ${PWD}/repositories:/app/repositories + - ${PWD}:/app/repositories/local + environment: + <<: *build-client-env + CI: ${CI:-} + CRYSTAL_PATH: lib:/lib/local-shards:/usr/share/crystal/src + REDIS_URL: redis://redis:6379 + TZ: $TZ + cap_add: + - "SYS_PTRACE" + security_opt: + - "seccomp:unconfined" + + build: + image: placeos/build:${PLACE_BUILD_TAG:-nightly} + restart: always + hostname: build + volumes: + - ${PWD}/repositories:/app/repositories + - ${PWD}:/app/repositories/local + - ${PWD}/binaries:/app/bin/drivers + environment: + AWS_REGION: ${AWS_REGION:-ap-southeast-2} + AWS_S3_BUCKET: ${AWS_S3_BUCKET:-placeos-drivers} + AWS_KEY: ${AWS_KEY} + AWS_SECRET: ${AWS_SECRET} + GIT_DISCOVERY_ACROSS_FILESYSTEM: 1 + PLACEOS_BUILD_LOCAL: 1 + PLACEOS_ENABLE_TRACE: 1 + TZ: $TZ + BUILD_SERVICE_DISABLED: ${BUILD_SERVICE_DISABLED:-true} + + redis: + image: eqalpha/keydb + restart: always + hostname: redis + environment: + TZ: $TZ + + # Ensures shards are installed. + install-shards: + image: placeos/crystal:${CRYSTAL_VERSION:-latest} + restart: "no" + working_dir: /wd + entrypoint: '' + command: sh -c 'shards check -q || shards install' + environment: + SHARDS_OPTS: "--ignore-crystal-version" + volumes: + - ${PWD}/shard.lock:/wd/shard.lock + - ${PWD}/shard.yml:/wd/shard.yml + - ${PWD}/shard.override.yml:/wd/shard.override.yml + - ${PWD}/.shards:/wd/.shards + - ${PWD}/lib:/wd/lib diff --git a/docs/directory_structure.md b/docs/directory_structure.md new file mode 100644 index 00000000000..f70e5b68b5b --- /dev/null +++ b/docs/directory_structure.md @@ -0,0 +1,23 @@ +# Directory Structures + +[PlaceOS Core](https://github.com/PlaceOS/core) and [PlaceOS Driver Spec Runner](https://github.com/PlaceOS/driver-spec-runner) make the assumption that the working directory is one level +up from the `drivers` directory. + +An example deployment structure: + +* Working directory: `/home/placeos/core` +* Executable: `/home/placeos/core/bin/core` +* Driver repositories: `/home/placeos/repositories` + * PlaceOS Drivers: `/home/placeos/repositories/drivers` +* Driver executables: `/home/placeos/core/bin/drivers` + * Samsung driver: `/home/placeos/core/bin/drivers/353b53_samsung_display_md_series_cr` + +However when developing the structure will look more like: + +* Working directory: `/home/placeos/drivers` +* Driver repository: `/home/placeos/drivers` +* Driver executables: `/home/placeos/drivers/bin/drivers` + * Samsung driver: `/home/placeos/core/bin/drivers/353b53_samsung_display_md_series_cr` + +The primary difference between production and development is [PlaceOS Core](https://github.com/PlaceOS/core). +In a production environment, PlaceOS Core handles cloning repositories, installing packages, and building Drivers as required. diff --git a/docs/gdb-entitlement.xml b/docs/gdb-entitlement.xml new file mode 100644 index 00000000000..9d9251f55d9 --- /dev/null +++ b/docs/gdb-entitlement.xml @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.debugger + + + + + diff --git a/docs/guide-event-emails.md b/docs/guide-event-emails.md new file mode 100644 index 00000000000..d9176bf4bae --- /dev/null +++ b/docs/guide-event-emails.md @@ -0,0 +1,443 @@ +# How to email people when an event occurs + +There are three aspects to this + +1. Sending an email in real-time as an event occurs +2. Batching events (either periodically or via a [CRON](https://crontab.guru/)) +3. Managing state (state machine management) + +For example... +- Send an email straight away if the event is today, otherwise, send them at 7 am every morning and mark the emails as sent. +- Poll every 15min to send any emails that were missed due to an outage (by checking state) + + +## Example logic driver + +```crystal +require "placeos-driver/interface/mailer" + +class DeskBookingNotification < PlaceOS::Driver + descriptive_name "Desk Booking Approval" + generic_name :BookingApproval + + default_settings({ + # https://www.iana.org/time-zones + timezone: "Australia/Sydney", + # https://crystal-lang.org/api/latest/Time/Format.html + date_time_format: "%c", + time_format: "%l:%M%p", + date_format: "%A, %-d %B", + booking_type: "desk", + buildings: ["zone-123", "zone-456"], + }) + + # this ensures these variables are not nilable + @time_zone : Time::Location = Time::Location.load("Australia/Sydney") + @date_time_format : String = "%c" + @time_format : String = "%l:%M%p" + @date_format : String = "%A, %-d %B" + @booking_type : String = "desk" + @buildings : Array(String) = [] of String + + def on_update + # Update the instance variables based on the settings + time_zone = setting?(String, :calendar_time_zone).presence || "Australia/Sydney" + @time_zone = Time::Location.load(time_zone) + @date_time_format = setting?(String, :date_time_format) || "%c" + @time_format = setting?(String, :time_format) || "%l:%M%p" + @date_format = setting?(String, :date_format) || "%A, %-d %B" + @booking_type = setting?(String, :booking_type).presence || "desk" + @buildings = setting?(Array(String), :buildings) || [] of String + + # configure any schedules here + # https://github.com/spider-gazelle/tasker + schedule.clear + schedule.every(5.minutes) { poll_bookings } + schedule.cron("30 7 * * *", @time_zone) { poll_bookings } + end + + def on_load + # Some form of asset booking has occurred (such as a desk booking) + monitor("staff/booking/changed") { |_subscription, payload| check_booking(payload) } + + on_update + end + + # Get a reference to a module that can be used to send emails + def mailer + system.implementing(Interface::Mailer) + end + + # Access another module in the system + accessor staff_api : StaffAPI_1 + + protected def check_booking(payload : String) + logger.debug { "received booking event payload: #{payload}" } + booking_details = Booking.from_json payload + process_booking(booking_details) + end + + # ensure we don't have two fibers processing this at once + # (technically the driver is thread-safe, but it is concurrent) + @check_bookings_mutex = Mutex.new + + @[Security(Level::Support)] + def poll_bookings(months_from_now : Int32 = 2) + # Clean up old debounce data + expired = 5.minutes.ago.to_unix + @debounce.reject! { |_, (_event, entered)| expired > entered } + + now = Time.utc.to_unix + later = months_from_now.months.from_now.to_unix + + @check_bookings_mutex.synchronize do + @buildings.each do |building_zone| + # bookings that haven't been approved + bookings = staff_api.query_bookings( + type: @booking_type, + period_start: now, + period_end: later, + zones: [building_zone], + approved: false, + rejected: false, + created_before: 2.minutes.ago.to_unix + ).get.as_a + + # bookings that have been approved + bookings = bookings + staff_api.query_bookings( + type: @booking_type, + period_start: now, + period_end: later, + zones: [building_zone], + approved: true, + rejected: false, + created_before: 2.minutes.ago.to_unix + ).get.as_a + + # Convert to nice objects + bookings = Array(Booking).from_json(bookings.to_json) + + logger.debug { "checking #{bookings.size} requested bookings in #{building_zone}" } + bookings.each { |booking_details| process_booking(booking_details) } + end + end + end + + # Booking id => event action, timestamp + @debounce = {} of Int64 => {String?, Int64} + @bookings_checked = 0_u64 + + # See the booking model at the end of this document + protected def process_booking(booking_details : Booking) + # Ignore when a bookings state is updated + return if {"process_state", "metadata_changed"}.includes?(booking_details.action) + + # Ignore the same event in a short period of time + previous = @debounce[booking_details.id]? + return if previous && previous[0] == booking_details.action + @debounce[booking_details.id] = {booking_details.action, Time.utc.to_unix} + + # timezone, if different from the default + timezone = booking_details.timezone.presence || @time_zone.name + location = Time::Location.load(timezone) + + # https://crystal-lang.org/api/0.35.1/Time/Format.html + # date and time (Tue Apr 5 10:26:19 2016) + starting = Time.unix(booking_details.booking_start).in(location) + ending = Time.unix(booking_details.booking_end).in(location) + + # Ignore changes to meetings that have already ended + return if Time.utc > ending + + building_zone, building_name = get_building_details(booking_details.zones) + + # These are the available keys for use in the templates + args = { + booking_id: booking_details.id, + start_time: starting.to_s(@time_format), + start_date: starting.to_s(@date_format), + start_datetime: starting.to_s(@date_time_format), + end_time: ending.to_s(@time_format), + end_date: ending.to_s(@date_format), + end_datetime: ending.to_s(@date_time_format), + starting_unix: booking_details.booking_start, + + desk_id: booking_details.asset_id, + user_id: booking_details.user_id, + user_email: booking_details.user_email, + user_name: booking_details.user_name, + reason: booking_details.title, + + level_zone: booking_details.zones.reject { |z| z == building_zone }.first?, + building_zone: building_zone, + building_name: building_name, + support_email: support_email, + + approver_name: booking_details.approver_name, + approver_email: booking_details.approver_email, + + booked_by_name: booking_details.booked_by_name, + booked_by_email: booking_details.booked_by_email, + } + + case booking_details.action + when "create", "changed" + # check if email already sent and we can ignore this one + next if booking_details.process_state == "notification_sent" + + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", "booking_notification"}, + args: args + ) + + # update the booking state (if there are multiple states a booking can be in) + staff_api.booking_state(booking_details.id, "notification_sent").get + when "approved" + # if there is an approval process + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", "booking_approved"}, + args: args + ) + + staff_api.booking_state(booking_details.id, "approval_sent").get + when "rejected", "checked_in" + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", booking_details.action}, + args: args + ) + when "cancelled" + # maybe someone else cancelled your booking and you have a custom template for that + third_party = booking_details.approver_email && booking_details.approver_email != booking_details.user_email.downcase + + mailer.send_template( + to: booking_details.user_email, + template: {"bookings", third_party ? "cancelled_by" : "cancelled"}, + args: args + ) + + # maybe you want to notifty the persons manager about this + if manager_email = get_manager(user_email).try(&.at(0)) + mailer.send_template( + to: manager_email, + template: {"bookings", "manager_notify_cancelled"}, + args: args + ) + end + end + + # nice to see some status in backoffice + @bookings_checked += 1 + self[:bookings_checked] = @bookings_checked + end + + # id => tags, name + @zone_cache = {} of String => Tuple(Array(String), String) + + def get_building_details(zones : Array(String)) + zones.each do |zone_id| + zone_info = @zone_cache[zone_id]? || get_zone(zone_id) + next unless zone_info + next unless zone_info[0].includes?("building") + + return {zone_id, zone_info[1]} + end + + nil + end + + def get_zone(zone_id : String) + zone = staff_api.zone(zone_id).get + tags = zone["tags"].as_a.map(&.as_s) + name = zone["name"].as_s + tuple = {tags, name} + @zone_cache[zone_id] = tuple + tuple + rescue error + logger.warn(exception: error) { "error obtaining zone details for #{zone_id}" } + nil + end + + @[Security(Level::Support)] + def get_manager(staff_email : String) + # The Calendar driver is hooked up to MS Graph API for example + # could have used an accessor here like `staff_api`, that's optional + manager = system[:Calendar_1].get_user_manager(staff_email).get + {(manager["email"]? || manager["username"]).as_s, manager["name"].as_s} + rescue error + logger.warn(exception: error) { "failed to obtain manager of #{staff_email}" } + {nil, nil} + end +end + +``` + + +### List of Staff API events + +These are events that can be monitored `monitor("event/path") { |sub, payload| }` + +* booking (desk, car space etc) - `"staff/booking/changed"` + * [boooking event model](https://github.com/place-labs/staff-api/blob/master/src/controllers/bookings.cr#L80) + * `action` types: create, cancelled, changed, metadata_changed, approved, rejected, checked_in, process_state +* events (calendar events) - `"staff/event/changed"` + * [event event model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L130) + * `action` types: create, update, cancelled +* a guest has been invited onsite - `"staff/guest/attending"` + * [guest attending model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L195) + * `action` types: meeting_created, meeting_update +* a guest has arrived onsite - `"staff/guest/checkin"` + * [guest checkin model](https://github.com/place-labs/staff-api/blob/master/src/controllers/events.cr#L723) + + +### Booking Model + +This model covers events and API responses + +```crystal + +class Booking + include JSON::Serializable + + # This is to support events + property action : String? + + property id : Int64 + property booking_type : String + property booking_start : Int64 + property booking_end : Int64 + property timezone : String? + + # events use resource_id instead of asset_id + property asset_id : String? + property resource_id : String? + + def asset_id : String + (@asset_id || @resource_id).not_nil! + end + + property user_id : String + property user_email : String + property user_name : String + + property zones : Array(String) + + property checked_in : Bool? + property rejected : Bool? + property approved : Bool? + property process_state : String? + property last_changed : Int64? + + property approver_name : String? + property approver_email : String? + + property booked_by_name : String + property booked_by_email : String + + property checked_in : Bool? + property title : String? + property description : String? + + property extension_data : Hash(String, JSON::Any) + + def in_progress? + now = Time.utc.to_unix + now >= @booking_start && now < @booking_end + end + + def changed + Time.unix(last_changed.not_nil!) + end +end + +``` + +### Email templates + +Email templates are applied to the mailer driver and then other drivers can use them to send emails. + +see the [mailer interface](https://github.com/PlaceOS/driver/blob/master/src/placeos-driver/interface/mailer.cr#L27) for details on available params + +The templates are settings, structured like: + +```yaml + +email_templates: + category: + template_name: + subject: the email subject line with %{variables} + text: the text version of an email + html:

the HTML version of the email

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

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

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

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

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

+ + Please reach out to your workplace support team should you have any other queries, + otherwise, we look forward to seeing you soon + + + +``` diff --git a/docs/http-api.md b/docs/http-api.md new file mode 100644 index 00000000000..9e908404b81 --- /dev/null +++ b/docs/http-api.md @@ -0,0 +1,154 @@ +# HTTP API + +Primarily for development. + + +## GET /build + +Returns the list of available drivers + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `compiled=true` (optional) if you only want the list of compiled drivers + +```json + +["drivers/place/spec_helper.cr", "..."] +``` + + +### GET /build/repositories + +Returns the list of 3rd party repositories + +```json + +["private_drivers", "..."] +``` + + +### GET /build/repository_commits + +Returns the list of available commits at the repository level + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### GET /build/{{escaped driver path}} + +Returns the list of compiled versions of the specified file are available + +```json + +["private_drivers_cr_01519d6", "..."] +``` + + +### GET /build/{{escaped driver path}}/commits + +Returns the list of available commits for the current driver + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### POST /build + +compiles a driver + +* `driver=drivers/path.cr` (required) the path to the driver +* `commit=01519d6` (optional) defaults to head + + +### DELETE /build/{{escaped driver path}} + +deletes compiled versions of a driver + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `commit=01519d6` (optional) deletes all versions of a driver if not specified + + +## GET /test + +Lists the available specs + +```json + +["drivers/place/spec_helper_spec.cr", "..."] +``` + + +### GET /test/{{escaped spec path}}/commits + +Returns the list of available commits for the specified spec + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### POST /test + +Compiles and runs a spec and returns the output + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `driver=drivers/path/to/file.cr` (required) the driver you want to test +* `spec=drivers/path/to/file_spec.cr` (required) the spec you want to run on the driver +* `commit=01519d6` (optional) the commit you would like the driver to be running at +* `spec_commit=01519d6` (optional) the commit you would like the spec to be running at +* `force=true` (optional) forces a re-compilation of the driver and spec +* `debug=true` (optional) compiles the files with debugging symbols + +```text +Launching spec runner +Launching driver: /Users/steve/Documents/projects/placeos/drivers/bin/drivers/drivers_place_private_helper_cr_4f6e0cd +... starting driver IO services +... starting module +... waiting for module +... module connected +... enabling debug output +... starting spec +... spec complete +... terminating driver gracefully +Driver terminated with: 0 + + +Finished in 15.65 milliseconds +0 examples, 0 failures, 0 errors, 0 pending + +spec runner exited with 0 +``` + + +### WebSocket /test/run_spec + +Same requirements as `POST /test` above however it streams the response diff --git a/docs/runtime-debugging.md b/docs/runtime-debugging.md new file mode 100644 index 00000000000..9fe51783978 --- /dev/null +++ b/docs/runtime-debugging.md @@ -0,0 +1,195 @@ +# Runtime Debugging + +This is supported via VS Code on OSX or Linux platforms. +It might be possible to do remote debugging on Windows in conjunction with the Linux Layer. + +* Requires [VS Code](https://code.visualstudio.com/) + * install [Crystal Lang](https://marketplace.visualstudio.com/items?itemName=faustinoaq.crystal-lang) extension + * install [Native Debug](https://marketplace.visualstudio.com/items?itemName=webfreak.debug) extension +* Requires [GDB](https://www.gnu.org/software/gdb/) + * On OSX install using [Homebrew](https://brew.sh/) + * Then code sign the executable: https://sourceware.org/gdb/wiki/PermissionsDarwin + * The `gdb-entitlement.xml` file is in this folder + * When creating the signing certificate follow [this guide](https://apple.stackexchange.com/questions/309017/unknown-error-2-147-414-007-on-creating-certificate-with-certificate-assist) + +This should also work with [LLDB](https://lldb.llvm.org/) on OSX however [has issues](https://github.com/crystal-lang/crystal/issues/4457). + + +## Debug on VSCode + +By convention the project directory name is the same as your application name, if you have changed it, please update `${workspaceFolderBasename}` with the name configured inside `shards.yml` + +### 1. `tasks.json` configuration to compile a crystal project + +```javascript +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Compile", + "command": "shards build --debug ${workspaceFolderBasename}", + "type": "shell" + } + ] +} +``` + +### 2. `launch.json` configuration to debug a binary + +#### Using GDB + +```javascript +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "gdb", + "request": "launch", + "target": "./bin/${workspaceFolderBasename}", + "cwd": "${workspaceRoot}", + "preLaunchTask": "Compile" + } + ] +} +``` + +#### Using LLDB + +```javascript +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "lldb-mi", + "request": "launch", + "target": "./bin/${workspaceFolderBasename}", + "cwd": "${workspaceRoot}", + "preLaunchTask": "Compile" + } + ] +} +``` + +### 3. Then hit the DEBUG green play button + +![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..94877792f3d --- /dev/null +++ b/docs/writing-a-driver.md @@ -0,0 +1,505 @@ +# How to write a PlaceOS Driver + +There are three kinds of PlaceOS Drivers... + +- [Streaming IO (TCP, SSH, UDP, Multicast, etc.)](#streaming-io) +- [HTTP Client](#http-client) +- [Logic](#logic-drivers) + +From a Driver structure standpoint, there is no difference between these types. + +- The same Driver can be used over a TCP, UDP or SSH transport. +- All Drivers support HTTP methods if a URI endpoint is defined. +- If a Driver is associated with a System then it has access to logic helpers + +However, typically a Driver will only implement one of these interfaces. + + +## Concepts + +There are a few components of the PlaceOS Driver system... + +- [Lifecycle](#lifecycle) +- [Queue](#queue) +- [Transport](#transport) +- [Subscriptions](#subscriptions) +- [Scheduler](#scheduler) +- [Settings](#settings) +- [Logger](#logger) +- [Metadata](#metadata) +- [Security](#security) +- [Interfaces](#interfaces) + +### Lifecycle + +All PlaceOS Drivers have a lifecycle that is managed by the system. + +There are 5 lifecycle events: + +* `#on_load` - Called when a driver is added to a system. +* `#on_update` - Called when settings are updated. +* `#on_unload` - Called when a driver is removed from a system. +* `#connected` - Called when a driver becomes active. +* `#disconnected` - Called when a driver becomes inactive. + +For more information on these and other driver methods, see [PlaceOS Driver](https://github.com/PlaceOS/driver). + +### Queue + +The queue is a list of potentially asynchronous tasks that should be performed in a sequence. + +* Each task has a priority (defaults to `50`) - higher priority tasks run first +* Tasks can be named. If a new task is added with the same name it replaces the existing task. +* Tasks have a timeout (defaults to `5.seconds`) +* Tasks can be retried (defaults to `3` before failing) + +Tasks have a callback that is used to run the task + +```crystal + +# => you can set queue defaults globally + +# set a delay between the current task completing and the next task +queue.delay = 1.second +queue.retries = 5 + +queue(priority: 20, timeout: 1.second) do |task| + # perform action here + + # signal result + task.success("optional success value") + task.abort("optional failure message") + task.retry + + # Give me more time to complete the task + task.reset_timers +end + +``` + +In most cases, you won't need to use the queue explicitly however it is good to understand that it is there and how it functions. + + +### Transport + +The transport loaded is defined by settings in the database. + +#### Streaming IO + +You should always tokenise your streams. +This can be handled automatically by the [built in tokeniser](https://github.com/spider-gazelle/tokenizer) + +```crystal + +def on_load + transport.tokenizer = Tokenizer.new("\r\n") +end + +``` + +There are a few ways to use streaming IO methods: + +1. send and receive + +```crystal + +def perform_action + # You call send with some data. + # You can also optionally pass some queue options to the function + send("message data", priority: 30, name: "generic-message") +end + +# A common received function for handling responses +def received(data, task) + # data is always `Bytes` + # task is always `PlaceOS::Driver::Task?` (i.e. could be nil if no active task) + + # convert data into the appropriate format + data = String.new(data) + + # decide if the request was a success or not + # you can pass any value that is JSON serialisable to success + # (if it can't be serialised then nil is sent) + task.try &.success(data) +end + +``` + +2. send and callback + +```crystal + +def perform_action + request = "build request" + + send(request, priority: 30, name: "generic-message") do |data, task| + data = String.new(data) + + # process response here (might need to know the request context) + + task.try &.success(data) + end +end + +``` + +3. send immediately (no queuing) + +```crystal + +def perform_action_now! + transport.send("no queue") +end + +``` + +You can also add a pre-processor to data coming in. This can be useful +if you want to strip away a protocol layer i.e. you are communicating +over Telnet and want to remove the telnet signals leaving the raw +comms for tokenising + +```crystal + +def on_load + transport.pre_processor do |bytes| + # you must return some byte data or nil if no processing is required + # tokenisation occurs on the data returned here + bytes[1..-2] + end +end + +def received(data, task) + # data coming in here is both pre_processed and tokenised +end + +``` + + +#### HTTP Client + +All PlaceOS Drivers have built-in methods for performing HTTP requests. + +* For streaming IO devices this defaults to `http://device.ip.address` or `https` if the transport is using TLS / SSH. +* All devices can provide a custom HTTP base URI. + +There are methods for all the typical HTTP verbs: get, post, put, patch, delete + +```crystal + +def perform_action + basic_auth = "Basic #{Base64.strict_encode("#{@username}:#{@password}")}" + + response = post("/v1/message/path", body: { + messages: numbers, + }.to_json, headers: { + "Authorization" => basic_auth, + "Content-Type" => "application/json", + "Accept" => "application/json", + }, params: { + "key" => "value" + }) + + raise "request failed with #{response.status_code}" unless (200...300).include?(data.status_code) +end + +``` + + +#### Special SSH methods + +SSH connections will attempt to open a shell to the remote device however sometimes you may be able to execute operations independently. + +```crystal + +def perform_action + # if the application launched supports input you can use the bidirectional IO + # to communicate with the app + io = exec("command") +end + +``` + + +#### Logic drivers + +The main difference between Logic Drivers and other transports is that a logic module is directly associated with a System and cannot be shared. (all other Drivers can appear in multiple systems) + +- You can access remote modules in the system via the `system` helper + +```crystal + +# Get a system proxy +sys = system +sys.name #=> "Name of system" +sys.email #=> "resource@email.address" +sys.capacity #=> 12 +sys.bookable #=> true +sys.id #=> "sys-tem~id" +sys.modules #=> ["Array", "Of", "Unique", "Module", "Names", "In", "System"] +sys.count("Module") #=> 3 +sys.implementing(PlaceOS::Driver::Interface::Powerable) #=> ["Camera", "Display"] + +# Look at status on a remote module +system[:Display][:power] #=> true + +# Access a different module index +system[:Display_2][:power] +system.get(:Display, 2)[:power] + +# Access all modules of a type +system.all(:Display) + +# Check if a module exists +system.exists?(:Display) #=> true +system.exists?(:Display_2) #=> false + +``` + +- You can bind to state in remote modules + +```crystal + +bind Display_1, :power, :power_changed + +private def power_changed(subscription, new_value) + logger.debug new_value +end + + +# You can also bind to Driver's internal state (available in all Drivers) +bind :power, :power_changed + +``` + +It's also possible to create shortcuts to other modules. +This is powerful as these shortcuts are exposed as metadata - allowing backoffice to perform system verification. + +For example, consider the following video conference system: + +```crystal + +# It requires at least one camera that can move and be turned on and off +accessor camera : Array(Camera), implementing: [Powerable, Moveable] + +# Optional room blinds that can be opened and closed +accessor blinds : Array(Blind)?, implementing: [Switchable] + +# A single display is required with an optional screen (maybe it's a projector) +accessor main_display : Display_1, implementing: Powerable +accessor screen : Screen? + +``` + +Cross-system communication is possible if you know the ID of the remote system. + +```crystal +# once you have reference to the remote system you can perform any +# actions that you might perform on the local system +sys = system("sys-12345") + +sys.name #=> "Name of remote system" +sys[:Display_2][:power] #=> true +``` + + +### Subscriptions + +You can dynamically bind to state of interest in remote modules + +```crystal + +# Subscription is returned and provided with every status update in the callback +subscription = system.subscribe(:Display_1, :power) do |subscription, new_value| + # values are always raw JSON strings + JSON.parse(new_value) +end + +# Local subscriptions +subscription = subscribe(:state) do |subscription, new_value| + # values are always raw JSON strings + JSON.parse(new_value) +end + +# Clearing all subscriptions +subscriptions.clear + +``` + +Similarly to subscriptions, channels can be set up for broadcasting +arbitrary data that might not need to be exposed as Driver state. + +```crystal + +subscription = monitor(:channel_name) do |subscription, new_value| + # values are always raw JSON strings + JSON.parse(new_value) +end + +# Publish something on the channel to all listeners +publish(:channel_name, "some event") + +``` + + +### Scheduler + +There is a built-in scheduler: https://github.com/spider-gazelle/tasker + +```crystal + +def connected + schedule.every(40.seconds) { poll_device } + schedule.in(200.milliseconds) { send_hello } +end + +def disconnected + schedule.clear +end + +``` + + +### Settings + +Settings are stored as JSON and then extracted as required, serialising to the specified type +There are two types: + +* Required settings - raise an error if the setting is unavailable +* Optional settings - return `nil` if the setting is unavailable + +NOTE:: All settings will raise an error if they exist but fail to serialise (as they are not formatted correctly etc) + +```crystal + +# Required settings +def on_update + @display_id = setting(Int32, :display_id) + + # Can extract deeply nested values + # i.e. {input: {list: ["HDMI", "VGA"] }} + @primary_input = setting(InputEnum, :input, :list, 0) +end + +# Optional settings (you can optionally provide a default) +def on_update + @display_id = setting?(Int32, :display_id) || 1 + @primary_input = setting?(InputEnum, :input, :list, 0) || InputEnum::HDMI +end + +``` + +You can update the local settings of a module, persisting them to the database. Settings must be JSON serialisable + +```crystal +define_setting(:my_setting_name, "some JSON serialisable data") +``` + + +### Logger + +There is a logger available: https://crystal-lang.org/api/latest/Logger.html + +* Warning and above are written to disk. +* debug and info are only available when there is an open debugging session. + +```crystal + +logger.warn { "error unknown response" } +logger.debug { "function called with #{value}" } + +``` + +The logging format has been pre-configured so all logging from PlaceOS is uniform and simple to parse + + +### Metadata + +Metadata is used by various components to simplify configuration. + +* `generic_name` => the name that should be used in a system to access the module +* `descriptive_name` => the manufacturers name for the device +* `description` => notes or any other descriptive information you wish to add +* `tcp_port` => TCP port the TCP transport should connect to +* `udp_port` => UDP port the UDP transport should connect to +* `uri_base` => The HTTP base for any HTTP requests +* `default_settings` => Defaults or example settings that should be used to configure a module + + +```crystal + +class MyDevice < PlaceOS::Driver + generic_name :Driver + descriptive_name "Driver model Test" + description "This is the Driver used for testing" + tcp_port 22 + default_settings({ + name: "Room 123", + username: "steve", + password: "$encrypt", + complex: { + crazy_deep: 1223, + }, + }) + + # ... + +end + +``` + + +### Security + +By default, all public functions are exposed for execution. +However, you can limit who can execute sensitive functions. + +```crystal + +@[Security(Level::Administrator)] +def perform_task(name : String | Int32) + queue &.success("hello #{name}") +end + +``` + +Use the `Security` annotation to define the access level of the function. +The options are: + +* Administrator `Level::Administrator` +* Support `Level::Support` + +### Interfaces + +PlaceOS Drivers can expose any methods that make sense for the device, service or logic they encapsulate. +Across these, there are often core sets of similar functionality. +Interfaces provide a standard way of implementing and interacting with this. + +Their usage is optional but highly encouraged as it both improves modularity and reduces complexity in Driver implementations. + +A full list of interfaces is [available in the PlaceOS Driver framework](https://github.com/PlaceOS/driver/tree/master/src/placeos-driver/interface). +This will expand over time to cover common, repeated patterns as they emerge. + +#### Implementing an Interface + +Each interface is a module containing abstract methods, types and functionality built from these. + +First, include the module within the Driver body. +```crystal +include Interface::Powerable +``` +You will then need to provide implementations of the abstract methods. +The compiler will guide you in this. + +Some interfaces will also provide a default implementation for other methods. +These may be overridden if the device or service provides a more efficient way to directly execute the desired behaviour. +To keep compatibility, overridden methods must maintain feature and functional parity with the original implementation. + +#### Using an Interface + +Drivers that provide an Interface can be discovered using the `system.implementing` method from any logic module. +This will return a list of all Drivers in the system which implement the Interface. + +Similarly, the `accessor` macro provides a way to declare a dependency on a sibling Driver that provides specific functionality. + +For more information on these and usage examples, see [Logic Drivers](#logic-drivers). + diff --git a/docs/writing-a-spec.md b/docs/writing-a-spec.md new file mode 100644 index 00000000000..450b7b37f3e --- /dev/null +++ b/docs/writing-a-spec.md @@ -0,0 +1,231 @@ +# How to test a PlaceOS Driver + +There are three kinds of PlaceOS Driver... + +* [Streaming IO (TCP, SSH, UDP, Multicast, etc.)](#testing-streaming-io) +* [HTTP Client](#testing-http-requests) +* [Logic](#testing-logic) + +From a PlaceOS Driver code structure standpoint, there is no difference between these types of Driver. + +* The same driver can be used over a TCP, UDP or SSH transport. +* All drivers support HTTP methods if a URI endpoint is defined. +* If a driver is associated with a System then it has access to logic helpers + +During a test, the loaded module is loaded with a TCP transport, HTTP enabled and logic module capabilities. +This allows for testing the full capabilities of any driver. + +The driver is launched as it would be in production. + + +## Expectations + +Specs have access to Crystal lang spec expectations. This allows you to confirm expectations. +https://crystal-lang.org/api/latest/Spec/Expectations.html + +```crystal + +variable = 34 +variable.should eq(34) + +``` + +There is a good overview on how to use expectations here: https://crystal-lang.org/reference/guides/testing.html + + +### Status + +Expectations are primarily there to test the state of the module. + +* You can access state via the status helper: `status[:state_name]` +* Then you can check it an expected value: `status[:state_name].should eq(14)` + + +## Testing Streaming IO + +The following functions are available for testing streaming IO: + +* `transmit(data)` -> transmits the object to the module over the streaming IO interface +* `responds(data)` -> alias for `transmit` +* `should_send(data, timeout = 500.milliseconds)` -> expects the module to respond with the data provided +* `expect_send(timeout = 500.milliseconds)` -> returns the next `Bytes` sent by the module (useful if the data sent is not deterministic, i.e. has a time stamp) + +A common test case is to ensure that module state updates as expected after transmitting some data to it: + +```crystal + +# Transmit some data +transmit(">V:2,C:11,G:2001,B:1,S:1,F:100#") + +# Check that the state was updated as expected +status[:area2001].should eq(1) + +``` + + +## Testing HTTP requests + +The test suite emulates an HTTP server so you can inspect HTTP requests and send canned responses to the module. + +```crystal + +expect_http_request do |request, response| + io = request.body + if io + data = io.gets_to_end + request = JSON.parse(data) + if request["message"] == "hello steve" + response.status_code = 202 + else + response.status_code = 401 + end + else + raise "expected request to include dialing details #{request.inspect}" + end +end + +# Check that the state was updated as expected +status[:area2001].should eq(1) + +``` + +Use `expect_http_request` to access an expected request coming from the module. + +* When the block completes, the response is sent to the module +* You can see `request` object details here: https://crystal-lang.org/api/latest/HTTP/Request.html +* You can see `response` object details here: https://crystal-lang.org/api/latest/HTTP/Server/Response.html + + +## Executing functions + +Functions allow you to request methods to be performed in the module via the standard public interface. + +* `exec(:function_name, argument_name: argument_value)` -> `response` a response future (async return value) +* You should send and `responds(data)` before inspecting the `response.get` + +```crystal + +# Execute a command +response = exec(:scene?, area: 1) + +# Check that the command causes the module to send some data +should_send("?AREA,1,6\r\n") +# Respond to that command +responds("~AREA,1,6,2\r\n") + +# Check if the functions return value is expected +response.get.should eq(2) +# Check if the module state is correct +status[:area1].should eq(2) + +``` + + +## Testing Logic + +Logic modules typically expect a system to contain some drivers which the logic modules interact with. + +```crystal + +# define mock versions of the drivers it will interact with + +class Display < DriverSpecs::MockDriver + include Interface::Powerable + include Interface::Muteable + + enum Inputs + HDMI + HDMI2 + VGA + VGA2 + Miracast + DVI + DisplayPort + HDBaseT + Composite + end + + include PlaceOS::Driver::Interface::InputSelection(Inputs) + + # Configure initial state in on_load + def on_load + self[:power] = false + self[:input] = Inputs::HDMI + end + + # implement the abstract methods required by the interfaces + def power(state : Bool) + self[:power] = state + end + + def switch_to(input : Inputs) + mute(false) + self[:input] = input + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + self[:mute] = state + self[:mute0] = state + end +end + +``` + +Then you can define the system configuration, +you can also change the system configuration throughout your spec to test different configurations. + +```crystal + +DriverSpecs.mock_driver "Place::LogicExample" do + + # Where `{Display, Display}` is referencing the `MockDriver` class defined above + # and `Display:` is the friendly name + # so this system would have `Display_1`, `Display_2`, `Switcher_1` + system({ + Display: {Display, Display}, + Switcher: {Switcher}, + }) + + # ... +end + +``` + +Along with the physical system configuration, you can test different setting configurations. +Settings can also be changed throughout the life cycle of your spec. + +```crystal + +DriverSpecs.mock_driver "Place::LogicExample" do + + settings({ + name: "Meeting Room 1", + map_id: "1.03" + }) + +end + +``` + +A Driver's method might be expected to update some state in the mock devices. +You can access this state via the `system` helper + +```crystal + +DriverSpecs.mock_driver "Place::LogicExample" do + + # Execute a function in your logic module + exec(:power, true) + + # Check that the expected state has been updated in your mock device + system(:Display_1)[:power].should eq(true) + +end + +``` + +All status queried in this manner is returned as a `JSON::Any` object diff --git a/drivers/amber_tech/grandview.cr b/drivers/amber_tech/grandview.cr new file mode 100644 index 00000000000..037a755907c --- /dev/null +++ b/drivers/amber_tech/grandview.cr @@ -0,0 +1,155 @@ +require "placeos-driver" +require "placeos-driver/interface/moveable" +require "placeos-driver/interface/stoppable" + +# Documentation: https://aca.im/driver_docs/AmberTech/grandview-screen.pdf +# https://www.ambertech.com.au/Documents/GV_IP%20CONTROL_Smart%20Screen_Trifold_Manual_April2020.pdf +require "./grandview_models" + +class AmberTech::Grandview < PlaceOS::Driver + include Interface::Moveable + include Interface::Stoppable + + # Discovery Information + generic_name :Screen + descriptive_name "Ambertech Grandview Projector Screen" + uri_base "http://192.168.0.2" + + # The device requires the HTTP port closed after every request + # (even though it responds with HTTP1.1 and doesn't return any headers) + default_settings({ + http_max_requests: 1, + }) + + def on_load + queue.delay = 2.seconds + schedule.every(1.minute) { status } + end + + # moveable interface + def move(position : MoveablePosition, index : Int32 | String = 0) + command = case position + when .up?, .close?, .in? + "/Close.js?a=100" + when .down?, .open?, .out? + "/Open.js?a=100" + else + raise "unsupported move option: #{position}" + end + + queue(name: "move") do |task| + response = get(command, headers: build_headers) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + self[:status] = status = parse_state StatusResp.from_json(response.body).status + task.success status + end + end + + # stoppable interface + def stop(index : Int32 | String = 0, emergency : Bool = false) + queue(name: "stop", priority: 999, clear_queue: emergency) do |task| + response = get("/Stop.js?a=100", headers: build_headers) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + + self[:status] = status = parse_state StatusResp.from_json(response.body).status + task.success status + end + end + + def status + if queue.online + queue(name: "status", priority: 0) do |task| + response = perform_status_request + if response.success? + task.success parse_status(response) + else + task.abort "request failed with #{response.status_code}\n#{response.body}" + end + end + else + response = perform_status_request + parse_status(response) if response.success? + end + end + + protected def perform_status_request + get("/GetDevInfoList.js", headers: build_headers) + end + + protected def build_headers + { + "Host" => URI.parse(config.uri.not_nil!).host.not_nil!, + "Connection" => "keep-alive", + } + end + + protected def parse_status(response) + info = AmberTech::Devices.from_json(response.body) + state = info.device_info.first + + self[:ver] = state.ver + self[:id] = state.id + self[:ip] = state.ip + self[:ip_subnet] = state.ip_subnet + self[:ip_gateway] = state.ip_gateway + self[:name] = state.name + self[:status] = parse_state state.status + info + end + + # compatibility with Screen Technics + def up(index : Int32 = 0) + move :up + end + + def up? + {"opened", "opening"}.includes?(self["status"]?) + end + + def down(index : Int32 = 0) + move :down + end + + def down? + {"closed", "closing"}.includes?(self["status"]?) + end + + protected def parse_state(state : AmberTech::Status | String) + case state + in String + self[:moving0] = false + self[:position0] = nil + self[:screen0] = "stopped" + in .stop? + self[:moving0] = false + self[:position0] = nil + self[:screen0] = "stopped" + in .opening? + self[:moving0] = true + self[:position0] = MoveablePosition::Open + self[:screen0] = "moving_bottom" + poll_state + in .opened? + self[:moving0] = false + self[:position0] = MoveablePosition::Open + self[:screen0] = "at_bottom" + in .closing? + self[:moving0] = true + self[:position0] = MoveablePosition::Close + self[:screen0] = "moving_top" + poll_state + in .closed? + self[:moving0] = false + self[:position0] = MoveablePosition::Close + self[:screen0] = "at_top" + end + + state.to_s.downcase + end + + protected def poll_state + schedule.clear + schedule.every(1.minute) { status; nil } + schedule.in(2.seconds) { status; nil } + end +end diff --git a/drivers/amber_tech/grandview_models.cr b/drivers/amber_tech/grandview_models.cr new file mode 100644 index 00000000000..9691cdffe55 --- /dev/null +++ b/drivers/amber_tech/grandview_models.cr @@ -0,0 +1,45 @@ +require "json" + +module AmberTech + enum Status + Stop + Opening + Opened + Closing + Closed + end + + class DevInfo + include JSON::Serializable + + getter ver : String + getter id : String + getter ip : String + + @[JSON::Field(key: "sub")] + getter ip_subnet : String + + @[JSON::Field(key: "gw")] + getter ip_gateway : String + getter name : String + getter pass : String? + getter pass2 : String? + getter status : Status + end + + class Devices + include JSON::Serializable + + @[JSON::Field(key: "devInfo")] + getter device_info : Array(DevInfo) + + @[JSON::Field(key: "currentIp")] + getter current_ip : String + end + + class StatusResp + include JSON::Serializable + + getter status : Status | String + end +end diff --git a/drivers/amber_tech/grandview_spec.cr b/drivers/amber_tech/grandview_spec.cr new file mode 100644 index 00000000000..aea60dd10fd --- /dev/null +++ b/drivers/amber_tech/grandview_spec.cr @@ -0,0 +1,40 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "AmberTech::Grandview" do + retval = exec(:status) + + expect_http_request do |request, response| + raise "unexpected path #{request.path} for info" unless request.path == "/GetDevInfoList.js" + response.status_code = 200 + response << %({ + "currentIp":"10.142.196.27", + "devInfo":[ + { + "ver":"1.0", + "id":"1015095851", + "ip":"10.142.196.27", + "sub":"255.255.255.128", + "gw":"10.142.196.1", + "name":"CII_Scrn", + "pass":"admin", + "pass2":"config", + "status":"Closed" + } + ] + }) + end + + retval.get + sleep 1 + status[:status].should eq "closed" + + retval = exec(:move, "down") + sleep 2 + expect_http_request do |request, response| + raise "unexpected path #{request.path} for move down" unless request.path == "/Open.js" + response.status_code = 200 + response << %({"status":"Opening"}) + end + retval.get.should eq "opening" + status[:status].should eq "opening" +end diff --git a/drivers/amx/svsi/models.cr b/drivers/amx/svsi/models.cr new file mode 100644 index 00000000000..53adb1770d1 --- /dev/null +++ b/drivers/amx/svsi/models.cr @@ -0,0 +1,10 @@ +require "json" + +module Amx + # Interface for enumerating devices + module Transmitter + end + + module Receiver + end +end diff --git a/drivers/amx/svsi/n_series_decoder.cr b/drivers/amx/svsi/n_series_decoder.cr new file mode 100644 index 00000000000..9e0d018890a --- /dev/null +++ b/drivers/amx/svsi/n_series_decoder.cr @@ -0,0 +1,220 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "inactive-support/mapped_enum" +require "./models" + +# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf + +class Amx::Svsi::NSeriesDecoder < PlaceOS::Driver + include Interface::Muteable + include PlaceOS::Driver::Interface::InputSelection(Int32) + include Amx::Receiver + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Decoder" + generic_name :Decoder + + @previous_stream : Int32? = nil + @mute : Bool = false + @stream : Int32? = nil + + private DELIMITER = "\r" + + mapped_enum Command do + GetStatus = "getStatus" + Set = "set" + SetSettings = "setSettings" + SwitchKVM = "KVMMasterIP" + Mute = "mute" + Unmute = "unmute" + SetAudio = "seta" + Live = "live" + Local = "local" + ScalerEnable = "scalerenable" + ScalerDisable = "scalerdisable" + ModeSet = "modeset" + end + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + do_send(Command::GetStatus, priority: 0) + end + + def switch_to(input : Int32) + switch_video(input) + switch_audio(0) # enable AFV + end + + def switch_video(stream_id : Int32) + do_send(Command::Set, stream_id) + end + + def switch_audio(stream_id : Int32) + @previous_stream = stream_id + unmute + end + + def switch_kvm(ip_address : String, video_follow : Bool = true) + host = "#{ip_address},#{video_follow ? 1 : 0}" + do_send(Command::SwitchKVM, host) + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo, + ) + if state + do_send(Command::Mute, name: :mute) + do_send(Command::SetAudio, 0) + else + do_send(Command::SetAudio, @previous_stream || 0) + do_send(Command::Unmute, name: :mute) + end + end + + def live(state : Bool = true) + state ? do_send(Command::Live) : local(self[:playlist].as_i) + end + + def local(playlist : Int32 = 0) + do_send(Command::Local, playlist) + end + + def scaler(state : Bool) + action = state ? Command::ScalerEnable : Command::ScalerDisable + do_send(action, name: :scaler) + end + + OutputModes = [ + "auto", + "1080p59.94", + "1080p60", + "720p60", + "4K30", + "4K25", + ] + + def output_resolution(mode : String) + unless OutputModes.includes?(mode) + logger.error { "\"#{mode}\" is not a valid resolution" } + return + end + do_send(Command::ModeSet, mode) + end + + def videowall( + width : Int32, + height : Int32, + x_pos : Int32, + y_pos : Int32, + scale : VideowallScalingMode = VideowallScalingMode::Auto, + ) + if width > 1 && height > 1 + videowall_size(width, height) + videowall_position(x_pos, y_pos) + videowall_scaling(scale) + videowall_enable + else + videowall_disable + end + end + + def videowall_enable(state : Bool = true) + state = state ? "on" : "off" + do_send(Command::SetSettings, "wallEnable", state) + end + + def videowall_disable + videowall_enable(false) + end + + def videowall_size(width : Int32, height : Int32) + do_send(Command::SetSettings, "wallHorMons", width) + do_send(Command::SetSettings, "wallVerMons", height) + end + + def videowall_position(x : Int32, y : Int32) + do_send(Command::SetSettings, "wallMonPosV", x) + do_send(Command::SetSettings, "wallMonPosH", y) + end + + enum VideowallScalingMode + Auto # decoder decides best method + Fit # aspect distort + Stretch # fill and crop + end + + def videowall_scaling(scaling_mode : VideowallScalingMode) + do_send(Command::SetSettings, "wallStretch", scaling_mode) + end + + mapped_enum Response do + Stream = "stream" + StreamAudio = "streamaudio" + Name = "name" + Playmode = "playmode" + Playlist = "playlist" + Mute = "mute" + ScalerBypass = "scalerbypass" + Mode = "mode" + InputRes = "inputres" + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + prop, value = data.split(':') + + case Response.from_mapped_value?(prop.downcase) + in Response::Stream + self[:video] = @stream = value.to_i + in Response::StreamAudio + stream_id = value.to_i + self[:audio_actual] = stream_id + self[:audio] = stream_id == 0 ? (@mute ? 0 : @stream) : stream_id + in Response::Name + self[:device_name] = value + in Response::Playmode + self[:local_playback] = value == "local" + in Response::Playlist + self[:playlist] = value.to_i + in Response::Mute + self[:mute] = @mute = value == "1" + in Response::ScalerBypass + self[:scaler_active] = value != "no" + in Response::Mode + self[:output_res] = value + in Response::InputRes + self[:input_res] = value + in Nil + raise "Unexpected response: #{prop}" + end + + task.try(&.success) + end + + def do_send(command : Command, *args, **options) + arguments = [command.mapped_value] + + unless (splat = args.to_a).is_a? Array(NoReturn) + arguments += splat + end + + request = "#{arguments.join(':')}#{DELIMITER}" + send(request, **options) + end +end diff --git a/drivers/amx/svsi/n_series_encoder.cr b/drivers/amx/svsi/n_series_encoder.cr new file mode 100644 index 00000000000..17a54988fef --- /dev/null +++ b/drivers/amx/svsi/n_series_encoder.cr @@ -0,0 +1,123 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "inactive-support/mapped_enum" +require "./models" + +# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf + +class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver + include Interface::Muteable + include Amx::Transmitter + + enum Input + Hdmionly + Vgaonly + Hdmivga + Vgahdmi + end + + include Interface::InputSelection(Input) + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Encoder" + generic_name :Encoder + + private DELIMITER = "\r" + + mapped_enum Command do + GetStatus = "getStatus" + VideoSource = "vidsrc" + Live = "live" + Local = "local" + Disable = "txdisable" + Mute = "mute" + Unmute = "unmute" + end + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + do_send(Command::GetStatus, priority: 0) + end + + def switch_to(input : Input, **options) + do_send(Command::VideoSource, input, **options) + end + + Modes = (1..8).map &.to_s + + def media_source(mode : String) + if mode == "live" + do_send(Command::Live) + elsif Modes.includes?(mode) + do_send(Command::Local, mode) + else + raise("invalid mode #{mode}") + end + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo, + ) + if state + do_send(Command::Disable) if layer.audio_video? || layer.video? + do_send(Command::Mute) if layer.audio_video? || layer.audio? + else + do_send(Command::Disable) if layer.audio_video? || layer.video? + do_send(Command::Unmute) if layer.audio_video? || layer.audio? + end + end + + enum Response + Name + Stream + Playmode + Mute + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + prop, value = data.split(':') + + case Response.parse? prop + in Response::Name + self[:device_name] = value + in Response::Stream + self[:stream_id] = value.to_i + in Response::Playmode + self[:mute] = value == "off" + in Response::Mute + self[:audio_mute] = value == "1" + in Nil + raise "Invalid response: #{prop}" + end + + task.try(&.success) + end + + def do_send(command : Command, *args, **options) + arguments = [command.mapped_value] + + unless (splat = args.to_a).is_a? Array(NoReturn) + arguments += splat + end + + request = "#{arguments.join(':')}#{DELIMITER}" + send(request, **options) + end +end diff --git a/drivers/amx/svsi/n_series_switcher.cr b/drivers/amx/svsi/n_series_switcher.cr new file mode 100644 index 00000000000..88ff69d7d3d --- /dev/null +++ b/drivers/amx/svsi/n_series_switcher.cr @@ -0,0 +1,228 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" + +# require "placeos-driver/interface/switchable" + +# Documentation: https://aca.im/driver_docs/AMX/N8000SeriesAPICommandListRev1.1.pdf + +class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver + include Interface::Muteable + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Switcher" + generic_name :Switcher + + alias InOut = String | Int32 + + @inputs : Hash(String, String) = {} of String => String + @outputs : Hash(String, String) = {} of String => String + @encoders = [] of String + @decoders = [] of String + @lookup : Hash(String, String) = {} of String => String + @list = [] of String + + def on_load + transport.tokenizer = Tokenizer.new("") + on_update + end + + def on_update + @inputs = setting?(Hash(String, String), :inputs) || {} of String => String + @outputs = setting?(Hash(String, String), :outputs) || {} of String => String + + @encoders = @inputs.keys + @decoders = @outputs.keys + + @lookup = @inputs.merge(@outputs) + @list = @encoders + @decoders + end + + def connected + @lookup.each_key do |ip_address| + monitor(ip_address, priority: 0) + monitornotify(ip_address, priority: 0) + end + + schedule.every(50.seconds) { + logger.debug { "-- Maintaining Connection --" } + monitornotify(@list.first, priority: 0) + } + end + + def disconnected + schedule.clear + end + + CommonCommands = [ + :monitor, :monitornotify, + :live, :local, :serial, :readresponse, :sendir, :sendirraw, :audioon, :audiooff, + :enablehdmiaudio, :disablehdmiaudio, :autohdmiaudio, + # recorder commands + :record, :dsrecord, :dvrswitch1, :dvrswitch2, :mpeg, :mpegall, :deletempegfile, + :play, :stop, :pause, :unpause, :fastforward, :rewind, :deletefile, :stepforward, + :stepreverse, :stoprecord, :recordhold, :recordrelease, :playhold, :playrelease, + :deleteallplaylist, :deleteallmpegs, :remotecopy, + # window processor commands + :wpswitch, :wpaudioin, :wpactive, :wpinactive, :wpaudioon, :wpaudiooff, :wpmodeon, + :wpmodeoff, :wparrange, :wpbackground, :wpcrop, :wppriority, :wpbordon, :wpbordoff, + :wppreset, + # audio transceiver commands + :atrswitch, :atrmute, :atrunmute, :atrtxmute, :atrtxunmute, :atrhpvol, :atrlovol, + :atrlovolup, :atrlovoldown, :atrhpvolup, :atrhpvoldown, :openrelay, :closerelay, + # video wall commands + :videowall, + # miscellaneous commands + :script, :goto, :tcpclient, :udpclient, :reboot, :gc_serial, :gc_openrelay, + :gc_closerelay, :gc_ir, + ] + + {% for name in CommonCommands %} + def {{name.id}}(ip_address : String, *args, **options) + do_send({{name.id.stringify}}, ip_address, *args, **options) + end + {% end %} + + def serialhex(ip_address : String, wait_time : Int32 = 1, *data, **options) + do_send("serialhex", wait_time, ip_address, *data, **options) + end + + # Encoder Commands + {% for name in [:modeoff, :enablecc, :disablecc, :autocc, :uncompressedoff] %} + def {{name.id}}(input : InOut, *args, **options) + do_send({{name.id.stringify}}, get_input(input), *args, **options) + end + {% end %} + + # Decoder Commands + {% for name in [:audiofollow, :volume, :dvion, :dvioff, :cropref, :getStatus] %} + def {{name.id}}(output : InOut, *args, **options) + do_send({{name.id.stringify}}, get_output(output), *args, **options) + end + {% end %} + + def switch(inouts : Hash(Int32, InOut | Array(InOut)), **options) + inouts.each do |input, output| + outputs = output.is_a?(InOut) ? [output] : output + if input != 0 + # 'in_ip' => ['ip1', 'ip2'] etc + input_actual = get_input(input) + outputs.each do |o| + output_actual = get_output(o) + + dvion(output_actual, **options) + audioon(output_actual, **options) + audiofollow(output_actual, **options) + + self["video#{output_actual}"] = input_actual + self["audio#{output_actual}"] = input_actual + do_send(:switch, output_actual, input_actual, **options) + end + else + # nil => ['ip1', 'ip2'] etc + outputs.each do |o| + output_actual = get_output(o) + dvioff(output_actual, **options) + audiooff(output_actual, **options) + end + end + end + end + + def switch_audio(inouts : Hash(Int32, InOut | Array(InOut)), **options) + inouts.each do |input, output| + outputs = output.is_a?(InOut) ? [output] : output + if input != 0 + # 'in_ip' => ['ip1', 'ip2'] etc + input_actual = get_input(input) + outputs.each do |o| + output_actual = get_output(o) + + audioon(input_actual, **options) + audioon(output_actual, **options) + + self["audio#{output_actual}"] = input_actual + do_send(:switchaudio, output_actual, input_actual, **options) + end + else + # nil => ['ip1', 'ip2'] etc + outputs.each do |o| + audiooff(get_output(o), **options) + end + end + end + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + address = index.is_a?(Int32) && (val = @encoders[index]? || @decoders[index]?) ? val : index.as(String) + if state + dvioff(address) if layer.audio_video? || layer.video? + audiooff(address) if layer.audio_video? || layer.audio? + else + dvion(address) if layer.audio_video? || layer.video? + audioon(address) if layer.audio_video? || layer.audio? + end + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + resp = data.split(':') + + case resp.size + when 13 # Encoder or decoder status + self[resp[0]] = { + communications: resp[1] == "1", + dvioff: resp[2] == "1", + scaler: resp[3] == "1", + source_detected: resp[4] == "1", + mode: resp[5], + audio_enabled: resp[6] == "1", + video_stream: resp[7].to_i, + audio_stream: resp[8] == "follow video" ? resp[8] : resp[8].to_i, + playlist: resp[9], + colorspace: resp[10], + hdmiaudio: resp[11], + resolution: resp[12], + } + when 10 # Audio Transceiver or window processor status + self[resp[0]] = resp + else + logger.warn { "unknown response type: #{resp}" } + end + + task.try(&.success) + end + + def do_send(*args, **options) + cmd = args.join(' ') + logger.debug { "sending #{cmd}" } + send("#{cmd}\r\n", **options) + end + + private def get_input(address : InOut) : String + if address.is_a?(String) && @inputs[address]? + address + elsif address.is_a?(Int32) && (input = @encoders[address]?) + input + else + logger.warn { "unknown address #{address}" } + address.to_s + end + end + + private def get_output(address : InOut) : String + if address.is_a?(String) && @outputs[address]? + address + elsif address.is_a?(Int32) && (output = @decoders[address]?) + output + else + logger.warn { "unknown address #{address}" } + address.to_s + end + end +end diff --git a/drivers/amx/svsi/virtual_switcher.cr b/drivers/amx/svsi/virtual_switcher.cr new file mode 100644 index 00000000000..23b3cacf6a1 --- /dev/null +++ b/drivers/amx/svsi/virtual_switcher.cr @@ -0,0 +1,69 @@ +require "placeos-driver" +require "placeos-driver/interface/switchable" +require "./models" + +# This driver provides an abstraction layer for systems using SVSI based signal +# distribution. In place of referencing specific receivers and stream id's, +# this may be used to enable all endpoints associated with a system to be +# grouped as a virtual matrix switcher and a familiar switcher API used. + +class Amx::Svsi::VirtualSwitcher < PlaceOS::Driver + include PlaceOS::Driver::Interface::Switchable(Int32, Int32) + + descriptive_name "AMX SVSI Virtual Switcher" + generic_name :Switcher + + private def transmitters + system.implementing(Amx::Transmitter) + end + + private def receivers + system.implementing(Amx::Receiver) + end + + def switch_to(input : Int32) + receivers.each(&.switch_to(input)) + end + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + layer ||= SwitchLayer::All + connect(map) do |mod, stream| + mod.switch_audio(stream) if layer.all? || layer.audio? + mod.switch_video(stream) if layer.all? || layer.video? + end + end + + def encoder_count + transmitters.size + end + + def decoder_count + receivers.size + end + + private def connect(inouts : Hash(Input, Array(Output)), &) + inouts.each do |input, outputs| + if input == 0 + stream = 0 # disconnected + else + # Subtract one as Encoder_1 on the system would be encoder[0] here + if encoder = transmitters[input - 1]? + stream = encoder[:stream_id] + else + logger.warn { "could not find Encoder_#{input}" } + break + end + end + + outputs = outputs.is_a?(Array) ? outputs : [outputs] + outputs.each do |output| + # Subtract one as Decoder_1 on the system would be decoder[0] here + if decoder = receivers[output - 1]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder_#{output}" } + end + end + end + end +end diff --git a/drivers/ashrae/bacnet.cr b/drivers/ashrae/bacnet.cr new file mode 100644 index 00000000000..89717628ed7 --- /dev/null +++ b/drivers/ashrae/bacnet.cr @@ -0,0 +1,634 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "socket" +require "./bacnet_models" + +class Ashrae::BACnet < PlaceOS::Driver + include Interface::Sensor + + generic_name :BACnet + descriptive_name "BACnet Connector" + description %(makes BACnet data available to other drivers in PlaceOS) + + # Hookup dispatch to the BACnet BBMD device + uri_base "ws://dispatch/api/dispatch/v1/udp_dispatch?port=47808&accept=192.168.0.1" + + default_settings({ + dispatcher_key: "secret", + bbmd_ip: "192.168.0.1", + known_devices: [{ + ip: "192.168.86.25", + id: 389999, + net: 0x0F0F, + addr: "0A", + }], + verbose_debug: false, + poll_period: 3, + }) + + def websocket_headers + dispatcher_key = setting?(String, :dispatcher_key) + HTTP::Headers{ + "Authorization" => "Bearer #{dispatcher_key}", + "X-Module-ID" => module_id, + } + end + + protected getter! udp_server : UDPSocket + protected getter! bacnet_client : ::BACnet::Client::IPv4 + protected getter! device_registry : ::BACnet::Client::DeviceRegistry + + alias DeviceInfo = ::BACnet::Client::DeviceRegistry::DeviceInfo + + @packets_processed : UInt64 = 0_u64 + @verbose_debug : Bool = false + @bbmd_ip : Socket::IPAddress = Socket::IPAddress.new("127.0.0.1", 0xBAC0) + @devices : Hash(UInt32, DeviceInfo) = {} of UInt32 => DeviceInfo + @mutex : Mutex = Mutex.new(:reentrant) + @bbmd_forwarding : Array(UInt8) = [] of UInt8 + @seen_devices : Hash(UInt32, DeviceAddress) = {} of UInt32 => DeviceAddress + + protected def get_device(device_id : UInt32) + @mutex.synchronize { @devices[device_id]? } + end + + def on_load + # We only use dispatcher for broadcast messages, a local port for primary comms + server = UDPSocket.new + server.bind "0.0.0.0", 0xBAC0 + server.write_timeout = 200.milliseconds + @udp_server = server + + queue.timeout = 2.seconds + + # Hook up the client to the transport + client = ::BACnet::Client::IPv4.new(0, 2.seconds) + client.on_transmit do |message, address| + if address.address == Socket::IPAddress::BROADCAST + if @bbmd_forwarding.size == 4 + message.data_link.request_type = ::BACnet::Message::IPv4::Request::ForwardedNPDU + message.data_link.address.ip1 = @bbmd_forwarding[0] + message.data_link.address.ip2 = @bbmd_forwarding[1] + message.data_link.address.ip3 = @bbmd_forwarding[2] + message.data_link.address.ip4 = @bbmd_forwarding[3] + message.data_link.address.port = 47808_u16 + end + + logger.debug { "sending broadcase message #{message.inspect}" } + + # send to the known devices (in case BBMD does not forward message) + devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress + devices.each do |dev| + begin + server.send message, to: dev.address + rescue error + logger.warn(exception: error) { "error sending message to #{dev.address}" } + end + end + + # Send this message to the BBMD + message.data_link.request_type = ::BACnet::Message::IPv4::Request::DistributeBroadcastToNetwork + payload = DispatchProtocol.new + payload.message = DispatchProtocol::MessageType::WRITE + payload.ip_address = @bbmd_ip.address + payload.id_or_port = @bbmd_ip.port.to_u64 + payload.data = message.to_slice + transport.send payload.to_slice + else + server.send message, to: address + end + end + @bacnet_client = client + + # Track the discovery of devices + registry = ::BACnet::Client::DeviceRegistry.new(client, logger) + registry.on_new_device { |device| new_device_found(device) } + @device_registry = registry + + spawn { process_data(server, client) } + on_update + end + + # This is our input read loop, grabs the incoming data and pumps it to our client + protected def process_data(server, client) + loop do + break if server.closed? + bytes, client_addr = server.receive + + begin + message = IO::Memory.new(bytes).read_bytes(::BACnet::Message::IPv4) + client.received message, client_addr + @packets_processed += 1_u64 + rescue error + logger.warn(exception: error) { "error parsing BACnet packet from #{client_addr}: #{bytes.to_slice.hexstring}" } + end + end + end + + def on_unload + udp_server.close + end + + def on_update + bbmd_ip = setting?(String, :bbmd_ip) || "" + bbmd_forwarding = setting?(String, :bbmd_forwarding) || "" + + @bbmd_forwarding = bbmd_forwarding.strip.split(".").select(&.presence).map(&.to_u8) + @bbmd_ip = Socket::IPAddress.new(bbmd_ip, 0xBAC0) if bbmd_ip.presence + @verbose_debug = setting?(Bool, :verbose_debug) || false + + schedule.clear + schedule.in(5.seconds) { query_known_devices } + + poll_period = setting?(UInt32, :poll_period) || 3 + schedule.every(poll_period.minutes) do + logger.debug { "--- Polling all known bacnet devices" } + keys = @mutex.synchronize { @devices.keys } + keys.each { |device_id| poll_device(device_id) } + end + + perform_discovery if bbmd_ip.presence + end + + def packets_processed + @packets_processed + end + + def connected + bbmd_ip = setting?(String, :bbmd_ip) + perform_discovery if bbmd_ip.presence + end + + protected def object_value(obj) + val = obj.value.try &.value + case val + in ::BACnet::Time, ::BACnet::Date + val.value + in ::BACnet::BitString, BinData + nil + in ::BACnet::PropertyIdentifier + val.property_type + in ::BACnet::ObjectIdentifier + {val.object_type, val.instance_number} + in Nil, Bool, UInt64, Int64, Float32, Float64, String + val + end + rescue + nil + end + + protected def device_details(device) + { + name: device.name, + model_name: device.model_name, + vendor_name: device.vendor_name, + + ip_address: device.ip_address.to_s, + network: device.network, + address: device.address, + id: device.object_ptr.instance_number, + + objects: device.objects.map { |obj| + { + name: obj.name, + type: obj.object_type, + id: obj.instance_id, + + unit: obj.unit, + value: object_value(obj), + seen: obj.changed, + } + }, + } + end + + def device(device_id : UInt32) + device_details get_device(device_id).not_nil! + end + + def devices + device_registry.devices.map { |device| device_details device } + end + + def query_known_devices + sent = [] of UInt32 + @seen_devices.each_value do |info| + sent << info.id.not_nil! + logger.debug { "inspecting #{info.address} - #{info.id}" } + device_registry.inspect_device(info.address, info.identifier, info.net, info.addr) + end + devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress + devices.each do |info| + if id = info.id + next if id.in? sent + sent << id + logger.debug { "inspecting #{info.address} - #{info.id}" } + device_registry.inspect_device(info.address, info.identifier, info.net, info.addr) + end + end + "inspected #{sent.size} devices" + end + + def poll_device(device_id : UInt32) + device = get_device(device_id) + return false unless device + + client = bacnet_client + objects = @mutex.synchronize { device.objects.dup } + objects.each do |obj| + next unless obj.object_type.in?(::BACnet::Client::DeviceRegistry::OBJECTS_WITH_VALUES) + name = object_binding(device_id, obj) + queue(name: name, priority: 0, timeout: 500.milliseconds) do |task| + spawn_action(task) do + obj.sync_value(client) + self[name] = object_value(obj) + end + end + Fiber.yield + end + true + end + + protected def spawn_action(task, &block : -> Nil) + spawn { task.success block.call } + Fiber.yield + end + + # Performs a WhoIs discovery against the BACnet network + def perform_discovery : Nil + bacnet_client.who_is + end + + alias ObjectType = ::BACnet::ObjectIdentifier::ObjectType + + def update_value(device_id : UInt32, instance_id : UInt32, object_type : ObjectType) + obj = get_object_details(device_id, instance_id, object_type) + name = object_binding(device_id, obj) + + queue(name: name, priority: 50) do |task| + spawn_action(task) do + obj.sync_value(bacnet_client) + self[name] = object_value(obj) + end + end + end + + protected def get_object_details(device_id : UInt32, instance_id : UInt32, object_type : ObjectType) + device = get_device(device_id).not_nil! + device.objects.find { |obj| obj.object_ptr.object_type == object_type && obj.object_ptr.instance_number == instance_id }.not_nil! + end + + def write_real(device_id : UInt32, instance_id : UInt32, value : Float32, object_type : ObjectType = ObjectType::AnalogValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_double(device_id : UInt32, instance_id : UInt32, value : Float64, object_type : ObjectType = ObjectType::LargeAnalogValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_unsigned_int(device_id : UInt32, instance_id : UInt32, value : UInt64, object_type : ObjectType = ObjectType::PositiveIntegerValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_signed_int(device_id : UInt32, instance_id : UInt32, value : Int64, object_type : ObjectType = ObjectType::IntegerValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_string(device_id : UInt32, instance_id : UInt32, value : String, object_type : ObjectType = ObjectType::CharacterStringValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_binary(device_id : UInt32, instance_id : UInt32, value : Bool, object_type : ObjectType = ObjectType::BinaryValue) + val = value ? 1 : 0 + object = get_object_details(device_id, instance_id, object_type) + val = ::BACnet::Object.new.set_value(val) + val.short_tag = 9_u8 + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + val, + network: object.network, + address: object.address + ) + end + end + value + end + + protected def new_device_found(device) + logger.debug { "new device found: #{device.name}, #{device.model_name} (#{device.vendor_name}) with #{device.objects.size} objects" } + logger.debug { device.inspect } if @verbose_debug + + @mutex.synchronize { @devices[device.object_ptr.instance_number] = device } + + device_id = device.object_ptr.instance_number + device.objects.each { |obj| self[object_binding(device_id, obj)] = object_value(obj) } + end + + protected def object_binding(device_id, obj) + "#{device_id}.#{obj.object_type}[#{obj.instance_id}]" + end + + def received(data, task) + # we should only be receiving broadcasted messages here + protocol = IO::Memory.new(data).read_bytes(DispatchProtocol) + + logger.debug { "received message: #{protocol.message} #{protocol.ip_address}:#{protocol.id_or_port} (size #{protocol.data_size})" } + + if protocol.message.received? + message = IO::Memory.new(protocol.data).read_bytes(::BACnet::Message::IPv4) + logger.debug { "dispatch sent:\n#{message.inspect}" } if @verbose_debug + bacnet_client.received message, @bbmd_ip + + app = message.application + + is_iam = false + is_cov = case app + when ::BACnet::ConfirmedRequest + app.service.cov_notification? + when ::BACnet::UnconfirmedRequest + is_iam = app.service.i_am? + app.service.cov_notification? + else + false + end + network = message.network + + if network && is_cov + ip = if message.data_link.request_type.forwarded_npdu? + ip_add = message.data_link.address + "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}" + else + protocol.ip_address + end + if network.source_specifier + addr = network.source_address + net = network.source.network + end + device = message.objects.find { |obj| obj.tag == 1 }.not_nil!.to_object_id.instance_number + # prop = message.objects.find { |obj| obj.tag == 2 } + @seen_devices[device] = DeviceAddress.new(ip, device, net, addr) + end + + if network && is_iam + ip = if message.data_link.request_type.forwarded_npdu? + ip_add = message.data_link.address + "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}" + else + protocol.ip_address + end + details = ::BACnet::Client::Message::IAm.parse(message) + device = details[:object_id].instance_number + @seen_devices[device] = DeviceAddress.new(ip, device, details[:network], details[:address]) + end + end + + task.try &.success + end + + def seen_devices + @seen_devices + end + + # ====================== + # Sensor interface + # ====================== + + protected def to_sensor(device_id, device, object, filter_type = nil) : Interface::Sensor::Detail? + sensor_type = case object.unit + when Nil + # required for case statement to work + if object.name.includes? "count" + SensorType::Counter + end + when .degrees_fahrenheit?, .degrees_celsius?, .degrees_kelvin? + SensorType::Temperature + when .percent_relative_humidity? + SensorType::Humidity + when .pounds_force_per_square_inch? + SensorType::Pressure + # when + # SensorType::Presence + when .volts?, .millivolts?, .kilovolts?, .megavolts? + SensorType::Voltage + when .milliamperes?, .amperes? + SensorType::Current + when .millimeters_of_water?, .centimeters_of_water?, .inches_of_water?, .cubic_feet?, .cubic_meters?, .imperial_gallons?, .milliliters?, .liters?, .us_gallons? + SensorType::Volume + when .milliwatts?, .watts?, .kilowatts?, .megawatts?, .watt_hours?, .kilowatt_hours?, .megawatt_hours? + SensorType::Power + when .hertz?, .kilohertz?, .megahertz? + SensorType::Frequency + when .cubic_feet_per_second?, .cubic_feet_per_minute?, .cubic_feet_per_hour?, .cubic_meters_per_second?, .cubic_meters_per_minute?, .cubic_meters_per_hour?, .imperial_gallons_per_minute?, .milliliters_per_second?, .liters_per_second?, .liters_per_minute?, .liters_per_hour?, .us_gallons_per_minute?, .us_gallons_per_hour? + SensorType::Flow + when .percent? + SensorType::Level + when .no_units? + if object.name.includes? "count" + SensorType::Counter + end + end + return nil unless sensor_type + return nil if filter_type && sensor_type != filter_type + + unit = case object.unit + when Nil + when .degrees_fahrenheit? then "[degF]" + when .degrees_celsius? then "Cel" + when .degrees_kelvin? then "K" + when .pounds_force_per_square_inch? then "[psi]" + when .volts? then "V" + when .millivolts? then "mV" + when .kilovolts? then "kV" + when .megavolts? then "MV" + when .milliamperes? then "mA" + when .amperes? then "A" + when .cubic_feet? then "[cft_i]" + when .cubic_meters? then "m3" + when .imperial_gallons? then "[gal_br]" + when .milliliters? then "ml" + when .liters? then "l" + when .us_gallons? then "[gal_us]" + when .milliwatts? then "mW" + when .watts? then "W" + when .kilowatts? then "kW" + when .megawatts? then "MW" + when .watt_hours? then "Wh" + when .kilowatt_hours? then "kWh" + when .megawatt_hours? then "MWh" + when .hertz? then "Hz" + when .kilohertz? then "kHz" + when .megahertz? then "MHz" + when .cubic_feet_per_second? then "[cft_i]/s" + when .cubic_feet_per_minute? then "[cft_i]/min" + when .cubic_feet_per_hour? then "[cft_i]/h" + when .cubic_meters_per_second? then "m3/s" + when .cubic_meters_per_minute? then "m3/min" + when .cubic_meters_per_hour? then "m3/h" + when .imperial_gallons_per_minute? then "[gal_br]/min" + when .milliliters_per_second? then "ml/s" + when .liters_per_second? then "l/s" + when .liters_per_minute? then "l/min" + when .liters_per_hour? then "l/h" + when .us_gallons_per_minute? then "[gal_us]/min" + when .us_gallons_per_hour? then "[gal_us]/h" + end + + obj_value = object_value(object) + value = case obj_value + in String, Nil, ::Time, ::BACnet::PropertyIdentifier::PropertyType, Tuple(ObjectType, UInt32) + nil + in Bool + obj_value ? 1.0 : 0.0 + in UInt64, Int64, Float32, Float64 + obj_value.to_f64 + end + return nil if value.nil? + + Interface::Sensor::Detail.new( + type: sensor_type, + value: value, + last_seen: object.changed.to_unix, + mac: device_id.to_s, + id: "#{object.object_type}[#{object.instance_id}]", + name: "#{device.name}: #{object.name}", + module_id: module_id, + binding: object_binding(device_id, object), + unit: unit + ) + end + + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + filter = type ? Interface::Sensor::SensorType.parse?(type) : nil + + if mac + device_id = mac.to_u32? + return NO_MATCH unless device_id + device = get_device device_id + return NO_MATCH unless device + return device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) } + end + + matches = @mutex.synchronize do + @devices.map do |(device_id, device)| + device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) } + end + end + matches.flatten + rescue error + logger.warn(exception: error) { "searching for sensors" } + NO_MATCH + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + device_id = mac.to_u32? + return nil unless device_id + device = get_device device_id + return nil unless device + + # id should be in the format "object_type[instance_id]" + obj_type_string, instance_id_string = id.split('[', 2) + instance_id = instance_id_string.rchop.to_u32? + return nil unless instance_id + + object_type = ObjectType.parse?(obj_type_string) + return nil unless object_type + + object = get_object_details(device_id, instance_id, object_type) + + if object.changed < 1.minutes.ago + begin + object.sync_value(bacnet_client) + rescue error + logger.warn(exception: error) { "failed to obtain latest value for sensor at #{mac}.#{id}" } + end + end + + to_sensor(device_id, device, object) + end + + @[Security(Level::Support)] + def save_seen_devices + define_setting(:known_devices, @seen_devices.values) + end +end diff --git a/drivers/ashrae/bacnet_datapoints.cr b/drivers/ashrae/bacnet_datapoints.cr new file mode 100644 index 00000000000..9a09ae9b658 --- /dev/null +++ b/drivers/ashrae/bacnet_datapoints.cr @@ -0,0 +1,26 @@ +require "placeos-driver" +require "json" + +class Ashrae::BACnetDataPoints < PlaceOS::Driver + descriptive_name "BACnet Data Points" + generic_name :DataPoints + + default_settings({ + points: { + "power" => "101003.AnalogValue[45]", + "humidity" => "101005.AnalogValue[4]", + }, + }) + + accessor bacnet : BACnet_1 + + def on_update + subscriptions.clear + points = setting(Hash(String, String), :points) + points.each do |(key, status)| + bacnet.subscribe(status) do |_sub, payload| + self[key] = JSON.parse(payload) + end + end + end +end diff --git a/drivers/ashrae/bacnet_datapoints_spec.cr b/drivers/ashrae/bacnet_datapoints_spec.cr new file mode 100644 index 00000000000..05559525362 --- /dev/null +++ b/drivers/ashrae/bacnet_datapoints_spec.cr @@ -0,0 +1,20 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Qbic::TouchPanel" do + system({ + BACnet: {BACnetMock}, + }) + + sleep 0.2 + + status["power"].should eq true + status["humidity"].should eq 34.4 +end + +# :nodoc: +class BACnetMock < DriverSpecs::MockDriver + def on_load + self["101003.AnalogValue[45]"] = true + self["101005.AnalogValue[4]"] = 34.4 + end +end diff --git a/drivers/ashrae/bacnet_models.cr b/drivers/ashrae/bacnet_models.cr new file mode 100644 index 00000000000..dc0d0a1d672 --- /dev/null +++ b/drivers/ashrae/bacnet_models.cr @@ -0,0 +1,42 @@ +require "bacnet" +require "json" + +module Ashrae + class DeviceAddress + include JSON::Serializable + + def initialize(@ip, @id, @net, @addr) + end + + getter ip : String + getter id : UInt32? + getter net : UInt16? + getter addr : String? + + def address + Socket::IPAddress.new(@ip, 0xBAC0) + end + + def identifier + ::BACnet::ObjectIdentifier.new :device, @id.not_nil! + end + end + + class DispatchProtocol < BinData + endian big + + enum MessageType + OPENED + CLOSED + RECEIVED + WRITE + CLOSE + end + + enum_field UInt8, message : MessageType = MessageType::RECEIVED + string :ip_address + uint64 :id_or_port + uint32 :data_size, value: ->{ data.size } + bytes :data, length: ->{ data_size }, default: Bytes.new(0) + end +end diff --git a/drivers/ashrae/bacnet_spec.cr b/drivers/ashrae/bacnet_spec.cr new file mode 100644 index 00000000000..e6d768eca87 --- /dev/null +++ b/drivers/ashrae/bacnet_spec.cr @@ -0,0 +1,8 @@ +require "placeos-driver/spec" + +# NOTE:: this spec only works if there is a BACnet network configured locally +# such as https://github.com/chipkin/BACnetServerExampleCPP/releases +DriverSpecs.mock_driver "Ashrae::BACnet" do + exec(:query_known_devices).get + (exec(:devices).get.not_nil!.size > 0).should be_true +end diff --git a/drivers/aver/cam520_pro.cr b/drivers/aver/cam520_pro.cr new file mode 100644 index 00000000000..01f566da0da --- /dev/null +++ b/drivers/aver/cam520_pro.cr @@ -0,0 +1,353 @@ +require "placeos-driver" +require "placeos-driver/interface/camera" +require "placeos-driver/interface/powerable" +require "./cam520_pro_models" + +class Aver::Cam520Pro < PlaceOS::Driver + include Interface::Powerable + include Interface::Camera + + # Discovery Information. + generic_name :Camera + descriptive_name "Aver 520 Pro Camera" + + # note: wss port is 9188 + uri_base "ws://10.110.144.40:9187/ws" + + default_settings({ + username: "spec", + password: "Aver", + + zoom_max: 28448, + invert_controls: false, + }) + + protected getter bearer_token : String = "" + @username : String = "" + @zoom_max : Int32 = 28448 + @invert : Bool = false + + def on_load + queue.wait = false + transport.before_request do |request| + logger.debug { "performing request: #{request.method} #{request.path}\n#{String.new(request.body.as(IO::Memory).to_slice)}" } + if request.path != "/login_name" + bearer = bearer_token.presence || authenticate + request.headers["Authorization"] = "Bearer #{bearer}" + end + end + on_update + end + + def on_update + @username = setting(String, :username) + if @username != "spec" + device_host = URI.parse(config.uri.not_nil!) + device_host.port = nil + transport.http_uri_override = device_host + end + + @zoom_max = setting(Int32, :zoom_max) + @presets = setting?(Presets, :camera_presets) || @presets + self[:presets] = @presets.keys + self[:inverted] = @invert = setting?(Bool, :invert_controls) || false + end + + def connected + send "token:#{authenticate}" + schedule.clear + schedule.every(10.minutes) { authenticate } + schedule.every(1.minutes) { keep_alive } + + pan? + tilt? + zoom? + end + + def disconnected + schedule.clear + end + + protected def check_success(response) : Bool + logger.debug { "http response #{response.status_code}: #{response.body}" } + return true if response.success? + @bearer_token = "" if response.status_code == 401 + details = HttpResponse(Nil?).from_json(response.body.not_nil!) + raise "unexpected response #{details.code} - #{details.msg}" + end + + macro parse(response, klass = Nil?) + check_success({{response}}) + HttpResponse({{klass}}).from_json({{response}}.body.not_nil!).data + end + + protected def authenticate + logger.debug { "Authenticating" } + + response = post("/login_name", body: { + name: setting(String, :username), + password: setting(String, :password), + }.to_json) + + @bearer_token = parse(response, Auth).token + end + + def keep_alive + send("alive") + end + + getter pan_pos : Int32 = 0 + getter tilt_pos : Int32 = 0 + getter zoom_pos : Int32 = 0 + + def received(data, task) : Nil + data = String.new(data) + logger.debug { "Camera sent: #{data}" } + + payload = Event.from_json(data).data + case payload + in Option + value = payload.value.to_i + case payload.option + in .ptz_ps? + @pan_pos = value + in .ptz_ts? + @tilt_pos = value + in .ptz_zs? + @zoom_pos = value + self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f) + end + in Event + raise "not possible" + end + ensure + task.try &.success + end + + # ====== Camera Interface ====== + + def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0) + tilt_speed = -tilt_speed if @invert + + if pan_speed.abs >= tilt_speed.abs + axis = AxisSelect::Pan + stop = AxisSelect::Tilt + dir = pan_speed >= 0.0 ? 0 : 1 + cmd = pan_speed.zero? ? 2 : 1 + else + stop = AxisSelect::Pan + axis = AxisSelect::Tilt + dir = tilt_speed >= 0.0 ? 0 : 1 + cmd = tilt_speed.zero? ? 2 : 1 + end + + # stop any previous move + spawn do + post("/camera_move", body: { + method: "SetPtzf", + axis: stop.to_i, + dir: dir, + cmd: 2, + }.to_json) + end + + Fiber.yield + + # start moving in the desired direction + response = post("/camera_move", body: { + method: "SetPtzf", + axis: axis.to_i, + dir: dir, + cmd: cmd, + }.to_json) + + parse(response, Nil) + end + + alias Presets = Hash(String, Tuple(Int32, Int32, Int32)) + @presets : Presets = {} of String => Tuple(Int32, Int32, Int32) + + def recall(position : String, index : Int32 | String = 0) + if pos = @presets[position]? + pan_pos, tilt_pos, zoom_pos = pos + zoom_native(zoom_pos) + pan_direct(pan_pos) + tilt_direct(tilt_pos) + else + raise "unknown preset #{position}" + end + end + + def save_position(name : String, index : Int32 | String = 0) + pan? + tilt? + zoom? + @presets[name] = {@pan_pos, @tilt_pos, @zoom_pos} + save_presets + end + + def remove_position(name : String, index : Int32 | String = 0) + @presets.delete(name) + save_presets + end + + protected def save_presets + define_setting(:camera_presets, @presets) + self[:presets] = @presets.keys + end + + def pan_direct(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_p", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def tilt_direct(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_t", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def pan? + response = post("/get_option", body: { + method: "Get", + option: "ptz_p_s", + }.to_json) + + @pan_pos = parse(response, Int32) + end + + def tilt? + response = post("/get_option", body: { + method: "Get", + option: "ptz_t_s", + }.to_json) + + @tilt_pos = parse(response, Int32) + end + + # ====== Zoomable Interface ====== + + # Zooms to an absolute position + def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0) + position = position.clamp(0.0, 100.0) + percentage = position / 100.0 + zoom_native (percentage * @zoom_max.to_f).to_i + end + + def zoom(direction : ZoomDirection, index : Int32 | String = 0) + case direction + in .stop? + dir = 0 + cmd = 2 + in .out? + dir = 1 + cmd = 1 + in .in? + dir = 0 + cmd = 1 + end + + response = post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Zoom.to_i, + dir: dir, + cmd: cmd, + }.to_json) + + parse(response, Nil) + end + + def zoom_native(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_z", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def zoom? + response = post("/get_option", body: { + method: "Get", + option: "ptz_z_s", + }.to_json) + + @zoom_pos = value = parse(response, Int32) + self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f) + end + + # ====== Moveable Interface ====== + + # moves at 50% of max speed + def move(position : MoveablePosition, index : Int32 | String = 0) + case position + in .up? + joystick(pan_speed: 0.0, tilt_speed: 50.0) + in .down? + joystick(pan_speed: 0.0, tilt_speed: -50.0) + in .left? + joystick(pan_speed: -50.0, tilt_speed: 0.0) + in .right? + joystick(pan_speed: 50.0, tilt_speed: 0.0) + in .in? + zoom(:in) + in .out? + zoom(:out) + in .open?, .close? + # not supported + end + end + + # ====== Stoppable Interface ====== + + def stop(index : Int32 | String = 0, emergency : Bool = false) + # tilt + spawn do + post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Tilt.to_i, + dir: 0, + cmd: 2, + }.to_json) + end + + # pan + spawn do + post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Pan.to_i, + dir: 0, + cmd: 2, + }.to_json) + end + + Fiber.yield + + # zoom + response = post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Zoom.to_i, + dir: 0, + cmd: 2, + }.to_json) + + parse(response, Nil) + end + + # ====== Powerable Interface ====== + + # dummy interface as no power command, camera is always on + def power(state : Bool) + state + end +end diff --git a/drivers/aver/cam520_pro_models.cr b/drivers/aver/cam520_pro_models.cr new file mode 100644 index 00000000000..473cdce22fa --- /dev/null +++ b/drivers/aver/cam520_pro_models.cr @@ -0,0 +1,53 @@ +require "json" + +module Aver + enum AxisSelect + Pan = 0 + Tilt + Zoom + Focus + end + + struct Auth + include JSON::Serializable + + getter token : String + end + + struct HttpResponse(Data) + include JSON::Serializable + + getter code : Int32 + getter msg : String + getter data : Data + end + + abstract struct Event + include JSON::Serializable + + getter event : String + + use_json_discriminator "event", { + "option" => EventOption, + } + end + + enum OptionType + PtzPS + PtzTS + PtzZS + end + + struct Option + include JSON::Serializable + + getter option : OptionType + getter value : String + end + + struct EventOption < Event + include JSON::Serializable + + getter data : Option + end +end diff --git a/drivers/aver/cam520_pro_spec.cr b/drivers/aver/cam520_pro_spec.cr new file mode 100644 index 00000000000..f3272673bb0 --- /dev/null +++ b/drivers/aver/cam520_pro_spec.cr @@ -0,0 +1,173 @@ +require "placeos-driver/spec" +require "./cam520_pro_models" + +DriverSpecs.mock_driver "Aver::Cam520Pro" do + # ==================== + # should send an authentication request + # ==================== + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2Njc3ODE0OTR9.blGZUSAekKJVi4VoOAEg9fARCOhyIMNiu_37L3Jv070" + expect_http_request do |request, response| + io = request.body + if io + data = io.gets_to_end + request = JSON.parse(data) + if request["name"] == "spec" && request["password"] == "Aver" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: { + token: token, + }, + }.to_json + else + response.status_code = 401 + end + else + raise "expected request to include dialing details #{request.inspect}" + end + end + + should_send "token:#{token}" + + # ====================== + # query state on connect + # ====================== + + # query pan? + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["method"] == "Get" && request["option"] == "ptz_p_s" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: 200, + }.to_json + else + response.status_code = 400 + end + end + + # query tilt? + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["method"] == "Get" && request["option"] == "ptz_t_s" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: 100, + }.to_json + else + response.status_code = 400 + end + end + + # query zoom? + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["method"] == "Get" && request["option"] == "ptz_z_s" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: 0, + }.to_json + else + response.status_code = 400 + end + end + + sleep 0.2 + + status[:zoom].should eq(0.0) + exec(:pan_pos).get.should eq(200) + exec(:tilt_pos).get.should eq(100) + + # ==================== + # test zoom value parsing + # ==================== + transmit({ + event: "option", + data: { + option: "ptz_z_s", + value: "28448", + }, + }.to_json) + + sleep 0.2 + + status[:zoom].should eq(100.0) + + # ==================== + # check zoom interface + # ==================== + resp = exec(:zoom_to, 0.0) + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["option"] == "ptz_z" && request["value"] == 0 + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: nil, + }.to_json + else + response.status_code = 400 + end + end + resp.get + + transmit({ + event: "option", + data: { + option: "ptz_z_s", + value: "0", + }, + }.to_json) + + sleep 0.2 + + status[:zoom].should eq(0.0) + + # ====================== + # check camera interface + # ====================== + resp = exec(:joystick, 80.0, 10.0) + # Stop tilt + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["axis"] == 1 && request["cmd"] == 2 + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: nil, + }.to_json + else + raise "stop move failed in joystick request" + end + end + # Move pan + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["axis"] == 0 && request["cmd"] == 1 + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: nil, + }.to_json + else + response.status_code = 400 + end + end + resp.get +end diff --git a/drivers/aws/sns_sms.cr b/drivers/aws/sns_sms.cr new file mode 100644 index 00000000000..d303aa444a1 --- /dev/null +++ b/drivers/aws/sns_sms.cr @@ -0,0 +1,74 @@ +require "placeos-driver" +require "placeos-driver/interface/sms" +require "awscr-signer" +require "uri/params" + +# Documentation: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html + +class AWS::SnsSms < PlaceOS::Driver + include Interface::SMS + + # Discovery Information + generic_name :SMS + descriptive_name "Amazon SNS - SMS service" + uri_base "https://sns.us-west-2.amazonaws.com" + + default_settings({ + aws_access_key: "12345", + aws_secret: "random", + }) + + getter! signer : Awscr::Signer::Signers::V4 + + def on_update + access_key = setting(String, :aws_access_key) + secret = setting(String, :aws_secret) + + # grab the bits required for the signer + uri_parts = URI.parse(config.uri.not_nil!).host.not_nil!.split('.') + service = uri_parts[0] + region = uri_parts[1] + + @signer = Awscr::Signer::Signers::V4.new(service, region, access_key, secret) + transport.before_request { |request| signer.sign(request) } + end + + def send_sms( + phone_numbers : String | Array(String), + message : String, + format : String? = "SMS", + source : String? = nil + ) + phone_numbers = [phone_numbers] unless phone_numbers.is_a?(Array) + + responses = phone_numbers.map do |number| + params = URI::Params.build do |form| + form.add "Action", "Publish" + form.add "PhoneNumber", number + form.add "Message", message + + if source + if source =~ /^\+?\d{5,14}$/ + form.add "MessageAttributes.entry.1.Name", "AWS.MM.SMS.OriginationNumber" + form.add "MessageAttributes.entry.1.Value.DataType", "String" + form.add "MessageAttributes.entry.1.Value.StringValue", source + else + form.add "MessageAttributes.entry.1.Name", "AWS.SNS.SMS.SenderID" + form.add "MessageAttributes.entry.1.Value.DataType", "String" + form.add "MessageAttributes.entry.1.Value.StringValue", source.gsub(' ', '-') + end + end + end + + post("/?#{params}", headers: HTTP::Headers{ + "Accept" => "application/json", + }) + end + + responses.each do |response| + raise "request failed with #{response.status_code}: #{response.body}" unless response.success? + end + + nil + end +end diff --git a/drivers/aws/sns_sms_spec.cr b/drivers/aws/sns_sms_spec.cr new file mode 100644 index 00000000000..932a6227789 --- /dev/null +++ b/drivers/aws/sns_sms_spec.cr @@ -0,0 +1,24 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "AWS::SnsSms" do + # Send the request + retval = exec(:send_sms, + phone_numbers: "61418419954", + message: "hello steve" + ) + + # sms should send a HTTP request + expect_http_request do |request, response| + params = request.query_params + if {params["Action"], params["PhoneNumber"], params["Message"]} == {"Publish", "61418419954", "hello steve"} + response.status_code = 200 + response << "{\"PublishResponse\":{\"PublishResult\":{\"MessageId\":\"0b486c18-fa23-5f82-a5a0-35200c5f3d96\",\"SequenceNumber\":null},\"ResponseMetadata\":{\"RequestId\":\"6710f384-1a8a-56e3-8b63-aabcecf664f7\"}}}" + else + response.status_code = 400 + response << "{}" + end + end + + # What the sms function should return + retval.get.should eq(nil) +end diff --git a/drivers/biamp/nexia.cr b/drivers/biamp/nexia.cr new file mode 100644 index 00000000000..bd76ef17c1a --- /dev/null +++ b/drivers/biamp/nexia.cr @@ -0,0 +1,154 @@ +require "placeos-driver" +require "inactive-support/mapped_enum" +require "./ntp" + +class Biamp::Nexia < PlaceOS::Driver + include Biamp::NTP + + tcp_port 23 + descriptive_name "Biamp Nexia/Audia" + generic_name :Mixer + + protected property device_id = 0 + + def on_load + queue.delay = 30.milliseconds + transport.tokenizer = Tokenizer.new("\r\n", "\xFF\xFE\x01") + end + + def connected + send Bytes[0xFF, 0xFE, 0x01], wait: false # Echo off + schedule.every(60.seconds, true) do + query_device_id + end + end + + def disconnected + schedule.clear + end + + def query_device_id + send Command[:GETD, 0, "DEVID"] + end + + def preset(number : Int32) + send Command[:RECALL, 0, "PRESET", number], name: "preset_#{number}" + end + + mapped_enum Mixer do + Matrix = "MMMUTEXP" + Standard = "SMMUTEXP" + Auto = "AMMUTEXP" + end + + def mixer(id : Int32, inouts : Hash(Int32, Array(Int32)) | Array(Int32), mute : Bool = false, type : Mixer = Mixer::Matrix) + value = mute ? 0 : 1 + + if inouts.is_a? Hash + inouts.each do |input, outputs| + outputs.each do |output| + send Command[:SET, device_id, type.mapped_value, id, input, output, value] + end + end + else + inouts.each do |input| + send Command[:SET, device_id, Mixer::Auto.mapped_value, id, input, value] + end + end + end + + mapped_enum Faders do + Fader = "FDRLVL" + MatrixIn = "MMLVLIN" + MatrixOut = "MMLVLOUT" + MatrixCrosspoint = "MMLVLXP" + StdmatrixIn = "SMLVLIN" + StdmatrixOut = "SMLVLOUT" + AutoIn = "AMLVLIN" + AutoOut = "AMLVLOUT" + IoIn = "INPLVL" + IoOut = "OUTLVL" + end + + protected def get_range(type : Faders) + return -100..0 if type.matrix_crosspoint? + -100..12 + end + + def fader(id : Int32, level : Float64 | Int32, index : Int32 = 1, type : Faders = Faders::Fader) + level = level.to_f.clamp(0.0, 100.0) + percentage = level / 100.0 + range = get_range type + + # adjust into range + level_actual = percentage * (range.size - 1).to_f + level_actual = level_actual + range.begin.to_f + + send Command[:SETD, device_id, type.mapped_value, id, index, level_actual], name: "fader_#{id}" + end + + def query_fader(id : Int32, index : Int32 = 1, type : Faders = Faders::Fader) + send Command[:GETD, device_id, type.mapped_value, id, index] + end + + mapped_enum Mutes do + Fader = "FDRMUTE" + MatrixIn = "MMMUTEIN" + MatrixOut = "MMMUTEOUT" + AutoIn = "AMMUTEIN" + AutoOut = "AMMUTEOUT" + StdmatrixIn = "SMMUTEIN" + StdmatrixOut = "SMOUTMUTE" + IoIn = "INPMUTE" + IoOut = "OUTMUTE" + end + + def mute(id : Int32, state : Bool = true, index : Int32 = 1, type : Mutes = Mutes::Fader) + value = state ? 1 : 0 + send Command[:SETD, device_id, type.mapped_value, id, index, value], name: "mute_#{id}" + end + + def unmute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader) + mute(id, false, index, type) + end + + def query_mute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader) + send Command[:GETD, device_id, type.mapped_value, id, index] + end + + def received(data, task) + case response = Response.parse data + in Response::FullPath + logger.debug { "Device responded #{response.message}" } + result = process_full_path_response response + task.try &.success result + in Response::OK + logger.info { "OK" } + task.try &.success + in Response::Error + logger.warn { "Device error: #{data}" } + task.try &.abort(response.message) + in Response::Invalid + logger.error { "Invalid response structure" } + task.try &.abort(response.data) + end + end + + protected def process_full_path_response(response) + case response.attribute + when "DEVID" + self["device_id"] = self.device_id = response.value.to_i + else + if mute = Mutes.from_mapped_value? response.attribute + id, index = response.params + self["#{mute.to_s.underscore}#{id}_#{index}_mute"] = response.value == "1" + elsif fader = Faders.from_mapped_value? response.attribute + range = get_range fader + vol_percent = ((response.value.to_f - range.begin.to_f) / (range.size - 1).to_f) * 100.0 + + id, index = response.params + self["#{fader.to_s.underscore}#{id}_#{index}"] = vol_percent + end + end + end +end diff --git a/drivers/biamp/nexia_spec.cr b/drivers/biamp/nexia_spec.cr new file mode 100644 index 00000000000..c690a28c6b4 --- /dev/null +++ b/drivers/biamp/nexia_spec.cr @@ -0,0 +1,48 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Biamp::Nexia" do + should_send "\xFF\xFE\x01" + + should_send("GETD 0 DEVID\n") + responds("#GETD 0 DEVID 1\r\n") + status["device_id"].should eq(1) + + exec(:preset, 1001) + should_send("RECALL 0 PRESET 1001\n") + responds("#RECALL 0 PRESET 1001 +OK\r\n") + + exec(:fader, 1, 0.0) + should_send("SETD 1 FDRLVL 1 1 -100.0\n") + responds("#SETD 1 FDRLVL 1 1 -100.0 +OK\r\n") + status["fader1_1"].should eq(0.0) + + exec(:fader, 1, 100.0, 2, "matrix_in") + should_send("SETD 1 MMLVLIN 1 2 12.0\n") + responds("#SETD 1 MMLVLIN 1 2 12.0 +OK\r\n") + status["matrix_in1_2"].should eq(100.0) + + exec(:mute, 1234, false, 3) + should_send("SETD 1 FDRMUTE 1234 3 0\n") + responds("#SETD 1 FDRMUTE 1234 3 0 +OK\r\n") + status["fader1234_3_mute"].should eq(false) + + exec(:mute, 1234, true, 5, "auto_in") + should_send("SETD 1 AMMUTEIN 1234 5 1\n") + responds("#SETD 1 AMMUTEIN 1234 5 1 +OK\r\n") + status["auto_in1234_5_mute"].should eq(true) + + exec(:unmute, 111) + should_send("SETD 1 FDRMUTE 111 1 0\n") + responds("#SETD 1 FDRMUTE 111 1 0 +OK\r\n") + status["fader111_1_mute"].should eq(false) + + exec(:query_fader, 133) + should_send("GETD 1 FDRLVL 133 1\n") + responds("#GETD 1 FDRLVL 133 1 -100.0\r\n") + status["fader133_1"].should eq(0.0) + + exec(:query_mute, 155) + should_send("GETD 1 FDRMUTE 155 1\n") + responds("#GETD 1 FDRMUTE 155 1 0\r\n") + status["fader155_1_mute"].should eq(false) +end diff --git a/drivers/biamp/ntp.cr b/drivers/biamp/ntp.cr new file mode 100644 index 00000000000..236772fdfec --- /dev/null +++ b/drivers/biamp/ntp.cr @@ -0,0 +1,80 @@ +# Biamp ATP/NTP protocol utilities. +# https://support.biamp.com/Audia-Nexia/Control/Audia-Nexia_Text_Protocol +module Biamp::NTP + record Command, + type : Type, + device : Int32, + attribute : String, + instance : Int32? = nil, + index_1 : Int32? = nil, + index_2 : Int32? = nil, + value : String | Int32 | Float64 | Nil = nil do + macro [](type, *params) + {% if type == :GET || type == :GETD %} + {{@type.name}}.new({{type}}, {{params.splat}}) + {% else %} + {{@type.name}}.new({{type}}, {{params[0...-1].splat}}, value: {{params[-1]}}) + {% end %} + end + + enum Type + SET + SETD + GET + GETD + INC + INCD + DEC + DECD + RECALL + DIAL + end + + def to_io(io : IO, format = nil) + io << type + {device, attribute, instance, index_1, index_2, value}.each do |field| + next if field.nil? + io << ' ' << field + end + io << '\n' + end + end + + module Response + record FullPath, + message : String, + type : Command::Type, + device : Int32, + attribute : String, + params : Array(String), + value : String + record OK + record Error, message : String + record Invalid, data : Bytes + + def self.parse(data : Bytes) + case data[0] + when '#' + response = String.new data + if response.includes? " -ERR" + Error.new response + else + fields = response[1..].split + type = Command::Type.parse fields[0] + device = fields[1].to_i + attribute = fields[2] + params = fields[3..] + # All responses except GETD provide an "+OK" in the last field + value = type.getd? ? fields[-1] : fields[-2] + FullPath.new response, type, device, attribute, params, value + end + when '+' + OK.new + when '-' + Error.new String.new data + else + Invalid.new data + end + end + end +end diff --git a/drivers/biamp/tesira.cr b/drivers/biamp/tesira.cr new file mode 100644 index 00000000000..da5dc03d9ea --- /dev/null +++ b/drivers/biamp/tesira.cr @@ -0,0 +1,222 @@ +require "placeos-driver" +require "telnet" + +module Biamp; end + +class Biamp::Tesira < PlaceOS::Driver + # Discovery Information + tcp_port 23 # Telnet + descriptive_name "Biamp Tesira" + generic_name :Mixer + + default_settings({ + no_password: true, + username: "default", + password: "default", + }) + + alias Num = Int32 | Float64 + alias Ids = String | Array(String) + + def on_load + # Nexia requires some breathing room + queue.wait = false + queue.delay = 30.milliseconds + end + + def connected + @telnet = telnet = Telnet.new do |telnet_response| + transport.send telnet_response + end + transport.pre_processor { |bytes| telnet.buffer(bytes) } + + if setting(Bool, :no_password) + do_send setting(String, :username) || "admin", wait: false, delay: 200.milliseconds, priority: 98 + do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97 + end + do_send "SESSION set verbose false", priority: 96 + + schedule.clear + schedule.every(60.seconds) do + do_send "DEVICE get serialNumber", priority: 95 + end + end + + def disconnected + transport.tokenizer = nil + schedule.clear + end + + def preset(number_or_name : String | Int32) + if number_or_name.is_a? Int32 + do_send "DEVICE recallPreset #{number_or_name}", priority: 30, name: "preset_#{number_or_name}" + else + do_send build(:DEVICE, :recallPresetByName, number_or_name), priority: 30, name: "preset_#{number_or_name}" + end + end + + def start_audio + do_send "DEVICE startAudio" + end + + def reboot + do_send "DEVICE reboot" + end + + def get_aliases + do_send "SESSION get aliases" + end + + MIXERS = { + "matrix" => "crosspointLevelState", + "mixer" => "crosspoint", + } + + def mixer(id : String, inouts : Hash(Int32, Int32 | Array(Int32)) | Array(Int32), mute : Bool = false, type : String = "matrix") + mixer_type = MIXERS[type] || type + + if inouts.is_a? Hash + inouts.each do |input, outs| + outputs = ensure_array(outs) + outputs.each do |output| + do_send build(id, :set, mixer_type, input, output, mute), priority: 30, name: "mixmute_#{input}_#{output}" + end + end + else # assume array (auto-mixer) + inouts.each do |input| + do_send build(id, :set, mixer_type, input, mute), priority: 30, name: "mixmute_#{input}" + end + end + end + + FADERS = { + "fader" => "level", + "matrix_in" => "inputLevel", + "matrix_out" => "outputLevel", + "matrix_crosspoint" => "crosspointLevel", + "level" => "fader", + "inputLevel" => "matrix_in", + "outputLevel" => "matrix_out", + "crosspointLevel" => "matrix_crosspoint", + } + + def fader(fader_id : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + # value range: -100 ~ 12 + fader_type = FADERS[type] || type + + fader_ids = ensure_array(fader_id) + indicies = ensure_array(index) + fader_ids.each do |fad| + indicies.each do |i| + do_send build(fad, :set, fader_type, i, level), priority: 30, name: "fade_#{fad}_#{i}" + self["#{fader_type}_#{fad}_#{i}"] = level + end + end + end + + # Named params version + def faders(ids : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + fader(ids, level, index, type) + end + + MUTES = { + "fader" => "mute", + "matrix_in" => "inputMute", + "matrix_out" => "outputMute", + "mute" => "fader", + "inputMute" => "matrix_in", + "outputMute" => "matrix_out", + } + + def mute(fader_id : Ids, value : Bool = true, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute_type = MUTES[type] || type + + fader_ids = ensure_array(fader_id) + indicies = ensure_array(index) + fader_ids.each do |fad| + indicies.each do |i| + do_send build(fad, :set, mute_type, i, value), priority: 30, name: "mute_#{fad}_#{i}" + self["#{mute_type}_#{fad}_#{i}_mute"] = value + end + end + end + + # Named params version + def mutes(ids : Ids, muted : Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute(ids, muted, index, type) + end + + def unmute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute(fader_id, false, index, type) + end + + def query_fader(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + fad_type = FADERS[type] || type + fader_id = ensure_array(fader_id)[0] + index = ensure_array(index)[0] + + do_send build(fader_id, :get, fad_type, index) + end + + # Named params version + def query_faders(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + query_fader(ids, index, type) + end + + def query_mute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute_type = MUTES[type] || type + fader_id = ensure_array(fader_id)[0] + index = ensure_array(index)[0] + + do_send build(fader_id, :get, mute_type, index) + end + + # Named params version + def query_mutes(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + query_mute(ids, index, type) + end + + def received(data, task) + data = String.new(data).strip + + logger.debug { "Tesira responded -> data: #{data}" } + result = data.split(" ") + + if result[0] == "-" + task.try(&.abort) + end + + if data =~ /login:|server/i + transport.tokenizer = Tokenizer.new "\r\n" + end + + task.try(&.success) + end + + private def build(*args) + cmd = "" + args.each do |arg| + data = arg.to_s + next if data.blank? + cmd = cmd + " " if cmd.size > 0 + + if data.includes? " " + cmd = cmd + "\"" + cmd = cmd + data + cmd = cmd + "\"" + else + cmd = cmd + data + end + end + cmd + end + + private def do_send(command, **options) + logger.debug { "requesting #{command}" } + send @telnet.not_nil!.prepare(command), **options + end + + private def ensure_array(object) + object.is_a?(Array) ? object : [object] + end +end diff --git a/drivers/biamp/tesira_spec.cr b/drivers/biamp/tesira_spec.cr new file mode 100644 index 00000000000..52a6afd2d03 --- /dev/null +++ b/drivers/biamp/tesira_spec.cr @@ -0,0 +1,39 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Biamp::Tesira" do + transmit "login: " + should_send "default\r\n" + should_send "default\r\n" + should_send "SESSION set verbose false\r\n" + + exec(:preset, 1001) + should_send "DEVICE recallPreset 1001" + + exec(:preset, "1001-test") + should_send "DEVICE recallPresetByName 1001-test" + + exec(:start_audio) + should_send "DEVICE startAudio" + + exec(:reboot) + should_send "DEVICE reboot" + + exec(:get_aliases) + should_send "SESSION get aliases" + + exec(:mixer, "123", [1]) + should_send "123 set crosspointLevelState 1 false" + + exec(:fader, "Fader123", 11) + should_send "Fader123 set level 1 11" + responds("+OK\r\n") + status["level_Fader123_1"] = 11 + + exec(:mute, "Fader123") + should_send "Fader123 set mute 1 true" + responds("+OK\r\n") + status["level_Fader123_1_mute"] = true + + exec(:query_fader, "Fader123") + should_send "Fader123 get level 1" +end diff --git a/drivers/bose/control_space_serial.cr b/drivers/bose/control_space_serial.cr new file mode 100644 index 00000000000..2044a9712bd --- /dev/null +++ b/drivers/bose/control_space_serial.cr @@ -0,0 +1,58 @@ +require "placeos-driver" + +# Documentation: https://aca.im/driver_docs/Bose/Bose-ControlSpace-SerialProtocol-v5.pdf + +class Bose::ControlSpaceSerial < PlaceOS::Driver + # Discovery Information + tcp_port 10055 + descriptive_name "Bose ControlSpace Serial Protocol" + generic_name :Mixer + + def on_load + # 0x0D ( carriage return \r) + transport.tokenizer = Tokenizer.new(Bytes[0x0D]) + on_update + end + + def on_update + end + + def connected + schedule.every(60.seconds) do + logger.debug { "-- maintaining connection" } + do_send "GS", priority: 99 + end + end + + def disconnected + schedule.clear + end + + private def do_send(data, **options) + logger.debug { "requesting: #{data}" } + send "#{data}\x0D", **options + end + + def set_parameter_group(id : UInt8) + do_send("SS #{id.to_s(16).upcase}", wait: false, name: "set_pgroup").get + self[:parameter_group] = id + end + + def get_parameter_group + do_send "GS" + end + + def received(data, task) + # Ignore the framing bytes + data = String.new(data).rchop + logger.debug { "ControlSpace sent: #{data}" } + + parts = data.split(" ") + case parts[0] + when "S" + self[:parameter_group] = parts[1].to_i(16) + end + + task.try &.success + end +end diff --git a/drivers/bose/control_space_serial_spec.cr b/drivers/bose/control_space_serial_spec.cr new file mode 100644 index 00000000000..939b6d6848e --- /dev/null +++ b/drivers/bose/control_space_serial_spec.cr @@ -0,0 +1,12 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Bose::ControlSpaceSerial" do + exec(:set_parameter_group, 12) + should_send("SS C\r") + status[:parameter_group].should eq(12) + + exec(:get_parameter_group) + should_send("GS\r") + responds("S FF\r") + status[:parameter_group].should eq(255) +end diff --git a/drivers/build.cr b/drivers/build.cr new file mode 100644 index 00000000000..84cf25664f1 --- /dev/null +++ b/drivers/build.cr @@ -0,0 +1,10 @@ +{% if env("COMPILE_DRIVER") %} + {% if env("COMPILE_DRIVER").ends_with?("_spec.cr") %} + require "placeos-driver/spec" + {% else %} + require "placeos-driver" + {% end %} + + # Dynamically require the desired driver + {{ ("require \"../" + env("COMPILE_DRIVER") + "\"").id }} +{% end %} diff --git a/drivers/cisco/booking_panel_led_sync.cr b/drivers/cisco/booking_panel_led_sync.cr new file mode 100644 index 00000000000..08e13bc53cb --- /dev/null +++ b/drivers/cisco/booking_panel_led_sync.cr @@ -0,0 +1,49 @@ +require "placeos-driver" + +class Cisco::BookingPanelLedSync < PlaceOS::Driver + descriptive_name "Cisco Webex Navigator Panel LED Sync" + generic_name :Navigator_LED_Sync + description "Sync Cisco Webex Navigator Panel LED to Bookings.in_use status" + + default_settings({ + led_color_when_room_booked: "Red", + led_color_when_room_available: "Green", + webex_panel_device_id: "Ensure this is set in each System's settings" + }) + + accessor webex_xapi : CloudXAPI + accessor room_bookings : Bookings + + @led_color_when_room_booked : String = "Red" + @led_color_when_room_available : String = "Green" + @webex_panel_device_id : String = "Ensure this is set in each System's settings" + + def on_load + on_update + sync_led_color_now + end + + def on_update + subscriptions.clear + @led_color_when_room_booked = setting(String, :led_color_when_room_booked) || "Red" + @led_color_when_room_available = setting(String, :led_color_when_room_available) || "Green" + @webex_panel_device_id = setting(String, :webex_panel_device_id) || "Ensure this is set in each System's settings" + + system.subscribe("Bookings_1", "in_use") do |_sub, value| + next unless ["true", "false"].includes?(value) + self[:room_in_use] = room_in_use = value == "true" + set_led_color(room_in_use) + end + end + + def sync_led_color_now + return unless ["true", "false"].includes?(room_in_use = system[:Bookings].status?(Bool, :in_use)) + set_led_color(room_in_use) unless room_in_use.nil? + end + + private def set_led_color(room_in_use : Bool) + new_led_color = room_in_use ? @led_color_when_room_booked : @led_color_when_room_available + webex_xapi.led_colour(@webex_panel_device_id, new_led_color) + self[:led_color] = new_led_color + end +end diff --git a/drivers/cisco/booking_panel_led_sync_spec.cr b/drivers/cisco/booking_panel_led_sync_spec.cr new file mode 100644 index 00000000000..ba2f8729286 --- /dev/null +++ b/drivers/cisco/booking_panel_led_sync_spec.cr @@ -0,0 +1,5 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::BookingPanelLedSync" do + +end diff --git a/drivers/cisco/collaboration_endpoint.cr b/drivers/cisco/collaboration_endpoint.cr new file mode 100644 index 00000000000..840797cbdf9 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint.cr @@ -0,0 +1,505 @@ +require "placeos-driver" +require "promise" +require "uuid" + +module Cisco::CollaborationEndpoint + macro included + @@status_mappings = {} of Symbol => String + + def self.map_status(**opts) + @@status_mappings.merge! opts.to_h + end + end + + # used by many of the commands + enum Toogle + On + Off + end + + getter peripheral_id : String do + uuid = generate_request_uuid + @ignore_update = true + define_setting(:peripheral_id, uuid) + uuid + end + + protected getter feedback : Feedback = Feedback.new + @ready : Bool = false + @init_called : Bool = false + + # Camera idx => Preset name => Preset id + alias Presets = Hash(Int32, Hash(String, Int32)) + @presets : Presets = {} of Int32 => Hash(String, Int32) + getter feedback_paths : Array(String) = [] of String + + def on_load + # NOTE:: on_load doesn't call on_update as on_update disconnects + queue.delay = 80.milliseconds + queue.timeout = 3.seconds + @peripheral_id = setting?(String, :peripheral_id) + @presets = setting?(Presets, :camera_presets) || @presets + self[:camera_presets] = @presets.transform_values { |val| val.keys } + driver = self + driver.load_settings if driver.responds_to?(:load_settings) + end + + # used when saving settings from the driver + # this prevents needless disconnects + @ignore_update : Bool = false + + def on_update + if @ignore_update + @ignore_update = false + return + end + @presets = setting?(Presets, :camera_presets) || @presets + self[:camera_presets] = @presets.transform_values { |val| val.keys } + driver = self + driver.load_settings if driver.responds_to?(:load_settings) + + # Force a reconnect and event resubscribe following module updates. + disconnect + end + + @last_received : Int64 = 0_i64 + + protected def reset_connection_flags + @ready = false + @init_called = false + @feedback_paths = [] of String + transport.tokenizer = nil + end + + def connected + reset_connection_flags + schedule.every(2.minutes) { ensure_feedback_registered } + schedule.every(30.seconds) do + if @last_received > 40.seconds.ago.to_unix + heartbeat timeout: 35 + else + disconnect + end + end + schedule.in(10.seconds) do + init_connection unless @ready || @init_called + schedule.in(15.seconds) { disconnect if !@ready || self["configuration"]?.nil? } + end + begin + transport.send "xPreferences OutputMode JSON\n" + rescue + end + queue.clear abort_current: true + end + + def disconnected + schedule.clear + reset_connection_flags + clear_feedback_subscriptions(false) + queue.clear abort_current: true + self[:ready] = false + end + + def generate_request_uuid + UUID.random.to_s + end + + def ensure_feedback_registered + send "xPreferences OutputMode JSON\n", priority: 0, wait: false, name: "output_json" + results = @feedback_paths.map do |path| + request = XAPI.xfeedback :register, path + # Always returns an empty response, nothing special to handle + do_send(request, priority: 0, name: path) + end + spawn do + success = 0 + results.each do |task| + begin + success += 1 if task.get.state.success? + rescue + end + end + logger.debug { "FEEDBACK REGISTERED #{success}" } + disconnect unless success > 0 + end + @feedback_paths.size + end + + # ------------------------------ + # Exec methods + + alias JSONBasic = Enumerable::JSONBasic + alias Config = Hash(String, Hash(String, JSONBasic)) + + # Push a configuration settings to the device. + def xconfigurations(config : Config) + config.each { |path, settings| xconfiguration(path, settings) } + end + + # Execute an xCommand on the device. + def xcommand( + command : String, + multiline_body : String? = nil, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + **kwargs + ) + request = XAPI.xcommand(command, **kwargs.merge({hash_args: hash_args})) + name = if kwargs.empty? + command + elsif kwargs.size == 1 + "#{command} #{kwargs.keys.to_a.first}" + end + + # use default queue priority is not specified + priority = kwargs[:priority]? || queue.priority + + do_send request, multiline_body, name: name, priority: priority do |response| + # The result keys are a little odd: they're a concatenation of the + # last two command elements and 'Result', unless the command + # failed in which case it's just 'Result'. + # For example: + # xCommand Video Input SetMainVideoSource ... + # becomes: + # InputSetMainVideoSourceResult + result_key = command.split(' ').last(2).join("") + "Result" + command_result = response["CommandResponse/#{result_key}/status"]? + failure_result = response["CommandResponse/Result/Reason"]? + + result = command_result || failure_result + + if result + if result == "OK" + result + else + failure_result ||= response["CommandResponse/#{result_key}/Reason"]? + logger.error { failure_result.inspect } + :abort + end + else + logger.warn { "Unexpected response format" } + :abort + end + end + end + + # Apply a single configuration on the device. + def xconfiguration( + path : String, + hash_args : Hash(String, JSONBasic) = {} of String => JSONBasic, + **kwargs + ) + promises = hash_args.map do |setting, value| + apply_configuration(path, setting, value) + end + kwargs.each do |setting, value| + promise = apply_configuration(path, setting, value) + promises << promise + end + Promise.all(promises).get.first + end + + protected def apply_configuration(path : String, setting : String, value : JSONBasic) + request = XAPI.xconfiguration(path, setting, value) + promise = Promise.new(Bool) + + task = do_send request, name: "#{path} #{setting}" do |response| + result = response["CommandResponse/Configuration/status"]? + + if result == "Error" + reason = response["CommandResponse/Configuration/Reason"]? + xpath = response["CommandResponse/Configuration/XPath"]? + + error_msg = "#{reason} (#{xpath})" + promise.reject(RuntimeError.new error_msg) + logger.error { error_msg } + :abort + else + promise.resolve true + true + end + end + + spawn do + task.get + promise.reject(RuntimeError.new "failed to set configuration: #{path} #{setting}: #{value}") if task.state == :abort + end + promise + end + + def xstatus(path : String) + request = XAPI.xstatus path + promise = Promise.new(Hash(String, Enumerable::JSONComplex)) + + task = do_send request do |response| + prefix = "Status/#{XAPI.tokenize(path).join('/')}" + results = {} of String => Enumerable::JSONComplex + response.each do |key, value| + results[key] = value if key.starts_with?(prefix) + end + + if !results.empty? + promise.resolve results + results + elsif error = response["Status/status"]? || response["CommandResponse/Status/status"]? + reason = response["Status/Reason"]? || response["CommandResponse/Status/Reason"]? + xpath = response["Status/XPath"]? || response["CommandResponse/Status/XPath"]? + error_msg = "#{reason} (#{xpath})" + promise.reject(RuntimeError.new error_msg) + logger.error { error_msg } + :abort + else + results[prefix] = nil + promise.resolve results + results + end + end + + spawn do + task.get + promise.reject(RuntimeError.new "failed to obtain status: #{path}") if task.state == :abort + end + promise.get + end + + # ------------------------------ + # Base comms + + protected def init_connection + @init_called = true + transport.tokenizer = Tokenizer.new do |io| + raw = io.gets_to_end + data = raw.lstrip + index = if data.starts_with?("{") + count = 0 + pos = 0 + data.each_char_with_index do |char, i| + pos = i + count += 1 if char == '{' + count -= 1 if char == '}' + break if count.zero? + end + pos if count.zero? + else + data =~ XAPI::COMMAND_RESPONSE + end + + if index + message = data[0..index] + index += raw.byte_index_to_char_index(raw.byte_index(message).not_nil!).not_nil! + index = raw.char_index_to_byte_index(index + 1) + end + + index || -1 + end + + raise "failed to register control system" unless register_control_system.get.state.success? + self[:ready] = @ready = true + + push_config + sync_config + @@status_mappings.each do |key, path| + begin + bind_status(path, key.to_s) + rescue error + logger.warn(exception: error) { "failed to bind status #{path} (#{key})" } + end + end + + driver = self + driver.connection_ready if driver.responds_to?(:connection_ready) + rescue error + @init_called = false + logger.warn(exception: error) { "error configuring xapi transport" } + end + + protected def do_send(command, multiline_body = nil, **options) + do_send(command, multiline_body, **options) { true } + end + + protected def do_send(command, multiline_body = nil, **options, &callback : ::PlaceOS::Driver::Task::ResponseCallback) + request_id = generate_request_uuid + request = "#{command} | resultId=\"#{request_id}\"\n" + + logger.debug { "-> #{request}" } + request = "#{request}#{multiline_body}\n.\n" if multiline_body + + task = send request, **options + task.xapi_request_id = request_id + task.xapi_callback = callback + task + end + + def received(data, task) + @last_received = Time.utc.to_unix + payload = String.new(data) + logger.debug { "<- #{payload}" } + + if transport.tokenizer.nil? && payload =~ XAPI::LOGIN_COMPLETE + queue.clear abort_current: true + sleep 500.milliseconds + transport.send "xPreferences OutputMode JSON\n" + logger.info { "initializing connection" } + spawn { init_connection } + return + end + + response = XAPI.parse payload + + return feedback.notify(response) if task.nil? + + if task.xapi_request_id == response["ResultId"]? + command_result = task.xapi_callback.try &.call(response) + + feedback.notify(response) if command_result.nil? + command_result == :abort ? task.abort : task.success(command_result) + else + feedback.notify(response) + end + rescue error : JSON::ParseException + payload = String.new(data).strip + case payload + when "OK" + task.try &.success payload + when "Command not recognized." + logger.error { "Command not recognized: `#{task.try &.request_payload}`" } + task.try &.abort payload + else + logger.debug { "Malformed device response: #{error}\n#{payload}" } + task.try &.abort "Malformed device response: #{error}" + end + end + + # ------------------------------ + # Event subscription + + # Subscribe to feedback from the device. + def register_feedback(path : String, &update_handler : Proc(String, Enumerable::JSONComplex, Nil)) + if !@ready + unless feedback.contains? path + @feedback_paths << path + @feedback_paths.uniq! + feedback.insert(path, &update_handler) + end + return true + end + + logger.debug { "Subscribing to device feedback for #{path}" } + + unless feedback.contains? path + @feedback_paths << path + @feedback_paths.uniq! + request = XAPI.xfeedback :register, path + # Always returns an empty response, nothing special to handle + result = do_send request, name: path + end + + feedback.insert path, &update_handler + + result.try(&.get) || true + end + + def unregister_feedback(path : String) + return clear_feedback_subscriptions if path == "/" + logger.debug { "Unsubscribing feedback for #{path}" } + feedback.remove path + @feedback_paths.delete path + do_send XAPI.xfeedback(:deregister, path) + end + + def clear_feedback_subscriptions(connected : Bool = true) + logger.debug { "Unsubscribing all feedback" } + @status_keys.clear + feedback.clear + @feedback_paths.clear + do_send XAPI.xfeedback(:deregister_all) if connected + end + + # ------------------------------ + # Module status + + @status_keys = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key| + hash[key] = {} of String => Enumerable::JSONComplex + end + + # Bind arbitary device feedback to a status variable. + def bind_feedback(path : String, status_key : String) + register_feedback path do |value_path, value| + if value_path == path + self[status_key] = value + else + key_path = value_path.sub(path, "") + hash = @status_keys[status_key] + hash[key_path] = value + self[status_key] = hash + end + end + end + + # Bind device status to a module status variable. + def bind_status(path : String, status_key : String) + bind_path = "Status/#{path.tr " ", "/"}" + bind_feedback "/#{bind_path}", status_key + payload = xstatus(path) + + # single value? + if payload.size == 1 && payload.has_key?(bind_path) + self[status_key] = payload[bind_path] + else + self[status_key] = @status_keys[status_key] = payload.transform_keys do |key| + key.sub(path, "") + end + end + payload + end + + def push_config + if config = setting?(Config, :configuration) + xconfigurations config + end + end + + def sync_config + bind_feedback "/Configuration", "configuration" + send "xConfiguration *\n", wait: false + end + + # ------------------------------ + # External feedback subscriptions + + # Subscribe another module to async device events. + # Callback methods must be of arity 1 and public. + def on_event(path : String, mod_id : String, channel : String) + logger.debug { "Registering callback for #{path} to #{mod_id}/#{channel}" } + register_feedback path do |event_path, value| + event_json = {event_path => value}.to_json + logger.debug { "Publishing #{path} event to #{mod_id}/#{channel} with payload #{event_json}" } + publish("#{mod_id}/#{channel}", event_json) + end + end + + # Clear external event subscribtions for a specific device path. + def clear_event(path : String) + logger.debug { "Clearing event subscription for #{path}" } + unregister_feedback path + end + + # ------------------------------ + # Connectivity management + + protected def register_control_system + xcommand "Peripherals Connect", + hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id}, + name: "PlaceOS", + type: :ControlSystem + end + + protected def heartbeat(timeout : Int32) + # high priority as otherwise the VC will indicate we've disconnected + xcommand "Peripherals HeartBeat", + hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id}, + timeout: timeout, + priority: 99 + end +end + +require "./collaboration_endpoint/xapi" diff --git a/drivers/cisco/collaboration_endpoint/cameras.cr b/drivers/cisco/collaboration_endpoint/cameras.cr new file mode 100644 index 00000000000..a8ae810b212 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/cameras.cr @@ -0,0 +1,193 @@ +require "placeos-driver/interface/camera" +require "./xapi" + +module Cisco::CollaborationEndpoint::Cameras + include PlaceOS::Driver::Interface::Camera + include Cisco::CollaborationEndpoint::XAPI + + alias Interface = PlaceOS::Driver::Interface + + protected def save_presets + @ignore_update = true + define_setting(:camera_presets, @presets) + self[:camera_presets] = @presets.transform_values { |val| val.keys } + end + + command({"Camera Preset Activate" => :camera_preset}, + preset_id: 1..35) + command({"Camera Preset Store" => :camera_store_preset}, + camera_id: 1..2, + preset_id: 1..35, # Optional - codec will auto-assign if omitted + name_: String, + take_snapshot_: Bool, + default_position_: Bool) + command({"Camera Preset Remove" => :camera_remove_preset}, + preset_id: 1..35) + + enum CameraAxis + All + Focus + PanTilt + Zoom + end + + enum FocusDirection + Far + Near + Stop + end + + command({"Camera PositionReset" => :camera_position_reset}, + camera_id: 1..2, + axis_: CameraAxis) + command({"Camera Ramp" => :camera_move}, + camera_id: 1..2, + pan_: Interface::Camera::PanDirection, + pan_speed_: 1..15, + tilt_: Interface::Camera::TiltDirection, + tilt_speed_: 1..15, + zoom_: Interface::Zoomable::ZoomDirection, + zoom_speed_: 1..15, + focus_: FocusDirection) + + # Camera Interface + # ================ + + def stop(index : Int32 | String = 0, emergency : Bool = false) + cam = index.to_i + cam = 1 if cam.zero? + + camera_move( + camera_id: cam, + pan: PanDirection::Stop, + tilt: TiltDirection::Stop, + zoom: ZoomDirection::Stop + ) + end + + def move(position : MoveablePosition, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + case position + in .open?, .close? + # iris not supported + in .down?, .up? + joystick( + pan_speed: 0.0, + tilt_speed: position.down? ? -50.0 : 50.0, + index: cam + ) + in .left?, .right? + joystick( + pan_speed: position.left? ? -50.0 : 50.0, + tilt_speed: 0.0, + index: cam + ) + in .in?, .out? + zoom(position.in? ? ZoomDirection::In : ZoomDirection::Out, cam) + end + end + + def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0) + raise "direct zoom unsupported on this camera" + end + + def zoom(direction : ZoomDirection, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + camera_move( + camera_id: cam, + zoom: direction, + zoom_speed: 6 + ) + end + + def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0) + pan_speed = pan_speed.clamp(-100.0, 100.0) + tilt_speed = tilt_speed.clamp(-100.0, 100.0) + + pan = if pan_speed.zero? + pan_speed = nil + PanDirection::Stop + else + pan_speed.negative? ? PanDirection::Left : PanDirection::Right + end + + tilt = if tilt_speed.zero? + tilt_speed = nil + TiltDirection::Stop + else + tilt_speed.negative? ? TiltDirection::Down : TiltDirection::Up + end + + cam = index.to_i + cam = 1 if cam.zero? + + if pan_speed + percentage = pan_speed.abs / 100.0 + pan_speed_actual = (percentage * 15.0).round.to_i + end + + if tilt_speed + percentage = tilt_speed.abs / 100.0 + tilt_speed_actual = (percentage * 15.0).round.to_i + end + + camera_move( + camera_id: cam, + pan: pan, + pan_speed: pan_speed_actual, + tilt: tilt, + tilt_speed: tilt_speed_actual, + zoom: ZoomDirection::Stop + ) + end + + def recall(position : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + preset = presets[position]? + raise "preset '#{position}' not found on camera #{index}" unless preset + + camera_preset(preset_id: preset) + end + + def save_position(name : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + in_use = @presets.values.flat_map(&.values) + next_available = ((1..35).to_a - in_use).first + presets[name] = next_available + + camera_store_preset( + camera_id: cam, + preset_id: next_available, # Optional - codec will auto-assign if omitted + name: name + ).get + + @presets[cam] = presets + save_presets + true + end + + def remove_position(name : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + presets.delete(name) + if presets.empty? + @presets.delete(cam) + else + @presets[cam] = presets + end + save_presets + true + end +end diff --git a/drivers/cisco/collaboration_endpoint/feedback.cr b/drivers/cisco/collaboration_endpoint/feedback.cr new file mode 100644 index 00000000000..0cf25f4333e --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/feedback.cr @@ -0,0 +1,49 @@ +class Cisco::CollaborationEndpoint::Feedback + def initialize + @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k| + h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil) + end + end + + # Nuke a subtree below the path + def remove(path : String) + remove = [] of String + @callbacks.each_key { |key| remove << key if key.starts_with?(path) } + remove.each { |key| @callbacks.delete(key) } + self + end + + # Insert a response handler block to be notified of updates effecting the + # specified feedback path. + def insert(path : String, &handler : Proc(String, Enumerable::JSONComplex, Nil)) + @callbacks[path] << handler + self + end + + def contains?(path : String) + found = false + @callbacks.each_key do |key| + if path.starts_with? key + found = true + break + end + end + found + end + + def notify(path : String, value : Enumerable::JSONComplex) + @callbacks.each do |key, callbacks| + callbacks.each &.call(path, value) if path.starts_with? key + end + end + + def notify(payload : Hash(String, Enumerable::JSONComplex)) + payload.each { |key, value| notify("/#{key}", value) } + end + + def clear + @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k| + h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil) + end + end +end diff --git a/drivers/cisco/collaboration_endpoint/powerable.cr b/drivers/cisco/collaboration_endpoint/powerable.cr new file mode 100644 index 00000000000..18962590cad --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/powerable.cr @@ -0,0 +1,40 @@ +require "placeos-driver/interface/powerable" +require "./xapi" + +module Cisco::CollaborationEndpoint::Powerable + include PlaceOS::Driver::Interface::Powerable + include Cisco::CollaborationEndpoint::XAPI + + alias Interface = PlaceOS::Driver::Interface + + # Powerable Interface: + # ==================== + + command({"Standby Deactivate" => :powerup}) + command({"Standby HalfWake" => :half_wake}) + command({"Standby Activate" => :standby}) + command({"Standby ResetTimer" => :reset_standby_timer}, delay: 1..480) + + def power(state : Bool) + state ? powerup : half_wake + self[:power] = state + end + + def power_state(state : Interface::Powerable::PowerState) + case state + in .on? + power true + in .off? + power false + in .full_off? + standby + self[:power] = false + end + self[:power_state] = state + end + + enum PowerOff + Restart + Shutdown + end +end diff --git a/drivers/cisco/collaboration_endpoint/presentation.cr b/drivers/cisco/collaboration_endpoint/presentation.cr new file mode 100644 index 00000000000..15e6ea535a7 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/presentation.cr @@ -0,0 +1,62 @@ +require "placeos-driver/interface/switchable" +require "./xapi" + +module Cisco::CollaborationEndpoint::Presentation + enum PresentationInputs + None + Input1 + Input2 + Input3 + Input4 + end + + include PlaceOS::Driver::Interface::InputSelection(PresentationInputs) + include Cisco::CollaborationEndpoint::XAPI + + enum SendingMode + LocalRemote + LocalOnly + end + + @sending_mode : SendingMode = SendingMode::LocalRemote + @presenting_input : Int32? = nil + + command({"Presentation Start" => :presentation_start}, + presentation_source_: 1..2, + sending_mode_: SendingMode, + connector_id_: 1..2, + instance_: 1..6) # TODO:: support "New" + command({"Presentation Stop" => :presentation_stop}, + instance_: 1..6, + presentation_source_: 1..4) + + # Provide compatabilty with the router module for activating presentation. + def switch_to(input : PresentationInputs) + if input.none? + @presenting_input = nil + presentation_stop + else + source = input.to_s[5..-1].to_i + @presenting_input = source + + presentation_start( + presentation_source: source, + sending_mode: @sending_mode + ) + end + + self[:presenting_input] = @presenting_input + end + + def send_presentation_to(remote : Bool) + @sending_mode = remote ? SendingMode::LocalRemote : SendingMode::LocalOnly + self[:present_to_remote] = remote + + if input = @presenting_input + presentation_start( + presentation_source: input, + sending_mode: @sending_mode + ) + end + end +end diff --git a/drivers/cisco/collaboration_endpoint/response.cr b/drivers/cisco/collaboration_endpoint/response.cr new file mode 100644 index 00000000000..46b73e4dcb9 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/response.cr @@ -0,0 +1,81 @@ +require "json" + +module Cisco::CollaborationEndpoint::XAPI + TRUTHY = {"true", "available", "standby", "on", "active"} + FALSEY = {"false", "unavailable", "off", "inactive"} + BOOLEAN = ->(val : String) { TRUTHY.includes?(val.downcase) } + BOOL_OR = ->(term : String) { ->(val : String) { val == term ? term : BOOLEAN.call(val) } } + PARSERS = { + "TTPAR_OnOff" => BOOLEAN, + "TTPAR_OnOffAuto" => BOOL_OR.call("Auto"), + "TTPAR_OnOffCurrent" => BOOL_OR.call("Current"), + "TTPAR_MuteEnabled" => BOOLEAN, + } + + def self.value_convert(value : String, valuespace : String? = nil) + parser = PARSERS[valuespace]? + return value.to_i64 unless parser + parser.call(value) + rescue + check = value.downcase + # probably wasn't an integer + if check.in? TRUTHY + true + elsif check.in? FALSEY + false + else + value + end + end + + def self.parse(data : String) + JSON.parse(data).as_h.flatten_xapi_json + end +end + +module Enumerable + alias JSONBasic = Bool | Float64 | Int64 | String | Nil + alias JSONComplex = JSONBasic | Hash(String, JSONComplex) + + def flatten_xapi_json(parent_prefix : String? = nil, delimiter : String = "/") + res = {} of String => JSONComplex + + self.each_with_index do |elem, i| + if elem.is_a?(Tuple) + k, v = elem + else + # this is an Array + k, v = i, elem + + # check if there is an ID element in the child + if id = v.as_h?.try &.delete("id") + k = id + end + end + + # assign key name for result hash + key = parent_prefix ? "#{parent_prefix}#{delimiter}#{k}" : k.to_s + raw = v.raw + + case raw + in Array(JSON::Any) + # recursive call to flatten child elements + res.merge!(raw.flatten_xapi_json(key, delimiter)) + in Hash(String, JSON::Any) + value = raw["Value"]? + if value && value.as_h?.nil? + valuespaceref = raw["valueSpaceRef"]?.try &.as_s.split('/').last + res[key] = Cisco::CollaborationEndpoint::XAPI.value_convert(value.as_s, valuespaceref) + elsif id + res[key] = raw.flatten_xapi_json(delimiter: delimiter) + else + res.merge!(raw.flatten_xapi_json(key, delimiter)) + end + in JSONBasic + res[key] = raw + end + end + + res + end +end diff --git a/drivers/cisco/collaboration_endpoint/ui_extensions.cr b/drivers/cisco/collaboration_endpoint/ui_extensions.cr new file mode 100644 index 00000000000..972923e91d8 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/ui_extensions.cr @@ -0,0 +1,76 @@ +require "./xapi" + +module Cisco::CollaborationEndpoint::UIExtensions + include Cisco::CollaborationEndpoint::XAPI + + command({"UserInterface Message Alert Clear" => :msg_alert_clear}) + command({"UserInterface Message Alert Display" => :msg_alert}, + text: String, + title_: String, + duration_: 0..3600) + + command({"UserInterface Message Prompt Clear" => :msg_prompt_clear}) + + def msg_prompt(text : String, options : Array(JSON::Any::Type), title : String? = nil, feedback_id : String? = nil, duration : Int64? = nil) + # TODO: return a promise, then prepend a async traffic monitor so it + # can be resolved with the response, or rejected after the timeout. + option_map = {} of String => JSON::Any::Type + ("Option.1".."Option.5").each_with_index do |key, i| + break if i >= options.size + option_map[key] = options[i] + end + + xcommand "UserInterface Message Prompt Display", + hash_args: Hash(String, JSON::Any::Type){ + "text" => text, + "title" => title, + "feedback_id" => feedback_id, + "duration" => duration, + }.merge(option_map) + end + + enum TextInputType + SingleLine + Numeric + Password + PIN + end + + enum TextKeyboardState + Open + Closed + end + + command({"UserInterface Message TextInput Clear" => :msg_text_clear}) + command({"UserInterface Message TextInput Display" => :msg_text}, + text: String, + feedback_id: String, + title_: String, + duration_: 0..3600, + input_type_: TextInputType, + keyboard_state_: TextKeyboardState, + place_holder_: String, + submit_text_: String) + + def ui_set_value(widget : String, value : JSON::Any::Type? = nil) + if value.nil? + xcommand "UserInterface Extensions Widget UnsetValue", + widget_id: widget + else + xcommand "UserInterface Extensions Widget SetValue", + value: value, widget_id: widget + end + end + + def ui_extensions_deploy(id : String, xml_def : String) + xcommand "UserInterface Extensions Set", xml_def, config_id: id + end + + def ui_extensions_list + xcommand "UserInterface Extensions List" + end + + def ui_extensions_clear + xcommand "UserInterface Extensions Clear" + end +end diff --git a/drivers/cisco/collaboration_endpoint/xapi.cr b/drivers/cisco/collaboration_endpoint/xapi.cr new file mode 100644 index 00000000000..5189c9164ef --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/xapi.cr @@ -0,0 +1,150 @@ +require "json" +require "./response" +require "./feedback" + +# monkey patching task is how we attach custom data +# request_payload is set by send if it's defined +class ::PlaceOS::Driver::Task + getter request_payload : String? = nil + + def request_payload=(payload : String) + @request_payload = payload.split("\n")[0] + end + + alias ResponseCallback = Proc(Hash(String, Enumerable::JSONComplex), Hash(String, Enumerable::JSONComplex) | Enumerable::JSONComplex | Symbol) + property xapi_request_id : String? = nil + property xapi_callback : ResponseCallback? = nil +end + +module Cisco::CollaborationEndpoint::XAPI + # Regexp's for tokenizing the xAPI command and response structure. + INVALID_COMMAND = /(?<=Command not recognized\.)[\r\n]+/ + + SUCCESS = /(?<=OK)[\r\n]+/ + + COMMAND_RESPONSE = Regex.union(INVALID_COMMAND, SUCCESS) + + LOGIN_COMPLETE = /Login successful/ + + enum ActionType + XConfiguration + XCommand + XStatus + XFeedback + XPreferences + end + + enum FeedbackAction + Register + Deregister + DeregisterAll + List + end + + # Serialize an xAPI action into transmittable command. + def self.create_action( + __action__ : ActionType, + *args, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + priority : Int32? = nil, # we want to ignore this param, hence we specified it here + **kwargs + ) + [ + __action__.to_s.camelcase(lower: true), + args.compact_map(&.to_s), + hash_args.map { |key, value| + if value + value = "\"#{value}\"" if value.is_a? String + "#{key.to_s.camelcase}: #{value}" + end + }, + kwargs.map { |key, value| + if value + value = "\"#{value}\"" if value.is_a? String + "#{key.to_s.camelcase}: #{value}" + end + }.to_a.compact!, + ].flatten.join " " + end + + # Serialize an xCommand into transmittable command. + def self.xcommand( + path : String, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + **kwargs + ) + create_action ActionType::XCommand, path, **kwargs.merge({hash_args: hash_args}) + end + + # Serialize an xConfiguration action into a transmittable command. + def self.xconfiguration(path : String, setting : String, value : JSON::Any::Type) + create_action ActionType::XConfiguration, path, hash_args: { + setting => value, + } + end + + # Serialize an xStatus request into transmittable command. + def self.xstatus(path : String) + create_action ActionType::XStatus, path + end + + # Serialize a xFeedback subscription request. + def self.xfeedback(action : FeedbackAction, path : String? = nil) + if path + xpath = tokenize path + create_action ActionType::XFeedback, action, "/#{xpath.join('/')}" + else + create_action ActionType::XFeedback, action + end + end + + def self.tokenize(path : String) + # Allow space or slash seperated paths + path.split(/[\s\/\\]/).reject(&.empty?) + end + + macro command(cmd_name, **params) + {% for cmd, name in cmd_name %} + def {{name.id}}( + {% for param, klass in params %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + + {% if klass.is_a?(RangeLiteral) %} + {{param.id}} : Int32{% if optional %}? = nil{% end %}, + {% else %} + {{param.id}} : {{klass}}{% if optional %}? = nil{% end %}, + {% end %} + {% end %} + ) + {% for param, klass in params %} + {% if klass.is_a?(RangeLiteral) %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + {% if optional %} if {{param.id}}{% end %} + raise ArgumentError.new("#{ {{param.stringify}} } must be within #{ {{klass}} }, was #{ {{param.id}} }") unless ({{klass}}).includes?({{param.id}}) + {% if optional %}end{% end %} + {% end %} + {% end %} + + # send the command + xcommand( + {{cmd}}, + {% for param, klass in params %} + {% if param.stringify.ends_with?("_") %} + {% param = param.stringify[0..-2] %} + {% end %} + + {{param.id}}: {{param.id}}, + {% end %} + ) + end + {% end %} + end +end diff --git a/drivers/cisco/dna_spaces.cr b/drivers/cisco/dna_spaces.cr new file mode 100644 index 00000000000..482a59756c4 --- /dev/null +++ b/drivers/cisco/dna_spaces.cr @@ -0,0 +1,677 @@ +require "placeos-driver" +require "set" +require "jwt" +require "s2_cells" +require "simple_retry" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" + +class Cisco::DNASpaces < PlaceOS::Driver + include Interface::Locatable + include Interface::Sensor + + # Discovery Information + descriptive_name "Cisco Spaces" + generic_name :Cisco_Spaces + uri_base "https://partners.dnaspaces.io" + + default_settings({ + _dna_spaces_activation_key: "provide this and the API / tenant ids will be generated automatically", + _dna_spaces_api_key: "X-API-KEY", + _tenant_id: "sfdsfsdgg", + verify_activation_key: false, + + # Time before a user location is considered probably too old (in minutes) + # we have a large time here as DNA spaces only updates when a user moves + # device exit is used to signal when a device has left the building + max_location_age: 300, + + floorplan_mappings: { + location_a4cb0: { + "level_name" => "optional name", + "building" => "zone-GAsXV0nc", + "level" => "zone-GAsmleH", + "offset_x" => 12.4, + "offset_y" => 5.2, + "map_width" => 50.3, + "map_height" => 100.9, + }, + }, + + debug_stream: false, + }) + + @streaming = false + @last_received = 0_i64 + @stream_active = false + + def on_unload + @channel.close + @stream_active = false + update_monitoring_status(running: false) + end + + @activation_token : String = "" + @verify_activation_key : Bool = false + @api_key : String = "" + @tenant_id : String = "" + @channel : Channel(String) = Channel(String).new + @max_location_age : Time::Span = 300.minutes + @s2_level : Int32 = 21 + @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new + @debug_stream : Bool = false + @events_received : UInt64 = 0_u64 + + def on_update + @max_location_age = (setting?(UInt32, :max_location_age) || 10).minutes + @s2_level = setting?(Int32, :s2_level) || 21 + @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings + @debug_stream = setting?(Bool, :debug_stream) || false + @verify_activation_key = setting?(Bool, :verify_activation_key) || false + + schedule.clear + schedule.every(30.minutes) { cleanup_caches } + schedule.every(5.minutes) { update_monitoring_status } + schedule.in(5.seconds) { update_monitoring_status } + + @activation_token = setting?(String, :dna_spaces_activation_key) || "" + if @activation_token.empty? + @api_key = setting(String, :dna_spaces_api_key) + @tenant_id = setting(String | Int64, :tenant_id).to_s + else + @api_key = setting?(String, :dna_spaces_api_key) || "" + @tenant_id = setting?(String | Int64, :tenant_id).try(&.to_s) || "" + + # Activate the API key using the activation_token + schedule.in(5.seconds) { activate } if @api_key.empty? + end + + @description_lock.synchronize do + if !@streaming && !@api_key.empty? + @streaming = true + spawn { start_streaming_events } + end + end + end + + @[Security(Level::Support)] + def activate + return if @activation_token.empty? + + response = get("/client/v1/partner/partnerPublicKey/") + raise "failed to obtain partner public key, code #{response.status_code}" unless response.success? + + logger.debug { "public key requested: #{response.body}" } + + payload = NamedTuple( + status: Bool, + message: String, + data: Array(ActivactionPublicKey)).from_json(response.body.not_nil!) + + raise "unexpected failure obtaining partner public key: #{payload[:message]}" unless payload[:status] + + public_key = payload[:data][0].public_key + payload, header = JWT.decode(@activation_token, public_key, JWT::Algorithm::RS256, @verify_activation_key) + app_id = payload["appId"].as_s + ref_id = payload["activationRefId"].as_s + tenant_id = payload["tenantId"].as_i64.to_s + + response = post("/client/v1/partner/activateOnPremiseApp", headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@activation_token}", + }, body: { + appId: app_id, + activationRefId: ref_id, + }.to_json) + raise "failed to obtain API key, code #{response.status_code}\n#{response.body}" unless response.success? + + logger.debug { "application activated: #{response.body}" } + + payload = NamedTuple( + status: Bool, + message: String, + data: NamedTuple(apiKey: String)).from_json(response.body.not_nil!) + + raise "unexpected failure obtaining API key: #{payload[:message]}" unless payload[:status] + + api_key = payload[:data][:apiKey] + logger.debug { "saving API key: #{tenant_id}, #{api_key}" } + + define_setting(:tenant_id, tenant_id) + define_setting(:dna_spaces_api_key, api_key) + define_setting(:dna_spaces_activation_key, "") + + logger.debug { "settings saved! Starting stream" } + @api_key = api_key + @tenant_id = tenant_id + + @description_lock.synchronize do + if !@streaming + @streaming = true + spawn { start_streaming_events } + end + end + end + + class LocationInfo + include JSON::Serializable + + getter location : Location + + @[JSON::Field(key: "locationDetails")] + getter details : LocationDetails + end + + def get_location_info(location_id : String) + response = get("/api/partners/v1/locations/#{location_id}?partnerTenantId=#{@tenant_id}", headers: { + "X-API-KEY" => @api_key, + }) + + raise "failed to obtain location id #{location_id}, code #{response.status_code}" unless response.success? + LocationInfo.from_json(response.body.not_nil!) + end + + @description_lock : Mutex = Mutex.new + @location_descriptions : Hash(String, String) = {} of String => String + + def seen_locations + @description_lock.synchronize { @location_descriptions.dup } + end + + # MAC Address => Location (including user) + @locations : Hash(String, DeviceLocationUpdate | IotTelemetry | WebexTelemetryUpdate) = {} of String => DeviceLocationUpdate | IotTelemetry | WebexTelemetryUpdate + @loc_lock : Mutex = Mutex.new + @devices : Hash(String, IotTelemetry | WebexTelemetryUpdate) = {} of String => IotTelemetry | WebexTelemetryUpdate + @dev_lock : Mutex = Mutex.new + + def locations + @loc_lock.synchronize { yield @locations } + end + + def devices + @dev_lock.synchronize { yield @devices } + end + + @user_lookup : Hash(String, Set(String)) = {} of String => Set(String) + @user_loc : Mutex = Mutex.new + + def user_lookup + @user_loc.synchronize { yield @user_lookup } + end + + def user_lookup(user_id : String) + formatted_user = format_username(user_id) + user_lookup { |lookup| lookup[formatted_user]? } + end + + def locate_mac(address : String) + formatted_address = format_mac(address) + locations { |locs| locs[formatted_address]? } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_state + logger.debug { + "MAC Locations: #{locations &.keys}" + } + {tracking: locations &.size, events_received: @events_received} + end + + @map_details : Hash(String, Dimension) = {} of String => Dimension + @map_lock : Mutex = Mutex.new + + def get_map_details(map_id : String) + map = @map_lock.synchronize { @map_details[map_id]? } + if !map + response = get("/api/partners/v1/maps/#{map_id}?partnerTenantId=#{@tenant_id}", headers: { + "X-API-KEY" => @api_key, + }) + if !response.success? + message = "failed to obtain map id #{map_id}, code #{response.status_code}" + logger.warn { message } + return nil + end + map = MapInfo.from_json(response.body.not_nil!).dimension + @map_lock.synchronize { @map_details[map_id] = map } + end + map + end + + @[Security(PlaceOS::Driver::Level::Support)] + def cleanup_caches : Nil + logger.debug { "removing location data that is over 30 minutes old" } + + old = 30.minutes.ago.to_unix + remove_keys = [] of String + locations do |locs| + locs.each { |mac, location| remove_keys << mac if location.last_seen < old } + remove_keys.each { |mac| locs.delete(mac) } + end + + logger.debug { "removed #{remove_keys.size} MACs" } + nil + end + + # we want to stream events until driver is terminated + protected def start_streaming_events + @streaming = true + SimpleRetry.try_to( + base_interval: 2.seconds, + max_interval: 10.seconds + ) do + logger.info { "connecting to event stream" } + stream_events unless terminated? + end + ensure + @streaming = false + end + + # as sometimes the map id is missing, but in the same location + # location id => map id + @location_id_maps = {} of String => String + + # Processes events as they come in, forces a disconnect if no events are sent + # for a period of time as the remote should be sending them periodically + protected def process_events(client) + loop do + select + when data = @channel.receive + logger.debug { "received push #{data}" } if @debug_stream + @events_received = @events_received &+ 1_u64 + begin + event = Cisco::DNASpaces::Events.from_json(data) + payload = event.payload + case payload + when DeviceExit + device_mac = format_mac(payload.device.mac_address) + locations &.delete(device_mac) + when DeviceEntry + # This is used entirely for + @description_lock.synchronize { payload.location.descriptions(@location_descriptions) } + when DeviceLocationUpdate, IotTelemetry, WebexTelemetryUpdate + device_mac = format_mac(payload.device.mac_address) + + # we want timestamps in seconds + payload.last_seen = payload.last_seen // 1000 + + case payload + when IotTelemetry + self[device_mac] = payload + devices { |dev| dev[device_mac] = payload } + + next unless payload.has_position? + when WebexTelemetryUpdate + if webex_obj = devices { |dev| dev[device_mac]? } + webex_obj = webex_obj.as(WebexTelemetryUpdate) + webex_obj.device = payload.device + webex_obj.location = payload.location + webex_obj.last_seen = payload.last_seen + webex_obj.telemetries = payload.telemetries + payload = webex_obj + else + @description_lock.synchronize { payload.location.descriptions(@location_descriptions) } + devices { |dev| dev[device_mac] = payload } + end + payload.update_telemetry + self[device_mac] = payload + end + + # Keep track of device location + existing = nil + + # ignore locations where we don't have enough details to put the device on a map + if payload.map_id.presence + @location_id_maps[payload.location.location_id] = payload.map_id + else + locations = payload.location_mappings.values + level_id = locations.find { |loc_id| @floorplan_mappings[loc_id]? } + + if level_id && (level_data = @floorplan_mappings[level_id]) && level_data["map_width"]? && level_data["map_height"]? + # we don't need the map ID as the x, y coordinates are defined by us + # we do need the map_id for grouping results, so we assign it the level id + payload.map_id = level_id + else + found = false + payload.location_mappings.values.each do |loc_id| + if map_id = @location_id_maps[loc_id]? + payload.map_id = map_id + found = true + break + end + end + + if !found + logger.debug { "ignoring device #{device_mac} location as map_id is empty, location id #{payload.location.location_id}, visit #{payload.visit_id}" } + next + end + end + end + + locations do |loc| + existing = loc[device_mac]? + loc[device_mac] = payload + end + + # Maintain user lookup + if payload.raw_user_id.presence + user_id = format_username(payload.raw_user_id) + + if existing && payload.raw_user_id != existing.raw_user_id + old_user_id = format_username(existing.raw_user_id) + + user_lookup do |lookup| + lookup[old_user_id]?.try &.delete(device_mac) + devices = lookup[old_user_id]? || Set(String).new + devices.delete(device_mac) + lookup.delete(old_user_id) if devices.empty? + + devices = lookup[user_id]? || Set(String).new + devices << device_mac + lookup[user_id] = devices + end + else + user_lookup do |lookup| + devices = lookup[user_id]? || Set(String).new + devices << device_mac + lookup[user_id] = devices + end + end + end + + # payload.location_mappings => { "ZONE" => loc_id, "FLOOR" => loc_id, "BUILDING" => loc_id, "CAMPUS" => loc_id } + else + logger.debug { "ignoring event: #{payload ? payload.class : event.class}" } + end + rescue error + logger.error(exception: error) { "parsing DNA Spaces event: #{data}" } + end + when timeout(20.seconds) + logger.debug { "no events received for 20 seconds, expected heartbeat at 15 seconds" } + @channel.close + break + end + end + ensure + client.close + end + + protected def stream_events + client = HTTP::Client.new URI.parse(config.uri.not_nil!) + client.get("/api/partners/v1/firehose/events", HTTP::Headers{ + "X-API-KEY" => @api_key, + }) do |response| + if !response.success? + @stream_active = false + logger.warn { "failed to connect to firehose api #{response.status_code}" } + raise "failed to connect to firehose api #{response.status_code}" + end + + @stream_active = true + + # We use a channel for event processing so we can make use of timeouts + @channel = Channel(String).new + spawn { process_events(client) } + + begin + loop do + if response.body_io.closed? + @channel.close + break + end + + if data = response.body_io.gets + @last_received = Time.utc.to_unix_ms + @channel.send data + else + @channel.close + break + end + end + rescue IO::Error + @channel.close + end + end + + # Trigger the retry behaviour + @stream_active = false + raise "stream closed" + end + + # ============================= + # Locatable interface + # ============================= + def locate_user(email : String? = nil, username : String? = nil) + if macs = user_lookup(username.presence || email.presence.not_nil!) + location_max_age = @max_location_age.ago.to_unix + + macs.compact_map { |mac| + if location = locate_mac(mac) + next if location.is_a?(WebexTelemetryUpdate) + if location.last_seen > location_max_age + # we update the mac_address to a formatted version + location.device.mac_address = mac + location + end + end + }.sort! { |a, b| + b.last_seen <=> a.last_seen + }.map { |location| + lat = location.latitude + lon = location.longitude + + loc = { + "location" => "wireless", + "coordinates_from" => "top-left", + "x" => location.x_pos, + "y" => location.y_pos, + "lon" => lon, + "lat" => lat, + "s2_cell_id" => S2Cells.at(lat, lon).parent(@s2_level).to_token, + "mac" => location.device.mac_address, + "variance" => location.unc, + "last_seen" => location.last_seen, + "dna_floor_id" => location.map_id, + "ssid" => location.ssid, + "manufacturer" => location.device.manufacturer, + "os" => location.device.os, + } + + map_width = 0.0 + map_height = 0.0 + offset_x = 0.0 + offset_y = 0.0 + + # Add our zone IDs to the response + location.location_mappings.each_value do |location_id| + if level_data = @floorplan_mappings[location_id]? + level_data.each do |key, value| + case key + when "offset_x" + offset_x = value.as(Float64) + loc["x"] = location.x_pos - offset_x + when "offset_y" + offset_y = value.as(Float64) + loc["y"] = location.y_pos - offset_y + when "map_width" + map_width = value.as(Float64) + when "map_height" + map_height = value.as(Float64) + else + loc[key] = value + end + end + break + end + end + + # Add map information to the response + if map_width > 0.0 && map_height > 0.0 + loc["map_width"] = map_width + loc["map_height"] = map_height + elsif map_size = get_map_details(location.map_id) + loc["map_width"] = map_width > 0.0 ? map_width : (map_size.length - offset_x) + loc["map_height"] = map_height > 0.0 ? map_height : (map_size.width - offset_y) + end + + loc + } + else + [] of Nil + end + end + + # Will return an array of MAC address strings + # lowercase with no seperation characters abcdeffd1234 etc + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + user_lookup(username.presence || email.presence.not_nil!).try(&.to_a) || [] of String + end + + # Will return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}` + def check_ownership_of(mac_address : String) : OwnershipMAC? + if location = locate_mac(mac_address) + { + location: "wireless", + assigned_to: format_username(location.raw_user_id), + mac_address: format_mac(mac_address), + } + end + end + + # Will return an array of devices and their x, y coordinates + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "looking up device locations in #{zone_id}" } + return [] of Nil if location.presence && location != "wireless" + + # Find the floors associated with the provided zone id + floors = [] of String + adjustments = {} of String => Tuple(Float64, Float64, Float64, Float64) + @floorplan_mappings.each do |floor_id, data| + if data.values.includes?(zone_id) + floors << floor_id + offset_x = (data["offset_x"]? || 0.0).as(Float64) + offset_y = (data["offset_y"]? || 0.0).as(Float64) + map_width = (data["map_width"]? || -1.0).as(Float64) + map_height = (data["map_height"]? || -1.0).as(Float64) + adjustments[floor_id] = {offset_x, offset_y, map_width, map_height} + end + end + logger.debug { "found matching meraki floors: #{floors}" } + return [] of Nil if floors.empty? + + checking_count = @locations.size + wrong_floor = 0 + too_old = 0 + + # Find the devices that are on the matching floors + oldest_location = @max_location_age.ago.to_unix + + matching = locations(&.compact_map { |mac, loc| + if loc.last_seen < oldest_location + too_old += 1 + next + end + if (floors & loc.location_mappings.values).empty? + wrong_floor += 1 + next + end + + # ensure the formatted mac is being used + loc.device.mac_address = mac + loc + }) + + logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" } + + matching.group_by(&.map_id).flat_map { |map_id, locations| + map_width = -1.0 + map_height = -1.0 + offset_x = 0.0 + offset_y = 0.0 + + # any adjustments required for these locations? + locations.first.location_mappings.each_value do |location_id| + if level_data = adjustments[location_id]? + offset_x, offset_y, map_width, map_height = level_data + break + end + end + + if map_width == -1.0 || map_height == -1.0 + if map_size = get_map_details(map_id) + map_width = map_width > -1.0 ? map_width : (map_size.length - offset_x) + map_height = map_height > -1.0 ? map_height : (map_size.width - offset_y) + end + end + + locations.compact_map do |loc| + next if loc.is_a?(WebexTelemetryUpdate) + lat = loc.latitude + lon = loc.longitude + + { + location: :wireless, + coordinates_from: "top-left", + x: loc.x_pos - offset_x, + y: loc.y_pos - offset_y, + lon: lon, + lat: lat, + s2_cell_id: S2Cells.at(lat, lon).parent(@s2_level).to_token, + mac: loc.device.mac_address, + variance: loc.unc, + last_seen: loc.last_seen, + map_width: map_width, + map_height: map_height, + ssid: loc.ssid, + manufacturer: loc.device.manufacturer, + os: loc.device.os, + } + end + } + end + + def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + def format_username(user : String) + if user.includes? "@" + user = user.split("@")[0] + elsif user.includes? "\\" + user = user.split("\\")[1] + end + user.downcase + end + + # This provides the DNA Spaces dashboard with stream consumption status + @[Security(PlaceOS::Driver::Level::Administrator)] + def update_monitoring_status(running : Bool = true) : Nil + response = put("/api/partners/v1/monitoring/status", headers: { + "Content-Type" => "application/json", + "X-API-KEY" => @api_key, + }, body: { + data: { + overallStatus: { + status: running ? "up" : "down", + notices: [] of Nil, + }, + instanceDetails: { + ipAddress: "", + instanceId: module_id, + }, + cloudFirehose: { + status: @stream_active ? "connected" : "disconnected", + lastReceived: @last_received, + }, + localFirehose: { + status: "disconnected", + lastReceived: 0, + }, + subsystems: [] of Nil, + }, + }.to_json) + raise "failed to update status, code #{response.status_code}\n#{response.body}" unless response.success? + end +end + +require "./dna_spaces/events" +require "./dna_spaces/sensor_interface" diff --git a/drivers/cisco/dna_spaces/activation_publickey.cr b/drivers/cisco/dna_spaces/activation_publickey.cr new file mode 100644 index 00000000000..eb30ec06dd3 --- /dev/null +++ b/drivers/cisco/dna_spaces/activation_publickey.cr @@ -0,0 +1,14 @@ +require "./events" + +class Cisco::DNASpaces::ActivactionPublicKey + include JSON::Serializable + + getter version : String + + @[JSON::Field(key: "publicKey")] + getter public_key : String + + def public_key + "-----BEGIN PUBLIC KEY-----\n#{@public_key}\n-----END PUBLIC KEY-----\n" + end +end diff --git a/drivers/cisco/dna_spaces/app_activaction.cr b/drivers/cisco/dna_spaces/app_activaction.cr new file mode 100644 index 00000000000..36202b21424 --- /dev/null +++ b/drivers/cisco/dna_spaces/app_activaction.cr @@ -0,0 +1,21 @@ +require "./events" + +class Cisco::DNASpaces::AppActivaction + include JSON::Serializable + + @[JSON::Field(key: "spacesTenantName")] + getter spaces_tenant_name : String + + @[JSON::Field(key: "spacesTenantId")] + getter spaces_tenant_id : String + + @[JSON::Field(key: "partnerTenantId")] + getter partner_tenant_id : String + getter name : String + + @[JSON::Field(key: "referenceId")] + getter reference_id : String + + @[JSON::Field(key: "instanceName")] + getter instance_name : String +end diff --git a/drivers/cisco/dna_spaces/ble_rssi_update.cr b/drivers/cisco/dna_spaces/ble_rssi_update.cr new file mode 100644 index 00000000000..b9f25eac962 --- /dev/null +++ b/drivers/cisco/dna_spaces/ble_rssi_update.cr @@ -0,0 +1,49 @@ +require "./events" +require "./location" + +class Cisco::DNASpaces::BlePayload + include JSON::Serializable + + property timestamp : Int64 + property data : String +end + +class Cisco::DNASpaces::RssiMeasurement + include JSON::Serializable + + @[JSON::Field(key: "apMacAddress")] + property access_point_mac : String + + @[JSON::Field(key: "ifSlotId")] + property if_slot_id : Int32 + + @[JSON::Field(key: "bandId")] + property band_id : Int32 + + @[JSON::Field(key: "antennaId")] + property antenna_id : Int32 + + property rssi : Int32 + property timestamp : Int64 +end + +class Cisco::DNASpaces::RssiNotification + include JSON::Serializable + + @[JSON::Field(key: "macAddress")] + property mac_address : String + + @[JSON::Field(key: "apRssiMeasurements")] + property measurements : Array(RssiMeasurement) + + @[JSON::Field(key: "blePayload")] + property payload : BlePayload +end + +class Cisco::DNASpaces::BleRssiUpdate + include JSON::Serializable + + @[JSON::Field(key: "rssiNotification")] + getter notification : RssiNotification + getter location : Location +end diff --git a/drivers/cisco/dna_spaces/device.cr b/drivers/cisco/dna_spaces/device.cr new file mode 100644 index 00000000000..cb917bcda95 --- /dev/null +++ b/drivers/cisco/dna_spaces/device.cr @@ -0,0 +1,48 @@ +require "./events" + +class Cisco::DNASpaces::Device + include JSON::Serializable + + @[JSON::Field(key: "deviceId")] + getter device_id : String + + @[JSON::Field(key: "userId")] + getter user_id : String + + getter tags : Array(String) = [] of String + getter mobile : String? + getter email : String? + + def email + @email.try &.downcase + end + + def email_raw + @email + end + + getter gender : String? + + @[JSON::Field(key: "firstName")] + getter first_name : String? + + @[JSON::Field(key: "lastName")] + getter last_name : String? + + @[JSON::Field(key: "postalCode")] + getter postal_code : String? + + # optIns + # otherFields + # socialNetworkInfo + + # We make this editable so we can store the formatted version here + @[JSON::Field(key: "macAddress")] + property mac_address : String + getter manufacturer : String? + getter os : String? + + @[JSON::Field(key: "osVersion")] + getter os_version : String? + getter type : String +end diff --git a/drivers/cisco/dna_spaces/device_count.cr b/drivers/cisco/dna_spaces/device_count.cr new file mode 100644 index 00000000000..c799d9b8286 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_count.cr @@ -0,0 +1,22 @@ +require "./events" + +class Cisco::DNASpaces::DeviceCount + include JSON::Serializable + + getter location : Location + + @[JSON::Field(key: "associatedCount")] + getter associated_count : Int32 + + @[JSON::Field(key: "estimatedProbingCount")] + getter estimated_probing_count : Int32 + + @[JSON::Field(key: "probingRandomizedPercentage")] + getter probing_randomized_percentage : Float64 + + @[JSON::Field(key: "estimatedDensity")] + getter estimated_density : Float64 + + @[JSON::Field(key: "estimatedCapacityPercentage")] + getter estimated_capacity_percentage : Float64 +end diff --git a/drivers/cisco/dna_spaces/device_entry.cr b/drivers/cisco/dna_spaces/device_entry.cr new file mode 100644 index 00000000000..befd6dd137e --- /dev/null +++ b/drivers/cisco/dna_spaces/device_entry.cr @@ -0,0 +1,26 @@ +require "./events" + +class Cisco::DNASpaces::DeviceEntry + include JSON::Serializable + + getter device : Device + getter location : Location + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "daysSinceLastVisit")] + getter days_sinc_last_visit : Int32 +end diff --git a/drivers/cisco/dna_spaces/device_exit.cr b/drivers/cisco/dna_spaces/device_exit.cr new file mode 100644 index 00000000000..151ef54cd5f --- /dev/null +++ b/drivers/cisco/dna_spaces/device_exit.cr @@ -0,0 +1,38 @@ +require "./events" + +class Cisco::DNASpaces::DeviceExit + include JSON::Serializable + + getter device : Device + getter location : Location + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_datetime : String + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "visitClassification")] + getter visit_classification : String +end diff --git a/drivers/cisco/dna_spaces/device_location_update.cr b/drivers/cisco/dna_spaces/device_location_update.cr new file mode 100644 index 00000000000..8be08d405f7 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_location_update.cr @@ -0,0 +1,55 @@ +require "./events" + +class Cisco::DNASpaces::DeviceLocationUpdate + include JSON::Serializable + + getter device : Device + getter location : Location + + getter ssid : String + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "lastSeen")] + property last_seen : Int64 + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "mapId")] + property map_id : String + + @[JSON::Field(key: "xPos")] + getter x_pos : Float64 + + @[JSON::Field(key: "yPos")] + getter y_pos : Float64 + + @[JSON::Field(key: "confidenceFactor")] + getter confidence_factor : Float64 + getter latitude : Float64 + getter longitude : Float64 + getter unc : Float64 + + def has_position? + true + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end +end diff --git a/drivers/cisco/dna_spaces/device_presence.cr b/drivers/cisco/dna_spaces/device_presence.cr new file mode 100644 index 00000000000..2c3973a4035 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_presence.cr @@ -0,0 +1,54 @@ +require "./events" + +class Cisco::DNASpaces::DevicePresence + include JSON::Serializable + + @[JSON::Field(key: "presenceEventType")] + getter presence_event_type : String + + @[JSON::Field(key: "wasInActive")] + getter was_in_active : Bool + getter device : Device + getter location : Location + + getter ssid : String + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "daysSinceLastVisit")] + getter days_since_last_visit : Int32 + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_date_time : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "visitClassification")] + getter visit_classification : String + + @[JSON::Field(key: "activeDevicesCount")] + getter active_devices_count : Int32 + + @[JSON::Field(key: "inActiveDevicesCount")] + getter inactive_devices_count : Int32 +end diff --git a/drivers/cisco/dna_spaces/events.cr b/drivers/cisco/dna_spaces/events.cr new file mode 100644 index 00000000000..2b0371a39a8 --- /dev/null +++ b/drivers/cisco/dna_spaces/events.cr @@ -0,0 +1,142 @@ +require "json" +require "../dna_spaces" +require "./location" +require "./device" +require "./*" + +# This is used to map the various events into a simpler data structure +abstract class Cisco::DNASpaces::Events + include JSON::Serializable + + # event type hint + use_json_discriminator "eventType", { + "KEEP_ALIVE" => KeepAlive, + "DEVICE_ENTRY" => DeviceEntryWrapper, + "DEVICE_EXIT" => DeviceExitWrapper, + "PROFILE_UPDATE" => ProfileUpdateWrapper, + "LOCATION_CHANGE" => LocationChangeWrapper, + "DEVICE_LOCATION_UPDATE" => DeviceLocationUpdateWrapper, + "TP_PEOPLE_COUNT_UPDATE" => PeopleCountUpdateWrapper, + "DEVICE_PRESENCE" => DevicePresenceWrapper, + "USER_PRESENCE" => UserPresenceWrapper, + "APP_ACTIVATION" => AppActivactionWrapper, + "DEVICE_COUNT" => DeviceCountWrapper, + "BLE_RSSI_UPDATE" => BleRssiUpdateWrapper, + "IOT_TELEMETRY" => IotTelemetryWrapper, + "WEBEX_TELEMETRY" => WebexTelemetryUpdateWrapper, + } + + @[JSON::Field(key: "recordUid")] + getter record_uid : String + + @[JSON::Field(key: "recordTimestamp")] + getter record_timestamp : Int64 + + @[JSON::Field(key: "spacesTenantId")] + getter spaces_tenant_id : String + + @[JSON::Field(key: "spacesTenantName")] + getter spaces_tenant_name : String + + @[JSON::Field(key: "partnerTenantId")] + getter partner_tenant_id : String +end + +class Cisco::DNASpaces::KeepAlive < Cisco::DNASpaces::Events + getter eventType : String = "KEEP_ALIVE" + + def payload + nil + end +end + +class Cisco::DNASpaces::DeviceEntryWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_ENTRY" + + @[JSON::Field(key: "deviceEntry")] + getter payload : DeviceEntry +end + +class Cisco::DNASpaces::DeviceExitWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_EXIT" + + @[JSON::Field(key: "deviceExit")] + getter payload : DeviceExit +end + +class Cisco::DNASpaces::ProfileUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "PROFILE_UPDATE" + + @[JSON::Field(key: "deviceProfileUpdate")] + getter payload : Device +end + +class Cisco::DNASpaces::LocationChangeWrapper < Cisco::DNASpaces::Events + getter eventType : String = "LOCATION_CHANGE" + + @[JSON::Field(key: "locationHierarchyChange")] + getter payload : LocationChange +end + +class Cisco::DNASpaces::DeviceLocationUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_LOCATION_UPDATE" + + @[JSON::Field(key: "deviceLocationUpdate")] + getter payload : DeviceLocationUpdate +end + +class Cisco::DNASpaces::PeopleCountUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "TP_PEOPLE_COUNT_UPDATE" + + @[JSON::Field(key: "tpPeopleCountUpdate")] + getter payload : PeopleCountUpdate +end + +class Cisco::DNASpaces::DevicePresenceWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_PRESENCE" + + @[JSON::Field(key: "devicePresence")] + getter payload : DevicePresence +end + +class Cisco::DNASpaces::UserPresenceWrapper < Cisco::DNASpaces::Events + getter eventType : String = "USER_PRESENCE" + + @[JSON::Field(key: "userPresence")] + getter payload : UserPresence +end + +class Cisco::DNASpaces::AppActivactionWrapper < Cisco::DNASpaces::Events + getter eventType : String = "APP_ACTIVATION" + + @[JSON::Field(key: "appActivation")] + getter payload : AppActivaction +end + +class Cisco::DNASpaces::DeviceCountWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_COUNT" + + @[JSON::Field(key: "deviceCounts")] + getter payload : DeviceCount +end + +class Cisco::DNASpaces::BleRssiUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "BLE_RSSI_UPDATE" + + @[JSON::Field(key: "bleRssiUpdate")] + getter payload : BleRssiUpdate +end + +class Cisco::DNASpaces::IotTelemetryWrapper < Cisco::DNASpaces::Events + getter eventType : String = "IOT_TELEMETRY" + + @[JSON::Field(key: "iotTelemetry")] + getter payload : IotTelemetry +end + +class Cisco::DNASpaces::WebexTelemetryUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "WEBEX_TELEMETRY" + + @[JSON::Field(key: "webexTelemetryUpdate")] + getter payload : WebexTelemetryUpdate +end diff --git a/drivers/cisco/dna_spaces/iot_telemetry.cr b/drivers/cisco/dna_spaces/iot_telemetry.cr new file mode 100644 index 00000000000..10084a8752a --- /dev/null +++ b/drivers/cisco/dna_spaces/iot_telemetry.cr @@ -0,0 +1,254 @@ +require "./events" +require "./location" + +class Cisco::DNASpaces::IotDeviceInfo + include JSON::Serializable + + @[JSON::Field(key: "deviceType")] + property type : String + + @[JSON::Field(key: "deviceId")] + property id : String + + @[JSON::Field(key: "deviceMacAddress")] + property mac_address : String + + @[JSON::Field(key: "deviceName")] + property device_name : String + + @[JSON::Field(key: "firmwareVersion")] + property firmware_version : String + + @[JSON::Field(key: "rawDeviceId")] + property raw_id : String + property manufacturer : String + + def os + type + end +end + +class Cisco::DNASpaces::IotPosition + include JSON::Serializable + + @[JSON::Field(key: "mapId")] + property map_id : String + + @[JSON::Field(key: "xPos")] + getter x_pos : Float64 + + @[JSON::Field(key: "yPos")] + getter y_pos : Float64 + + @[JSON::Field(key: "confidenceFactor")] + getter confidence_factor : Float64 + getter latitude : Float64 + getter longitude : Float64 + + @[JSON::Field(key: "locationId")] + property location_id : String + + @[JSON::Field(key: "lastLocatedTime")] + property time_located : Int64 +end + +class Cisco::DNASpaces::TpData + include JSON::Serializable + + @[JSON::Field(key: "peopleCount")] + property people_count : Int32 + + @[JSON::Field(key: "standbyState")] + property standby_state : Int32 + + @[JSON::Field(key: "ambientNoise")] + property ambient_noise : Int32 + + @[JSON::Field(key: "drynessScore")] + property dryness_score : Int32 + + @[JSON::Field(key: "activeCalls")] + property active_calls : Int32 + + @[JSON::Field(key: "presentationState")] + property presentation_state : Int32 + + @[JSON::Field(key: "timeStamp")] + property time_stamp : Int64 + + @[JSON::Field(key: "airQualityIndex")] + property air_quality_index : Float64 + + @[JSON::Field(key: "temperatureInCelsius")] + property temperature_in_celsius : Float64 + + @[JSON::Field(key: "humidityInPercentage")] + property humidity_in_percentage : Float64 + + getter presence : Bool +end + +class Cisco::DNASpaces::IotTelemetry + include JSON::Serializable + + @[JSON::Field(key: "deviceInfo")] + getter device : IotDeviceInfo + + @[JSON::Field(key: "detectedPosition")] + getter detected_position : IotPosition? + + @[JSON::Field(key: "placedPosition")] + getter placed_position : IotPosition? + + getter location : Location + + @[JSON::Field(key: "deviceRtcTime")] + getter device_rtc : Int64 + + @[JSON::Field(key: "rawHeader")] + getter raw_header : Int64 + + @[JSON::Field(key: "rawPayload")] + getter raw_payload : String + + @[JSON::Field(key: "sequenceNum")] + getter sequence_num : Int64 + + @[JSON::Field(key: "airQuality")] + getter air_quality_index : NamedTuple(airQualityIndex: Float64)? + + @[JSON::Field(key: "temperature")] + getter temperature_celsius : NamedTuple(temperatureInCelsius: Float64)? + + @[JSON::Field(key: "humidity")] + getter humidity_percent : NamedTuple(humidityInPercentage: Float64)? + + @[JSON::Field(key: "airPressure")] + getter air_pressure_actual : NamedTuple(pressure: Float64)? + + @[JSON::Field(key: "pirTrigger")] + getter pir_trigger : NamedTuple(timestamp: Int64)? + + @[JSON::Field(key: "tpData")] + getter tele_presence_data : TpData? + + def people_count + tele_presence_data.try &.people_count + end + + def presence + tele_presence_data.try &.presence + end + + def ambient_noise + tele_presence_data.try &.ambient_noise + end + + def air_quality + if index = @air_quality_index + index[:airQualityIndex] + end + end + + def temperature + if temp = @temperature_celsius + temp[:temperatureInCelsius] + end + end + + def humidity + if humidity = @humidity_percent + humidity[:humidityInPercentage] + end + end + + def air_pressure + if pressure = @air_pressure_actual + pressure[:pressure] + end + end + + def pir_triggered + if pir_trigger = @pir_trigger + pir_trigger[:timestamp] + end + end + + def binding(type : SensorType, mac : String) + case type + when .humidity? + "#{mac}->humidity->humidityInPercentage" + when .air_quality? + "#{mac}->airQuality->airQualityIndex" + when .people_count? + "#{mac}->tpData->peopleCount" + when .temperature? + "#{mac}->temperature->temperatureInCelsius" + end + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end + + def has_position? + !!(@detected_position || @placed_position) + end + + def position : IotPosition + (@detected_position || @placed_position).not_nil! + end + + # make this class quack like a wifi DeviceLocationUpdate + delegate latitude, to: position + delegate longitude, to: position + delegate confidence_factor, to: position + delegate x_pos, to: position + delegate y_pos, to: position + delegate map_id, to: position + + def map_id=(id) + position.map_id = id + end + + def visit_id + "unknown for IoT" + end + + def last_seen + tele_presence_data.try(&.time_stamp) || (has_position? ? position.time_located : device_rtc) + end + + def last_seen=(time) + if tele_data = tele_presence_data + tele_data.time_stamp = time + elsif has_position? + position.time_located = time + else + @device_rtc = time + end + time + end + + def raw_user_id + "" + end + + def unc : Float64 + 3.0 + end + + def ssid + "IoT" + end +end diff --git a/drivers/cisco/dna_spaces/location.cr b/drivers/cisco/dna_spaces/location.cr new file mode 100644 index 00000000000..26cb978eef1 --- /dev/null +++ b/drivers/cisco/dna_spaces/location.cr @@ -0,0 +1,30 @@ +require "./events" + +class Cisco::DNASpaces::Location + include JSON::Serializable + + @[JSON::Field(key: "locationId")] + getter location_id : String + getter name : String + + # TODO:: this might be better as an enum + # if there are only limited types + @[JSON::Field(key: "inferredLocationTypes")] + getter tags : Array(String) = [] of String + + getter parent : Location? + + # Maps tag names to location_ids + def details(mappings = {} of String => String) + parent.try &.details(mappings) + tags.each { |tag| mappings[tag] = location_id } + mappings + end + + # Maps location_ids to location names + def descriptions(mappings = {} of String => String) + parent.try &.descriptions(mappings) + mappings[location_id] = name + mappings + end +end diff --git a/drivers/cisco/dna_spaces/location_change.cr b/drivers/cisco/dna_spaces/location_change.cr new file mode 100644 index 00000000000..6043e172f14 --- /dev/null +++ b/drivers/cisco/dna_spaces/location_change.cr @@ -0,0 +1,32 @@ +require "./events" + +class Cisco::DNASpaces::LocationChange + include JSON::Serializable + + @[JSON::Field(key: "changeType")] + getter change_type : String + getter location : Location + + class Metadata + include JSON::Serializable + + getter key : String + getter values : Array(String) + end + + class LocationDetails + include JSON::Serializable + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + getter city : String + getter state : String + getter country : String + getter category : String + + getter latitude : Float64 + getter longitude : Float64 + + getter metadata : Array(Metadata) + end +end diff --git a/drivers/cisco/dna_spaces/location_details.cr b/drivers/cisco/dna_spaces/location_details.cr new file mode 100644 index 00000000000..c69048af78f --- /dev/null +++ b/drivers/cisco/dna_spaces/location_details.cr @@ -0,0 +1,16 @@ +require "./events" + +class Cisco::DNASpaces::LocationDetails + include JSON::Serializable + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + getter city : String + getter state : String + getter country : String + getter category : String + + getter latitude : Float64 + getter longitude : Float64 +end diff --git a/drivers/cisco/dna_spaces/map_info.cr b/drivers/cisco/dna_spaces/map_info.cr new file mode 100644 index 00000000000..31be13841d7 --- /dev/null +++ b/drivers/cisco/dna_spaces/map_info.cr @@ -0,0 +1,30 @@ +require "./events" + +class Cisco::DNASpaces::Dimension + include JSON::Serializable + + getter length : Float64 + getter width : Float64 + getter height : Float64 + + @[JSON::Field(key: "offsetX")] + getter offset_x : Float64 + + @[JSON::Field(key: "offsetY")] + getter offset_y : Float64 +end + +class Cisco::DNASpaces::MapInfo + include JSON::Serializable + + @[JSON::Field(key: "mapId")] + getter id : String + + @[JSON::Field(key: "imageWidth")] + getter image_width : Float64 + + @[JSON::Field(key: "imageHeight")] + getter image_height : Float64 + + getter dimension : Cisco::DNASpaces::Dimension +end diff --git a/drivers/cisco/dna_spaces/people_count_update.cr b/drivers/cisco/dna_spaces/people_count_update.cr new file mode 100644 index 00000000000..99d8f35cd22 --- /dev/null +++ b/drivers/cisco/dna_spaces/people_count_update.cr @@ -0,0 +1,32 @@ +require "./events" + +# This is triggered from telepresence devices +class Cisco::DNASpaces::PeopleCountUpdate + include JSON::Serializable + + @[JSON::Field(key: "tpDeviceId")] + getter tp_device_id : String + getter location : Location + getter presence : Bool + + @[JSON::Field(key: "peopleCount")] + getter people_count : Int32 + + @[JSON::Field(key: "standbyState")] + getter standby_state : Int32 + + @[JSON::Field(key: "ambientNoise")] + getter ambient_noise : Int32 + + @[JSON::Field(key: "drynessScore")] + getter dryness_score : Int32 + + @[JSON::Field(key: "activeCalls")] + getter active_calls : Int32 + + @[JSON::Field(key: "presentationState")] + getter presentation_state : Int32 + + @[JSON::Field(key: "timeStamp")] + getter timestamp : Int64 +end diff --git a/drivers/cisco/dna_spaces/sensor_interface.cr b/drivers/cisco/dna_spaces/sensor_interface.cr new file mode 100644 index 00000000000..9825f9af11a --- /dev/null +++ b/drivers/cisco/dna_spaces/sensor_interface.cr @@ -0,0 +1,129 @@ +class Cisco::DNASpaces + IOT_SENSORS = { + SensorType::Presence, SensorType::PeopleCount, SensorType::Humidity, + SensorType::AirQuality, SensorType::SoundPressure, SensorType::Temperature, + } + NO_MATCH = [] of Interface::Sensor::Detail + + protected def to_sensors(zone_id, filter, device : IotTelemetry | WebexTelemetryUpdate) + if level_loc = device.location_mappings["FLOOR"]? + if floorplan = @floorplan_mappings[level_loc]? + building = floorplan["building"]?.as(String?) + level = floorplan["level"]?.as(String?) + end + end + + sensors = [] of Interface::Sensor::Detail + return sensors if zone_id && (building || level) && !zone_id.in?({building, level}) + + formatted_mac = format_mac(device.device.mac_address) + time = device.last_seen + device_name = device.device.device_name.presence || device.device.id + + IOT_SENSORS.each do |type| + next if filter && filter != type + # next if device.is_a?(WebexTelemetryUpdate) && !filter.in?({SensorType::PeopleCount, SensorType::Presence}) + + unit = nil + value = nil + binding = device.binding(type, formatted_mac) + + case type + when SensorType::Presence + if !(presence = device.presence).nil? + value = presence ? 1.0 : 0.0 + end + when SensorType::Humidity + if humidity = device.humidity + value = humidity + unit = "%" + end + when SensorType::AirQuality + if air_quality = device.air_quality + value = air_quality + end + when SensorType::PeopleCount + if count = device.people_count + value = count.to_f + end + when SensorType::Temperature + if temp = device.temperature + value = temp + unit = "Cel" + end + when SensorType::SoundPressure + if noise = device.ambient_noise + value = noise.to_f + unit = "dB[SPL]" # NOTE:: this is a guess + end + else + next + end + + next unless value + + sensor = Interface::Sensor::Detail.new( + type: type, + value: value, + last_seen: time, + mac: formatted_mac, + id: type.to_s, + name: "#{device_name} #{device.device.type} (#{type})", + module_id: module_id, + binding: binding, + unit: unit + ) + + sensor.building = building + sensor.level = level + sensors << sensor + end + + sensors + end + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + filter = type ? SensorType.parse(type) : nil + return NO_MATCH if filter && !filter.in?(IOT_SENSORS) + + if mac + mac = format_mac(mac) + device = devices { |dev| dev[mac]? } + return NO_MATCH unless device + return case device + in IotTelemetry, WebexTelemetryUpdate + to_sensors(zone_id, filter, device) + in DeviceLocationUpdate + NO_MATCH + end + end + + device_values = devices &.values + device_values.flat_map do |device| + case device + in IotTelemetry, WebexTelemetryUpdate + to_sensors(zone_id, filter, device) + in DeviceLocationUpdate + NO_MATCH + end + end + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + + return nil unless id + mac = format_mac(mac) + device = devices { |dev| dev[mac]? } + return nil unless device + + filter = SensorType.parse(id) + case device + in IotTelemetry, WebexTelemetryUpdate + to_sensors(nil, filter, device).first? + in DeviceLocationUpdate + end + end +end diff --git a/drivers/cisco/dna_spaces/user_presence.cr b/drivers/cisco/dna_spaces/user_presence.cr new file mode 100644 index 00000000000..762c06de514 --- /dev/null +++ b/drivers/cisco/dna_spaces/user_presence.cr @@ -0,0 +1,83 @@ +require "./events" + +class Cisco::DNASpaces::UserPresence + include JSON::Serializable + + class User + include JSON::Serializable + + @[JSON::Field(key: "userId")] + getter user_id : String + + @[JSON::Field(key: "deviceIds")] + getter device_ids : Array(String) + getter tags : Array(String) = [] of String + getter mobile : String? + getter email : String? + getter gender : String? + + @[JSON::Field(key: "firstName")] + getter first_name : String? + + @[JSON::Field(key: "lastName")] + getter last_name : String? + + @[JSON::Field(key: "postalCode")] + getter postal_code : String? + + # otherFields + # socialNetworkInfo + end + + class UserCount + include JSON::Serializable + + @[JSON::Field(key: "usersWithUserId")] + getter users_with_user_id : Int64 + + @[JSON::Field(key: "usersWithoutUserId")] + getter users_without_user_id : Int64 + + @[JSON::Field(key: "totalUsers")] + getter total_users : Int64 + end + + @[JSON::Field(key: "presenceEventType")] + getter presence_event_type : String + + @[JSON::Field(key: "wasInActive")] + getter was_in_active : Bool + + getter user : User + getter location : Location + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_datetime : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "activeUsersCount")] + getter active_users_count : UserCount + + @[JSON::Field(key: "inActiveUsersCount")] + getter inactive_users_count : UserCount +end diff --git a/drivers/cisco/dna_spaces/webex_telemetry.cr b/drivers/cisco/dna_spaces/webex_telemetry.cr new file mode 100644 index 00000000000..fef7ac2c336 --- /dev/null +++ b/drivers/cisco/dna_spaces/webex_telemetry.cr @@ -0,0 +1,174 @@ +require "./events" +require "./location" + +# https://partners.dnaspaces.io/docs/v1/basic/c-dnas-firehose-api-references.html#!c-firehose-proto-buf-doc.html +class Cisco::DNASpaces::WebexDeviceInfo + include JSON::Serializable + + @[JSON::Field(key: "deviceId")] + getter id : String + + @[JSON::Field(key: "macAddress")] + property mac_address : String + + @[JSON::Field(key: "ipAddress")] + getter ip_address : String + + # these fields are named to be compatible with the IoT field names + @[JSON::Field(key: "product")] + getter type : String + + @[JSON::Field(key: "displayName")] + getter device_name : String + + @[JSON::Field(key: "serialNumber")] + getter serial_number : String + + @[JSON::Field(key: "softwareVersion")] + getter software_version : String + + @[JSON::Field(key: "workspaceId")] + getter workspace_id : String + + @[JSON::Field(key: "orgId")] + getter org_id : String +end + +struct Cisco::DNASpaces::WebexTelemetry + include JSON::Serializable + + getter presence : Bool? + + @[JSON::Field(key: "peopleCount")] + getter count : Int32? + + @[JSON::Field(key: "soundLevel")] + getter sound_level : Float64? + + @[JSON::Field(key: "airQuality")] + getter air_quality : Float64? + + @[JSON::Field(key: "ambientTemp")] + getter ambient_temp : Float64? + + @[JSON::Field(key: "ambientNoise")] + getter ambient_noise : Float64? + + @[JSON::Field(key: "relativeHumidity")] + getter relative_humidity : Float64? +end + +class Cisco::DNASpaces::WebexTelemetryUpdate + include JSON::Serializable + + @[JSON::Field(key: "deviceInfo")] + property device : WebexDeviceInfo + property location : Location + + @[JSON::Field(ignore_serialize: true)] + property telemetries : Array(WebexTelemetry) { [] of WebexTelemetry } + + getter people_count : Int32 do + telemetries.compact_map(&.count).first? || 0 + end + + getter presence : Bool do + telemetries.compact_map(&.presence).first? || (people_count > 0) + end + + getter humidity : Float64? do + telemetries.compact_map(&.relative_humidity).first? + end + + getter air_quality : Float64? do + telemetries.compact_map(&.air_quality).first? + end + + getter temperature : Float64? do + telemetries.compact_map(&.ambient_temp).first? + end + + getter ambient_noise : Float64? do + telemetries.compact_map(&.ambient_noise).first? + end + + def update_telemetry + telemetries.each do |telemetry| + if !telemetry.presence.nil? + @presence = telemetry.presence + next + end + + if count = telemetry.count + @people_count = count + next + end + + if float = telemetry.relative_humidity + @humidity = float + next + end + + if float = telemetry.air_quality + @air_quality = float + next + end + + if float = telemetry.ambient_temp + @temperature = float + next + end + + if float = telemetry.ambient_noise + @ambient_noise = float + end + end + end + + def binding(type : SensorType, mac : String) + case type + when .presence? + "#{mac}->presence" + when .humidity? + "#{mac}->humidity" + when .air_quality? + "#{mac}->air_quality" + when .people_count? + "#{mac}->people_count" + when .temperature? + "#{mac}->temperature" + when .sound_pressure? + "#{mac}->ambient_noise" + end + end + + @[JSON::Field(ignore: true)] + property last_seen : Int64 do + Time.utc.to_unix_ms + end + + @[JSON::Field(ignore: true)] + property map_id : String = "" + + def visit_id + nil + end + + def raw_user_id : String + "" + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end +end diff --git a/drivers/cisco/dna_spaces_spec.cr b/drivers/cisco/dna_spaces_spec.cr new file mode 100644 index 00000000000..87e6ec0656d --- /dev/null +++ b/drivers/cisco/dna_spaces_spec.cr @@ -0,0 +1,38 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::DNASpaces" do + settings({ + dna_spaces_activation_key: "provide this and the API / tenant ids will be generated automatically", + dna_spaces_api_key: "X-API-KEY", + tenant_id: "sfdsfsdgg", + verify_activation_key: false, + max_location_age: 300, + floorplan_mappings: { + location_a4cb0: { + "level_name" => "optional name", + "building" => "zone-GAsXV0nc", + "level" => "zone-GAsmleH", + "offset_x" => 12.4, + "offset_y" => 5.2, + "map_width" => 50.3, + "map_height" => 100.9, + }, + }, + debug_stream: false, + }) + + # The dashboard should request the streaming API + expect_http_request do |request, response| + headers = request.headers + if headers["X-API-KEY"]? == "X-API-KEY" + response.headers["Transfer-Encoding"] = "chunked" + response.status_code = 200 + response << %({"recordUid":"event-85b84f15","recordTimestamp":1605502585236,"spacesTenantId":"","spacesTenantName":"","partnerTenantId":"","eventType":"KEEP_ALIVE"}) + else + response.status_code = 401 + end + end + + # Should standardise the format of MAC addresses + exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b) +end diff --git a/drivers/cisco/ise/guest_users.cr b/drivers/cisco/ise/guest_users.cr new file mode 100644 index 00000000000..b79e2b81cec --- /dev/null +++ b/drivers/cisco/ise/guest_users.cr @@ -0,0 +1,194 @@ +require "placeos-driver" +require "xml" + +# Tested with Cisco ISE API v2.2 +# NOTE:: DO NOT USE, HERE FOR COMPATIBILITY REASONS +# https://developer.cisco.com/docs/identity-services-engine/3.0/#!guest-user/resource-definition +# However, should work and conform to v1.4 requirements +# https://www.cisco.com/c/en/us/td/docs/security/ise/1-4/api_ref_guide/api_ref_book/ise_api_ref_guest.html#79039 + +class Cisco::Ise::Guests < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco ISE Guest Control" + generic_name :Guests + uri_base "https://ise-pan:9060/ers/config" + + default_settings({ + username: "user", + password: "pass", + portal_id: "Required, ask cisco ISE admins", + timezone: "Australia/Sydney", + guest_type: "Required, ask cisco ISE admins for valid subset of values", # e.g. Contractor + location: "Required for ISE v2.2, ask cisco ISE admins for valid value. Else, remove for ISE v1.4", # e.g. New York + custom_data: {} of String => JSON::Any::Type, + }) + + @basic_auth : String = "" + @portal_id : String = "" + @sms_service_provider : String? = nil + @guest_type : String = "default_guest_type" + @timezone : Time::Location = Time::Location.load("Australia/Sydney") + @location : String? = nil + @custom_data = {} of String => JSON::Any::Type + + TYPE_HEADER = "application/vnd.com.cisco.ise.identity.guestuser.2.0+xml" + TIME_FORMAT = "%m/%d/%Y %H:%M" + + def on_update + @basic_auth = "Basic #{Base64.strict_encode("#{setting?(String, :username)}:#{setting?(String, :password)}")}" + @portal_id = setting?(String, :portal_id) || "portal101" + @guest_type = setting?(String, :guest_type) || "default_guest_type" + @location = setting?(String, :location) + @sms_service_provider = setting?(String, :sms_service_provider) + + time_zone = setting?(String, :timezone).presence + @timezone = Time::Location.load(time_zone) if time_zone + @custom_data = setting?(Hash(String, JSON::Any::Type), :custom_data) || {} of String => JSON::Any::Type + end + + def create_guest( + event_start : Int64, + attendee_email : String, + attendee_name : String, + company_name : String? = nil, # Mandatory but driver will extract from email if not passed + phone_number : String = "0123456789", # Mandatory, use a fake value as default + sms_service_provider : String? = nil, # Use this param to override the setting + guest_type : String? = nil, # Mandatory but use this param to override the setting + portal_id : String? = nil # Mandatory but use this param to override the setting + ) + # Determine the name of the attendee for ISE + guest_names = attendee_name.split + first_name_index_end = guest_names.size > 1 ? -2 : -1 + first_name = guest_names[0..first_name_index_end].join(' ') + last_name = guest_names[-1] + username = genererate_username(first_name, last_name) + + return {"username" => username, "password" => UUID.random.to_s[0..3]}.merge(@custom_data) if setting?(Bool, :test) + + sms_service_provider ||= @sms_service_provider + guest_type ||= @guest_type + portal_id ||= @portal_id + + time_object = Time.unix(event_start).in(@timezone) + from_date = time_object.at_beginning_of_day.to_s(TIME_FORMAT) + to_date = time_object.at_end_of_day.to_s(TIME_FORMAT) + + # If company_name isn't passed + # Hackily grab a company name from the attendee's email (we may be able to grab this from the signal if possible) + company_name ||= attendee_email.split('@')[1].split('.')[0].capitalize + + # Now generate our XML body + xml_string = %( + ) + + # customFields is required for ISE API v2.2 + # since location is also required for 2.2, we can check if location is present + xml_string += %( + ) if @location + + xml_string += %( + + #{from_date}) + + xml_string += %( + #{@location}) if @location + + xml_string += %( + #{to_date} + 1 + + + #{company_name} + #{attendee_email} + #{first_name} + #{last_name} + English + #{phone_number}) + + xml_string += %( + #{sms_service_provider}) if sms_service_provider + + xml_string += %( + #{username} + + #{guest_type} + #{portal_id} + ) + + response = post("/guestuser/", body: xml_string, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success? + + guest_id = response.headers["Location"].split('/').last + guest_crendentials(guest_id).merge(@custom_data) + end + + # Will be 9 characters in length until 2081-08-05 10:16:46.208000000 UTC + # when it will increase to 10 + private def genererate_username(firstname, lastname) + "#{firstname[0].downcase}#{lastname[0].downcase}#{Time.utc.to_unix_ms.to_s(62)}" + end + + def guest_crendentials(id : String) + response = get("/guestuser/#{id}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + parsed_body = XML.parse(response.body) + guest_user = parsed_body.first_element_child.not_nil! + guest_info = guest_user.children.find { |c| c.name == "guestInfo" }.not_nil! + { + "username" => guest_info.children.find { |c| c.name == "userName" }.not_nil!.content, + "password" => guest_info.children.find { |c| c.name == "password" }.not_nil!.content, + } + end + + def test_xml(xml_string : String) + response = post("/guestuser/", body: XML.parse(xml_string).to_s, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success? + end + + def test2 + xml_string = %( + + +08/06/2014 23:22 +08/07/2014 23:22 +1 + + +New Company +john@example.com +John +Doe +English +9999998877 +Global Default +autoguestuser1 + +Daily +sponsor +portal101 +interview +) + test_xml(xml_string) + end + + def test_json(json : String) + response = post("/guestuser/", body: json, headers: { + "Accept" => "application/json", + "Content-Type" => "application/json", + "Authorization" => @basic_auth, + }) + raise "failed to create guest, code #{response.status_code}\n#{response.body}" unless response.success? + end +end diff --git a/drivers/cisco/ise/guest_users_spec.cr b/drivers/cisco/ise/guest_users_spec.cr new file mode 100644 index 00000000000..ac109bd570e --- /dev/null +++ b/drivers/cisco/ise/guest_users_spec.cr @@ -0,0 +1,62 @@ +require "placeos-driver/spec" +require "xml" + +TIME_FORMAT = "%m/%d/%Y %H:%M" + +DriverSpecs.mock_driver "Cisco::Ise::Guests" do + portal = "portal101" + phone = "0123456789" + type = "Contractor" + lo = "New York" + + settings({ + portal_id: portal, + guest_type: type, + location: lo, + }) + + start_time = Time.local(Time::Location.load("Australia/Sydney")) + start_date = start_time.at_beginning_of_day.to_s(TIME_FORMAT) + end_date = start_time.at_end_of_day.to_s(TIME_FORMAT) + attendee_email = "attendee@test.com" + company_name = "PlaceOS" + + sms = "Global Default" + exec(:create_guest, start_time.to_unix, attendee_email, "First Last", company_name, phone, sms, "Daily") + + # POST to /guestuser/ + expect_http_request do |request, response| + parsed_body = XML.parse(request.body.not_nil!) + guest_user = parsed_body.first_element_child.not_nil! + + guest_access_info = guest_user.children.find { |c| c.name == "guestAccessInfo" }.not_nil! + from_date = guest_access_info.children.find { |c| c.name == "fromDate" }.not_nil!.content + from_date.should eq start_date + to_date = guest_access_info.children.find { |c| c.name == "toDate" }.not_nil!.content + to_date.should eq end_date + + guest_info = guest_user.children.find { |c| c.name == "guestInfo" }.not_nil! + company = guest_info.children.find { |c| c.name == "company" }.not_nil!.content + company.should eq company_name + email_address = guest_info.children.find { |c| c.name == "emailAddress" }.not_nil!.content + email_address.should eq attendee_email + first_name = guest_info.children.find { |c| c.name == "firstName" }.not_nil!.content + first_name.should eq "First" + last_name = guest_info.children.find { |c| c.name == "lastName" }.not_nil!.content + last_name.should eq "Last" + phone_number = guest_info.children.find { |c| c.name == "phoneNumber" }.not_nil!.content + phone_number.should eq phone + sms_service_provider = guest_info.children.find { |c| c.name == "smsServiceProvider" }.not_nil!.content + sms_service_provider.should eq sms + + portal_id = guest_user.children.find { |c| c.name == "portalId" }.not_nil!.content + portal_id.should eq portal + + guest_type = guest_user.children.find { |c| c.name == "guestType" }.not_nil!.content + guest_type.should eq "Daily" + + response.status_code = 201 + response.headers["Location"] = "https://ise-pan:9060/ers/config/guestuser/e1bb8290-6ccb-11e3-8cdf-000c29c56fc7" + response.headers["Content-Type"] = "application/xml" + end +end diff --git a/drivers/cisco/ise/models/internal_user.cr b/drivers/cisco/ise/models/internal_user.cr new file mode 100644 index 00000000000..332c4364a70 --- /dev/null +++ b/drivers/cisco/ise/models/internal_user.cr @@ -0,0 +1,41 @@ +require "json" + +class Cisco::Ise::Models::InternalUser + include JSON::Serializable + + @[JSON::Field(key: "name")] + property name : String + + @[JSON::Field(key: "id")] + property id : String? + + @[JSON::Field(key: "identityGroups")] + property identity_groups : String? + + @[JSON::Field(key: "description")] + property description : String? + + @[JSON::Field(key: "changePassword")] + property change_password : Bool = false + + @[JSON::Field(key: "email")] + property email : String? + + @[JSON::Field(key: "enabled")] + property enabled : Bool = true + + @[JSON::Field(key: "customAttributes")] + property custom_attributes : Hash(String, String) = {} of String => String + + @[JSON::Field(key: "firstName")] + property first_name : String? + + @[JSON::Field(key: "lastName")] + property last_name : String? + + @[JSON::Field(key: "password")] + property password : String? + + @[JSON::Field(key: "passwordIDStore")] + property password_store : String = "Internal Users" +end diff --git a/drivers/cisco/ise/network_access.cr b/drivers/cisco/ise/network_access.cr new file mode 100644 index 00000000000..9200c63f747 --- /dev/null +++ b/drivers/cisco/ise/network_access.cr @@ -0,0 +1,293 @@ +require "placeos-driver" +require "./models/internal_user" +require "uuid" + +require "../../place/password_generator_helper" + +# Tested with Cisco ISE API v3.1 +# https://developer.cisco.com/docs/identity-services-engine/v1/#!internaluser + +class Cisco::Ise::NetworkAccess < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco ISE REST API" + generic_name :NetworkAccess + uri_base "https://ise-pan:9060/ers/config" + + default_settings({ + username: "user", + password: "pass", + portal_id: "Required for Guest Users, ask cisco ISE admins", + timezone: "UTC", + guest_type: "Required for Guest Users, ask cisco ISE admins for valid subset of values", # e.g. Contractor + custom_data: {} of String => String, + password_length: DEFAULT_PASSWORD_LENGTH, + password_exclude: DEFAULT_PASSWORD_EXCLUDE, + password_minimum_lowercase: DEFAULT_PASSWORD_MINIMUM_LOWERCASE, + password_minimum_uppercase: DEFAULT_PASSWORD_MINIMUM_UPPERCASE, + password_minimum_numbers: DEFAULT_PASSWORD_MINIMUM_NUMBERS, + password_minimum_symbols: DEFAULT_PASSWORD_MINIMUM_SYMBOLS, + debug: false, + test_mode: false, + }) + + @basic_auth : String = "" + @portal_id : String = "" + @sms_service_provider : String? = nil + @guest_type : String = "default_guest_type" + @password_length : Int32 = DEFAULT_PASSWORD_LENGTH + @password_exclude : String = DEFAULT_PASSWORD_EXCLUDE + @password_minimum_lowercase : Int32 = DEFAULT_PASSWORD_MINIMUM_LOWERCASE + @password_minimum_uppercase : Int32 = DEFAULT_PASSWORD_MINIMUM_UPPERCASE + @password_minimum_numbers : Int32 = DEFAULT_PASSWORD_MINIMUM_NUMBERS + @password_minimum_symbols : Int32 = DEFAULT_PASSWORD_MINIMUM_SYMBOLS + @timezone : Time::Location = Time::Location.load("Australia/Sydney") + @custom_data = {} of String => String + + TYPE_HEADER = "application/json" + TIME_FORMAT = "%m/%d/%Y %H:%M" + + def on_update + username = setting?(String, :username) + password = setting?(String, :password) + + @basic_auth = ["Basic", Base64.strict_encode([username, password].join(":"))].join(" ") + + @debug = setting?(Bool, :debug) || false + @test_mode = setting?(Bool, :test) || false + + @portal_id = setting?(String, :portal_id) || "portal101" + @guest_type = setting?(String, :guest_type) || "default_guest_type" + @sms_service_provider = setting?(String, :sms_service_provider) + @password_length = setting?(Int32, :password_length) || DEFAULT_PASSWORD_LENGTH + @password_exclude = setting?(String, :password_exclude) || DEFAULT_PASSWORD_EXCLUDE + @password_minimum_lowercase = setting?(Int32, :password_minimum_lowercase) || DEFAULT_PASSWORD_MINIMUM_LOWERCASE + @password_minimum_uppercase = setting?(Int32, :password_minimum_uppercase) || DEFAULT_PASSWORD_MINIMUM_UPPERCASE + @password_minimum_numbers = setting?(Int32, :password_minimum_numbers) || DEFAULT_PASSWORD_MINIMUM_NUMBERS + @password_minimum_symbols = setting?(Int32, :password_minimum_symbols) || DEFAULT_PASSWORD_MINIMUM_SYMBOLS + + time_zone = setting?(String, :timezone).presence + @timezone = Time::Location.load(time_zone) if time_zone + @custom_data = setting?(Hash(String, String), :custom_data) || {} of String => String + + logger.debug { "Basic auth details: #{@basic_auth}" } if @debug + end + + def create_internal_user( + email : String, + name : String? = nil, + first_name : String? = nil, + last_name : String? = nil, + description : String? = nil, + password : String? = nil, + identity_groups : Array(String) = [] of String + ) + name ||= email + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + + internal_user = Models::InternalUser.from_json( + { + name: name, + email: email, + password: password, + firstName: first_name, + lastName: last_name, + description: description, # custom_attributes: custom_attributes + identityGroups: identity_groups.join(","), + }.to_json) + + logger.debug { "Creating Internal User: #{internal_user.to_json}" } if @debug + + response = post("/internaluser/", body: {"InternalUser" => internal_user}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "Failed to create internal user, code #{response.status_code}\n#{response.body}" unless response.success? + + user = get_internal_user_by_name(name) + user.password = password + user + end + + def get_internal_user_by_id(id : String) + response = get("/internaluser/#{id}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by id, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + internal_user + end + + def get_internal_user_by_name(name : String) + response = get("/internaluser/name/#{name}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by name, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + internal_user + end + + def get_internal_user_by_email(email : String) + response = get("/internaluser/?filter=email.CONTAINS.#{email}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + + resources = parsed_body["SearchResult"].as_h.["resources"].as_a + + raise "returned body has no resources" if resources.empty? + + get_internal_user_by_id(resources.first.as_h.["id"].to_s) + end + + def update_internal_user_password_by_id(id : String, password : String? = nil) + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + + response = put("/internaluser/#{id}", body: {"InternalUser" => {"password" => password}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed: #{response.status_code}: #{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_password_by_name(name : String, password : String? = nil) + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + + response = put("/internaluser/name/#{name}", body: {"InternalUser" => {"password" => password}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed: #{response.status_code}: #{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_password_by_email(email : String, password : String? = nil) + password ||= generate_password( + length: @password_length, + exclude: @password_exclude, + minimum_lowercase: @password_minimum_lowercase, + minimum_uppercase: @password_minimum_uppercase, + minimum_numbers: @password_minimum_numbers, + minimum_symbols: @password_minimum_symbols + ) + internal_user = get_internal_user_by_email(email) + + update_internal_user_password_by_id(internal_user.id.to_s, password) + end + + def update_internal_user_identity_groups_by_id(id : String, identity_groups : Array(String)) + internal_user = get_internal_user_by_id(id) + + response = put("/internaluser/#{internal_user.id}", body: {"InternalUser" => {"identityGroups" => identity_groups.join(",")}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_identity_groups_by_name(name : String, identity_groups : Array(String)) + response = put("/internaluser/name/#{name}", body: {"InternalUser" => {"identityGroups" => identity_groups.join(",")}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed: #{response.status_code}: #{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_identity_groups_by_email(email : String, identity_groups : Array(String)) + internal_user = get_internal_user_by_email(email) + + update_internal_user_identity_groups_by_id(internal_user.id.to_s, identity_groups) + end + + # Todo, when ISE doesn't return 401 for Guest related api calls + # def create_guest (...) + # # sms_service_provider ||= @sms_service_provider + # # guest_type ||= @guest_type + # # portal_id ||= @portal_id + + # # time_object = Time.unix(event_start).in(@timezone) + # # from_date = time_object.at_beginning_of_day.to_s(TIME_FORMAT) + # # to_date = time_object.at_end_of_day.to_s(TIME_FORMAT) + + # # If company_name isn't passed + # # Hackily grab a company name from the attendee's email (we may be able to grab this from the signal if possible) + # # company_name ||= attendee_email.split('@')[1].split('.')[0].capitalize + + # These custom attributes and any custom attribute needs to be predefined + # in the ISE GUI. + # custom_attributes = { + # "fromDate" => from_date, + # "toDate" => to_date, + # "location" => @location.to_s, + # "companyName" => company_name, + # "phoneNumber" => phone_number, + # "smsServiceProvider" => sms_service_provider.to_s, + # "guestType" => guest_type, + # "portalId" => portal_id, + # } of String => String + + # custom_attributes.merge!(@custom_data) + # end +end diff --git a/drivers/cisco/ise/network_access_spec.cr b/drivers/cisco/ise/network_access_spec.cr new file mode 100644 index 00000000000..5f0b76aa78d --- /dev/null +++ b/drivers/cisco/ise/network_access_spec.cr @@ -0,0 +1,39 @@ +require "placeos-driver" +require "./network_access" +require "./models/internal_user" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Ise::NetworkAccess" do + # Mock data for GUEST Users only + # portal = "portal101" + # phone = "0123456789" + # type = "Contractor" + # lo = "New York" + # settings({ + # portal_id: portal, + # guest_type: type, + # location: lo, + # }) + # start_time = Time.local(Time::Location.load("Australia/Sydney")) + # start_date = start_time.at_beginning_of_day.to_s(Cisco::Ise::NetworkAccess::TIME_FORMAT) + # end_date = start_time.at_end_of_day.to_s(Cisco::Ise::NetworkAccess::TIME_FORMAT) + + # Test INTERNAL User creation + attendee_email = "attendee@test.com" + exec(:create_internal, email: attendee_email, name: attendee_email) # The attendee name must be unique, and in most real-world use cases, the clients prefer that to be the email address + # POST to /internaluser/ + expect_http_request do |request, response| + parsed_body = JSON.parse(request.body.not_nil!) + internal_user = Cisco::Ise::Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + email_address = internal_user.email + email_address.should eq attendee_email + + name = internal_user.name + name.should eq attendee_email + + response.status_code = 201 + response.headers["Location"] = "https://ise-pan:9060/ers/config/internaluser/e1bb8290-6ccb-11e3-8cdf-000c29c56fc7" + response.headers["Content-Type"] = "application/xml" + end +end diff --git a/drivers/cisco/meraki/captive_portal.cr b/drivers/cisco/meraki/captive_portal.cr new file mode 100644 index 00000000000..acf9d316f7f --- /dev/null +++ b/drivers/cisco/meraki/captive_portal.cr @@ -0,0 +1,138 @@ +require "json" +require "openssl" +require "placeos-driver" + +class Cisco::Meraki::CaptivePortal < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Meraki Captive Portal" + generic_name :CaptivePortal + description %( + for more information visit: https://meraki.cisco.com/lib/pdf/meraki_whitepaper_captive_portal.pdf + ) + + default_settings({ + wifi_secret: "anything really", + default_timezone: "Australia/Sydney", + date_format: "%Y%m%d", + # duration of access in hours + access_duration: 12, + # Length of the clients wifi code + code_length: 4, + success_url: "https://company.com/welcome", + }) + + @wifi_secret : String = "" + @date_format : String = "%Y%m%d" + @success_url : String = "https://place.technology/" + @default_timezone : Time::Location = Time::Location.load("Australia/Sydney") + @access_duration : Time::Span = 12.hours + @code_length : Int32 = 4 + + @denied : UInt64 = 0_u64 + @granted : UInt64 = 0_u64 + @errors : UInt64 = 0_u64 + + @guests : Hash(String, ChallengePayload) = {} of String => ChallengePayload + + def on_update + @wifi_secret = setting?(String, :wifi_secret) || "anything really" + @date_format = setting?(String, :date_format) || "%Y%m%d" + @success_url = setting?(String, :success_url) || "https://place.technology/" + @access_duration = (setting?(Int32, :access_duration) || 12).hours + @code_length = setting?(Int32, :code_length) || 4 + + time_zone = setting?(String, :default_timezone).presence + @default_timezone = Time::Location.load(time_zone) if time_zone + end + + @[Security(Level::Support)] + def guests + @guests + end + + @[Security(Level::Support)] + def lookup(mac : String) + @guests[format_mac(mac)] + end + + def generate_guest_data(email : String, time : Int64, time_zone : String? = nil) + time_zone = time_zone.presence ? Time::Location.load(time_zone.not_nil!) : @default_timezone + date = Time.unix(time).in(time_zone).to_s(@date_format) + guest_string = "#{email.downcase}-#{date}-#{@wifi_secret}" + + OpenSSL::Digest.new("SHA256").update(guest_string).final.hexstring + end + + # Splits the SHA256 into code length and then randomly selects one + def generate_guest_token(email : String, time : Int64, time_zone : String? = nil) + generate_guest_data(email, time, time_zone).scan(/.{#{@code_length}}/).sample(1)[0][0] + end + + class ChallengePayload + include JSON::Serializable + + property ap_mac : String + property client_ip : String + property client_mac : String + property base_grant_url : String + property user_continue : String? + + # key they were provided in their invite email + property code : String + property email : String + property timezone : String? + + property expires : Time? = nil + end + + EMPTY_HEADERS = {} of String => String + JSON_HEADERS = { + "Content-Type" => "application/json", + } + + # Webhook for providing guest access + def challenge(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "guest access attempt: #{method},\nheaders #{headers},\nbody #{body}" } + + challenge = ChallengePayload.from_json(body) + + check_code = challenge.code + guest_codes = generate_guest_data(challenge.email, Time.utc.to_unix, challenge.timezone) + matched = guest_codes.scan(/.{#{@code_length}}/).count { |code| code[0] == check_code } > 0 + + if matched + challenge.expires = @access_duration.from_now + @guests[format_mac(challenge.client_mac)] = challenge + @granted += 1_u64 + self[:granted_access] = @granted + + redirect_url = "#{challenge.base_grant_url}?duration=#{@access_duration.to_i}&continue_url=#{challenge.user_continue || @success_url}" + response = { + redirect_to: redirect_url, + }.to_json + + logger.debug { "successful joined network #{challenge.inspect}" } + + # Redirect to the success URL + {HTTP::Status::OK, JSON_HEADERS, response} + else + @denied += 1_u64 + self[:denied_access] = @denied + + logger.debug { "failed wifi access attempt by #{challenge.inspect}" } + + {HTTP::Status::NOT_ACCEPTABLE, JSON_HEADERS, "{}"} + end + rescue error + @errors += 1_u64 + self[:errors] = @errors + last_error = error.inspect_with_backtrace + self[:last_error] = last_error + logger.error { "failed to parse wifi challenge payload\n#{error}" } + {HTTP::Status::INTERNAL_SERVER_ERROR, EMPTY_HEADERS, nil} + end + + protected def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end +end diff --git a/drivers/cisco/meraki/captive_portal_spec.cr b/drivers/cisco/meraki/captive_portal_spec.cr new file mode 100644 index 00000000000..11b01cf020b --- /dev/null +++ b/drivers/cisco/meraki/captive_portal_spec.cr @@ -0,0 +1,16 @@ +require "openssl" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Meraki::CaptivePortal" do + date = Time.unix(1599477274).in(Time::Location.load("Australia/Sydney")).to_s("%Y%m%d") + hexdigest = OpenSSL::Digest.new("SHA256").update("guest@email.com-#{date}-anything really").final.hexstring + + # Check the hex codes match + retval = exec(:generate_guest_data, "guest@email.com", 1599477274, "Australia/Sydney") + retval.get.should eq hexdigest + + # check it matches on of the codes + codes = hexdigest.scan(/.{4}/).map { |code| code[0] } + retval = exec(:generate_guest_token, "guest@email.com", 1599477274, "Australia/Sydney") + codes.includes?(retval.get.not_nil!.as_s).should eq true +end diff --git a/drivers/cisco/meraki/dashboard.cr b/drivers/cisco/meraki/dashboard.cr new file mode 100644 index 00000000000..9e5c8b2edf7 --- /dev/null +++ b/drivers/cisco/meraki/dashboard.cr @@ -0,0 +1,252 @@ +require "uri" +require "json" +require "link-header" +require "placeos-driver" +require "./scanning_api" + +class Cisco::Meraki::Dashboard < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Meraki Dashboard" + generic_name :Dashboard + uri_base "https://api.meraki.com" + description %( + for more information visit: + * Dashboard API: https://documentation.meraki.com/zGeneral_Administration/Other_Topics/The_Cisco_Meraki_Dashboard_API + * Scanning API: https://developer.cisco.com/meraki/scanning-api/#!introduction/scanning-api + + NOTE:: API Call volume is rate limited to 5 calls per second per organization. + ) + + default_settings({ + meraki_validator: "configure if scanning API is enabled", + meraki_secret: "configure if scanning API is enabled", + meraki_api_key: "configure for the dashboard API", + + # Max requests a second made to the dashboard + rate_limit: 4, + debug_payload: false, + + # filter message type + scanning_api_filter: "WiFi", + }) + + def on_load + spawn { rate_limiter } + on_update + end + + def on_unload + @channel.close + end + + @scanning_validator : String = "" + @scanning_secret : String = "" + @api_key : String = "" + @scanning_api_filter : MessageType = MessageType::WiFi + + @rate_limit : Int32 = 4 + @channel : Channel(Nil) = Channel(Nil).new(1) + @queue_lock : Mutex = Mutex.new + @queue_size = 0 + @wait_time : Time::Span = 300.milliseconds + + @debug_payload : Bool = false + + def on_update + @scanning_validator = setting?(String, :meraki_validator) || "" + @scanning_secret = setting?(String, :meraki_secret) || "" + @api_key = setting?(String, :meraki_api_key) || "" + @scanning_api_filter = setting?(MessageType, :scanning_api_filter) || MessageType::WiFi + + @rate_limit = setting?(Int32, :rate_limit) || 4 + @wait_time = 1.second / @rate_limit + + @debug_payload = setting?(Bool, :debug_payload) || false + end + + # Perform fetch with the required API request limits in place + @[Security(PlaceOS::Driver::Level::Support)] + def fetch(location : String) + req(location, &.body) + end + + @[Security(PlaceOS::Driver::Level::Support)] + def fetch_all(location : String) + responses = [] of String + req_all_pages(location) { |response| responses << response.body } + responses + end + + protected def req(location : String) + if (@wait_time * @queue_size) > 10.seconds + raise "wait time would be exceeded for API request, #{@queue_size} requests already queued" + end + + @queue_lock.synchronize { @queue_size += 1 } + @channel.receive + @queue_lock.synchronize { @queue_size -= 1 } + + headers = HTTP::Headers{ + "X-Cisco-Meraki-API-Key" => @api_key, + "Content-Type" => "application/json", + "Accept" => "application/json", + "User-Agent" => "PlaceOS/2.0 PlaceTechnology", + } + + uri = URI.parse(location) + response = if uri.host.nil? + get(location, headers: headers) + else + HTTP::Client.get(location, headers: headers) + end + + if response.success? + yield response + elsif response.status.found? + # Meraki might return a `302` on GET requests + response = HTTP::Client.get(response.headers["Location"], headers: headers) + if response.success? + yield response + else + raise "request #{location} failed with status: #{response.status_code}" + end + else + raise "request #{location} failed with status: #{response.status_code}" + end + end + + protected def req_all_pages(location : String) : Nil + next_page = location + + loop do + break unless next_page + + next_page = req(next_page) do |response| + yield response + LinkHeader.new(response)["next"]? + end + end + end + + EMPTY_HEADERS = {} of String => String + SUCCESS_RESPONSE = {HTTP::Status::OK.to_i, EMPTY_HEADERS, nil} + + @[Security(PlaceOS::Driver::Level::Support)] + def organizations + req("/api/v1/organizations?perPage=1000") do |response| + Array(Organization).from_json(response.body) + end + end + + @[Security(PlaceOS::Driver::Level::Support)] + def networks(organization_id : String) + nets = [] of Network + req_all_pages("/api/v1/organizations/#{organization_id}/networks?perPage=1000") do |response| + nets.concat Array(Network).from_json(response.body) + end + nets + end + + @[Security(PlaceOS::Driver::Level::Support)] + def poll_clients( + network_id : String? = nil, + timespan : UInt32 = 900_u32, + connection : ConnectionType? = nil, + device_serial : String? = nil, + statuses : String = "Online" + ) + params = URI::Params.build do |form| + form.add "perPage", "1000" + form.add "timespan", timespan.to_s + form.add "statuses[]", statuses + form.add "recentDeviceConnections[]", connection.to_s if connection + end + + clients = [] of Client + req_all_pages "/api/v1/networks/#{network_id}/clients?#{params}" do |response| + clients.concat Array(Client).from_json(response.body) + end + + if device_serial + clients.select! { |client| client.recent_device_serial == device_serial }.sort! { |a, b| b.last_seen <=> a.last_seen } + else + clients.sort! { |a, b| b.last_seen <=> a.last_seen } + end + end + + def ports_statuses(device_serial : String) + req("/api/v1/devices/#{device_serial}/switch/ports/statuses") do |response| + Array(PortStatusResponse).from_json(response.body) + end + end + + def get_zones(camera_serial : String) + req("/api/v1/devices/#{camera_serial}/camera/analytics/zones") do |response| + Array(CameraZone).from_json(response.body) + end + end + + # Webhook endpoint for scanning API, expects version 3 + def scanning_api(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "scanning API received: #{method},\nheaders #{headers},\nbody size #{body.size}" } + logger.debug { body } if @debug_payload + + # Return the scanning API validator code on a GET request + return {HTTP::Status::OK.to_i, EMPTY_HEADERS, @scanning_validator} if method == "GET" + + # Check the version matches + if !body.starts_with?(%({"version":"3.0")) + logger.warn { "unknown scanning API message received:\n#{body[0..96]}" } + return SUCCESS_RESPONSE + end + + # Parse the data posted + begin + seen = DevicesSeen.from_json(body) + logger.debug { "parsed meraki payload" } + + # filter out observations we're not interested in + if !@scanning_api_filter.none? && seen.message_type != @scanning_api_filter + logger.debug { "ignoring message type: #{seen.message_type}" } + return SUCCESS_RESPONSE + end + + # Check the secret matches + raise "secret mismatch, sent: #{seen.secret}" unless seen.secret == @scanning_secret + + self[seen.data.network_id] = seen.data.observations + rescue e + logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" } + logger.debug { "failed payload body was\n#{body}" } + end + + # Return a 200 response + SUCCESS_RESPONSE + end + + # a webhook for obtaining changes in port status + def port_status(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "Webhook Alert received: #{method},\nheaders #{headers},\nbody #{body}" } + + self[:port_update] = WebhookAlert.from_json(body) + + # Return a 200 response + SUCCESS_RESPONSE + end + + protected def rate_limiter + loop do + break if @channel.closed? + begin + @channel.send(nil) + rescue error + logger.error(exception: error) { "issue with rate limiter" } + ensure + sleep @wait_time + end + end + rescue + # Possible error with logging exception, restart rate limiter silently + spawn { rate_limiter } unless @channel.closed? + end +end diff --git a/drivers/cisco/meraki/dashboard_spec.cr b/drivers/cisco/meraki/dashboard_spec.cr new file mode 100644 index 00000000000..b5ca78a6486 --- /dev/null +++ b/drivers/cisco/meraki/dashboard_spec.cr @@ -0,0 +1,21 @@ +require "./scanning_api" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Meraki::Dashboard" do + # Send the request + retval = exec(:fetch, "/api/v0/organizations") + + # The dashboard should send a HTTP request with the API key + expect_http_request do |request, response| + headers = request.headers + if headers["X-Cisco-Meraki-API-Key"]? == "configure for the dashboard API" + response.status_code = 202 + response << %([{"id":"org id","name":"place tech"}]) + else + response.status_code = 401 + end + end + + # Should return the payload + retval.get.should eq %([{"id":"org id","name":"place tech"}]) +end diff --git a/drivers/cisco/meraki/geo.cr b/drivers/cisco/meraki/geo.cr new file mode 100644 index 00000000000..cf2c419721c --- /dev/null +++ b/drivers/cisco/meraki/geo.cr @@ -0,0 +1,73 @@ +require "math" +require "json" + +module Cisco; end + +module Cisco::Meraki; end + +module Cisco::Meraki::Geo + struct Point + include JSON::Serializable + + def initialize(@lat, @lng) + end + + property lat : Float64 + property lng : Float64 + end + + struct Distance + include JSON::Serializable + + def initialize(@x, @y) + end + + property x : Float64 + property y : Float64 + end + + def self.calculate_xy(top_left : Point, bottom_left : Point, bottom_right : Point, position, distance : Distance) + y_base = geo_distance(top_left, bottom_left) + a = geo_distance(top_left, position) + c = geo_distance(bottom_left, position) + x_raw = triangle_height(a, y_base, c) + + x_base = geo_distance(bottom_left, bottom_right) + a = geo_distance(bottom_left, position) + c = geo_distance(bottom_right, position) + y_raw = triangle_height(a, x_base, c) + + # find the percentage distance from the origin + percentage_height = y_raw / y_base + percentage_width = x_raw / x_base + + # adjust into range provided by the original distances + Distance.new(distance.x * percentage_width, distance.y * percentage_height) + end + + # radius in meters, approx as we're using a perfect sphere the same volume as the earth + EarthRadiusApprox = 6371000.7900_f64 + Radians = Math::PI / 180_f64 + + # https://www.movable-type.co.uk/scripts/latlong.html + # returns the distance in meters + def self.geo_distance(start : Point, ending) + lat_diff = (ending.lat - start.lat) * Radians + lng_diff = (ending.lng - start.lng) * Radians + start_lat_radian = start.lat * Radians + end_lng_radian = ending.lng * Radians + + a = Math.sin(lat_diff / 2_f64) * Math.sin(lat_diff / 2_f64) + + Math.cos(start_lat_radian) * Math.cos(end_lng_radian) * + Math.sin(lng_diff / 2_f64) * Math.sin(lng_diff / 2_f64) + + c = 2_f64 * Math.atan2(Math.sqrt(a), Math.sqrt(1_f64 - a)) + + EarthRadiusApprox * c + end + + # https://www.omnicalculator.com/math/triangle-height + def self.triangle_height(a : Float64, base : Float64, c : Float64) + 0.5_f64 * Math.sqrt((a + base + c) * (base + c - a) * (a - base + c) * (a + base - c)) / base + end +end diff --git a/drivers/cisco/meraki/meraki_locations.cr b/drivers/cisco/meraki/meraki_locations.cr new file mode 100644 index 00000000000..11347efa2a0 --- /dev/null +++ b/drivers/cisco/meraki/meraki_locations.cr @@ -0,0 +1,1337 @@ +require "placeos-driver" +require "json" +require "s2_cells" +require "./mqtt_models" +require "./scanning_api" +require "../../place/area_polygon" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" + +class Cisco::Meraki::Locations < PlaceOS::Driver + include Interface::Locatable + include Interface::Sensor + + # Discovery Information + descriptive_name "Meraki Location Service" + generic_name :MerakiLocations + + description %(requires meraki dashboard driver for API calls) + + accessor dashboard : Dashboard_1 + accessor staff_api : StaffAPI_1 + accessor area_manager : AreaManagement_1 + + default_settings({ + # We will always accept a reading with a confidence lower than this + acceptable_confidence: 5.0, + + # Max Uncertainty in meters - we don't accept positions that are less certain + maximum_uncertainty: 25.0, + + # For confident yet inaccurate location data/maps. If a location's variance is below this threshold, increase it to this value. + # 0.0 disables the override + override_min_variance: 0.0, + + # Optionally only store locations for devices whose "os" property matches this regex string. + regex_filter_device_os: nil, + + # can we use the meraki dashboard API for user lookups + default_network_id: "network_id", + + # Area index each point on a floor lands on + # 21 == ~4 meters squared, which given wifi variance is good enough for tracing + # S2 cell levels: https://s2geometry.io/resources/s2cell_statistics.html + s2_level: 21, + debug_payload: false, + debug_webhook: false, + + # Level mappings, level name for human readability + floorplan_mappings: { + "g_727894289773756672" => { + "building": "zone-12345", + "level": "zone-123456", + "level_name": "BUILDING - L1", + }, + }, + + # Time before a user location is considered probably too old + max_location_age: 10, + + # Ignore certain usernames from the dashboard + ignore_usernames: ["host/"], + + # Enable / Disable dashboard username lookup completely + disable_username_lookup: false, + + # Where desks have no occupancy + return_empty_spaces: true, + + # wired desks mappings + wired_desks: [ + { + serial: "switch-serial-number", + level_id: "zone-1234", + ports: { + 11 => "desk-1234", + 12 => "desk-5678", + }, + }, + ], + }) + + alias WiredDesks = Hash(String, Hash(Int32, String)) + + def on_load + # We want to store our user => mac_address mappings in redis + @user_mac_mappings = PlaceOS::Driver::RedisStorage.new(module_id, "user_macs") + on_update + end + + @acceptable_confidence : Float64 = 5.0 + @maximum_uncertainty : Float64 = 25.0 + @override_min_variance : Float64 = 0.0 + @regex_filter_device_os : String? = nil + getter building_zone : String = "searching..." + + @time_multiplier : Float64 = 0.0 + @confidence_multiplier : Float64 = 0.0 + @max_location_age : Time::Span = 6.minutes + @drift_location_age : Time::Span = 4.minutes + @confidence_time : Time::Span = 2.minutes + + @storage_lock : Mutex = Mutex.new + @user_mac_mappings : PlaceOS::Driver::RedisStorage? = nil + @default_network : String = "" + @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new + @floorplan_sizes = {} of String => FloorPlan + @network_devices = {} of String => NetworkDevice + + @s2_level : Int32 = 21 + @ignore_usernames : Array(String) = [] of String + @return_empty_spaces : Bool = true + + @debug_payload : Bool = false + @debug_webhook : Bool = false + + def on_update + @default_network = setting?(String, :default_network_id) || "" + @return_empty_spaces = setting?(Bool, :return_empty_spaces) || false + + @acceptable_confidence = setting?(Float64, :acceptable_confidence) || 5.0 + @maximum_uncertainty = setting?(Float64, :maximum_uncertainty) || 25.0 + @override_min_variance = setting?(Float64, :override_min_variance) || 0.0 + @regex_filter_device_os = setting?(String, :regex_filter_device_os) + + @max_location_age = (setting?(UInt32, :max_location_age) || 6).minutes + # Age we keep a confident value (without drifting towards less confidence) + @confidence_time = @max_location_age / 3 + # Age at which we discard a drifting value (accepting a less confident value) + @drift_location_age = @max_location_age - @confidence_time + + # How much confidence do we have in this new value, relative to an old confident value + @time_multiplier = 1.0_f64 / (@drift_location_age.to_i - @confidence_time.to_i).to_f64 + @confidence_multiplier = 1.0_f64 / (@maximum_uncertainty.to_i - @acceptable_confidence.to_i).to_f64 + + @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings + + @s2_level = setting?(Int32, :s2_level) || 21 + @debug_payload = setting?(Bool, :debug_payload) || false + @debug_webhook = setting?(Bool, :debug_webhook) || false + @ignore_usernames = setting?(Array(String), :ignore_usernames) || [] of String + disable_username_lookup = setting?(Bool, :disable_username_lookup) || false + + schedule.clear + + # Wired desk data + init_wired_port_mappings + + if @default_network.presence + schedule.every(59.seconds) { update_sensor_cache } + schedule.every(2.minutes) { map_users_to_macs } unless disable_username_lookup + schedule.every(29.minutes) { sync_floorplan_sizes } + + schedule.in(30.milliseconds) do + sync_floorplan_sizes + update_sensor_cache + end + end + schedule.every(30.minutes) { cleanup_caches } + + subscriptions.clear + if @default_network.presence + dashboard.subscribe(@default_network) do |_subscription, new_value| + # values are always raw JSON strings + parse_new_locations(new_value) + end + end + + zones = config.control_system.not_nil!.zones + spawn { find_building(zones) } + + # Grab desk data from the MQTT connection + if system.exists? :MerakiMQTT + mqtt_module = system[:MerakiMQTT] + mqtt_module.subscribe(:floor_lookup) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + @floor_lookup = Hash(String, FloorMapping).from_json(new_value) + update_desk_mappings unless @zone_lookup.empty? + end + mqtt_module.subscribe(:zone_lookup) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + @zone_lookup = Hash(String, Array(String)).from_json(new_value) + update_desk_mappings unless @floor_lookup.empty? + end + schedule.every(10.minutes) { update_desk_mappings } + mqtt_module.subscribe(:camera_updated) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + _time, camera_serial = Tuple(Int64, String).from_json(new_value) + + if @desk_mappings.has_key? camera_serial + check_camera_status(mqtt_module, camera_serial) + end + end + end + end + + # grab building zone from the current system + protected def find_building(zones : Array(String)) : Nil + zones.each do |zone_id| + zone = ZoneDetails.from_json staff_api.zone(zone_id).get.to_json + if zone.tags.includes?("building") + @building_zone = zone.id + break + end + end + raise "no building zone found in System" unless @building_zone + rescue error + logger.warn(exception: error) { "error looking up building zone" } + schedule.in(5.seconds) { find_building(zones) } + end + + protected def check_camera_status(mqtt_module, camera_serial) + detected_desks = mqtt_module.status(DetectedDesks, "camera_#{camera_serial}_desks") + desk_details[camera_serial] = detected_desks + average_results(camera_serial, detected_desks) + if lux_level = mqtt_module.status?(Float64, "camera_#{camera_serial}_lux") + lux[camera_serial] = lux_level + end + end + + # serial => desks detected + getter desk_details : Hash(String, DetectedDesks) = {} of String => DetectedDesks + + # serial => lux + getter lux : Hash(String, Float64) = {} of String => Float64 + + protected def user_mac_mappings + @storage_lock.synchronize { + yield @user_mac_mappings.not_nil! + } + end + + protected def req(location : String) + response = dashboard.fetch(location).get.as_s + begin + yield response + rescue error + logger.debug(exception: error) { "processing failed for #{location} with response: #{response}" } + raise error + end + end + + protected def req_all(location : String) + dashboard.fetch_all(location).get.as_a.each { |resp| yield resp.as_s } + end + + struct Lookup + include JSON::Serializable + + property time : Time + property mac : String + + def initialize(@time, @mac) + end + end + + # MAC Address => Location + @locations : Hash(String, DeviceLocation) = {} of String => DeviceLocation + @ip_lookup : Hash(String, Lookup) = {} of String => Lookup + + def lookup_ip(address : String) + @ip_lookup[address.downcase]? + end + + def locate_mac(address : String) + @locations[format_mac(address)]? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_foorplans + @floorplan_sizes + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_network_devices + @network_devices + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_state + logger.debug { + "IP Mappings: #{@ip_lookup.keys}\n\nMAC Locations: #{@locations.keys}\n\nClient Details: #{@client_details.keys}" + } + {ip_mappings: @ip_lookup.size, tracking: @locations.size, client_details: @client_details.size} + end + + # Returns the list of users who can be located + @[Security(PlaceOS::Driver::Level::Support)] + def locateable + too_old = @max_location_age.ago + @client_details.compact_map do |mac, client| + location = @locations[mac]? + client.user if location && ((location.time > too_old) || (client.time_added > too_old)) + end + end + + @[Security(PlaceOS::Driver::Level::Support)] + def poll_clients( + network_id : String? = nil, + timespan : UInt32 = 900_u32, + connection : ConnectionType? = nil, + device_serial : String? = nil + ) + network_id = network_id.presence || @default_network + Array(Client).from_json dashboard.poll_clients(network_id, timespan, connection, device_serial).get.to_json + end + + @client_details : Hash(String, Client) = {} of String => Client + + @[Security(PlaceOS::Driver::Level::Support)] + def map_users_to_macs(network_id : String? = nil) + network_id = network_id.presence || @default_network + + logger.debug { "mapping users to device MACs" } + clients = poll_clients(network_id) + + new_devices = 0 + updated_dev = 0 + now = Time.utc + + logger.debug { "mapping found #{clients.size} devices" } + + user_mac_mappings do |storage| + clients.each do |client| + # So we can merge additional details into device location responses + user_mac = format_mac(client.mac) + client.time_added = now + + # Store the mac to hostname lookups to help with learning + if hostname = client.description + @mac_hostnames[user_mac] = hostname + end + + user_id = client.user + + if user_id + @ignore_usernames.each do |name| + if user_id.starts_with?(name) + client.user = user_id = nil + break + end + end + end + + # Attempt to lookup username via learning + if user_id.nil? + if known_id = storage[user_mac]? + client.user = known_id + end + end + + @client_details[user_mac] = client + next unless user_id + + was_update, was_new = map_user_mac(user_mac, user_id, storage) + updated_dev += 1 if was_update + new_devices += 1 if was_new + end + end + + logger.debug { "mapping assigned #{new_devices} new devices, #{updated_dev} user updated" } + nil + end + + protected def map_user_mac(user_mac, user_id, storage) + updated_dev = false + new_devices = false + user_id = format_username(user_id) + + # Check if mac mapping already exists + existing_user = storage[user_mac]? + return {false, false} if existing_user == user_id + + # Remove any pervious mappings + if existing_user + updated_dev = true + if user_macs = storage[existing_user]? + macs = Array(String).from_json(user_macs) + macs.delete(user_mac) + storage[existing_user] = macs.to_json + end + else + new_devices = true + end + + # Update the user mappings + storage[user_mac] = user_id + macs = if user_macs = storage[user_id]? + tmp_macs = Array(String).from_json(user_macs) + tmp_macs.unshift(user_mac) + tmp_macs.uniq! + tmp_macs[0...9] + else + [user_mac] + end + storage[user_id] = macs + + {updated_dev, new_devices} + end + + def format_username(user : String) + if user.includes? "@" + user = user.split("@")[0] + elsif user.includes? "\\" + user = user.split("\\")[1] + end + user.downcase + end + + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + username = format_username(username.presence || email.presence.not_nil!) + if macs = user_mac_mappings(&.[username]?) + Array(String).from_json(macs) + else + [] of String + end + end + + def check_ownership_of(mac_address : String) : OwnershipMAC? + lookup = format_mac(mac_address) + if user = user_mac_mappings(&.[lookup]?) + { + location: "wireless", + assigned_to: user, + mac_address: lookup, + } + end + end + + # returns locations based on most recently seen + # versus most accurate location + def locate_user(email : String? = nil, username : String? = nil) + username = format_username(username.presence || email.presence.not_nil!) + + if macs = user_mac_mappings(&.[username]?) + location_max_age = @max_location_age.ago + + Array(String).from_json(macs).compact_map { |mac| + if location = locate_mac(mac) + client = @client_details[mac]? + + # If a filter is set, then ignore this device unless it matches + if @regex_filter_device_os + if client && client.os + unless /#{@regex_filter_device_os}/.match(client.os.not_nil!) + logger.debug { "[#{username}] IGNORING #{mac} as OS does not match regex filter" } + next + end + else + logger.debug { "[#{username}] IGNORING #{mac} as OS is UNKNOWN" } + next + end + end + + # We set these here to speed up processing + location.client = client + location.mac = mac + + if client && client.time_added > location_max_age + location + elsif location.time > location_max_age + location + end + end + }.sort! { |a, b| + b.time <=> a.time + }.map { |location| + lat = location.lat + lon = location.lng + + loc = { + "location" => "wireless", + "coordinates_from" => "bottom-left", + "x" => location.x, + "y" => location.y, + "lon" => lon, + "lat" => lat, + "s2_cell_id" => lat ? S2Cells.at(lat.not_nil!, lon.not_nil!).parent(@s2_level).to_token : nil, + "mac" => location.mac, + "variance" => location.variance, + "last_seen" => location.time.to_unix, + "meraki_floor_id" => location.floor_plan_id, + "meraki_floor_name" => location.floor_plan_name, + } + + # Add our zone IDs to the response + if level_data = @floorplan_mappings[location.floor_plan_id]? + level_data.each { |k, v| loc[k] = v } + end + + # Add meraki map information to the response + if map_size = @floorplan_sizes[location.floor_plan_id]? + loc["map_width"] = map_size.width + loc["map_height"] = map_size.height + end + + # Add additional client information if it's available + if client = location.client + loc["manufacturer"] = client.manufacturer if client.manufacturer + loc["os"] = client.os if client.os + loc["ssid"] = client.ssid if client.ssid + end + + loc + } + else + [] of Nil + end + end + + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "looking up device locations in #{zone_id}" } + case location.presence + when "wireless" + wireless_locations(zone_id) + when "desk" + desk_locs = wired_desk_locations(zone_id) + cam_locs = desk_locations(zone_id) + combind = Array(typeof(cam_locs[0]) | typeof(desk_locs[0])).new(cam_locs.size + desk_locs.size) + combind.concat(desk_locs) + combind.concat(cam_locs) + when nil + wireless_locs = wireless_locations(zone_id) + desk_locs = wired_desk_locations(zone_id) + cam_locs = desk_locations(zone_id) + combind = Array(typeof(wireless_locs[0]) | typeof(cam_locs[0]) | typeof(desk_locs[0])).new(wireless_locs.size + cam_locs.size + desk_locs.size) + combind.concat(wireless_locs) + combind.concat(desk_locs) + combind.concat(cam_locs) + else + [] of String + end + end + + def wireless_locations(zone_id : String) + # Find the floors associated with the provided zone id + floors = [] of String + @floorplan_mappings.each do |floor_id, data| + floors << floor_id if data.values.includes?(zone_id) + end + logger.debug { "found matching meraki floors: #{floors}" } + return [] of String if floors.empty? + + checking_count = @locations.size + wrong_floor = 0 + too_old = 0 + + # Find the devices that are on the matching floors + oldest_location = @max_location_age.ago + matching = @locations.compact_map do |mac, loc| + # We set this here to speed up processing + client = @client_details[mac]? + loc.client = client + + if loc.time < oldest_location + if client + if client.time_added < oldest_location + too_old += 1 + next + end + else + too_old += 1 + next + end + end + if !floors.includes?(loc.floor_plan_id) + wrong_floor += 1 + next + end + # ensure the formatted mac is being used + loc.mac = mac + loc + end + + logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" } + + # Build the payload on the matching locations + matching.group_by(&.floor_plan_id).flat_map { |floor_id, locations| + map_width = -1.0 + map_height = -1.0 + + if map_size = @floorplan_sizes[floor_id]? + map_width = map_size.width + map_height = map_size.height + elsif mappings = @floorplan_mappings[floor_id]? + map_width = (mappings["width"]? || map_width).as(Float64) + map_height = (mappings["height"]? || map_width).as(Float64) + end + + locations.compact_map do |loc| + lat = loc.lat + lon = loc.lng + + # Add additional client information if it's available + if client = @client_details[loc.mac]? + manufacturer = client.manufacturer + os = client.os + ssid = client.ssid + end + + # Skip payloads with invalid coordinates + if (x = loc.x) && (y = loc.y) + if x.is_a?(Float64) && y.is_a?(Float64) + if loc.x.as(Float64).nan? || loc.y.as(Float64).nan? + logger.warn { "ignoring bad location for #{loc.mac}, NaN" } + next + end + else + logger.warn { "ignoring bad location for #{loc.mac}, unexpected value #{loc.x.inspect}" } + next + end + else + logger.warn { "ignoring bad location for #{loc.mac}, no coordinates provided" } + next + end + + { + location: :wireless, + coordinates_from: "bottom-left", + x: loc.x, + y: loc.y, + lon: lon, + lat: lat, + s2_cell_id: lat ? S2Cells.at(lat.not_nil!, lon.not_nil!).parent(@s2_level).to_token : nil, + mac: loc.mac, + variance: loc.variance, + last_seen: loc.time.to_unix, + map_width: map_width, + map_height: map_height, + manufacturer: manufacturer, + os: os, + ssid: ssid, + } + end + } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def cleanup_caches : Nil + logger.debug { "removing IP and location data that is over 30 minutes old" } + + # IP => MAC mappings + old = 30.minutes.ago + remove_keys = [] of String + @ip_lookup.each { |ip, lookup| remove_keys << ip if lookup.time < old } + remove_keys.each { |ip| @ip_lookup.delete(ip) } + logger.debug { "removed #{remove_keys.size} IP => MAC mappings" } + + # IP => Username mappings + remove_keys.clear + @ip_usernames.each { |ip, lookup| remove_keys << ip if lookup.time < old } + remove_keys.each { |ip| @ip_usernames.delete(ip) } + logger.debug { "removed #{remove_keys.size} IP => Username mappings" } + + # Client details + remove_keys.clear + @client_details.each { |mac, client| remove_keys << mac if client.time_added < old } + remove_keys.each { |mac| @client_details.delete(mac) } + logger.debug { "removed #{remove_keys.size} client details" } + + # MACs + remove_keys.clear + @locations.each do |mac, location| + if location.time < old + if client = @client_details[mac]? + remove_keys << mac if client.time_added < old + else + remove_keys << mac + end + end + end + remove_keys.each { |mac| @locations.delete(mac) } + logger.debug { "removed #{remove_keys.size} MACs" } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def sync_floorplan_sizes(network_id : String? = nil) + network_id = network_id.presence || @default_network + logger.debug { "syncing floor plan sizes for network #{network_id}" } + + floor_plans = {} of String => FloorPlan + + req_all("/api/v1/networks/#{network_id}/floorPlans?perPage=1000") { |response| + Array(FloorPlan).from_json(response).each do |plan| + floor_plans[plan.id] = plan + end + nil + } + + @floorplan_sizes = floor_plans + + # mac address => device location + network_devices = {} of String => NetworkDevice + cameras = [] of NetworkDevice + + req_all("/api/v1/networks/#{network_id}/devices?perPage=1000") { |response| + Array(NetworkDevice).from_json(response).each do |device| + cameras << device if device.firmware.starts_with?("cam") + next unless device.floor_plan_id + network_devices[format_mac(device.mac)] = device + end + nil + } + + @network_devices = network_devices + @cameras = cameras + + {floor_plans, network_devices} + end + + @[Security(PlaceOS::Driver::Level::Support)] + def camera_analytics(serial : String) + req("/api/v1/devices/#{serial}/camera/analytics/live") do |response| + CameraAnalytics.from_json(response) + end + end + + alias CamAnalytics = NamedTuple( + camera: NetworkDevice, + details: CameraAnalytics, + building: String?, + level: String?) + + @camera_analytics = {} of String => CamAnalytics + @cameras = [] of NetworkDevice + + getter cameras + + def update_sensor_cache + analytics = {} of String => CamAnalytics + cameras.each do |cam| + begin + mappings = @floorplan_mappings[cam.floor_plan_id]? + counts = camera_analytics(cam.serial) + mac = format_mac(cam.mac) + if mappings + analytics[mac] = { + camera: cam, + details: counts, + building: mappings["building"]?.as(String?), + level: mappings["level"]?.as(String?), + } + else + analytics[mac] = { + camera: cam, + details: counts, + building: nil.as(String?), + level: nil.as(String?), + } + end + + counts.zones.each do |area_id, count| + self["people-#{mac}-#{area_id}"] = count.person + self["presence-#{mac}-#{area_id}"] = count.person > 0 + end + rescue error + logger.debug(exception: error) { "failed to obtain analytics for #{cam.name} (serial: #{cam.serial})" } + end + end + @camera_analytics = analytics + end + + # Webhook endpoint for scanning API, expects version 3 + def parse_new_locations(payload : String) : Nil + logger.debug { payload } if @debug_payload + + locations_updated = 0 + + # Parse the data posted + begin + observations = Array(Observation).from_json(payload) + logger.debug { "parsed meraki payload" } + + ignore_older = @max_location_age.ago.in Time::Location::UTC + drift_older = @drift_location_age.ago.in Time::Location::UTC + current_time = Time.utc + + observations.each do |observation| + client_mac = format_mac(observation.client_mac) + existing = @locations[client_mac]? + logger.debug { "parsing new observation for #{client_mac}" } if @debug_webhook + + # If a filter is set, then ignore this device unless it matches + if @regex_filter_device_os + # client.os has more accurate data (observation.os is usually nil for iPhones) + client = @client_details[format_mac(observation.client_mac)]? + if client.nil? || /#{@regex_filter_device_os}/.match(client.os || "").nil? + logger.debug { "FILTERED OUT #{client_mac}: OS \"#{observation.os}\" did not match \"#{@regex_filter_device_os}\"" } if @debug_webhook + next + end + end + location = parse(existing, ignore_older, drift_older, observation) + if location + @locations[client_mac] = location + locations_updated += 1 + end + update_ipv4(observation.ipv4, client_mac, current_time) + update_ipv6(observation.ipv6.try(&.downcase), client_mac, current_time) + end + rescue e + logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" } + logger.debug { "failed payload body was\n#{payload}" } + end + + logger.debug { "updated #{locations_updated} locations" } + end + + protected def parse(existing, ignore_older, drift_older, observation) : DeviceLocation? + locations_raw = observation.locations + + # We'll attempt to return a location based on the nearest WAP + if locations_raw.empty? + last_seen = observation.latest_record + if wap_device = @network_devices[format_mac(last_seen.nearest_ap_mac)]? + return wap_device.location unless wap_device.location.nil? + + if floor_plan = @floorplan_sizes[wap_device.floor_plan_id.not_nil!]? + return wap_device.location = DeviceLocation.calculate_location(floor_plan, wap_device, last_seen.time) + end + end + return nil + end + + # existing.time is our ajusted time + if existing_time = existing.try &.time + existing = nil if existing_time < ignore_older + end + + # remove locations that don't have an x,y or very uncertain or very old + locations = locations_raw.reject do |loc| + loc.get_x.nil? || loc.variance > @maximum_uncertainty + end + + if locations.empty? + logger.debug { + if locations_raw.empty? + "ignored as no location data provided" + else + "ignored as no location in observation met minimum requirements, had coordinates: #{!!locations_raw[0].get_x}, uncertainty: #{locations_raw[0].variance}" + end + } if @debug_webhook + return existing + end + + # ensure oldest -> newest (we adjusted these already) + locations = locations.sort { |a, b| a.time <=> b.time } + + # estimate the location given the current observations + location = existing || locations.shift + locations.each do |new_loc| + next unless new_loc.time >= location.time + + # If acceptable then this is newer + if new_loc.variance < @acceptable_confidence + location = new_loc + next + end + + # if more accurate and newer then we'll take this + if new_loc.variance < location.variance + location = new_loc + location.variance = @override_min_variance if location.variance < @override_min_variance + next + end + + # should we drift the older location towards a less accurate newer location + if location.time < drift_older + # has the floor changed, we should probably accept the newer less accurate location + if location.floor_plan_id != new_loc.floor_plan_id + location = new_loc + next + end + + new_uncertainty = new_loc.variance + old_uncertainty = location.variance + + confidence_factor = 1.0 - (@confidence_multiplier * (new_uncertainty - @acceptable_confidence)) + confidence_factor = 0.0 if confidence_factor < 0 + + time_diff = new_loc.time.to_unix - location.time.to_unix + time_factor = @time_multiplier * (time_diff - @confidence_time.to_i).to_f + time_factor = 0.0 if time_factor < 0 + + # Average of the confidence factors + average_multiplier = (confidence_factor + time_factor) / 2.0 + + new_x = new_loc.x! + new_y = new_loc.y! + old_x = location.x! + old_y = location.y! + + # 7.5 = 5 + (( 10 - 5 ) * 0.5) + new_x = old_x + ((new_x - old_x) * average_multiplier) + new_y = old_y + ((new_y - old_y) * average_multiplier) + new_uncertainty = old_uncertainty + ((new_uncertainty - old_uncertainty) * average_multiplier) + + new_loc.x = new_x + new_loc.y = new_y + new_loc.variance = new_uncertainty < @override_min_variance ? @override_min_variance : new_uncertainty + + location = new_loc + end + end + + location + end + + protected def update_ipv4(ipv4, client_mac, current_time) + return unless ipv4 + + lookup = @ip_lookup[ipv4]? || Lookup.new(current_time, client_mac) + lookup.time = current_time + lookup.mac = client_mac + @ip_lookup[ipv4] = lookup + + if lookup = @ip_usernames[ipv4]? + username = lookup.mac + user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) } + end + end + + protected def update_ipv6(ipv6, client_mac, current_time) + return unless ipv6 + + lookup = @ip_lookup[ipv6]? || Lookup.new(current_time, client_mac) + lookup.time = current_time + lookup.mac = client_mac + @ip_lookup[ipv6] = lookup + + if lookup = @ip_usernames[ipv6]? + username = lookup.mac + user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) } + end + end + + def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + # ip => {username, time} + @ip_usernames : Hash(String, Lookup) = {} of String => Lookup + + @[Security(PlaceOS::Driver::Level::Administrator)] + def ip_username_mappings(ip_map : Array(Tuple(String, String, String, String?))) : Nil + now = Time.utc + user_mac_mappings do |storage| + ip_map.each do |(ip, username, domain, hostname)| + username = format_username(username) + @ip_usernames[ip] = Lookup.new(now, username) + + if lookup = @ip_lookup[ip]? + map_user_mac(lookup.mac, username, storage) + end + end + end + end + + @[Security(PlaceOS::Driver::Level::Administrator)] + def mac_address_mappings(username : String, macs : Array(String), domain : String = "") + username = format_username(username) + user_mac_mappings do |storage| + macs.each { |mac| map_user_mac(format_mac(mac), username, storage) } + end + end + + # ========================== + # Wired Port Sensing / desks + # ========================== + + bind Dashboard_1, :port_update, :port_updated + + # Serial => level + port mappings + @wired_desks : Hash(String, DeskMappings) = {} of String => DeskMappings + + # level_id => [switch serial numbers] + @level_serials : Hash(String, Array(String)) = {} of String => Array(String) + + # serial => port => status + mac address? + @port_status : Hash(String, Hash(Int32, PortStatusResponse)) = {} of String => Hash(Int32, PortStatusResponse) + + # User lookup helpers using device hostnames + getter mac_hostnames : Hash(String, String) = {} of String => String + + protected def init_wired_port_mappings + @port_status = Hash(String, Hash(Int32, PortStatusResponse)).new { |h, k| h[k] = {} of Int32 => PortStatusResponse } + + wired_desks = setting?(Array(DeskMappings), :wired_desks) || [] of DeskMappings + level_serials = Hash(String, Array(String)).new { |h, k| h[k] = [] of String } + desk_mappings = {} of String => DeskMappings + wired_desks.each do |switch| + level_serials[switch.level_id] << switch.serial + desk_mappings[switch.serial] = switch + end + @wired_desks = desk_mappings + @level_serials = level_serials + + spawn { get_port_status(@wired_desks.keys) } + + if (serials = @wired_desks.keys) && !serials.empty? + schedule.in(5.seconds) { get_port_status(serials) } + schedule.every(5.minutes) { get_port_status(serials) } + end + rescue error + logger.warn(exception: error) { "error initializing wired port mappings" } + end + + def hostname_ownership(hostname : String, username : String?) : Nil + macs = @mac_hostnames.compact_map { |(mac, host)| host == hostname ? mac : nil } + + if username && username.presence + user_mac_mappings { |storage| macs.each { |mac| map_user_mac(mac, username, storage) } } + else + # just in case the client doesn't show up again, we don't need to perform lookups + macs.each { |mac| @mac_hostnames.delete mac } + end + end + + # grab all the client data we have (applies to all ports) + # grab the port information + # check we have a desk mapping for the port + # see if we can find the client information for the port. + protected def get_port_status(devices : Iterable(String)) + all_clients = poll_clients(@default_network, connection: :wired) + devices.each do |serial| + begin + ports = @port_status[serial] + mappings = @wired_desks[serial] + + Array(PortStatusResponse).from_json(dashboard.ports_statuses(serial).get.to_json).each do |port| + # ensure the port has a desk mapping + desk_id = mappings.ports[port.port]? + next unless desk_id + + port.desk_id = desk_id + port.level_id = mappings.level_id + port.switch_serial = serial + + if port.status.connected? && (client = all_clients.find { |c| c.recent_device_serial == serial && c.switch_port == port.port }) + port.mac = client.mac + end + + ports[port.port] = port + end + rescue error + logger.warn(exception: error) { "error querying port statuses for #{serial}" } + end + end + end + + private def port_updated(_subscription, new_value) + details = WebhookAlert.from_json(new_value) + logger.debug { "switch #{details.device_serial}, port #{details.port_num} = #{details.alert_type}" } + + serial = details.device_serial + if mappings = @wired_desks[serial]? + # query the switch for the port status + get_port_status({serial}) + area_manager.update_available({mappings.level_id}) + end + rescue error + logger.warn(exception: error) { "failed to parse port update\n#{new_value.inspect}" } + end + + # grabs the wired desk data for a level + def wired_desk_locations(zone_id : String) + return_empty_spaces = @return_empty_spaces + + serials = if zone_id == @building_zone + @level_serials.values.flatten + else + @level_serials[zone_id]? || [] of String + end + + serials.compact_map { |serial| + ports = @port_status[serial]? + next unless ports + + ports.map do |(port_num, port)| + occupied = port.status.connected? ? 1 : 0 + + # Do we want to return empty desks (depends on the frontend) + next if !return_empty_spaces && occupied == 0 + + { + location: "desk", + at_location: occupied, + map_id: port.desk_id, + level: port.level_id, + building: @building_zone, + capacity: 1, + mac: port.mac, + port: port_num, + switch: port.switch_serial, + } + end + }.flatten + end + + # ====================== + # Sensor interface: + # ====================== + + protected def to_sensors(zone_id, filter, camera, details, building, level) + sensors = [] of Interface::Sensor::Detail + return sensors if zone_id && (building || level) && !zone_id.in?({building, level}) + + formatted_mac = format_mac(camera.mac) + + {SensorType::PeopleCount, SensorType::Presence}.each do |type| + next if filter && filter != type + + time = details.ts.to_unix + type_indicator = type.to_s.underscore.split('_', 2)[0] + + details.zones.each do |area_id, count| + value = case type + when SensorType::PeopleCount + count.person.to_f + when SensorType::Presence + count.person > 0 ? 1.0 : 0.0 + else + # Will never make it here + raise "unknown sensor" + end + + sensor = Interface::Sensor::Detail.new( + type: type, + value: value, + last_seen: time, + mac: camera.mac, + id: "#{area_id}-#{type_indicator}", + name: "#{camera.name} Presence: #{camera.model} (#{camera.serial})", + + module_id: module_id, + binding: "#{type_indicator}-#{formatted_mac}-#{area_id}" + ) + + sensor.building = building + sensor.level = level + sensors << sensor + end + end + + sensors + end + + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if type && !type.in?({"Presence", "PeopleCount"}) + filter = type ? SensorType.parse(type) : nil + + if mac + cam_state = @camera_analytics[format_mac(mac)]? + return NO_MATCH unless cam_state + return to_sensors(zone_id, filter, **cam_state) + end + + @camera_analytics.values.flat_map { |cam_data| to_sensors(zone_id, filter, **cam_data) } + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + + return nil unless id + cam_state = @camera_analytics[format_mac(mac)]? + return nil unless cam_state + + # https://crystal-lang.org/api/1.1.0/String.html#rpartition(search:Char%7CString):Tuple(String,String,String)-instance-method + area_str, _, sensor_type = id.rpartition('-') + + filter = case sensor_type + when "people" + SensorType::PeopleCount + when "presence" + SensorType::Presence + else + return nil + end + + area_id = area_str.to_i64? + return nil unless area_id + + zone_count = cam_state[:details].zones[area_id]?.try &.person + return nil unless zone_count + + to_sensors(nil, filter, **cam_state).find { |sensor| sensor.id == id } + end + + # ================= + # Camera Desk data: + # ================= + # desk_id => [{time, occupied}] + getter desk_occupancy : Hash(String, Array(Tuple(Int64, Bool))) + + @desk_occupancy : Hash(String, Array(Tuple(Int64, Bool))) = Hash(String, Array(Tuple(Int64, Bool))).new do |hash, key| + hash[key] = Array(Tuple(Int64, Bool)).new(4) + end + + protected def average_results(serial, detected) + desks = @desk_mappings[serial]? + return unless desks + + time = Time.utc.to_unix + past = desk_data_expiry_time + + # id => Array({distance, occupied}) + results = Hash(String, Array(Tuple(Float64, Bool))).new { |h, k| h[k] = [] of Tuple(Float64, Bool) } + + # we store the closest desk point to the line, + # as this detected desk might be the occupancy we care about + detected.desks.each do |(lx, ly, cx, cy, rx, ry, occupancy)| + desks.each { |desk| desk.distance = calculate_distance(lx, ly, cx, cy, rx, ry, desk) } + if desk = desks.sort! { |a, b| a.distance <=> b.distance }.first? + results[desk.label] << {desk.distance, !occupancy.zero?} + end + end + + # then for each desk id, we take the closest detected desk and use that + # occupancy value + results.each do |desk_id, distances| + _distance, occupancy = distances.sort! { |a, b| a[0] <=> b[0] }.first + desk_occupation = @desk_occupancy[desk_id] + desk_occupation << {time, occupancy} + cleanup_old_data(desk_occupation, past) + end + end + + # We want to find the line closest to the offical desk point + protected def calculate_distance(lx, ly, cx, cy, rx, ry, desk) + desk = Point.new(desk.x, desk.y) + { + Point.new(lx, ly).distance_to(desk), + Point.new(rx, ry).distance_to(desk), + Point.new(cx, cy).distance_to(desk), + }.sum + end + + protected def desk_data_expiry_time + 90.seconds.ago.to_unix + end + + protected def cleanup_old_data(desk_occupation, expiry_time) + desk_occupation.reject! { |(time, _occupancy)| time < expiry_time } + end + + # ============================= + # Camera Desk location service: + # ============================= + # zone_id => array of camera serials + @zone_lookup : Hash(String, Array(String)) = {} of String => Array(String) + + # camera serial => level + building + @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping + + # Camera serial => [desk location] + getter desk_mappings : Hash(String, Array(CameraZone)) = {} of String => Array(CameraZone) + + def desk_locations(zone_id : String) + serials = @zone_lookup[zone_id]? || [] of String + return_empty_spaces = @return_empty_spaces + expiry_time = desk_data_expiry_time + + serials.compact_map { |serial| + desks = @desk_mappings[serial]? + next unless desks + + # does data exist for the desks? + next unless desk_details[serial]? + + floor = @floor_lookup[serial] + illumination = lux[serial]? + + desks.compact_map do |desk| + desk_id = desk.label + occupied = is_occupied?(desk_id, expiry_time) + + # Do we want to return empty desks (depends on the frontend) + next if !return_empty_spaces && occupied == 0 + + { + location: "desk", + at_location: occupied, + map_id: desk_id, + level: floor.level_id, + building: floor.building_id, + capacity: 1, + + area_lux: illumination, + merakimv: serial, + } + end + }.flatten + end + + def update_desk_mappings + desk_mappings = Hash(String, Array(CameraZone)).new + @floor_lookup.keys.each do |serial| + begin + desk_mappings[serial] = Array(CameraZone).from_json(dashboard.get_zones(serial).get.to_json).reject!(&.id.==("0")) + rescue error + logger.warn(exception: error) { "fetching zones for camera: #{serial}" } + end + end + + @desk_mappings = desk_mappings + + mqtt_module = system[:MerakiMQTT] + desk_mappings.keys.each { |camera_serial| check_camera_status(mqtt_module, camera_serial) } + end + + protected def desk_data_expiry_time + 90.seconds.ago.to_unix + end + + protected def is_occupied?(desk_id, expiry_time) + desk_occupation = @desk_occupancy[desk_id]? + return 0 unless desk_occupation + + occupied = 0 + desk_occupation.reject! do |(time, occupancy)| + if time < expiry_time + next true + elsif occupancy + occupied += 1 + end + false + end + + size = desk_occupation.size + return 0 if size.zero? + + # We care if the desk basically had signs of life + (occupied / size) > 0.3 ? 1 : 0 + end +end diff --git a/drivers/cisco/meraki/meraki_locations_spec.cr b/drivers/cisco/meraki/meraki_locations_spec.cr new file mode 100644 index 00000000000..ada209e876f --- /dev/null +++ b/drivers/cisco/meraki/meraki_locations_spec.cr @@ -0,0 +1,199 @@ +require "./scanning_api" +require "placeos-driver/spec" + +# :nodoc: +class DashboardMock < DriverSpecs::MockDriver + def fetch(location : String) + logger.info { "fetching: #{location}" } + case location + when "/api/v1/networks/network_id/floorPlans" + %([{"floorPlanId":"floor-123","name":"Level 1","width":30.5,"height":20,"topLeftCorner":{"lat":0,"lng":0},"bottomLeftCorner":{"lat":0,"lng":0},"bottomRightCorner":{"lat":0,"lng":0}}]) + when "/api/v1/networks/network_id/devices" + %([]) + when "/api/v1/devices/Q2HV-KAM-ETSG/camera/analytics/live" + %({ + "ts": "2021-08-09T23:56:52.236Z", + "zones": { + "582653201791058186": { + "person": 0 + }, + "582653201791058185": { + "person": 0 + }, + "0": { + "person": 0 + } + } + }) + else + %([]) + end + end + + def fetch_all(location : String) + [fetch(location)] + end + + def get_zones(serial : String) + logger.info { "ZONE REQ: request made for camera '#{serial}'" } + [{ + zoneId: "ignored", + type: "something", + label: "desk-1234", + regionOfInterest: { + x0: "0.44", + y0: "0.56", + x1: "0.44", + y1: "0.56", + }, + }] + end +end + +# :nodoc: +class MQTTMock < DriverSpecs::MockDriver + def on_load + self["camera_camera_serial_desks"] = { + "_v" => 2, + "time" => "2022-01-20 02:14:00", + "desks" => [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0], + ], + } + + self["camera_updated"] = {0, "camera_serial"} + + self["floor_lookup"] = { + "camera_serial" => { + camera_serials: ["camera_serial"], + level_id: "zone-123", + building_id: "zone-456", + }, + } + + self["zone_lookup"] = { + "zone-456" => {"camera_serial"}, + } + end + + def trigger_update + self["camera_updated"] = {1, "camera_serial"} + end +end + +# :nodoc: +class StaffAPIMock < DriverSpecs::MockDriver + def zone(id : String) + { + id: "zone-building", + tags: ["building"], + name: "building zone", + } + end +end + +DriverSpecs.mock_driver "Cisco::Meraki::Locations" do + system({ + Dashboard: {DashboardMock}, + MerakiMQTT: {MQTTMock}, + StaffAPI: {StaffAPIMock}, + }) + + sleep 0.5 + + # Should standardise the format of MAC addresses + exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b) + + floors_raw = %({"g_727894289773756676": { + "floorPlanId": "g_727894289773756676", + "width": 84.73653902424, + "height": 55.321510873304, + "topLeftCorner": { + "lat": 25.20105494120424, + "lng": 55.27527794417147 + }, + "bottomLeftCorner": { + "lat": 25.20128402691947, + "lng": 55.27478983574903 + }, + "bottomRightCorner": { + "lat": 25.200607564298647, + "lng": 55.27440203743774 + }, + "name": "BUILDING - L3" + }, + "g_727894289773756679": { + "floorPlanId": "g_727894289773756679", + "width": 82.037895885132, + "height": 48.035263155936, + "topLeftCorner": { + "lat": 25.201070920997147, + "lng": 55.27523029269689 + }, + "bottomLeftCorner": { + "lat": 25.20126383588677, + "lng": 55.274803104166594 + }, + "bottomRightCorner": { + "lat": 25.200603702563107, + "lng": 55.27443896882145 + }, + "name": "Building - GF" + }}) + floors = Hash(String, Cisco::Meraki::FloorPlan).from_json(floors_raw) + + macs_raw = %({"683a1e545b0c": { + "floorPlanId": "g_727894289773756676", + "lat": 25.2011012305148, + "lng": 55.2749184519053, + "mac": "68:3a:1e:54:5b:0c", + "name": "1F-07", + "model": "MV22", + "firmware": "camera-4-13", + "serial": "Q2HV-KAM-ETSG" + }, + "683a1e5474ed": { + "floorPlanId": "g_727894289773756679", + "lat": 25.2008175846893, + "lng": 55.2746475487948, + "mac": "68:3a:1e:54:74:ed", + "name": "GF-29", + "model": "MV22", + "firmware": "camera-4-13", + "serial": "Q2HV-KAM-ETSG" + }}) + macs = Hash(String, Cisco::Meraki::NetworkDevice).from_json(macs_raw) + + macs.each do |_mac, wap_device| + floor_plan = floors[wap_device.floor_plan_id] + # do some unit testing + loc = Cisco::Meraki::DeviceLocation.calculate_location(floor_plan, wap_device, Time.utc) + loc.to_json + end + + exec(:camera_analytics, "Q2HV-KAM-ETSG").get.should eq({ + "ts" => "2021-08-09T23:56:52.236+0000", + "zones" => { + "582653201791058186" => {"person" => 0}, + "582653201791058185" => {"person" => 0}, + "0" => {"person" => 0}, + }, + }) + + exec(:device_locations, "zone-456").get.should eq([{ + "location" => "desk", + "at_location" => 0, + "map_id" => "desk-1234", + "level" => "zone-123", + "building" => "zone-456", + "capacity" => 1, + "area_lux" => nil, + "merakimv" => "camera_serial", + }]) +end diff --git a/drivers/cisco/meraki/mqtt.cr b/drivers/cisco/meraki/mqtt.cr new file mode 100644 index 00000000000..0271cfaf737 --- /dev/null +++ b/drivers/cisco/meraki/mqtt.cr @@ -0,0 +1,421 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" +require "../../place/mqtt_transport_adaptor" +require "./mqtt_models" + +# documentation: https://developer.cisco.com/meraki/mv-sense/#!mqtt +# Use https://www.desmos.com/calculator for plotting points (sample code for copy and paste) +# data = [[1,2,3,4,5,6, 0]] +# data.each do |d| +# puts "(#{d[0]}, #{d[1]}),(#{d[2]}, #{d[3]}),(#{d[4]}, #{d[5]})" +# end + +class Cisco::Meraki::MQTT < PlaceOS::Driver + include Interface::Sensor + + descriptive_name "Meraki MQTT" + generic_name :MerakiMQTT + + tcp_port 1883 + description %(subscribes to Meraki MV Sense camera data) + + default_settings({ + username: "user", + password: "pass", + keep_alive: 60, + client_id: "placeos", + + floor_mappings: [ + { + camera_serials: ["1234", "camera_serial"], + level_id: "zone-123", + building_id: "zone-456", + }, + ], + + line_crossing_combined: { + area_name: ["camera_serial1", "camera_serial2"], + }, + + timezone: "America/New_York", + disable_line_crossing_reset: false, + }) + + SUBS = { + # Meraki desk occupancy (coords and occupancy are floats) + # {ts: unix_time, desks: [[lx, ly, rx, ry, cx, cy, occupancy], [...]]} + "/merakimv/+/net.meraki.detector", + + # lux levels on a camera + # {lux: float} + "/merakimv/+/light", + + # Number of entrances in the camera’s complete field of view + # {ts: unix_time, counts: {person: number, vehicle: number}} + "/merakimv/+/0", + + # meraki entry and exist monitoring + "/merakimv/+/crossing/+", + } + + @keep_alive : Int32 = 60 + @username : String? = nil + @password : String? = nil + @client_id : String = "placeos" + + @mqtt : ::MQTT::V3::Client? = nil + @subs : Array(String) = [] of String + @transport : Place::TransportAdaptor? = nil + @sub_proc : Proc(String, Bytes, Nil) = Proc(String, Bytes, Nil).new { |_key, _payload| nil } + + @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping + + # area name => array of serials + @line_crossing : Hash(String, Array(String)) = {} of String => Array(String) + + # serial => area name + @crossing_lookup : Hash(String, String) = {} of String => String + + def on_load + @sub_proc = Proc(String, Bytes, Nil).new { |key, payload| on_message(key, payload) } + on_update + end + + def on_unload + end + + def on_update + @username = setting?(String, :username) + @password = setting?(String, :password) + @keep_alive = setting?(Int32, :keep_alive) || 60 + @client_id = setting?(String, :client_id) || ::MQTT.generate_client_id("placeos_") + + # zone_id => camera serial + zone_lookup = Hash(String, Array(String)).new { |h, k| h[k] = [] of String } + # camera serial => level + building + floor_lookup = {} of String => FloorMapping + floor_mappings = setting?(Array(FloorMapping), :floor_mappings) || [] of FloorMapping + floor_mappings.each do |mapping| + mapping.camera_serials.each do |serial| + zone_lookup[mapping.level_id] << serial + zone_lookup[mapping.building_id.not_nil!] << serial if mapping.building_id + floor_lookup[serial] = mapping + end + end + self[:floor_lookup] = @floor_lookup = floor_lookup + self[:zone_lookup] = zone_lookup + + existing = @subs + @subs = SUBS.to_a + + @line_crossing = line_crossing_combined = setting?(Hash(String, Array(String)), :line_crossing_combined) || {} of String => Array(String) + line_crossing_mapping = {} of String => String + line_crossing_combined.each do |name, serials| + serials.each { |serial| line_crossing_mapping[serial] = name } + end + @crossing_lookup = line_crossing_mapping + + schedule.clear + schedule.every((@keep_alive // 3).seconds) { ping } + + if !setting?(Bool, :disable_line_crossing_reset) + time_zone = setting?(String, :timezone).presence || "America/New_York" + tz = Time::Location.load(time_zone) + schedule.cron("30 3 * * *", tz) do + crossing_people.each_key { |key| self["camera_mvx-#{key}_person"] = 0 } + crossing_people.clear + crossing_vehicle.each_key { |key| self["camera_mvx-#{key}_vehicle"] = 0 } + crossing_vehicle.clear + end + end + + if client = @mqtt + unsub = existing - @subs + newsub = @subs - existing + + unsub.each do |sub| + logger.debug { "unsubscribing to #{sub}" } + client.unsubscribe(sub) + end + + newsub.each do |sub| + logger.debug { "subscribing to #{sub}" } + client.subscribe(sub, &@sub_proc) + end + end + end + + def connected + transp = Place::TransportAdaptor.new(transport, queue) + client = ::MQTT::V3::Client.new(transp) + @transport = transp + @mqtt = client + + logger.debug { "sending connect message" } + client.connect(@username, @password, @keep_alive, @client_id) + @subs.each do |sub| + logger.debug { "subscribing to #{sub}" } + client.subscribe(sub, &@sub_proc) + end + end + + def disconnected + @transport = nil + @mqtt = nil + end + + def ping + logger.debug { "sending ping" } + @mqtt.not_nil!.ping + end + + def received(data, task) + logger.debug { "received #{data.size} bytes: 0x#{data.hexstring}" } + @transport.try &.process(data) + task.try &.success + end + + getter people_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do + Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key| + hash[key] = {} of String => Tuple(Float64, Int64) + end + end + + getter vehicle_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do + Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key| + hash[key] = {} of String => Tuple(Float64, Int64) + end + end + + # Serial => count + getter crossing_people : Hash(String, Tuple(Int32, Int64)) do + Hash(String, Tuple(Int32, Int64)).new { |hash, key| hash[key] = {0, 0_i64} } + end + + getter crossing_vehicle : Hash(String, Tuple(Int32, Int64)) do + Hash(String, Tuple(Int32, Int64)).new { |hash, key| hash[key] = {0, 0_i64} } + end + + getter lux : Hash(String, Tuple(Float64, Int64)) = {} of String => Tuple(Float64, Int64) + + # this is where we do all of the MQTT message processing + protected def on_message(key : String, playload : Bytes) : Nil + json_message = String.new(playload) + key = key[1..-1] if key.starts_with?("/") + + logger.debug { "new message: #{key} = #{json_message}" } + _merakimv, serial_no, status = key.split("/") + + case status + when "net.meraki.detector" + # we assume version 3 of the API here for sanity reasons + detected_desks = DetectedDesks.from_json(json_message) + self["camera_#{serial_no}_desks"] = detected_desks + self["camera_updated"] = {Time.utc.to_unix, serial_no} + when "light" + light = LuxLevel.from_json(json_message) + lux[serial_no] = {light.lux, light.timestamp} + self["camera_#{serial_no}_lux"] = light.lux + when "crossing" + crossing = Crossing.from_json(json_message) + count_hash = crossing.type.person? ? crossing_people : crossing_vehicle + lookup_name = @crossing_lookup[serial_no]? || serial_no + current_count, _timestamp = count_hash[lookup_name] + case crossing.event + when .crossing_in? + current_count += 1 + when .crossing_out? + current_count -= 1 + end + current_count = 0 if current_count < 0 + count_hash[lookup_name] = {current_count, crossing.timestamp} + self["camera_mvx-#{serial_no}_#{crossing.type.to_s.downcase}"] = current_count + else + # Everything else is a zone count + entry = Entrances.from_json json_message + case entry.count_type + in CountType::People + people_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix} + in CountType::Vehicles + vehicle_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix} + in CountType::Unknown + # ignore + end + self["camera_#{serial_no}_zone#{status}_#{entry.count_type.to_s.downcase}"] = entry.count + end + end + + # ---------------- + # Sensor Interface + # ---------------- + + # return the specified sensor details + def sensor(mac : String, id : String? = nil) : Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + + if id == "lux" + add_lux_values([] of Detail, mac).first? + elsif id.starts_with? "zone" + zone, count_type = id.split('_', 2) + zone = zone[4..-1] # remove the word "zone" + + sensor_type = SensorType::PeopleCount + lookup = case count_type + when "people" + people_counts + when "vehicles" + sensor_type = SensorType::Counter + vehicle_counts + end + + if lookup + if counts = lookup[mac]? + if count = counts[zone]? + to_sensor(sensor_type, mac, "zone#{zone}_#{count_type}", count[0], count[1]) + end + end + end + else + nil + end + end + + NO_MATCH = [] of Interface::Sensor::Detail + LUX_ID = "lux" + + # return an array of sensor details + # zone_id can be ignored if location is unknown by the sensor provider + # mac_address can be used to grab data from a single device (basic grouping) + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + serial_filter = nil + if zone_id && !@floor_lookup.empty? + serial_filter = [] of String + @floor_lookup.each do |serial, floor| + serial_filter << serial if {floor.level_id, floor.building_id}.includes?(zone_id) + end + end + + sensors = [] of Detail + filter = type ? Interface::Sensor::SensorType.parse?(type) : nil + + case filter + when nil + add_lux_values(sensors, mac, serial_filter) + add_people_counts(sensors, mac, serial_filter) + add_vehicle_counts(sensors, mac, serial_filter) + add_people_crossing(sensors, mac, serial_filter) + add_vehicle_crossing(sensors, mac, serial_filter) + when .people_count? + add_people_counts(sensors, mac, serial_filter) + add_people_crossing(sensors, mac, serial_filter) + when .counter? + add_vehicle_counts(sensors, mac, serial_filter) + add_vehicle_crossing(sensors, mac, serial_filter) + when .illuminance? + add_lux_values(sensors, mac, serial_filter) + else + sensors + end + rescue error + logger.warn(exception: error) { "searching for sensors" } + NO_MATCH + end + + protected def add_people_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + people_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, mac, "zone#{zone_name}_people", count, time) } + else + people_counts.each do |serial, zones| + next if serial_filter && !serial_filter.includes?(serial) + zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, serial, "zone#{zone_name}_people", count, time) } + end + end + sensors + end + + protected def add_vehicle_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + vehicle_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, mac, "zone#{zone_name}_vehicles", count, time) } + else + vehicle_counts.each do |serial, zones| + next if serial_filter && !serial_filter.includes?(serial) + zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, serial, "zone#{zone_name}_vehicles", count, time) } + end + end + sensors + end + + protected def add_people_crossing(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors unless mac.starts_with?("mvx-") + mac = mac[4..-1] + + if data = crossing_people[mac]? + count, time = data + sensors << to_sensor(SensorType::PeopleCount, "mvx-#{mac}", "person", count, time) + end + else + crossing_people.each do |mac, (count, time)| + serial = @line_crossing[mac]?.try(&.first?) || mac + next if serial_filter && !serial_filter.includes?(serial) + sensors << to_sensor(SensorType::PeopleCount, "mvx-#{mac}", "person", count, time) + end + end + sensors + end + + protected def add_vehicle_crossing(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors unless mac.starts_with?("mvx-") + mac = mac[4..-1] + + if data = crossing_vehicle[mac]? + count, time = data + sensors << to_sensor(SensorType::Counter, "mvx-#{mac}", "vehicle", count, time) + end + else + crossing_vehicle.each do |mac, (count, time)| + serial = @line_crossing[mac]?.try(&.first?) || mac + next if serial_filter && !serial_filter.includes?(serial) + sensors << to_sensor(SensorType::Counter, "mvx-#{mac}", "vehicle", count, time) + end + end + sensors + end + + protected def add_lux_values(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + if lux_val = lux[mac]? + level, time = lux_val + sensors << to_sensor(SensorType::Illuminance, mac, LUX_ID, level, time) + end + else + lux.each do |serial, (level, time)| + next if serial_filter && !serial_filter.includes?(serial) + sensors << to_sensor(SensorType::Illuminance, serial, LUX_ID, level, time) + end + end + sensors + end + + protected def to_sensor(sensor_type, serial, id, value, timestamp) : Interface::Sensor::Detail + Interface::Sensor::Detail.new( + type: sensor_type, + value: value, + last_seen: timestamp, + mac: serial, + id: id, + name: "Meraki Camera #{serial}: #{id}", + module_id: module_id, + binding: "camera_#{serial}_#{id}", + unit: sensor_type.illuminance? ? "lx" : nil + ) + end +end diff --git a/drivers/cisco/meraki/mqtt_models.cr b/drivers/cisco/meraki/mqtt_models.cr new file mode 100644 index 00000000000..395e322f71f --- /dev/null +++ b/drivers/cisco/meraki/mqtt_models.cr @@ -0,0 +1,98 @@ +require "json" + +# Meraki MQTT Data Models +module Cisco::Meraki + class FloorMapping + include JSON::Serializable + + getter camera_serials : Array(String) + getter level_id : String + getter building_id : String? + end + + class DetectedDesks + include JSON::Serializable + + @[JSON::Field(key: "_v")] + getter api_version : Int32 + + # Time in milliseconds v3, + @[JSON::Field(key: "ts")] + getter time_unix : Int64? + + @[JSON::Field(key: "time")] + getter time_string : String? + + getter desks : Array(Tuple(Float64, Float64, # left +Float64, Float64, # center +Float64, Float64, # right +Float64 # occupancy +)) + end + + class LuxLevel + include JSON::Serializable + + # Not actually provided for this message, but here for testing + @[JSON::Field(key: "ts")] + getter timestamp : Int64 { Time.utc.to_unix } + + getter lux : Float64 + end + + enum CountType + People + Vehicles + Unknown + end + + class Entrances + include JSON::Serializable + + @[JSON::Field(key: "ts")] + getter timestamp : Int64 + + getter counts : NamedTuple( + person: Int32?, + vehicle: Int32?, + ) + + @[JSON::Field(ignore: true)] + getter count_type : CountType do + if counts[:person] + CountType::People + elsif counts[:vehicle] + CountType::Vehicles + else + CountType::Unknown + end + end + + @[JSON::Field(ignore: true)] + getter count : Int32 { counts[:person] || counts[:vehicle] || 0 } + end + + enum CrossingObject + Person + Vehicle + Unknown + end + + enum CrossingEvent + CrossingIn + CrossingOut + Expired + Appeared + end + + struct Crossing + include JSON::Serializable + + @[JSON::Field(key: "ts")] + getter timestamp : Int64 + # getter object_id : Int64 + getter label : String? + getter event : CrossingEvent + getter type : CrossingObject + end +end diff --git a/drivers/cisco/meraki/mqtt_spec.cr b/drivers/cisco/meraki/mqtt_spec.cr new file mode 100644 index 00000000000..0f8bd3d439a --- /dev/null +++ b/drivers/cisco/meraki/mqtt_spec.cr @@ -0,0 +1,179 @@ +require "placeos-driver/spec" +require "mqtt" + +DriverSpecs.mock_driver "Place::MQTT" do + # ============================ + # CONNECTION + # ============================ + puts "===== CONNECTION NEGOTIATION =====" + connect = MQTT::V3::Connect.new + connect.id = MQTT::RequestType::Connect + connect.keep_alive_seconds = 60_u16 + connect.client_id = "placeos" + connect.clean_start = true + connect.username = "user" + connect.password = "pass" + connect.packet_length = connect.calculate_length + should_send(connect.to_slice) + + connack = MQTT::V3::Connack.new + connack.id = MQTT::RequestType::Connack + connack.packet_length = connack.calculate_length + responds(connack.to_slice) + + # ============================ + # SUBSCRIPTIONS + # ============================ + puts "===== CHECKING DESKS SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 2_u16 + packet.topic = "/merakimv/+/net.meraki.detector" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 2_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + puts "===== CHECKING LUX SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 4_u16 + packet.topic = "/merakimv/+/light" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 4_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + puts "===== CHECKING COUNTS SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 6_u16 + packet.topic = "/merakimv/+/0" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 6_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + # ============================ + # REMOTE PUBLISH + # ============================ + puts "===== REMOTE PUBLISH =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/1234/light" + publish.payload = %({"lux":33.2,"ts":1642564552}) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_1234_lux"].should eq(33.2) + + # ============================ + # CHECK SENSOR INTERFACE + # ============================ + lux_sensor = { + "status" => "normal", + "type" => "illuminance", + "value" => 33.2, + "last_seen" => 1642564552, + "mac" => "1234", + "id" => "lux", + "name" => "Meraki Camera 1234: lux", + "module_id" => "spec_runner", + "binding" => "camera_1234_lux", + "unit" => "lx", + "location" => "sensor", + } + exec(:sensors).get.should eq([lux_sensor]) + exec(:sensor, "1234", "lux").get.should eq(lux_sensor) + + # ============================ + # CHECK LOCATABLE INTERFACE + # ============================ + puts "===== CHECKING LOCATABLE INTERFACE =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/camera_serial/net.meraki.detector" + publish.payload = %({ + "_v": 2, + "time": "2022-01-20 02:14:00", + "coords":[], + "desks": [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0] + ] + }) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_1234_lux"].should eq(33.2) + status["camera_camera_serial_desks"].should eq({ + "_v" => 2, + "time" => "2022-01-20 02:14:00", + "desks" => [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0], + ], + }) + + # ============================ + # Check Line Crossing + # ============================ + puts "===== REMOTE PUBLISH =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/56789/crossing/uuid" + publish.payload = %({"label":"testing","event":"crossing_in","type":"person","ts":1642564558,"object_id":2}) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_mvx-56789_person"].should eq(1) + + exec(:sensors, "people_count", "mvx-56789").get.should eq([ + { + "status" => "normal", + "type" => "people_count", + "value" => 1.0, + "last_seen" => 1642564558, + "mac" => "mvx-56789", + "id" => "person", + "name" => "Meraki Camera mvx-56789: person", + "module_id" => "spec_runner", + "binding" => "camera_mvx-56789_person", + "location" => "sensor", + }, + ]) +end diff --git a/drivers/cisco/meraki/scanning_api.cr b/drivers/cisco/meraki/scanning_api.cr new file mode 100644 index 00000000000..63d8ae7a2c3 --- /dev/null +++ b/drivers/cisco/meraki/scanning_api.cr @@ -0,0 +1,433 @@ +require "json" +require "./geo" + +module Cisco::Meraki + ISO8601 = "%FT%T%z" + + class Organization + include JSON::Serializable + + property id : String + property name : String + property url : String + property api : NamedTuple(enabled: Bool) + end + + class Network + include JSON::Serializable + + property id : String + + @[JSON::Field(key: "organizationId")] + property organization_id : String + + property name : String + + @[JSON::Field(key: "productTypes")] + property product_types : Array(String) + + @[JSON::Field(key: "timeZone")] + property time_zone : String + property tags : Array(String) + property url : String + + @[JSON::Field(key: "enrollmentString")] + property enrollment_string : String? + property notes : String? + end + + class CameraAnalytics + include JSON::Serializable + ISO8601_MS = "%FT%T.%3N%z" + + class PeopleCount + include JSON::Serializable + + property person : Int32 + end + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::CameraAnalytics::ISO8601_MS))] + property ts : Time + property zones : Hash(Int64, PeopleCount) + end + + class FloorPlan + include JSON::Serializable + + @[JSON::Field(key: "floorPlanId")] + property id : String + property width : Float64 + property height : Float64 + + @[JSON::Field(key: "topLeftCorner")] + property top_left : Geo::Point + + @[JSON::Field(key: "bottomLeftCorner")] + property bottom_left : Geo::Point + + @[JSON::Field(key: "bottomRightCorner")] + property bottom_right : Geo::Point + + # This is useful for when we have to map meraki IDs to our zones + property name : String? + + def to_distance + Geo::Distance.new(width, height) + end + end + + class FloorPlanLocation + include JSON::Serializable + + property id : String + property name : String + property x : Float64 + property y : Float64 + end + + class NetworkDevice + include JSON::Serializable + + # Used for caching the location calculated for this device + # where an observation doesn't have location values but has a closest WAP + @[JSON::Field(ignore: true)] + property location : DeviceLocation? + + @[JSON::Field(key: "floorPlanId")] + property floor_plan_id : String? + + property lat : Float64 + property lng : Float64 + property mac : String + + property serial : String + property model : String + property firmware : String + + # This is useful for when we have to map meraki IDs to our zones + property name : String? + end + + class Client + include JSON::Serializable + + property id : String + property mac : String + property description : String? + + property ip : String? + property ip6 : String? + + @[JSON::Field(key: "ip6Local")] + property ip6_local : String? + + property user : String? + + # 2020-09-29T07:53:08Z + @[JSON::Field(key: "firstSeen")] + property first_seen : String + + @[JSON::Field(key: "lastSeen")] + property last_seen : Time + + property manufacturer : String? + property os : String? + + @[JSON::Field(key: "recentDeviceSerial")] + property recent_device_serial : String? + + @[JSON::Field(key: "recentDeviceMac")] + property recent_device_mac : String? + property ssid : String? + property vlan : String? + property switchport : String? + property status : String + property notes : String? + + @[JSON::Field(ignore: true)] + property! time_added : Time + + @[JSON::Field(ignore: true)] + getter switch_port : Int32 { @switchport.as(String).to_i } + end + + class RSSI + include JSON::Serializable + + @[JSON::Field(key: "apMac")] + property access_point_mac : String + property rssi : Int32 + end + + class DeviceLocation + include JSON::Serializable + + def initialize(@x, @y, @lng, @lat, @variance, floor_plan_id, floor_plan_name, @time) + @wifi_floor_plan_name = floor_plan_name + @wifi_floor_plan_id = floor_plan_id + @mac = nil + @client = nil + @rssi_records = [] of RSSI + end + + def self.calculate_location(floor : FloorPlan, device : NetworkDevice, time : Time) : DeviceLocation + distance = Geo.calculate_xy(floor.top_left, floor.bottom_left, floor.bottom_right, device, floor.to_distance) + DeviceLocation.new(distance.x, distance.y, device.lng, device.lat, 25_f64, floor.id, floor.name, time) + end + + # NOTE:: This is not part of the location response, + # it is here to simplify processing + @[JSON::Field(ignore: true)] + property mac : String? + + # NOTE:: this is not part of the location response, + # it is here to speed up processing + @[JSON::Field(ignore: true)] + property client : Client? = nil + + # Multiple types as the location when parsed might include javascript `"NaN"` + property x : Float64 | String | Nil + property y : Float64 | String | Nil + property lng : Float64? + property lat : Float64? + property variance : Float64 + + @[JSON::Field(key: "floorPlanId")] + property wifi_floor_plan_id : String? + + @[JSON::Field(key: "floorPlanName")] + property wifi_floor_plan_name : String? + + @[JSON::Field(key: "floorPlan")] + property floor_plan : FloorPlanLocation? + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))] + property time : Time + + @[JSON::Field(key: "nearestApTags")] + property nearest_ap_tags : Array(String) { [] of String } + + @[JSON::Field(key: "rssiRecords")] + property rssi_records : Array(RSSI) + + def x! + get_x.not_nil! + end + + def y! + get_y.not_nil! + end + + def get_x : Float64? + if tmp = x || floor_plan.try(&.x) + if tmp.is_a?(Float64) + tmp + end + end + end + + def get_y : Float64? + if tmp = y || floor_plan.try(&.y) + if tmp.is_a?(Float64) + tmp + end + end + end + + def floor_plan_id + wifi_floor_plan_id || floor_plan.try(&.id) + end + + def floor_plan_name + wifi_floor_plan_name || floor_plan.try(&.name) + end + end + + class LatestRecord + include JSON::Serializable + + @[JSON::Field(key: "nearestApMac")] + property nearest_ap_mac : String + + @[JSON::Field(key: "nearestApRssi")] + property nearest_ap_rssi : Int32 + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))] + property time : Time + end + + class Observation + include JSON::Serializable + + @[JSON::Field(key: "clientMac")] + property client_mac : String + + property manufacturer : String? + property ipv4 : String? + property ipv6 : String? + property ssid : String? + property os : String? + + @[JSON::Field(key: "latestRecord")] + property latest_record : LatestRecord + property locations : Array(DeviceLocation) + end + + class Data + include JSON::Serializable + + @[JSON::Field(key: "networkId")] + property network_id : String + property observations : Array(Observation) + end + + enum MessageType + None + WiFi + Bluetooth + end + + class DevicesSeen + include JSON::Serializable + + property version : String + property secret : String + + @[JSON::Field(key: "type")] + property message_type : MessageType + + property data : Data + end + + struct CameraZone + include JSON::Serializable + + struct Region + include JSON::Serializable + + getter x0 : String + getter y0 : String + getter x1 : String + getter y1 : String + end + + @[JSON::Field(key: "zoneId")] + getter id : String + getter type : String + getter label : String + + @[JSON::Field(key: "regionOfInterest")] + getter region : Region + + @[JSON::Field(ignore: true)] + property distance : Float64 = 0.0 + + def mid_point + mid_x = (region.x0.to_f64 + region.x1.to_f64) / 2.0 + mid_y = (region.y0.to_f64 + region.y1.to_f64) / 2.0 + {mid_x, mid_y} + end + + getter x : Float64 do + xpos, @y = mid_point + xpos + end + + getter y : Float64 do + @x, ypos = mid_point + ypos + end + end + + struct DeskMappings + include JSON::Serializable + + getter serial : String + getter level_id : String + + # port_id => desk_id + getter ports : Hash(Int32, String) + end + + struct ZoneDetails + include JSON::Serializable + + property id : String + property name : String + property tags : Array(String) + end + + enum ConnectionType + Wired + Wireless + end + + enum AlertType + PortConnected + PortDisconnected + end + + struct WebhookAlert + include JSON::Serializable + + struct PortData + include JSON::Serializable + + @[JSON::Field(key: "portNum")] + getter port_num : Int32 + end + + @[JSON::Field(key: "networkId")] + getter network_id : String + + @[JSON::Field(key: "alertTypeId")] + getter alert_type : AlertType + + @[JSON::Field(key: "alertData")] + getter alert_data : PortData + + @[JSON::Field(key: "deviceSerial")] + getter device_serial : String + + @[JSON::Field(key: "sharedSecret")] + getter shared_secret : String + + def port_num : Int32 + alert_data.port_num + end + end + + enum PortState + Connected + Disconnected + Disabled + end + + class PortStatusResponse + include JSON::Serializable + + @[JSON::Field(key: "portId")] + getter port_id : String + + @[JSON::Field(ignore: true)] + getter port : Int32 { port_id.to_i } + + getter? enabled : Bool + getter status : PortState + + @[JSON::Field(key: "isUplink")] + getter? is_uplink : Bool + + @[JSON::Field(ignore: true)] + property! switch_serial : String + + @[JSON::Field(ignore: true)] + property mac : String? = nil + + @[JSON::Field(ignore: true)] + property! desk_id : String + + @[JSON::Field(ignore: true)] + property! level_id : String + end +end diff --git a/drivers/cisco/room_kit.cr b/drivers/cisco/room_kit.cr new file mode 100644 index 00000000000..d6416d49c29 --- /dev/null +++ b/drivers/cisco/room_kit.cr @@ -0,0 +1,385 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "promise" +require "uuid" + +require "./collaboration_endpoint" +require "./collaboration_endpoint/ui_extensions" +require "./collaboration_endpoint/presentation" +require "./collaboration_endpoint/powerable" +require "./collaboration_endpoint/cameras" + +class Cisco::RoomKit < PlaceOS::Driver + include Interface::Sensor + + # Discovery Information + descriptive_name "Cisco Room Kit" + generic_name :VidConf + tcp_port 22 + + description <<-DESC + Control of Cisco SX20 devices. + + API access requires a local user with the "admin" role to be + created on the codec + DESC + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + peripheral_id: "uuid", + configuration: { + "RoomAnalytics" => { + "PeopleCountOutOfCall" => "On", + "PeoplePresenceDetector" => "On", + "WakeupOnMotionDetection" => "On", + }, + }, + presets: { + "Front Lecturn": 1, + }, + }) + + include Cisco::CollaborationEndpoint + include Cisco::CollaborationEndpoint::UIExtensions + include Cisco::CollaborationEndpoint::Presentation + include Cisco::CollaborationEndpoint::Powerable + include Cisco::CollaborationEndpoint::Cameras + + enum PresentationMode + None + Local + Remote + end + + @presentation_mode : PresentationMode = PresentationMode::None + @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new + + def connected + super + schedule.in(40.seconds) { disconnect if self["calls"]?.nil? } + end + + protected def connection_ready + subscriptions.clear + subscribe("presentation") do |_sub, state| + if state != "null" + # presentation is typically false or "Sending" + if state == "false" + self[:presentation_mode] = @presentation_mode + else + self[:presentation_mode] = PresentationMode::Remote + end + end + end + + register_feedback "/Event/PresentationPreviewStarted" do + self[:presentation_mode] = PresentationMode::Local + end + register_feedback "/Event/PresentationPreviewStopped" do + @presentation_mode = PresentationMode::None + self[:presentation_mode] = @presentation_mode if self[:presentation]? == false + end + + @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key| + hash[key] = {} of String => Enumerable::JSONComplex + end + self[:calls] = @calls + register_feedback "/Status/Call" do |value_path, value| + if value.is_a? Hash(String, Enumerable::JSONComplex) + if value["Status"]? == "Idle" || value["ghost"]? == "True" + @calls.delete value_path + else + @calls[value_path].merge! value + end + self[:calls] = @calls + else + logger.debug { "unexpected call status value #{value}" } + end + end + end + + map_status mic_mute: "Audio Microphones Mute" + map_status volume: "Audio Volume" + map_status speaker_track: "Cameras SpeakerTrack" + map_status presence_detected: "RoomAnalytics PeoplePresence" + map_status people_count: "RoomAnalytics PeopleCount Current" + map_status do_not_disturb: "Conference DoNotDisturb" + map_status presentation: "Conference Presentation Mode" + map_status peripherals: "Peripherals ConnectedDevice" + # selfview == camera pip + map_status selfview: "Video Selfview Mode" + map_status selfview_fullscreen: "Video Selfview FullScreenMode" + map_status video_input: "Video Input" + map_status video_output: "Video Output" + map_status video_layout: "Video Layout LayoutFamily Local" + map_status standby: "Standby State" + + command({"Audio Microphones Mute" => :mic_mute_on}) + command({"Audio Microphones Unmute" => :mic_mute_off}) + command({"Audio Microphones ToggleMute" => :mic_mute_toggle}) + + def mic_mute(state : Bool = true) + state ? mic_mute_on : mic_mute_off + end + + enum Toogle + On + Off + end + + enum Sound + Alert + Bump + Busy + CallDisconnect + CallInitiate + CallWaiting + Dial + KeyInput + KeyInputDelete + KeyTone + Nav + NavBack + Notification + OK + PresentationConnect + Ringing + SignIn + SpecialInfo + TelephoneCall + VideoCall + VolumeAdjust + WakeUp + end + + command({"Audio Sound Play" => :play_sound}, + sound: Sound, + loop_: Toogle) + command({"Audio Sound Stop" => :stop_sound}) + + command({"Bookings List" => :bookings}, + days_: 1..365, + day_offset_: 0..365, + limit_: Int32, + offset_: Int32) + + command({"Call Accept" => :call_accept}, call_id_: Int32) + command({"Call Reject" => :call_reject}, call_id_: Int32) + command({"Call Disconnect" => :hangup}, call_id_: Int32) + command({"Call Hold" => :call_place_on_hold}, call_id_: Int32) + command({"Call Resume" => :call_resume}, call_id_: Int32) + + command({"Call DTMFSend" => :dtmf_send}, + d_t_m_f_string: String, + call_id_: 0..65534) + + enum DialProtocol + H320 + H323 + Sip + Spark + end + + enum CallType + Audio + Video + end + + command({"Dial" => :dial}, + number: String, + protocol_: DialProtocol, + call_rate_: 64..6000, + call_type_: CallType) + + enum VideoLayout + Equal + PIP + end + + command({"Video Input SetMainVideoSource" => :camera_select}, + connector_id_: 1..3, # Source can either be specified as the + layout_: VideoLayout, # physical connector... + source_id_: 1..3) # ...or the logical source ID + + enum LayoutFamily + Auto + Equal + Overlay + Prominent + Single + end + + enum LayoutTarget + Local + Remote + end + + command({"Video Layout LayoutFamily Set" => :video_layout}, + layout_family: LayoutFamily, + target_: LayoutTarget) + + enum PiPPosition + CenterLeft + CenterRight + LowerLeft + LowerRight + UpperCenter + UpperLeft + UpperRight + end + + enum MonitorRole + First + Second + Third + Fourth + end + + command({"Video Selfview Set" => :selfview}, + mode_: Toogle, + full_screen_mode_: Toogle, + p_i_p_position_: PiPPosition, + on_monitor_role_: MonitorRole) + + @[Security(Level::Support)] + command({"Cameras AutoFocus Diagnostics Start" => :autofocus_diagnostics_start}, + camera_id: 1..1) + + @[Security(Level::Support)] + command({"Cameras AutoFocus Diagnostics Stop" => :autofocus_diagnostics_stop}, + camera_id: 1..1) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Diagnostics Start" => :speaker_track_diagnostics_start}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Diagnostics Stop" => :speaker_track_diagnostics_stop}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Activate" => :speaker_track_activate}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Deactivate" => :speaker_track_deactivate}) + + def speaker_track(state : Bool = true) + state ? speaker_track_activate : speaker_track_deactivate + end + + enum PhonebookType + Corporate + Local + end + + command({"Phonebook Search" => :phonebook_search}, + search_string: String, + phonebook_type_: PhonebookType, + limit_: Int32, + offset_: Int32) + + command({"UserInterface WebView Display" => :webview_display}, + url: String) + + command({"UserInterface WebView Clear" => :webview_clear}) + + @[Security(Level::Support)] + command({"SystemUnit Boot" => :reboot}, action_: PowerOff) + + # Helper methods + # ============== + + def show_camera_pip(visible : Bool) + mode = visible ? Toogle::On : Toogle::Off + selfview mode: mode + end + + def mic_mute(state : Bool = true) + state ? mic_mute_on : mic_mute_off + end + + def presentation_mode(value : PresentationMode) + case value + in .remote? + presentation_start sending_mode: :LocalRemote + in .local? + @presentation_mode = PresentationMode::Local + presentation_start sending_mode: :LocalOnly + in .none? + @presentation_mode = PresentationMode::None + presentation_stop + end + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if mac && mac != config.ip + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + + if sensor_type + sensor = build_sensor_details(sensor_type) + return NO_MATCH unless sensor + [sensor] + else + space_sensors + end + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + return nil unless mac == config.ip + + case id + when "people" + build_sensor_details(:people_count) + when "presence" + build_sensor_details(:presence) + end + end + + protected def build_sensor_details(sensor : SensorType) : Detail? + id = "people_count" + + value = case sensor + when .people_count? + self[:people_count].as_i.to_f64 + when .presence? + id = "presence_detected" + self[:presence_detected] == "No" ? 0.0 : 1.0 + else + raise "sensor type unavailable: #{sensor}" + end + return nil unless value + + Detail.new( + type: sensor, + value: value, + last_seen: Time.utc.to_unix, + mac: config.ip.as(String), + id: id, + name: "Cisco Room Kit (#{config.ip})", + module_id: module_id, + binding: id + ) + end + + protected def space_sensors + [ + build_sensor_details(:people_count), + build_sensor_details(:presence), + ].compact + end +end diff --git a/drivers/cisco/room_os.cr b/drivers/cisco/room_os.cr new file mode 100644 index 00000000000..ec3af9fa146 --- /dev/null +++ b/drivers/cisco/room_os.cr @@ -0,0 +1,42 @@ +require "placeos-driver" +require "promise" +require "uuid" + +require "./collaboration_endpoint" +require "./collaboration_endpoint/ui_extensions" + +class Cisco::RoomOS < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Room OS" + generic_name :RoomOS + tcp_port 22 + + description <<-DESC + Low level driver for any Cisco Room OS device. This may be used + if direct access is required to the device API, or a required feature + is not provided by the device specific implementation. + + Where possible use the implementation for room device in use + i.e. SX80, Room Kit etc. + DESC + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + peripheral_id: "uuid", + configuration: { + "Audio Microphones Mute" => {"Enabled" => "False"}, + "Audio Input Line 1 VideoAssociation" => { + "MuteOnInactiveVideo" => "On", + "VideoInputSource" => 2, + }, + }, + }) + + include Cisco::CollaborationEndpoint + include Cisco::CollaborationEndpoint::UIExtensions + + map_status volume: "Audio Volume" +end diff --git a/drivers/cisco/room_os_spec.cr b/drivers/cisco/room_os_spec.cr new file mode 100644 index 00000000000..9e15843da94 --- /dev/null +++ b/drivers/cisco/room_os_spec.cr @@ -0,0 +1,608 @@ +require "placeos-driver/spec" +require "./collaboration_endpoint/xapi" + +DriverSpecs.mock_driver "Cisco::RoomOS" do + # Test command generation helpers + action = Cisco::CollaborationEndpoint::XAPI.xcommand( + "Camera PositionSet", + camera_id: 1, + lens: "Wide", + optional: nil + ) + action.should eq(%(xCommand Camera PositionSet CameraId: 1 Lens: "Wide")) + + action = Cisco::CollaborationEndpoint::XAPI.xcommand( + "Audio Volume Decrease" + ) + action.should eq(%(xCommand Audio Volume Decrease)) + + # Test the response processing helpers + response = JSON.parse(%({ + "Configuration":{ + "Audio":{ + "DefaultVolume":{ + "valueSpaceRef":"/Valuespace/INT_0_100", + "Value":"50" + }, + "Input":{ + "Line":[ + { + "id":"1", + "VideoAssociation":{ + "MuteOnInactiveVideo":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "VideoInputSource":{ + "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2", + "Value":"2" + } + } + } + ], + "Microphone":[ + { + "id":"AAA", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + { + "id":"2", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + } + ] + }, + "Microphones":{ + "Mute":{ + "Enabled":{ + "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled", + "Value":"True" + } + } + } + } + } + })).as_h.flatten_xapi_json + response.should eq({ + "Configuration/Audio/DefaultVolume" => 50, + "Configuration/Audio/Input/Line/1" => { + "VideoAssociation/MuteOnInactiveVideo" => true, + "VideoAssociation/VideoInputSource" => 2, + }, + "Configuration/Audio/Input/Microphone/AAA" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "Configuration/Audio/Input/Microphone/2" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "Configuration/Audio/Microphones/Mute/Enabled" => true, + }) + + transmit "welcome\n*r Login successful\r\n" + + # ==== + # Connection setup + puts "\nCONNECTION SETUP:\n==============" + should_send "xPreferences OutputMode JSON\n" + should_send "xPreferences OutputMode JSON\n" + + # ==== + # System registration + puts "\nSYSTEM REGISTRATION:\n==============" + + data = String.new expect_send + data.starts_with?(%(xCommand Peripherals Connect ID: "uuid" Name: "PlaceOS" Type: ControlSystem | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "PeripheralsConnectResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + + # ==== + # Config push + puts "\nCONFIG PUSH:\n==============" + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Microphones Mute Enabled: "False" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation MuteOnInactiveVideo: "On" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation VideoInputSource: 2 | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + # MAPS Status ==== + data = String.new expect_send + data.starts_with?(%(xFeedback Register /Configuration | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + should_send "xConfiguration *\n" + responds %({ + "Configuration":{ + "Audio":{ + "DefaultVolume":{ + "valueSpaceRef":"/Valuespace/INT_0_100", + "Value":"50" + }, + "Input":{ + "Line":[ + { + "id":"1", + "VideoAssociation":{ + "MuteOnInactiveVideo":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "VideoInputSource":{ + "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2", + "Value":"2" + } + } + } + ], + "Microphone":[ + { + "id":"1", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + { + "id":"2", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + } + ] + }, + "Microphones":{ + "Mute":{ + "Enabled":{ + "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled", + "Value":"True" + } + } + } + } + } + }) + + status[:configuration].should eq({ + "/Audio/DefaultVolume" => 50, + "/Audio/Input/Line/1" => { + "VideoAssociation/MuteOnInactiveVideo" => true, + "VideoAssociation/VideoInputSource" => 2, + }, + "/Audio/Input/Microphone/1" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "/Audio/Input/Microphone/2" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "/Audio/Microphones/Mute/Enabled" => true, + }) + + data = String.new expect_send + puts "GOT: #{data}" + data.starts_with?(%(xFeedback Register /Status/Audio/Volume | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + puts "GOT: #{data}" + data.starts_with?(%(xStatus Audio Volume | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "Audio":{ + "Volume":{ + "Value":"50" + } + } + }, + "ResultId": "#{id}" + }) + + # Finish mapping status + status[:volume].should eq(50) + + # ==== + # Audio Status + resp = exec(:xstatus, "Audio") + data = String.new expect_send + data.starts_with?(%(xStatus Audio | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "Status":{ + "Audio":{ + "Input":{ + "Connectors":{ + "Microphone":[ + { + "id":"1", + "ConnectionStatus":{ + "Value":"Connected" + } + }, + { + "id":"2", + "ConnectionStatus":{ + "Value":"NotConnected" + } + } + ] + } + }, + "Microphones":{ + "Mute":{ + "Value":"On" + } + }, + "Output":{ + "Connectors":{ + "Line":[ + { + "id":"1", + "DelayMs":{ + "Value":"0" + } + } + ] + } + }, + "Volume":{ + "Value":"50" + } + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq({ + "Status/Audio/Input/Connectors/Microphone/1" => { + "ConnectionStatus" => "Connected", + }, + "Status/Audio/Input/Connectors/Microphone/2" => { + "ConnectionStatus" => "NotConnected", + }, + "Status/Audio/Microphones/Mute" => true, + "Status/Audio/Output/Connectors/Line/1" => { + "DelayMs" => 0, + }, + "Status/Audio/Volume" => 50, + }) + + # ==== + # Time Status + resp = exec(:xstatus, "Time") + data = String.new expect_send + data.starts_with?(%(xStatus Time | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "Time":{ + "SystemTime":{ + "Value":"2017-11-27T15:14:25+1000" + } + } + }, + "ResultId": "#{id}" + }) + + resp.get.should eq({ + "Status/Time/SystemTime" => "2017-11-27T15:14:25+1000", + }) + + # ==== + # Time Status fail + resp = exec(:xstatus, "Wrong") + data = String.new expect_send + data.starts_with?(%(xStatus Wrong | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "status":"Error", + "Reason":{ + "Value":"No match on address expression." + }, + "XPath":{ + "Value":"Status/Wrong" + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Basic command + resp = exec(:xcommand, "Standby Deactivate") + data = String.new expect_send + data.starts_with?(%(xCommand Standby Deactivate | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "StandbyDeactivateResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq "OK" + + # Command with arguments + resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, Layout: :PIP}) + data = String.new expect_send + data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 Layout: "PIP" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq "OK" + + # Return device argument errors + resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, SourceId: 1}) + data = String.new expect_send + data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 SourceId: 1 | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"Error", + "Reason":{ + "Value":"Must supply either SourceId or ConnectorId (but not both.)" + } + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Return error from invalid / inaccessable xCommands + resp = exec(:xcommand, "Not A Real Command") + data = String.new expect_send + data.starts_with?(%(xCommand Not A Real Command | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "Result":{ + "status":"Error", + "Reason":{ + "Value":"Unknown command" + } + }, + "XPath":{ + "Value":"/Not/A/Real/Command" + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Multiline commands + resp = exec(:xcommand, "SystemUnit SignInBanner Set", "Hello\nWorld!") + data = String.new expect_send + data.starts_with?(%(xCommand SystemUnit SignInBanner Set | resultId=")).should be_true + data.ends_with?(%(Hello\nWorld!\n.\n)).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "SignInBannerSetResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + + resp.get.should eq "OK" + + # Multuple settings return a unit :success when all ok + resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Name: "Borris", Quality: :Motion}) + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Name: "Borris" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + resp.get.should eq true + + # Multiple settings with failure with return a command failure + resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Foo: "Bar", Quality: :Motion}) + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Foo: "Bar" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "CommandResponse":{ + "Configuration":{ + "status":"Error", + "Reason":{ + "Value":"No match on address expression." + }, + "XPath":{ + "Value":"Configuration/Video/Input/Connector[1]/Foo" + } + } + }, + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Out of order send + responds %({ + "Status":{ + "Audio":{ + "Volume":{ + "Value":"52" + } + } + } + }) + + # Finish mapping status + status[:volume].should eq(52) +end diff --git a/drivers/cisco/spaces_room.cr b/drivers/cisco/spaces_room.cr new file mode 100644 index 00000000000..8f5fc49873b --- /dev/null +++ b/drivers/cisco/spaces_room.cr @@ -0,0 +1,59 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" + +class Cisco::SpacesRoom < PlaceOS::Driver + include Interface::Sensor + + descriptive_name "Cisco Spaces Room Sensors" + generic_name :SpacesRoomSensors + description "exposes sensor information to the room" + + default_settings({ + _cisco_spaces_system: "sys-12345", + _cisco_spaces_module: "Cisco_Spaces", + + space_room_id: "a410b6d676", + }) + + getter system_id : String = "" + getter module_name : String = "" + getter room_id : String = "" + + def on_update + @system_id = setting?(String, :cisco_spaces_system).presence || config.control_system.not_nil!.id + @module_name = setting?(String, :cisco_spaces_module).presence || "Cisco_Spaces" + @room_id = setting(String, :space_room_id) + end + + private def cisco_spaces + system(system_id)[module_name] + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence, SensorType::Humidity, SensorType::Temperature, SensorType::AirQuality, SensorType::SoundPressure} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if mac && mac != @room_id + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + return NO_MATCH if zone_id && !system.zones.includes?(zone_id) + + Array(Interface::Sensor::Detail).from_json cisco_spaces.sensors(type, @room_id, zone_id).get.to_json + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + return nil unless mac == @room_id + + Interface::Sensor::Detail?.from_json(cisco_spaces.sensors(@room_id, id).get.to_json) + end +end diff --git a/drivers/cisco/spaces_room_spec.cr b/drivers/cisco/spaces_room_spec.cr new file mode 100644 index 00000000000..88118d8df83 --- /dev/null +++ b/drivers/cisco/spaces_room_spec.cr @@ -0,0 +1,4 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::SpacesRoom" do +end diff --git a/drivers/cisco/switch/snooping_catalyst.cr b/drivers/cisco/switch/snooping_catalyst.cr new file mode 100644 index 00000000000..9680d0b5ece --- /dev/null +++ b/drivers/cisco/switch/snooping_catalyst.cr @@ -0,0 +1,264 @@ +require "placeos-driver" +require "set" + +class Cisco::Switch::SnoopingCatalyst < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Catalyst Switch IP Snooping" + generic_name :Snooping + tcp_port 22 + + # Communication settings + # tokenize delimiter: /\n|-- / + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + building: "building_code", + ignore_macs: { + "Cisco Phone Dock" => "7001b5", + }, + }) + + # Interfaces that indicate they have a device connected + @check_interface = ::Set(String).new + + # MAC, IP, Interface + @snooping = [] of Tuple(String, String, String) + + # interface to MAC address mappings + @interface_macs = {} of String => String + @devices = {} of String => NamedTuple(mac: String, ip: String) + + @hostname : String? = nil + @switch_name : String? = nil + @ignore_macs = ::Set(String).new + + def on_load + # "--More--" is sent without a newline + transport.tokenizer = Tokenizer.new("\n", "--More--") + + on_update + end + + def on_update + @ignore_macs = ::Set.new((setting?(Hash(String, String), :ignore_macs) || {} of String => String).values) + + self[:name] = @switch_name = setting?(String, :switch_name) + self[:ip_address] = config.ip.not_nil!.downcase + self[:building] = setting?(String, :building) + self[:level] = setting?(String, :level) + self[:last_successful_query] ||= 0 + end + + def connected + schedule.in(1.second) { query_connected_devices } + schedule.every(1.minute) { query_connected_devices } + end + + def disconnected + schedule.clear + queue.clear + end + + # Don't want the every day user using this method + @[Security(Level::Administrator)] + def run(command : String) + do_send command + end + + def query_interface_status + do_send "show interfaces status" + end + + def query_mac_addresses + @interface_macs.clear + do_send "show mac address-table" + end + + def query_snooping_bindings + @snooping.clear + do_send "show ip dhcp snooping binding" + end + + @querying_devices : Bool = false + + def query_connected_devices + return if @querying_devices + @querying_devices = true + + logger.debug { "Querying for connected devices" } + + query_interface_status.get + sleep 3.seconds + + query_mac_addresses.get + sleep 3.seconds + + query_snooping_bindings.get + sleep 2.seconds + + nil + ensure + @querying_devices = false + end + + def received(data, task) + data = String.new(data) + logger.debug { "Switch sent: #{data}" } + + # determine the hostname + if @hostname.nil? + parts = data.split(">") + if parts.size == 2 + self[:hostname] = @hostname = parts[0] + + # Exit early as this line is not a response + return task.try &.success + end + end + + case data + when /More/ + # Detect more data available + # ==> --More-- + send(" ", priority: 99, retries: 0) + return task.try &.success + when /STATIC|DYNAMIC/ + # Interface MAC Address detection + # 33 e4b9.7aa5.aa7f STATIC Gi3/0/8 + # 10 f4db.e618.10a4 DYNAMIC Te2/0/40 + parts = data.split(/\s+/).reject(&.empty?) + mac = format(parts[1]) + interface = normalise(parts[-1]) + + @interface_macs[interface] = mac if mac && interface + + return :success + when /%LINK/ + # Interface change detection + # 07-Aug-2014 17:28:26 %LINK-I-Up: gi2 + # 07-Aug-2014 17:28:31 %STP-W-PORTSTATUS: gi2: STP status Forwarding + # 07-Aug-2014 17:44:43 %LINK-I-Up: gi2, aggregated (1) + # 07-Aug-2014 17:44:47 %STP-W-PORTSTATUS: gi2: STP status Forwarding, aggregated (1) + # 07-Aug-2014 17:45:24 %LINK-W-Down: gi2, aggregated (2) + interface = normalise(data.split(",")[0].split(/\s/)[-1]) + + if data =~ /Up:/ + logger.debug { "Notify Up: #{interface}" } + @check_interface << interface + + # Delay here is to give the PC some time to negotiate an IP address + # schedule.in(3000) { query_snooping_bindings } + elsif data =~ /Down:/ + logger.debug { "Notify Down: #{interface}" } + # We are no longer interested in this interface + @check_interface.delete(interface) + end + + self[:interfaces] = @check_interface + + return task.try &.success + when .starts_with?("Total number") + logger.debug { "Processing #{@snooping.size} bindings" } + checked = Set(String).new + devices = {} of String => NamedTuple(mac: String, ip: String) + state_changed = false + + @snooping.each do |mac, ip, interface| + next unless @check_interface.includes?(interface) + next unless @interface_macs[interface]? == mac + next if checked.includes?(interface) + + checked << interface + iface = @devices[interface]? || {mac: "", ip: ""} + + if iface[:ip] != ip || iface[:mac] != mac + logger.debug { "New connection on #{interface} with #{ip}: #{mac}" } + devices[interface] = {mac: mac, ip: ip} + state_changed = true + else + devices[interface] = iface + end + end + + # did an interface change state + if state_changed + @devices = devices + self[:devices] = devices + end + + # As a link up or down might have modified this list + if @check_interface != checked + @check_interface = checked + self[:interfaces] = checked + end + + self[:last_successful_query] = Time.utc.to_unix + + return task.try &.success + end + + # Grab the parts of the response + entries = data.split(/\s+/).reject(&.empty?) + + # show interfaces status + # Port Name Status Vlan Duplex Speed Type + # Gi1/1 notconnect 1 auto auto No Gbic + # Fa6/1 connected 1 a-full a-100 10/100BaseTX + case entries + when .includes?("connected") + interface = entries[0].downcase + unless @check_interface.includes? interface + logger.debug { "Interface Up: #{interface}" } + @check_interface << interface + end + when .includes?("notconnect") + interface = entries[0].downcase + if @check_interface.includes? interface + # Delete the lookup records + logger.debug { "Interface Down: #{interface}" } + @check_interface.delete(interface) + end + else + if entries.size > 2 + # We are looking for MAC to IP address mappings + # ============================================= + # MacAddress IpAddress Lease(sec) Type VLAN Interface + # ------------------ --------------- ---------- ------------- ---- -------------------- + # 00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/43 + # Total number of bindings: 3 + interface = normalise(entries[-1]) + + # We only want entries that are currently active + if @check_interface.includes? interface + # Ensure the data is valid + mac = entries[0] + if mac =~ /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/ + mac = format(mac) + ip = entries[1] + + @snooping << {mac, ip, interface} unless @ignore_macs.includes?(mac[0..5]) + end + end + end + end + + task.try &.success + end + + protected def do_send(cmd, **options) + logger.debug { "requesting: #{cmd}" } + send("#{cmd}\n", **options) + end + + protected def format(mac) + mac.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + protected def normalise(interface) + # Port-channel == po + interface.downcase.gsub("tengigabitethernet", "te").gsub("twogigabitethernet", "tw").gsub("gigabitethernet", "gi").gsub("fastethernet", "fa") + end +end diff --git a/drivers/cisco/switch/snooping_catalyst_spec.cr b/drivers/cisco/switch/snooping_catalyst_spec.cr new file mode 100644 index 00000000000..b3c8cdee782 --- /dev/null +++ b/drivers/cisco/switch/snooping_catalyst_spec.cr @@ -0,0 +1,63 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Switch::SnoopingCatalyst" do + transmit "SG-MARWFA61301>" + sleep 1.5.seconds + + should_send "show interfaces status\n" + transmit "show interfaces status\n" + status[:hostname].should eq("SG-MARWFA61301") + + transmit %(Port Name Status Vlan Duplex Speed Type +Gi1/0/1 notconnect 113 auto auto 10/100/1000BaseTX +Gi1/0/2 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/11 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/12 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/13 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/14 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/15 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/16 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/17 notconnect 113 auto auto 10/100/1000BaseTX +Gi3/0/8 connected 33 auto auto 10/100/1000BaseTX + --More--) + + should_send " " + transmit %( +Gi4/0/48 notconnect 113 auto auto 10/100/1000BaseTX +Gi4/1/1 notconnect 1 auto auto unknown +Gi4/1/2 notconnect 1 auto auto unknown +Te4/1/4 connected trunk full 10G SFP-10GBase-SR +Po1 connected trunk a-full a-10G +) + + sleep 3.1.seconds + + should_send "show mac address-table\n" + transmit "show mac address-table\n" + + transmit %(Vlan MAC Type Port +33 e4b9.7aa5.aa7f STATIC Gi3/0/8 +10 f4db.e618.10a4 DYNAMIC Te2/0/40 +) + + sleep 3.1.seconds + + should_send "show ip dhcp snooping binding\n" + transmit %(MacAddress IpAddress Lease(sec) Type VLAN Interface +------------------ --------------- ---------- ------------- ---- -------------------- +38:C9:86:17:A2:07 192.168.1.15 19868 dhcp-snooping 113 tenGigabitEthernet4/1/4 +E4:B9:7A:A5:AA:7F 10.151.128.150 16532 dhcp-snooping 33 GigabitEthernet3/0/8 +00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/34 +Total number of bindings: 3 + +) + + status["devices"].should eq({ + "gi3/0/8" => { + "mac" => "e4b97aa5aa7f", + "ip" => "10.151.128.150", + }, + }) + + status["interfaces"].should eq(["gi3/0/8"]) +end diff --git a/drivers/cisco/ui_extender.cr b/drivers/cisco/ui_extender.cr new file mode 100644 index 00000000000..bd7783dd3a9 --- /dev/null +++ b/drivers/cisco/ui_extender.cr @@ -0,0 +1,428 @@ +require "promise" +require "placeos-driver" +require "./collaboration_endpoint/response" + +class Cisco::UIExtender < PlaceOS::Driver + descriptive_name "Cisco UI Extender" + generic_name :CiscoUI + description "Cisco Touch 10 UI extensions" + + default_settings({ + codec: "VidConf_1", + cisco_ui_layout: "XML Config", + cisco_ui_bindings: { + "id" => "VidConf_1.binding", + }, + }) + + @event_handlers : Hash(Tuple(String, String), Proc(JSON::Any, Nil)) = {} of Tuple(String, String) => Proc(JSON::Any, Nil) + + # ------------------------------ + # Module callbacks + + def on_load + on_update(true) + end + + def on_unload + clear_extensions + unbind + end + + alias Binding = String | Hash(String, String | Hash(String, String | Hash(String, Array(String)))) + + # id => binding + alias Bindings = Hash(String, Binding) + + def on_update(loading = false) + # we don't want a failure here to prevent loading new settings + unless loading + begin + clear_events + rescue + end + end + + codec_mod = setting?(String, :codec) || "VidConf_1" + unless system.exists? codec_mod + logger.warn { "could not find codec #{codec_mod}" } + return + end + + ui_layout = setting?(String, :cisco_ui_layout) + bindings = setting?(Bindings, :cisco_ui_bindings) || {} of String => Binding + + bind(codec_mod) do + deploy_extensions "PlaceOS", ui_layout if ui_layout + bindings.each { |id, config| link_widget id, config } + end + end + + # ------------------------------ + # Deployment + + # Push a UI definition build with the in-room control editor to the device. + def deploy_extensions(id : String, xml_def : String) + codec.xcommand "UserInterface Extensions Set", xml_def, {"config_id" => id} + end + + # Retrieve the extensions currently loaded. + def list_extensions + codec.xcommand "UserInterface Extensions List" + end + + # Clear any deployed UI extensions. + def clear_extensions + codec.xcommand "UserInterface Extensions Clear" + end + + # ------------------------------ + # Panel interaction + + def close_panel + codec.xcommand "UserInterface Extensions Panel Close" + end + + protected def on_extensions_panel_clicked(event) : Nil + id = event["/Event/UserInterface/Extensions/Panel/Clicked/PanelId"]?.try &.as_s + return unless id + logger.debug { "#{id} opened" } + self[:__active_panel] = id + end + + # ------------------------------ + # Element interaction + + protected def set_actual(id : String, value : String) + logger.debug { "setting #{id} to #{value}" } + update = codec.xcommand "UserInterface Extensions Widget SetValue", + hash_args: {WidgetId: id, Value: value} + + # The device does not raise an event when a widget state is changed via + # the API. In these cases, ensure locally tracked state remains valid. + Promise.defer do + update.get + self[id] = Cisco::CollaborationEndpoint::XAPI.value_convert(value) + value.as(String | Nil) + end + end + + protected def set_actual(id : String, value : Nil) + unset id + end + + protected def set_actual(id : String, value : Bool) + switch(id, value).catch { highlight(id, value).get } + end + + # Set the value of a widget. + def set(id : String, value : String | Bool | Nil) + set_actual(id, value) + end + + # Clear the value associated with a widget. + def unset(id : String) + logger.debug { "clearing #{id}" } + + update = codec.xcommand "UserInterface Extensions Widget UnsetValue", + hash_args: {WidgetId: id} + + Promise.defer do + update.get + self[id] = nil + nil.as(String | Nil) + end + end + + # Set the state of a switch widget. + def switch(id : String, state : Bool? = nil) + state = !status?(Bool, id) if state.nil? + value = state ? "on" : "off" + set id, value + end + + # Set the highlight state for a button widget. + def highlight(id : String, state : Bool = true, momentary : Bool = false, time : Int32 = 500) + value = state ? "active" : "inactive" + schedule.in(time.milliseconds) { highlight(id, !state); nil } if momentary + set id, value + end + + # Set the text label used on text or spinner widget. + def label(id : String, value : String | Bool | Nil) + set_actual(id, value) + end + + # Callback for changes to widget state. + @action_merged : Hash(String, JSON::Any) = {} of String => JSON::Any + + def on_extensions_widget_action(event : Hash(String, JSON::Any)) + logger.debug { "received widget action update #{event}" } + current_key = event.keys.first + case current_key + when "/Event/UserInterface/Extensions/Widget/Action/WidgetId" + @action_merged["WidgetId"] = event[current_key] + when "/Event/UserInterface/Extensions/Widget/Action", "/Event/UserInterface/Extensions/Widget/Action/Value" + @action_merged["Value"] = event[current_key] + when "/Event/UserInterface/Extensions/Widget/Action/Type" + @action_merged["Type"] = event[current_key] + else + logger.debug { "ignoring key #{current_key} processing widget_action event" } + end + logger.debug { "current action state: #{@action_merged}" } + return unless @action_merged.size == 3 + id, value, type = @action_merged.values_at "WidgetId", "Value", "Type" + @action_merged = {} of String => JSON::Any + + logger.debug { "#{id} #{type} = #{value}" } + + id = id.as_s + type = type.as_s + + # Track values of stateful widgets + self[id] = value unless ["", "increment", "decrement"].includes?(value.raw) + + # Trigger any bindings defined for the widget action + begin + handler = @event_handlers.fetch [id, type], nil + handler.try &.call(value) + rescue e + logger.error(exception: e) { "error in binding for #{id}.#{type}" } + end + + # Provide an event stream for other modules to subscribe to + self[:__event_stream] = {id: id, type: type, value: value} + end + + # ------------------------------ + # Popup messages + + def alert(text : String, title : String = "", duration : Int32 = 0) + codec.xcommand( + "UserInterface Message Alert Display", + hash_args: { + Text: text, + Title: title, + Duration: duration, + } + ) + end + + def clear_alert + codec.xcommand "UserInterface Message Alert Clear" + end + + # ------------------------------ + # Internals + + @codec_mod : String = "" + @subscriptions : Array(PlaceOS::Driver::Subscriptions::Subscription) = [] of PlaceOS::Driver::Subscriptions::Subscription + + protected def clear_subscriptions + logger.debug { "clearing subscriptions!" } + @subscriptions.each { |sub| subscriptions.unsubscribe(sub) } + @subscriptions.clear + end + + # Bind to a Cisco CE device module. + protected def bind(mod : String, &bind_cb : Proc(Nil)) + logger.debug { "binding to #{mod}" } + + @codec_mod = mod + subscriptions.clear + @subscriptions.clear + system.subscribe(@codec_mod, :ready) do |_sub, value| + logger.debug { "codec ready: #{value}" } + next unless value == "true" + clear_subscriptions + subscribe_events + bind_cb.call + sync_widget_state + end + @codec_mod + end + + # Unbind from the device module. + protected def unbind + logger.debug { "unbinding" } + clear_events async: true + @codec_mod = "" + end + + protected def bound? + !@codec_mod.empty? + end + + protected def codec + raise "not currently bound to a codec module" unless bound? + system[@codec_mod] + end + + # Push the current module state to the device. + def sync_widget_state + @__status__.each do |key, value| + next if key == "connected" + + # Non-widget related status prefixed with `__` + next if key =~ /^__.*/ + case value + when .starts_with?("\"") + set key, String.from_json(value) + when "true", "false" + set key, value == "true" + end + end + end + + # Build a list of device XPath -> callback mappings. + protected def event_mappings + ui_callbacks.map do |(function_name, callback)| + path = "/Event/UserInterface/#{function_name[3..-1].split("_").map(&.capitalize).join("/")}" + {path, function_name, callback} + end + end + + protected def each_mapping(async : Bool) + device_mod = codec + event_mappings.each { |(path, function, callback)| yield path, function, callback, device_mod } + end + + # Perform an action for each event -> callback mapping. + protected def each_mapping + device_mod = codec + interactions = event_mappings.map do |(path, function, callback)| + future = yield path, function, callback, device_mod + Promise.defer { future.get } + end + Promise.all(interactions).get + end + + protected def subscribe_events(**opts) + mod_id = module_id + each_mapping(**opts) do |path, function, callback, codec| + logger.debug { "monitoring #{mod_id}/#{function}" } + @subscriptions << monitor("#{mod_id}/#{function}") do |_sub, event_json| + logger.debug { "#{function} received #{event_json}" } + spawn do + begin + callback.call(Hash(String, JSON::Any).from_json(event_json)) + rescue error + logger.error(exception: error) { "processing panel event" } + end + end + end + codec.on_event path, mod_id, function + end + end + + protected def clear_events(**opts) + clear_subscriptions + each_mapping(**opts) do |path, _function, _callback, _codec| + future = codec.clear_event(path) + future.get + future + end + end + + # Wire up a widget based on a binding target. + def link_widget(id : String, bindings : Binding) + logger.debug { "setting up bindings for #{id}" } + + binding = case bindings + in String + %w(clicked changed status).product([bindings]).to_h + in Hash(String, Hash(String, Hash(String, Array(String)) | String) | String) + bindings + end + + binding.each do |type, target| + # Status / feedback state binding + if type == "status" + # String | Hash(String, String | Hash(String, String)) + case target + in String + # "mod.status" + mod, state = target.split "." + link_feedback id, mod, state + in Hash(String, String | Hash(String, Array(String))) + # mod => status (provided for compatability with event bindings) + mod, state = target.first + link_feedback id, mod, state.as(String) + end + + # Event binding + else + handler = build_handler target + if handler + @event_handlers[{id, type}] = handler + else + logger.warn { "invalid #{type} binding for #{id}" } + end + end + end + end + + # Bind a widget to another modules status var for feedback. + protected def link_feedback(id : String, mod : String, state : String) + logger.debug { "linking #{id} state to #{mod}.#{state}" } + + system[mod].subscribe(state) do |_sub, value| + spawn do + begin + logger.debug { "#{mod}.#{state} changed to #{value}, updating #{id}" } + payload = value.presence ? JSON.parse(value).raw.as(String | Bool | Nil) : nil + set id, payload + rescue error + logger.error(exception: error) { "module status update" } + end + end + end + end + + # Given the action for a binding, construct the executable event handler. + protected def build_handler(action) + case action + # Implicit arguments + in String + # "mod.method" + raise "action expected to be in format Module_1.binding not: #{action.inspect}" unless action.includes?(".") + mod, method = action.split "." + ->(value : JSON::Any) { + logger.debug { "proxying event to #{mod}.#{method}" } + proxy = system[mod] + args = proxy.__metadata__.arity(method).zero? ? nil : {value} + proxy.__send__ method, args + nil + } + + # Explicit / static arguments + # mod => { method => [params] } + in Hash(String, String | Hash(String, Array(String))) + mod, command = action.first + method, args = command.as(Hash(String, Array(String))).first + ->(_value : JSON::Any) { + logger.debug { "proxying event to #{mod}.#{method}" } + system[mod].__send__ method, args + nil + } + end + end + + # Build a list of all callback methods that have been defined. + # + # Callback methods are denoted being single arity and beginning with `on_`. + IGNORE_METHODS = %w(on_load on_unload on_update) + + {% begin %} + protected def ui_callbacks + [ + {% for method in @type.methods %} + {% method_name = method.name.stringify %} + {% if method.args.size == 1 && !IGNORE_METHODS.includes?(method_name) && method_name[0..2] == "on_" %} + { {{method_name}}, ->(event : Hash(String, JSON::Any)) { {{method_name.id}}(event); nil } }, + {% end %} + {% end %} + ] + end + {% end %} +end diff --git a/drivers/cisco/ui_extender_spec.cr b/drivers/cisco/ui_extender_spec.cr new file mode 100644 index 00000000000..5bd779fac6f --- /dev/null +++ b/drivers/cisco/ui_extender_spec.cr @@ -0,0 +1,53 @@ +require "placeos-driver/spec" +# require "./collaboration_endpoint" + +DriverSpecs.mock_driver "Cisco::UIExtender" do + system({ + VidConf: {VidConfMock}, + }) + sleep 1 + + resp = exec(:set, "something", true).get + puts resp.inspect + sleep 1 + status[:something].should eq(true) + + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action/WidgetId" => "something", + }.to_json) + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action" => false, + }.to_json) + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action/Type" => "changed", + }.to_json) + sleep 1 + status[:something].should eq(false) + sleep 1 +end + +# :nodoc: +class VidConfMock < DriverSpecs::MockDriver + def on_load + spawn { + sleep 0.5 + self[:ready] = self[:connected] = true + } + end + + def xcommand( + command : String, + multiline_body : String? = nil, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type + ) + puts "Running command: #{command} #{hash_args} + body #{multiline_body.try(&.size) || 0}" + end + + def on_event(path : String, mod_id : String, channel : String) + puts "Registering callback for #{path} to #{mod_id}/#{channel}" + end + + def clear_event(path : String) + puts "Clearing event subscription for #{path}" + end +end diff --git a/drivers/cisco/webex/api/messages.cr b/drivers/cisco/webex/api/messages.cr new file mode 100644 index 00000000000..24bfa2edc2c --- /dev/null +++ b/drivers/cisco/webex/api/messages.cr @@ -0,0 +1,41 @@ +module Cisco + module Webex + module Api + class Messages + def initialize(@session : Session) + end + + def list(room_id : String, parent_id : String = "", mentioned_people : String = "", before : String = "", before_message : String = "", max : Int32 = 50) : Array(Models::Message) + params = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, mentionedPeople: mentioned_people, before: before, beforeMessage: before_message, max: max) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def list_direct(person_id : String = "", person_email : String = "", parent_id : String = "") : Array(Models::Message) + params = Utils.hash_from_items_with_values(personId: person_id, personEmail: person_email, parentId: parent_id) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def create(room_id : String = "", parent_id : String = "", to_person_id : String = "", to_person_email : String = "", text : String = "", markdown : String = "") : Models::Message + json = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, toPersonId: to_person_id, toPersonEmail: to_person_email, text: text, markdown: markdown) + response = @session.post([Constants::MESSAGES_ENDPOINT, "/"].join(""), json: json) + Models::Message.from_json(response.body) + end + + def get(message_id : String) : Models::Message + response = @session.get([Constants::MESSAGES_ENDPOINT, "/", message_id].join("")) + Models::Message.from_json(response.body) + end + end + end + end +end diff --git a/drivers/cisco/webex/api/people.cr b/drivers/cisco/webex/api/people.cr new file mode 100644 index 00000000000..500cd455c05 --- /dev/null +++ b/drivers/cisco/webex/api/people.cr @@ -0,0 +1,15 @@ +module Cisco + module Webex + module Api + class People + def initialize(@session : Session) + end + + def me : Models::Person + response = @session.get([Constants::PEOPLE_ENDPOINT, "/", "me"].join("")) + Models::Person.from_json(response.body) + end + end + end + end +end diff --git a/drivers/cisco/webex/api/rooms.cr b/drivers/cisco/webex/api/rooms.cr new file mode 100644 index 00000000000..bf80133d252 --- /dev/null +++ b/drivers/cisco/webex/api/rooms.cr @@ -0,0 +1,41 @@ +module Cisco + module Webex + module Api + class Rooms + def initialize(@session : Session) + end + + def list(room_id : String, parent_id : String = "", mentioned_people : String = "", before : String = "", before_message : String = "", max : Int32 = 50) : Array(Models::Message) + params = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, mentionedPeople: mentioned_people, before: before, beforeMessage: before_message, max: max) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def list_direct(person_id : String = "", person_email : String = "", parent_id : String = "") : Array(Models::Message) + params = Utils.hash_from_items_with_values(personId: person_id, personEmail: person_email, parentId: parent_id) + response = @session.get([Constants::MESSAGES_ENDPOINT, "/"].join(""), params: params) + data = JSON.parse(response.body) + + data.["items"].as_a.map do |item| + Models::Message.from_json(item.to_json) + end + end + + def create(room_id : String = "", parent_id : String = "", to_person_id : String = "", to_person_email : String = "", text : String = "", markdown : String = "") : Models::Message + json = Utils.hash_from_items_with_values(roomId: room_id, parentId: parent_id, toPersonId: to_person_id, toPersonEmail: to_person_email, text: text, markdown: markdown) + response = @session.post([Constants::MESSAGES_ENDPOINT, "/"].join(""), json: json) + Models::Message.from_json(response.body) + end + + def get(message_id : String) : Models::Message + response = @session.get([Constants::MESSAGES_ENDPOINT, "/", message_id].join("")) + Models::Message.from_json(response.body) + end + end + end + end +end diff --git a/drivers/cisco/webex/client.cr b/drivers/cisco/webex/client.cr new file mode 100644 index 00000000000..8d5dc1aed32 --- /dev/null +++ b/drivers/cisco/webex/client.cr @@ -0,0 +1,153 @@ +module Cisco + module Webex + class Client + Log = ::Log.for(self) + + property id : String + property keywords : Hash(String, Command) + property socket : HTTP::WebSocket? + + def initialize(@name : String, @access_token : String, @emails : String, @session : Session, @commands : Array(Command)) + @rooms = Api::Rooms.new(@session) + @people = Api::People.new(@session) + @messages = Api::Messages.new(@session) + + @keywords = + @commands + .flat_map { |command| command.keywords.map { |keyword| {"#{keyword}" => command} } } + .reduce { |acc, i| acc.try(&.merge(i.not_nil!)) } + + @id = @people.me.id + end + + def rooms + @rooms + end + + def people + @people + end + + def messages + @messages + end + + private def device(check_existing : Bool = true) : Models::Device + if check_existing + response = @session.get([Constants::DEFAULT_DEVICE_URL, "/", "devices"].join("")) + data = JSON.parse(response.body) + + devices = data.["devices"].as_a.map do |item| + Models::Device.from_json(item.to_json) + end + + devices.each do |device| + if device.name == nil + next + end + + if device.name == Constants::DEVICE["name"] + return device + end + end + end + + response = @session.post([Constants::DEFAULT_DEVICE_URL, "/", "devices"].join(""), json: Constants::DEVICE) + Models::Device.from_json(response.body) + end + + private def message_id(activity) : String + # In order to geo-locate the correct DC to fetch the message from, you need to use the base64 Id of the message. + id = activity.id + target_url = activity.target.url + target_id = activity.target.id + + verb = activity.verb == "post" ? "messages" : "attachment/actions" + + message_url = target_url.gsub(["conversations", "/", target_id].join(""), [verb, "/", id].join("")) + response = Halite.get(message_url, headers: {"Authorization" => ["Bearer", @access_token].join(" ")}) + + message = JSON.parse(response.body) + message["id"].to_s + end + + private def process_incoming_websocket_message(socket, message) + peek = Models::Peek.from_json(message) + return if peek.data.event_type == "status.start_typing" + + begin + event = Models::Event.from_json(message) + + if event.data.event_type == "conversation.activity" + activity = event.data.activity + Log.debug { "Activity verb is: #{activity.verb}" } + + if activity.verb == "post" + id = message_id(activity) + message = self.messages.get(id) + + if message.person_id != @id + # Ack that this message has been processed. This will prevent the message coming again. + socket.send({"type" => "ack", "messageId" => id}.to_json) + + if message.text.starts_with?(@name) + message.text = message.text.sub(@name, "").strip + end + + return if @emails.none?(activity.actor.email) + + keyword = message.text.split.first.downcase + + if @keywords[keyword]? + message.text = message.text.sub(keyword, "").strip + message = @keywords[keyword].execute(event, keyword, message) + + room_id = message["id"]? || "" + parent_id = message["parent_id"]? || "" + to_person_id = message["to_person_id"]? || "" + to_person_email = message["to_person_email"]? || "" + text = message["text"]? || "" + markdown = message["markdown"]? || "" + + self.messages.create(room_id, parent_id, to_person_id, to_person_email, text, markdown) + else + end + end + else + end + end + rescue e : Exception + Log.debug(exception: e) { } + end + end + + def run : Void + device = device() + @socket = socket = HTTP::WebSocket.new(URI.parse(device.websocket_url)) + + socket.on_message do |message| + process_incoming_websocket_message(socket, message) + end + + socket.on_binary do |binary| + process_incoming_websocket_message(socket, String.new(binary)) + end + + message = { + "id" => UUID.random.to_s, + "type" => "authorization", + "trackingId" => ["webex", "-", UUID.random.to_s].join(""), + "data" => { + "token" => ["Bearer", @access_token].join(" "), + }, + } + socket.send(message.to_json) + socket.run + end + + def stop : Void + @socket.close + end + end + end +end diff --git a/drivers/cisco/webex/cloud_xapi.cr b/drivers/cisco/webex/cloud_xapi.cr new file mode 100644 index 00000000000..29997d660e1 --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi.cr @@ -0,0 +1,258 @@ +require "placeos-driver" +require "./cloud_xapi/ui_extensions" + +class Cisco::Webex::Cloud < PlaceOS::Driver + include CloudXAPI::UIExtensions + + # Discovery Information + descriptive_name "Webex Cloud xAPI" + generic_name :CloudXAPI + + uri_base "https://webexapis.com" + + default_settings({ + cisco_client_id: "", + cisco_client_secret: "", + cisco_target_orgid: "", + cisco_app_id: "", + cisco_personal_token: "", + debug_payload: false, + }) + + getter! device_token : DeviceToken + + @cisco_client_id : String = "" + @cisco_client_secret : String = "" + @cisco_target_orgid : String = "" + @cisco_app_id : String = "" + @cisco_personal_token : String = "" + @debug_payload : Bool = false + + def on_load + on_update + schedule.every(1.minute) { keep_token_refreshed } + end + + def on_update + @cisco_client_id = setting(String, :cisco_client_id) + @cisco_client_secret = setting(String, :cisco_client_secret) + @cisco_target_orgid = setting(String, :cisco_target_orgid) + @cisco_app_id = setting(String, :cisco_app_id) + @cisco_personal_token = setting(String, :cisco_personal_token) + @debug_payload = setting?(Bool, :debug_payload) || false + + @device_token = setting?(DeviceToken, :cisco_token_pair) || @device_token + end + + def led_mode?(device_id : String) + config?(device_id, "UserInterface.LedControl.Mode") + end + + def led_mode(device_id : String, value : String) + value = value.downcase.capitalize + config("UserInterface.LedControl.Mode", device_id, value) + end + + def led_colour?(device_id : String) + status(device_id, "UserInterface.LedControl.Color") + end + + command({"UserInterface LedControl Color Set" => :led_colour}, color: Colour) + + def list_workspaces(org_id : String? = nil, location_id : String? = nil, workspace_location_id : String? = nil, floor_id : String? = nil, + display_name : String? = nil, capacity : Int32? = nil, workspace_type : String? = nil, start : Int32? = nil, max : Int32? = nil, + calling : String? = nil, supported_devices : String? = nil, calendar : String? = nil, device_hosted_meetings_enabled : Bool? = nil, + device_platform : String? = nil, health_level : String? = nil) + params = URI::Params.build do |form| + form.add("orgId", org_id.to_s) if org_id + form.add("locationId", location_id.to_s) if location_id + form.add("workspaceLocationId", workspace_location_id.to_s) if workspace_location_id + form.add("floorId", floor_id.to_s) if floor_id + form.add("displayName", display_name.to_s) if display_name + form.add("capacity", capacity.to_s) if capacity + form.add("type", workspace_type.to_s) if workspace_type + form.add("start", start.to_s) if start + form.add("max", max.to_s) if max + form.add("calling", calling.to_s) if calling + form.add("supportedDevices", supported_devices.to_s) if supported_devices + form.add("calendar", calendar.to_s) if calendar + form.add("deviceHostedMeetingsEnabled", device_hosted_meetings_enabled.to_s) if device_hosted_meetings_enabled + form.add("devicePlatform", device_platform.to_s) if device_platform + form.add("healthLevel", health_level.to_s) if health_level + end + + query = params.empty? ? nil : params.to_s + api_get("/v1/workspaces", query) + end + + def workspace_details(workspace_id : String) + api_get("/v1/workspaces/#{workspace_id}") + end + + def list_devices(max : Int32? = nil, start : Int32? = nil, display_name : String? = nil, person_id : String? = nil, workspace_id : String? = nil, + org_id : String? = nil, connection_status : String? = nil, product : String? = nil, device_type : String? = nil, serial : String? = nil, + tag : String? = nil, software : String? = nil, upgrade_channel : String? = nil, error_code : String? = nil, capability : String? = nil, + permission : String? = nil, location_id : String? = nil, workspace_location_id : String? = nil, mac : String? = nil, device_platform : String? = nil) + params = URI::Params.build do |form| + form.add("max", max.to_s) if max + form.add("start", start.to_s) if start + form.add("displayName", display_name.to_s) if display_name + + form.add("personId", person_id.to_s) if person_id + form.add("workspaceId", workspace_id.to_s) if workspace_id + form.add("orgId", org_id.to_s) if org_id + form.add("connectionStatus", connection_status.to_s) if connection_status + form.add("product", product.to_s) if product + form.add("type", device_type.to_s) if device_type + form.add("tag", tag.to_s) if tag + form.add("serial", serial.to_s) if serial + form.add("software", software.to_s) if software + form.add("upgradeChannel", upgrade_channel.to_s) if upgrade_channel + form.add("errorCode", error_code.to_s) if error_code + form.add("capability", capability.to_s) if capability + form.add("permission", permission.to_s) if permission + form.add("locationId", location_id.to_s) if location_id + form.add("workspaceLocationId", workspace_location_id.to_s) if workspace_location_id + form.add("mac", mac.to_s) if mac + form.add("devicePlatform", device_platform.to_s) if device_platform + end + query = params.empty? ? nil : params.to_s + api_get("/v1/devices", query) + end + + def device_details(device_id : String, org_id : String? = nil) + params = URI::Params.build do |form| + form.add("orgId", org_id.to_s) if org_id + end + + query = params.empty? ? nil : params.to_s + api_get("/v1/devices/#{device_id}", query) + end + + def status(device_id : String, name : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + form.add("name", name) + end + + headers = get_headers + logger.debug { {msg: "Status HTTP Data:", headers: headers.to_json, query: query.to_s} } if @debug_payload + + response = get("/v1/xapi/status?#{query}", headers: headers) + raise "failed to query status for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def command(name : String, payload : String) + headers = get_headers + logger.debug { {msg: "Command HTTP Data:", headers: headers.to_json, command: name, payload: payload} } if @debug_payload + + response = post("/v1/xapi/command/#{name}", headers: headers, body: payload) + raise "failed to execute command #{name}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def api_get(resource : String, query : String? = nil) + headers = get_headers + logger.debug { {msg: "GET #{resource}:", headers: headers.to_json, query: query.to_s} } if @debug_payload + uri = query.presence ? resource + "?#{query}" : resource + response = get(uri, headers: headers) + raise "failed to get #{resource}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def config?(device_id : String, name : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + form.add("key", name) + end + + headers = get_headers + logger.debug { {msg: "Status HTTP Data:", headers: headers.to_json, query: query.to_s} } if @debug_payload + + response = get("/v1/deviceConfigurations?#{query}", headers: headers) + raise "failed to query configuration for device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + def config(name : String, device_id : String, value : String) + body = { + "op" => "replace", + "path" => "#{name}/sources/configured/value", + "value" => value, + } + + config(device_id, body.to_json) + end + + def config(device_id : String, payload : String) + query = URI::Params.build do |form| + form.add("deviceId", device_id) + end + + headers = get_headers("application/json-patch+json") + logger.debug { {msg: "Config HTTP Data:", headers: headers.to_json, query: query, payload: payload} } if @debug_payload + + response = patch("/v1/deviceConfigurations?#{query}", headers: headers, body: payload) + raise "failed to patch config on device #{device_id}, code #{response.status_code}, body: #{response.body}" unless response.success? + JSON.parse(response.body) + end + + protected def get_access_token + if device_token? + logger.debug { {msg: "Access Token expiry", expiry: device_token.expiry} } if @debug_payload + return device_token.auth_token if 1.minute.from_now <= device_token.expiry + logger.debug { {msg: "Access Token expiring, refreshing token", token_expiry: device_token.expiry, refresh_expiry: device_token.refresh_expiry} } if @debug_payload + return refresh_token if 1.minute.from_now <= device_token.refresh_expiry + end + + body = { + "clientId": @cisco_client_id, + "clientSecret": @cisco_client_secret, + "targetOrgId": @cisco_target_orgid, + }.to_json + + headers = HTTP::Headers{ + "Authorization" => "Bearer #{@cisco_personal_token}", + "Content-Type" => "application/json", + "Accept" => "application/json", + } + response = post("/v1/applications/#{@cisco_app_id}/token", headers: headers, body: body) + raise "failed to retriee access token for client-id #{@cisco_client_id}, code #{response.status_code}, body #{response.body}" unless response.success? + @device_token = DeviceToken.from_json(response.body) + define_setting(:cisco_token_pair, device_token) + device_token.auth_token + end + + protected def refresh_token + body = URI::Params.build do |form| + form.add("grant_type", "refresh_token") + form.add("client_id", @cisco_client_id) + form.add("client_secret", @cisco_client_secret) + form.add("refresh_token", device_token.refresh_token) + end + + headers = HTTP::Headers{ + "Content-Type" => "application/x-www-form-urlencoded", + "Accept" => "application/json", + } + response = post("/v1/access_token", headers: headers, body: body) + raise "failed to refresh access token for client-id #{@cisco_client_id}, code #{response.status_code}, body #{response.body}" unless response.success? + @device_token = DeviceToken.from_json(response.body) + define_setting(:cisco_token_pair, device_token) + device_token.auth_token + end + + protected def keep_token_refreshed : Nil + return if @device_token.nil? + refresh_token if 1.minute.from_now >= device_token.refresh_expiry + end + + private def get_headers(content_type : String = "application/json") + HTTP::Headers{ + "Authorization" => get_access_token, + "Content-Type" => content_type, + "Accept" => "application/json", + } + end +end diff --git a/drivers/cisco/webex/cloud_xapi/models.cr b/drivers/cisco/webex/cloud_xapi/models.cr new file mode 100644 index 00000000000..7e5f6a53531 --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi/models.cr @@ -0,0 +1,105 @@ +module CloudXAPI::Models + enum Colour + Green + Yellow + Red + Purple + Blue + Orange + Orchid + Aquamarine + Fuchsia + Violet + Magenta + Scarlet + Gold + Lime + Turquoise + Cyan + Off + + def to_json(json : JSON::Builder) + json.string(to_s) + end + end + + record DeviceToken, expires_in : Int64, token_type : String, refresh_token : String, refresh_token_expires_in : Int64, + access_token : String do + include JSON::Serializable + + @[JSON::Field(ignore: true)] + getter! expiry : Time + + @[JSON::Field(ignore: true)] + getter! refresh_expiry : Time + + def after_initialize + @expiry = Time.utc + expires_in.seconds + @refresh_expiry = Time.utc + refresh_token_expires_in.seconds + end + + def auth_token + "#{token_type} #{access_token}" + end + end + + enum TextInputType + SingleLine + Numeric + Password + PIN + end + + enum TextKeyboardState + Open + Closed + end + + macro command(cmd_name, **params) + {% for cmd, name in cmd_name %} + def {{name.id}}(device_id : String, + {% for param, klass in params %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + + {% if klass.is_a?(RangeLiteral) %} + {{param.id}} : Int32{% if optional %}? = nil{% end %}, + {% else %} + {{param.id}} : {{klass}}{% if optional %}? = nil{% end %}, + {% end %} + {% end %} + ) + {% for param, klass in params %} + {% if klass.is_a?(RangeLiteral) %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + {% if optional %} if {{param.id}}{% end %} + raise ArgumentError.new("#{ {{param.stringify}} } must be within #{ {{klass}} }, was #{ {{param.id}} }") unless ({{klass}}).includes?({{param.id}}) + {% if optional %}end{% end %} + {% end %} + {% end %} + + command({{cmd.split(" ").join(".")}},{ + "deviceId" => JSON::Any.new(device_id), + {% if params.size > 0 %} + "arguments" => { + {% for param, klass in params %} + {% if param.stringify.ends_with?("_") %} + {% param = param.stringify[0..-2] %} + {% end %} + "{{param.id.capitalize}}" => JSON.parse({{param.id}}.to_json), + {% end %} + } of String => JSON::Any + {% end %} + }.to_json + ) + end + {% end %} + end +end diff --git a/drivers/cisco/webex/cloud_xapi/ui_extensions.cr b/drivers/cisco/webex/cloud_xapi/ui_extensions.cr new file mode 100644 index 00000000000..57a294a52b3 --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi/ui_extensions.cr @@ -0,0 +1,58 @@ +require "./models" + +module CloudXAPI::UIExtensions + include CloudXAPI::Models + command({"UserInterface Message Alert Clear" => :msg_alert_clear}) + command({"UserInterface Message Alert Display" => :msg_alert}, + text: String, + title_: String, + duration_: 0..3600) + + command({"UserInterface Message Prompt Clear" => :msg_prompt_clear}) + + def msg_prompt(device_id : String, text : String, options : Array(JSON::Any::Type), title : String? = nil, feedback_id : String? = nil, duration : Int64? = nil) + option_map = {} of String => JSON::Any::Type + ("Option.1".."Option.5").each_with_index do |key, i| + break if i >= options.size + option_map[key] = options[i] + end + + command "UserInterface.Message.Prompt.Display", + { + "deviceId" => device_id, + "arguments" => { + "text" => text, + "title" => title, + "feedback_id" => feedback_id, + "duration" => duration, + }.merge(option_map), + }.to_json + end + + command({"UserInterface Message TextInput Clear" => :msg_text_clear}) + command({"UserInterface Message TextInput Display" => :msg_text}, + text: String, + feedback_id: String, + title_: String, + duration_: 0..3600, + input_type_: TextInputType, + keyboard_state_: TextKeyboardState, + place_holder_: String, + submit_text_: String) + + def ui_set_value(device_id : String, widget : String, value : JSON::Any::Type? = nil) + cmd = (value.nil? ? "UserInterface Extensions Widget UnsetValue" : "UserInterface Extensions Widget SetValue").tap { |v| break v.split(" ").join(".") } + payload = { + "deviceId" => JSON::Any.new(device_id), + "arguments" => JSON::Any.new({ + "widget_id" => JSON::Any.new(widget), + }), + } of String => JSON::Any + payload["arguments"].as_h["value"] = JSON::Any.new(value) unless value.nil? + command(cmd, payload.to_json) + end + + command({"UserInterface Extensions Set" => :ui_extensions_deploy}, id: String, xml_def: String) + command({"UserInterface Extensions List" => :ui_extensions_list}) + command({"UserInterface Extensions Clear" => :ui_extensions_clear}) +end diff --git a/drivers/cisco/webex/cloud_xapi_spec.cr b/drivers/cisco/webex/cloud_xapi_spec.cr new file mode 100644 index 00000000000..1141a73a1fd --- /dev/null +++ b/drivers/cisco/webex/cloud_xapi_spec.cr @@ -0,0 +1,172 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Webex::Cloud" do + settings({ + cisco_client_id: "client-id", + cisco_client_secret: "client-secret", + cisco_target_orgid: "target-org", + cisco_app_id: "my-app", + cisco_personal_token: "my-personal-token", + debug_payload: true, + }) + + ret_val = exec(:led_colour?, "device1-id") + + expect_http_request(2.seconds) do |request, response| + if request.path == "/v1/applications/my-app/token" + response.status_code = 200 + response << device_resp_json.to_json + else + response.status_code = 401 + end + end + + expect_http_request(2.seconds) do |request, response| + if request.headers["Authorization"]? == "Bearer generated-access-token" + response.status_code = 200 + response << color_resp(request.query_params["deviceId"]).to_json + else + response.status_code = 401 + end + end + + ret_val.get.should eq(color_resp("device1-id")) + + ret_val = exec(:led_colour, "device1-id", :green) + + # invoking another endpoint request should use previously obtained access token + + expect_http_request do |request, response| + headers = request.headers + io = request.body + if io + data = io.gets_to_end + request = JSON.parse(data) + if request["deviceId"] == "device1-id" && request["arguments"]["Color"] == "Green" && headers["Authorization"] == "Bearer generated-access-token" + response.status_code = 202 + response << color_set_resp.to_json + else + response.status_code = 401 + end + else + raise "expected request to include excute command body params #{request.inspect}" + end + end + + ret_val.get.should eq(color_set_resp) + + ret_val = exec(:led_mode?, "device1-id") + expect_http_request do |request, response| + response.status_code = 200 + response << %({"mode": "Auto"}) + end + + ret_val.get.try &.as_h["mode"].should eq "Auto" + + ret_val = exec(:msg_prompt, "device1-id", "text", [JSON::Any.new("one")], "title", "feedback_id", 32) + expect_http_request do |request, response| + response.status_code = 200 + response << %({"status": "OK"}) + end + + ret_val.get.try &.as_h["status"].should eq "OK" + + ret_val = exec(:list_workspaces) + + expect_http_request(2.seconds) do |request, response| + if request.path == "/v1/workspaces" && request.query_params.empty? + response.status_code = 200 + response << workspace_resp.to_json + else + response.status_code = 401 + end + end + + ret_val.get.try &.as_h["items"].as_a.size.should eq 1 + + ret_val = exec(:workspace_details, "some-workspace-id") + + expect_http_request(2.seconds) do |request, response| + if request.path == "/v1/workspaces/some-workspace-id" && request.query_params.empty? + response.status_code = 200 + response << workspace_resp[:items][0].to_json + else + response.status_code = 401 + end + end + + ret_val.get.try &.as_h["capacity"].as_i.should eq 5 +end + +def color_resp(device_id : String) + {"deviceId" => device_id, "result" => {"LedControl" => {"Color" => "Green"}}} +end + +def color_set_resp + {"deviceId" => "device1-id", "arguments" => {"Color" => "Green"}} +end + +def device_resp_json + { + "expires_in": 64799, + "token_type": "Bearer", + "refresh_token": "MjZmMzcyZWUtMzI2MS00MmE4LTgyZWMtYTVlMWIxYzBjZjhiODJmYzViOTItMGFi_PF84_1eb65fdf-9643-417f-9974-ad72cae0e10f", + "access_token": "generated-access-token", + "refresh_token_expires_in": 7697037, + } +end + +def workspace_resp + { + "items": [ + { + "id": "Y2lzY29zcGFyazovL3VzL1BMQUNFUy81MTAxQjA3Qi00RjhGLTRFRjctQjU2NS1EQjE5QzdCNzIzRjc", + "orgId": "Y2lzY29zcGFyazovL3VzL09SR0FOSVpBVElPTi8xZWI2NWZkZi05NjQzLTQxN2YtOTk3NC1hZDcyY2FlMGUxMGY", + "locationId": "Y2lzY29...", + "workspaceLocationId": "YL34GrT...", + "floorId": "Y2lzY29z...", + "displayName": "SFO-12 Capanina", + "capacity": 5, + "type": "notSet", + "sipAddress": "test_workspace_1@trialorg.room.ciscospark.com", + "created": "2016-04-21T17:00:00.000Z", + "calling": { + "type": "hybridCalling", + "hybridCalling": { + "emailAddress": "workspace@example.com", + }, + "webexCalling": { + "licenses": [ + "Y2lzY29g4...", + ], + }, + }, + "notes": "this is a note", + "hotdeskingStatus": "on", + "supportedDevices": "collaborationDevices", + "calendar": { + "type": "microsoft", + "emailAddress": "workspace@example.com", + }, + "deviceHostedMeetings": { + "enabled": true, + "siteUrl": "'example.webex.com'", + }, + "devicePlatform": "cisco", + "health": { + "level": "error", + "issues": [ + { + "id": "", + "createdAt": "", + "title": "", + "description": "", + "recommendedAction": "", + "level": "", + }, + ], + }, + }, + ], + } +end diff --git a/drivers/cisco/webex/command.cr b/drivers/cisco/webex/command.cr new file mode 100644 index 00000000000..4fdac03b1d3 --- /dev/null +++ b/drivers/cisco/webex/command.cr @@ -0,0 +1,9 @@ +module Cisco + module Webex + abstract class Command + abstract def keywords : Array(String) + abstract def description : String + abstract def execute(event, keyword, message) + end + end +end diff --git a/drivers/cisco/webex/commands/echo.cr b/drivers/cisco/webex/commands/echo.cr new file mode 100644 index 00000000000..64c4bcdad3b --- /dev/null +++ b/drivers/cisco/webex/commands/echo.cr @@ -0,0 +1,19 @@ +module Cisco + module Webex + module Commands + class Echo < Command + def keywords : Array(String) + ["echo"] + end + + def description : String + "This command simply replies your message!" + end + + def execute(event, keyword, message) + {"id" => message.room_id, "text" => message.text} + end + end + end + end +end diff --git a/drivers/cisco/webex/commands/greeting.cr b/drivers/cisco/webex/commands/greeting.cr new file mode 100644 index 00000000000..dc1a396a1be --- /dev/null +++ b/drivers/cisco/webex/commands/greeting.cr @@ -0,0 +1,19 @@ +module Cisco + module Webex + module Commands + class Greeting < Command + def keywords : Array(String) + ["hello", "hi"] + end + + def description : String + "This command simply responds to hello, hi, how are you, etc." + end + + def execute(event, keyword, message) + {"id" => message.room_id, "text" => "👋"} + end + end + end + end +end diff --git a/drivers/cisco/webex/constants.cr b/drivers/cisco/webex/constants.cr new file mode 100644 index 00000000000..ef5605e0096 --- /dev/null +++ b/drivers/cisco/webex/constants.cr @@ -0,0 +1,45 @@ +module Cisco + module Webex + module Constants + VERSION = {{ `shards version "#{__DIR__}"`.chomp.stringify.downcase }} + + STATUS_CODES = { + 200 => "Successful request with body content.", + 204 => "Successful request without body content.", + 400 => "The request was invalid or cannot be otherwise served.", + 401 => "Authentication credentials were missing or incorrect.", + 403 => "The request is understood, but it has been refused or access is not allowed.", + 404 => "The URI requested is invalid or the resource requested, such as a user, does not exist. Also returned when the requested format is not supported by the requested method.", + 405 => "The request was made to a resource using an HTTP request method that is not supported.", + 409 => "The request could not be processed because it conflicts with some established rule of the system. For example, a person may not be added to a room more than once.", + 410 => "The requested resource is no longer available.", + 415 => "The request was made to a resource without specifying a media type or used a media type that is not supported.", + 423 => "The requested resource is temporarily unavailable. A `Retry-After` header may be present that specifies how many seconds you need to wait before attempting the request again.", + 429 => "Too many requests have been sent in a given amount of time and the request has been rate limited. A `Retry-After` header should be present that specifies how many seconds you need to wait before a successful request can be made.", + 500 => "Something went wrong on the server. If the issue persists, feel free to contact the Webex Developer Support team (https://developer.webex.com/support).", + 502 => "The server received an invalid response from an upstream server while processing the request. Try again later.", + 503 => "Server is overloaded with requests. Try again later.", + } + + DEFAULT_BASE_URL = "https://webexapis.com/v1/" + DEFAULT_DEVICE_URL = "https://wdm-a.wbx2.com/wdm/api/v1/" + DEFAULT_SINGLE_REQUEST_TIMEOUT = 60 + DEFAULT_WAIT_ON_RATE_LIMIT = true + + DEVICE = { + "deviceType" => "DESKTOP", + "localizedModel" => "crystal", + "model" => "crystal", + "name" => UUID.random.to_s, + "systemName" => "webex-bot-client", + "systemVersion" => VERSION, + } + + ROOMS_ENDPOINT = "rooms" + PEOPLE_ENDPOINT = "people" + MESSAGES_ENDPOINT = "messages" + + WEBEX_TEAMS_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + end + end +end diff --git a/drivers/cisco/webex/exceptions/argument.cr b/drivers/cisco/webex/exceptions/argument.cr new file mode 100644 index 00000000000..4885bbce93f --- /dev/null +++ b/drivers/cisco/webex/exceptions/argument.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class Argument < Exception + end + end + end +end diff --git a/drivers/cisco/webex/exceptions/method.cr b/drivers/cisco/webex/exceptions/method.cr new file mode 100644 index 00000000000..3daec0b5636 --- /dev/null +++ b/drivers/cisco/webex/exceptions/method.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class Method < Exception + end + end + end +end diff --git a/drivers/cisco/webex/exceptions/rate_limit.cr b/drivers/cisco/webex/exceptions/rate_limit.cr new file mode 100644 index 00000000000..af22d61182f --- /dev/null +++ b/drivers/cisco/webex/exceptions/rate_limit.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class RateLimit < Exception + end + end + end +end diff --git a/drivers/cisco/webex/exceptions/status_code.cr b/drivers/cisco/webex/exceptions/status_code.cr new file mode 100644 index 00000000000..de113fad804 --- /dev/null +++ b/drivers/cisco/webex/exceptions/status_code.cr @@ -0,0 +1,8 @@ +module Cisco + module Webex + module Exceptions + class StatusCode < Exception + end + end + end +end diff --git a/drivers/cisco/webex/extensions/chainable.cr b/drivers/cisco/webex/extensions/chainable.cr new file mode 100644 index 00000000000..2707afeb345 --- /dev/null +++ b/drivers/cisco/webex/extensions/chainable.cr @@ -0,0 +1,536 @@ +require "base64" + +module Halite + module Chainable + {% for verb in %w(get head) %} + # {{ verb.id.capitalize }} a resource + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", params: { + # first_name: "foo", + # last_name: "bar" + # }) + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response + request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls) + end + + # {{ verb.id.capitalize }} a streaming resource + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil, + &block : Halite::Response ->) + request({{ verb }}, uri, headers: headers, params: params, raw: raw, tls: tls, &block) + end + {% end %} + + {% for verb in %w(put post patch delete options) %} + # {{ verb.id.capitalize }} a resource + # + # ### Request with form data + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", form: { + # first_name: "foo", + # last_name: "bar" + # }) + # ``` + # + # ### Request with json data + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", json: { + # first_name: "foo", + # last_name: "bar" + # }) + # ``` + # + # ### Request with raw string + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything", raw: "name=Peter+Lee&address=%23123+Happy+Ave&Language=C%2B%2B") + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response + request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls) + end + + # {{ verb.id.capitalize }} a streaming resource + # + # ``` + # Halite.{{ verb.id }}("http://httpbin.org/anything") do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def {{ verb.id }}(uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil, + &block : Halite::Response ->) + request({{ verb }}, uri, headers: headers, params: params, form: form, json: json, raw: raw, tls: tls, &block) + end + {% end %} + + # Adds a endpoint to the request. + # + # + # ``` + # Halite.endpoint("https://httpbin.org") + # .get("/get") + # ``` + def endpoint(endpoint : String | URI) : Halite::Client + branch(default_options.with_endpoint(endpoint)) + end + + # Make a request with the given Basic authorization header + # + # ``` + # Halite.basic_auth("icyleaf", "p@ssw0rd") + # .get("http://httpbin.org/get") + # ``` + # + # See Also: [http://tools.ietf.org/html/rfc2617](http://tools.ietf.org/html/rfc2617) + def basic_auth(user : String, pass : String) : Halite::Client + auth("Basic " + Base64.strict_encode(user + ":" + pass)) + end + + # Make a request with the given Authorization header + # + # ``` + # Halite.auth("private-token", "6abaef100b77808ceb7fe26a3bcff1d0") + # .get("http://httpbin.org/get") + # ``` + def auth(value : String) : Halite::Client + headers({"Authorization" => value}) + end + + # Accept the given MIME type + # + # ``` + # Halite.accept("application/json") + # .get("http://httpbin.org/get") + # ``` + def accept(value : String) : Halite::Client + headers({"Accept" => value}) + end + + # Set requests user agent + # + # ``` + # Halite.user_agent("Custom User Agent") + # .get("http://httpbin.org/get") + # ``` + def user_agent(value : String) : Halite::Client + headers({"User-Agent" => value}) + end + + # Make a request with the given headers + # + # ``` + # Halite.headers({"Content-Type", "application/json", "Connection": "keep-alive"}) + # .get("http://httpbin.org/get") + # # Or + # Halite.headers({content_type: "application/json", connection: "keep-alive"}) + # .get("http://httpbin.org/get") + # ``` + def headers(headers : Hash(String, _) | NamedTuple) : Halite::Client + branch(default_options.with_headers(headers)) + end + + # Make a request with the given headers + # + # ``` + # Halite.headers(content_type: "application/json", connection: "keep-alive") + # .get("http://httpbin.org/get") + # ``` + def headers(**kargs) : Halite::Client + branch(default_options.with_headers(kargs)) + end + + # Make a request with the given cookies + # + # ``` + # Halite.cookies({"private-token", "6abaef100b77808ceb7fe26a3bcff1d0"}) + # .get("http://httpbin.org/get") + # # Or + # Halite.cookies({private-token: "6abaef100b77808ceb7fe26a3bcff1d0"}) + # .get("http://httpbin.org/get") + # ``` + def cookies(cookies : Hash(String, _) | NamedTuple) : Halite::Client + branch(default_options.with_cookies(cookies)) + end + + # Make a request with the given cookies + # + # ``` + # Halite.cookies(name: "icyleaf", "gender": "male") + # .get("http://httpbin.org/get") + # ``` + def cookies(**kargs) : Halite::Client + branch(default_options.with_cookies(kargs)) + end + + # Make a request with the given cookies + # + # ``` + # cookies = HTTP::Cookies.from_client_headers(headers) + # Halite.cookies(cookies) + # .get("http://httpbin.org/get") + # ``` + def cookies(cookies : HTTP::Cookies) : Halite::Client + branch(default_options.with_cookies(cookies)) + end + + # Adds a timeout to the request. + # + # How long to wait for the server to send data before giving up, as a int, float or time span. + # The timeout value will be applied to both the connect and the read timeouts. + # + # Set `nil` to timeout to ignore timeout. + # + # ``` + # Halite.timeout(5.5).get("http://httpbin.org/get") + # # Or + # Halite.timeout(2.minutes) + # .post("http://httpbin.org/post", form: {file: "file.txt"}) + # ``` + def timeout(timeout : (Int32 | Float64 | Time::Span)?) + timeout ? timeout(timeout, timeout, timeout) : branch + end + + # Adds a timeout to the request. + # + # How long to wait for the server to send data before giving up, as a int, float or time span. + # The timeout value will be applied to both the connect and the read timeouts. + # + # ``` + # Halite.timeout(3, 3.minutes, 5) + # .post("http://httpbin.org/post", form: {file: "file.txt"}) + # # Or + # Halite.timeout(3.04, 64, 10.0) + # .get("http://httpbin.org/get") + # ``` + def timeout(connect : (Int32 | Float64 | Time::Span)? = nil, + read : (Int32 | Float64 | Time::Span)? = nil, + write : (Int32 | Float64 | Time::Span)? = nil) + branch(default_options.with_timeout(connect, read, write)) + end + + # Returns `Options` self with automatically following redirects. + # + # ``` + # # Automatically following redirects. + # Halite.follow + # .get("http://httpbin.org/relative-redirect/5") + # + # # Always redirect with any request methods + # Halite.follow(strict: false) + # .get("http://httpbin.org/get") + # ``` + def follow(strict = Halite::Options::Follow::STRICT) : Halite::Client + branch(default_options.with_follow(strict: strict)) + end + + # Returns `Options` self with given max hops of redirect times. + # + # ``` + # # Max hops 3 times + # Halite.follow(3) + # .get("http://httpbin.org/relative-redirect/3") + # + # # Always redirect with any request methods + # Halite.follow(4, strict: false) + # .get("http://httpbin.org/relative-redirect/4") + # ``` + def follow(hops : Int32, strict = Halite::Options::Follow::STRICT) : Halite::Client + branch(default_options.with_follow(hops, strict)) + end + + # Returns `Options` self with enable or disable logging. + # + # #### Enable logging + # + # Same as call `logging` method without any argument. + # + # ``` + # Halite.logging.get("http://httpbin.org/get") + # ``` + # + # #### Disable logging + # + # ``` + # Halite.logging(false).get("http://httpbin.org/get") + # ``` + def logging(enable : Bool = true) + options = default_options + options.logging = enable + branch(options) + end + + # Returns `Options` self with given the logging which it integration from `Halite::Logging`. + # + # #### Simple logging + # + # ``` + # Halite.logging + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post + # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json + # { ... } + # ``` + # + # #### Logger configuration + # + # By default, Halite will logging all outgoing HTTP requests and their responses(without binary stream) to `STDOUT` on DEBUG level. + # You can configuring the following options: + # + # - `skip_request_body`: By default is `false`. + # - `skip_response_body`: By default is `false`. + # - `skip_benchmark`: Display elapsed time, by default is `false`. + # - `colorize`: Enable colorize in terminal, only apply in `common` format, by default is `true`. + # + # ``` + # Halite.logging(skip_request_body: true, skip_response_body: true) + # .post("http://httpbin.org/get", form: {image: File.open("halite-logo.png")}) + # + # # => 2018-08-28 14:33:19 +08:00 | request | POST | http://httpbin.org/post + # # => 2018-08-28 14:33:21 +08:00 | response | 200 | http://httpbin.org/post | 1.61s | application/json + # ``` + # + # #### Use custom logging + # + # Creating the custom logging by integration `Halite::Logging::Abstract` abstract class. + # Here has two methods must be implement: `#request` and `#response`. + # + # ``` + # class CustomLogger < Halite::Logging::Abstract + # def request(request) + # @logger.info "| >> | %s | %s %s" % [request.verb, request.uri, request.body] + # end + # + # def response(response) + # @logger.info "| << | %s | %s %s" % [response.status_code, response.uri, response.content_type] + # end + # end + # + # # Add to adapter list (optional) + # Halite::Logging.register_adapter "custom", CustomLogger.new + # + # Halite.logging(logging: CustomLogger.new) + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # We can also call it use format name if you added it. + # Halite.logging(format: "custom") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # => 2017-12-13 16:40:13 +08:00 | >> | GET | http://httpbin.org/get?name=foobar + # # => 2017-12-13 16:40:15 +08:00 | << | 200 | http://httpbin.org/get?name=foobar application/json + # ``` + def logging(logging : Halite::Logging::Abstract = Halite::Logging::Common.new) + branch(default_options.with_logging(logging)) + end + + # Returns `Options` self with given the file with the path. + # + # #### JSON-formatted logging + # + # ``` + # Halite.logging(format: "json") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + # + # #### create a http request and log to file + # + # ``` + # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "a"))) + # Halite.logging(for: "halite.file") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + # + # #### Always create new log file and store data to JSON formatted + # + # ``` + # Log.setup("halite.file", backend: Log::IOBackend.new(File.open("/tmp/halite.log", "w")) + # Halite.logging(for: "halite.file", format: "json") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + # + # Check the log file content: **/tmp/halite.log** + def logging(format : String = "common", *, for : String = "halite", + skip_request_body = false, skip_response_body = false, + skip_benchmark = false, colorize = true) + opts = { + for: for, + skip_request_body: skip_request_body, + skip_response_body: skip_response_body, + skip_benchmark: skip_benchmark, + colorize: colorize, + } + branch(default_options.with_logging(format, **opts)) + end + + # Turn on given features and its options. + # + # Available features to review all subclasses of `Halite::Feature`. + # + # #### Use JSON logging + # + # ``` + # Halite.use("logging", format: "json") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # => { ... } + # ``` + # + # #### Use common format logging and skip response body + # ``` + # Halite.use("logging", format: "common", skip_response_body: true) + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # + # # => 2018-08-28 14:58:26 +08:00 | request | GET | http://httpbin.org/get + # # => 2018-08-28 14:58:27 +08:00 | response | 200 | http://httpbin.org/get | 615.8ms | application/json + # ``` + def use(feature : String, **opts) + branch(default_options.with_features(feature, **opts)) + end + + # Turn on given the name of features. + # + # Available features to review all subclasses of `Halite::Feature`. + # + # ``` + # Halite.use("logging", "your-custom-feature-name") + # .get("http://httpbin.org/get", params: {name: "foobar"}) + # ``` + def use(*features) + branch(default_options.with_features(*features)) + end + + # Make an HTTP request with the given verb + # + # ``` + # Halite.request("get", "http://httpbin.org/get", { + # "headers" = { "user_agent" => "halite" }, + # "params" => { "nickname" => "foo" }, + # "form" => { "username" => "bar" }, + # }) + # ``` + def request(verb : String, uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) : Halite::Response + request(verb, uri, options_with(headers, params, form, json, raw, tls)) + end + + # Make an HTTP request with the given verb and options + # + # > This method will be executed with oneshot request. + # + # ``` + # Halite.request("get", "http://httpbin.org/stream/3", headers: {"user-agent" => "halite"}) do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def request(verb : String, uri : String, *, + headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil, + &block : Halite::Response ->) + request(verb, uri, options_with(headers, params, form, json, raw, tls), &block) + end + + # Make an HTTP request with the given verb and options + # + # > This method will be executed with oneshot request. + # + # ``` + # Halite.request("get", "http://httpbin.org/get", Halite::Options.new( + # "headers" = { "user_agent" => "halite" }, + # "params" => { "nickname" => "foo" }, + # "form" => { "username" => "bar" }, + # ) + # ``` + def request(verb : String, uri : String, options : Halite::Options? = nil) : Halite::Response + branch(options).request(verb, uri) + end + + # Make an HTTP request with the given verb and options + # + # > This method will be executed with oneshot request. + # + # ``` + # Halite.request("get", "http://httpbin.org/stream/3") do |response| + # puts response.status_code + # while line = response.body_io.gets + # puts line + # end + # end + # ``` + def request(verb : String, uri : String, options : Halite::Options? = nil, &block : Halite::Response ->) + branch(options).request(verb, uri, &block) + end + + private def branch(options : Halite::Options? = nil) : Halite::Client + options ||= default_options + Halite::Client.new(options) + end + + private def default_options + {% if @type.superclass %} + @default_options + {% else %} + DEFAULT_OPTIONS.clear! + {% end %} + end + + private def options_with(headers : (Hash(String, _) | NamedTuple)? = nil, + params : (Hash(String, _) | NamedTuple)? = nil, + form : (Hash(String, _) | NamedTuple)? = nil, + json : (Hash(String, _) | NamedTuple)? = nil, + raw : String? = nil, + tls : OpenSSL::SSL::Context::Client? = nil) + options = Halite::Options.new(headers: headers, params: params, form: form, json: json, raw: raw, tls: tls) + default_options.merge!(options) + end + end +end diff --git a/drivers/cisco/webex/instant_connect.cr b/drivers/cisco/webex/instant_connect.cr new file mode 100644 index 00000000000..09032909dbb --- /dev/null +++ b/drivers/cisco/webex/instant_connect.cr @@ -0,0 +1,118 @@ +require "placeos-driver" +require "base64" +require "jwt" + +class Cisco::Webex::InstantConnect < PlaceOS::Driver + # Discovery Information + generic_name :InstantConnect + descriptive_name "Webex InstantConnect" + uri_base "https://mtg-broker-a.wbx2.com" + + default_settings({ + bot_access_token: "token", + jwt_audience: "a4d886b0-979f-4e2c-a958-3e8c14605e51", + webex_guest_issuer: "a4d886b0", + webex_guest_secret: "a958-3e8c14605e51", + }) + + @jwt_audience : String = "a4d886b0-979f-4e2c-a958-3e8c14605e51" + @bot_access_token : String = "" + @webex_guest_issuer : String = "" + @webex_guest_secret : String = "" + + def on_update + @webex_guest_issuer = setting?(String, :webex_guest_issuer) || "" + @webex_guest_secret = setting?(String, :webex_guest_secret) || "" + + @audience_setting = setting?(String, :jwt_audience) || "a4d886b0-979f-4e2c-a958-3e8c14605e51" + @bot_access_token = setting(String, :bot_access_token) + end + + # Cisco docs on the subject: + # * Guest JWT: https://developer.webex.com/docs/guest-issuer + # * Testing site: https://webexsamples.github.io/browser-sdk-samples/browser-auth-jwt/ + def create_guest_bearer(user_id : String, display_name : String, expiry : Int64? = nil) + expires_at = expiry || 12.hours.from_now.to_unix + JWT.encode({ + "sub": user_id, + "name": display_name, + "iss": @webex_guest_issuer, + "iat": 3.minutes.ago.to_unix, + "exp": expires_at, + }, Base64.decode_string(@webex_guest_secret), :hs256) + end + + def create_meeting(room_id : String) + expiry = 24.hours.from_now.to_unix + request = { + aud: @jwt_audience, + provideShortUrls: true, + jwt: { + # the encounter id, should be unique for each patient encounter + sub: room_id, + exp: expiry, + }, + }.to_json + + get_meeting_details get_hash(request) + end + + protected def get_meeting_details(meeting_keys) + host_details = meeting_keys.host.first + guest_details = meeting_keys.guest.first + + response = get("/api/v1/space/?int=jose&data=#{host_details.cipher}") + logger.debug { "host config returned:\n#{response.body}" } + raise "host token request failed with #{response.status_code}" if response_failed?(response) + meeting_config = Hash(String, JSON::Any).from_json(response.body) + + response = get("/api/v1/space/?int=jose&data=#{guest_details.cipher}") + logger.debug { "guest config returned:\n#{response.body}" } + raise "guest token request failed with #{response.status_code}" if response_failed?(response) + guest_token = String.from_json(response.body, root: "token") + + { + # space_id seems to be an internal id for the meeting room + space_id: meeting_config["spaceId"].as_s, + host_token: meeting_config["token"].as_s, + guest_token: guest_token, + host_url: "#{meeting_keys.base_url}#{host_details.short}", + guest_url: "#{meeting_keys.base_url}#{guest_details.short}", + } + end + + struct JoseEncryptResponse + include JSON::Serializable + + getter host : Array(MeetingDetails) + getter guest : Array(MeetingDetails) + + @[JSON::Field(key: "baseUrl")] + getter base_url : String + end + + struct MeetingDetails + include JSON::Serializable + + getter cipher : String + getter short : String + end + + protected def get_hash(request : String) + response = post("/api/v2/joseencrypt", body: request, headers: HTTP::Headers{ + "Accept" => "application/json", + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@bot_access_token}", + }) + + logger.debug { "get_hash returned:\n#{response.body}" } + raise "request failed with #{response.status_code}" if response_failed?(response) + + JoseEncryptResponse.from_json(response.body) + end + + protected def response_failed?(response) + logger.warn { "instant connect response failure\ncode: #{response.status_code}, status: #{response.status}\nbody:\n#{response.body.inspect}" } unless response.success? + response.status_code != 200 || response.body.nil? + end +end diff --git a/drivers/cisco/webex/instant_connect_spec.cr b/drivers/cisco/webex/instant_connect_spec.cr new file mode 100644 index 00000000000..f747868df63 --- /dev/null +++ b/drivers/cisco/webex/instant_connect_spec.cr @@ -0,0 +1,94 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Webex::InstantConnect" do + # Send the request + retval = exec(:create_meeting, + room_id: "1" + ) + + # HTTP request to get host/guest hash + expect_http_request do |request, response| + headers = request.headers + io = request.body + if io + data = io.gets_to_end + request = JSON.parse(data) + if request.to_s.includes?(%("aud" => "a4d886b0-979f-4e2c-a958-3e8c14605e51")) && headers["Authorization"].includes?(%(Bearer)) + response.status_code = 200 + response << RAW_HASH_RESPONSE + else + response.status_code = 401 + end + else + raise "expected request to include aud & sub details #{request.inspect}" + end + end + + # HTTP request to get token/spaceId using host JWT + expect_http_request do |request, response| + headers = request.headers + if request.resource.includes?("api/v1/space/?int=jose&data=") + response.status_code = 200 + response << RAW_HOST_RESPONSE + else + response.status_code = 401 + end + end + + # HTTP request to get token using guest JWT + expect_http_request do |request, response| + headers = request.headers + if request.resource.includes?("api/v1/space/?int=jose&data=") + response.status_code = 200 + response << RAW_GUEST_RESPONSE + else + response.status_code = 401 + end + end + + retval.get.should eq(JSON.parse(RETVAL)) +end + +RAW_HOST_RESPONSE = %({ + "userIdentifier": "Host", + "isLoggedIn": false, + "isHost": true, + "organizationId": "16917798-5582-49a7-92d0-4410f6964000", + "orgName": "PlaceOS", + "token": "NmFmZGQwODYtZmIzNi00OTlmLWE3N2QtNzUyNzk2MDk4NDU5MjZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "spaceId": "Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1", + "visitId": "1", + "integrationType": "jose" +}) + +RAW_GUEST_RESPONSE = %({ + "userIdentifier": "Guest", + "isLoggedIn": false, + "isHost": false, + "organizationId": "16917798-5582-49a7-92d0-4410f6964000", + "orgName": "PlaceOS", + "token": "NmFmZGQwODYtZmIzNi05OTlmLWE3N2QtMzUyNzk2MDk4NDU5MeZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "spaceId": "Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1", + "visitId": "1", + "integrationType": "jose" +}) + +RAW_HASH_RESPONSE = %({ + "host": [{ + "cipher": "eyJwMnMiOiJCWXpoYmV4W", + "short": "abc1234" + }], + "guest": [{ + "cipher": "eyJwMnMiOiJaVVJsejNsb1", + "short": "def1234" + }], + "baseUrl": "https://somedomain.com/chat/" +}) + +RETVAL = %({ + "space_id":"Y2lzY29zcGFyazovL3VzL1JPT00vODhhZGM1ODAtOThmMi0xMWVjLThiYjQtZjM2MmNkNDBlZDQ1", + "host_token":"NmFmZGQwODYtZmIzNi00OTlmLWE3N2QtNzUyNzk2MDk4NDU5MjZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "guest_token":"NmFmZGQwODYtZmIzNi05OTlmLWE3N2QtMzUyNzk2MDk4NDU5MeZlNmM2YmQtNjY2_PF84_e2d06a2e-ac4e-464f-968d-a5f8a5ac6303", + "host_url": "https://somedomain.com/chat/abc1234", + "guest_url": "https://somedomain.com/chat/def1234" +}) diff --git a/drivers/cisco/webex/models/device.cr b/drivers/cisco/webex/models/device.cr new file mode 100644 index 00000000000..3ccad2ccce3 --- /dev/null +++ b/drivers/cisco/webex/models/device.cr @@ -0,0 +1,15 @@ +module Cisco + module Webex + module Models + class Device + include JSON::Serializable + + @[JSON::Field(key: "webSocketUrl")] + property websocket_url : String + + @[JSON::Field(key: "name")] + property name : String? + end + end + end +end diff --git a/drivers/cisco/webex/models/event.cr b/drivers/cisco/webex/models/event.cr new file mode 100644 index 00000000000..d0ff44cd00b --- /dev/null +++ b/drivers/cisco/webex/models/event.cr @@ -0,0 +1,27 @@ +module Cisco + module Webex + module Models + class Event + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "data")] + property data : Events::Data + + @[JSON::Field(key: "timestamp")] + property timestamp : Int64 + + @[JSON::Field(key: "trackingId")] + property tracking_id : String + + @[JSON::Field(key: "sequenceNumber")] + property sequence_number : Int64 + + @[JSON::Field(key: "filterMessage")] + property filter_message : Bool + end + end + end +end diff --git a/drivers/cisco/webex/models/events/activity.cr b/drivers/cisco/webex/models/events/activity.cr new file mode 100644 index 00000000000..504b2b87c60 --- /dev/null +++ b/drivers/cisco/webex/models/events/activity.cr @@ -0,0 +1,35 @@ +module Cisco + module Webex + module Models + module Events + class Activity + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "url")] + property url : String + + @[JSON::Field(key: "published")] + property published : String + + @[JSON::Field(key: "verb")] + property verb : String + + @[JSON::Field(key: "actor")] + property actor : Actor + + @[JSON::Field(key: "target")] + property target : Target + + @[JSON::Field(key: "clientTempId")] + property client_temp_id : String? + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/actor.cr b/drivers/cisco/webex/models/events/actor.cr new file mode 100644 index 00000000000..01c3b9f8007 --- /dev/null +++ b/drivers/cisco/webex/models/events/actor.cr @@ -0,0 +1,32 @@ +module Cisco + module Webex + module Models + module Events + class Actor + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "displayName")] + property display_name : String + + @[JSON::Field(key: "orgId")] + property organisation_id : String + + @[JSON::Field(key: "emailAddress")] + property email : String + + @[JSON::Field(key: "entryUUID")] + property entry_uuid : String + + @[JSON::Field(key: "type")] + property type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/data.cr b/drivers/cisco/webex/models/events/data.cr new file mode 100644 index 00000000000..6cb51913264 --- /dev/null +++ b/drivers/cisco/webex/models/events/data.cr @@ -0,0 +1,17 @@ +module Cisco + module Webex + module Models + module Events + class Data + include JSON::Serializable + + @[JSON::Field(key: "activity")] + property activity : Activity + + @[JSON::Field(key: "eventType")] + property event_type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/target.cr b/drivers/cisco/webex/models/events/target.cr new file mode 100644 index 00000000000..4c5b77d196c --- /dev/null +++ b/drivers/cisco/webex/models/events/target.cr @@ -0,0 +1,23 @@ +module Cisco + module Webex + module Models + module Events + class Target + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "url")] + property url : String + + @[JSON::Field(key: "published")] + property published : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/type.cr b/drivers/cisco/webex/models/events/type.cr new file mode 100644 index 00000000000..f4a6f84f360 --- /dev/null +++ b/drivers/cisco/webex/models/events/type.cr @@ -0,0 +1,14 @@ +module Cisco + module Webex + module Models + module Events + class Type + include JSON::Serializable + + @[JSON::Field(key: "eventType")] + property event_type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/message.cr b/drivers/cisco/webex/models/message.cr new file mode 100644 index 00000000000..a2136fcc694 --- /dev/null +++ b/drivers/cisco/webex/models/message.cr @@ -0,0 +1,77 @@ +module Cisco + module Webex + module Models + class Message + include JSON::Serializable + + # The unique identifier for the message. + @[JSON::Field(key: "id")] + property id : String + + # The unique identifier for the parent message. + @[JSON::Field(key: "parentId")] + property parent_id : String? + + # The room ID of the message. + @[JSON::Field(key: "roomId")] + property room_id : String + + # The type of room. + @[JSON::Field(key: "roomType")] + property room_type : String + + # The person ID of the recipient when sending a 1:1 message. + @[JSON::Field(key: "toPersonId")] + property to_person_id : String? + + # The email address of the recipient when sending a 1:1 message. + @[JSON::Field(key: "toPersonEmail")] + property to_person_email : String? + + # The message, in plain text. + @[JSON::Field(key: "text")] + property text : String + + # The message, in Markdown format. + @[JSON::Field(key: "markdown")] + property markdown : String? + + # The text content of the message, in HTML format. This read-only property is used by the Webex Teams clients. + @[JSON::Field(key: "html")] + property html : String? + + # Public URLs for files attached to the message. + @[JSON::Field(key: "files")] + property files : Array(String)? + + # The person ID of the message author. + @[JSON::Field(key: "personId")] + property person_id : String + + # The email address of the message author. + @[JSON::Field(key: "personEmail")] + property person_email : String + + # People IDs for anyone mentioned in the message. + @[JSON::Field(key: "mentionedPeople")] + property mentioned_people : Array(String)? + + # Group names for the groups mentioned in the message. + @[JSON::Field(key: "mentionedGroups")] + property mentioned_groups : Array(String)? + + # Message content attachments attached to the message. + @[JSON::Field(key: "attachments")] + property attachments : Array(String)? + + # The date and time the message was created. + @[JSON::Field(key: "created")] + property created : String + + # The date and time the message was created. + @[JSON::Field(key: "updated")] + property updated : String? + end + end + end +end diff --git a/drivers/cisco/webex/models/peek.cr b/drivers/cisco/webex/models/peek.cr new file mode 100644 index 00000000000..2198be1757f --- /dev/null +++ b/drivers/cisco/webex/models/peek.cr @@ -0,0 +1,15 @@ +module Cisco + module Webex + module Models + class Peek + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "data")] + property data : Events::Type + end + end + end +end diff --git a/drivers/cisco/webex/models/person.cr b/drivers/cisco/webex/models/person.cr new file mode 100644 index 00000000000..0d74284904b --- /dev/null +++ b/drivers/cisco/webex/models/person.cr @@ -0,0 +1,12 @@ +module Cisco + module Webex + module Models + class Person + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + end + end + end +end diff --git a/drivers/cisco/webex/models/room.cr b/drivers/cisco/webex/models/room.cr new file mode 100644 index 00000000000..d674cda6f9c --- /dev/null +++ b/drivers/cisco/webex/models/room.cr @@ -0,0 +1,45 @@ +module Cisco + module Webex + module Models + class Room + include JSON::Serializable + + # A unique identifier for the room. + @[JSON::Field(key: "id")] + property id : String + + # The name of the room. + @[JSON::Field(key: "title")] + property title : String + + # The room type. + @[JSON::Field(key: "type")] + property type : String + + # Whether the room is moderated (locked) or not. + @[JSON::Field(key: "isLocked")] + property is_locked : Bool + + # The ID for the team with which this room is associated.. + @[JSON::Field(key: "teamId")] + property team_id : String? + + # The date and time of the room"s last activity.. + @[JSON::Field(key: "lastActivity")] + property last_activity : String + + # The ID of the person who created this room. + @[JSON::Field(key: "creatorId")] + property creator_id : String + + # The date and time the room was created. + @[JSON::Field(key: "created")] + property created : String + + # The ID of the organization which owns this room. + @[JSON::Field(key: "ownerId")] + property owner_id : String + end + end + end +end diff --git a/drivers/cisco/webex/session.cr b/drivers/cisco/webex/session.cr new file mode 100644 index 00000000000..f67597a687c --- /dev/null +++ b/drivers/cisco/webex/session.cr @@ -0,0 +1,82 @@ +module Cisco + module Webex + class Session + Log = ::Log.for(self) + + property base_url : String = Constants::DEFAULT_BASE_URL + property single_request_timeout : Int32 = Constants::DEFAULT_SINGLE_REQUEST_TIMEOUT + property user_agent : String = ["Tepha", Constants::VERSION].join(" ") + property wait_on_rate_limit : Bool = Constants::DEFAULT_WAIT_ON_RATE_LIMIT + + private property client : Halite::Client = Halite::Client.new + + def initialize(@access_token : String) + end + + def request(method : String, url : String, **kwargs) : Halite::Response + # Abstract base method for making requests to the Webex Teams APIs. + # This base method: + # * Expands the API endpoint URL to an absolute URL + # * Makes the actual HTTP request to the API endpoint + # * Provides support for Webex Teams rate-limiting + # * Inspects response codes and raises exceptions as appropriate + + absolute_url = URI.parse(base_url).resolve(url).to_s + + @client.headers({"Authorization" => ["Bearer", @access_token].join(" ")}) + @client.headers({"Content-Type" => "application/json;charset=utf-8"}) + @client.timeout single_request_timeout + + loop do + case method + when "GET" + response = @client.get absolute_url, **kwargs + when "POST" + response = @client.post absolute_url, **kwargs + when "PUT" + response = @client.put absolute_url, **kwargs + when "DELETE" + response = @client.delete absolute_url, **kwargs + else + raise Exceptions::Method.new("The request-method type is invalid.") + end + + begin + status_code = StatusCode.new(response.status_code) + raise Exceptions::RateLimit.new(status_code.message) if response.status_code == 429 + raise Exceptions::StatusCode.new(status_code.message) if !status_code.valid? + + return response + rescue e : Exceptions::StatusCode + Log.error(exception: e) { } + rescue e : Exceptions::RateLimit + Log.error(exception: e) { } + + retry_after = (response.headers["Retry-After"]? || "15").to_i * 1000 + sleep(retry_after) + end + end + end + + def get(url : String, **kwargs) : Halite::Response + # Sends a GET request. + request("GET", url, **kwargs) + end + + def post(url : String, **kwargs) : Halite::Response + # Sends a POST request. + request("POST", url, **kwargs) + end + + def put(url : String, **kwargs) : Halite::Response + # Sends a PUT request. + request("PUT", url, **kwargs) + end + + def delete(url : String, **kwargs) : Halite::Response + # Sends a DELETE request. + request("DELETE", url, **kwargs) + end + end + end +end diff --git a/drivers/cisco/webex/status_code.cr b/drivers/cisco/webex/status_code.cr new file mode 100644 index 00000000000..4e48f8d80ba --- /dev/null +++ b/drivers/cisco/webex/status_code.cr @@ -0,0 +1,23 @@ +module Cisco + module Webex + class StatusCode + private property code : Int32 + + def initialize(@code : Int32) + end + + def valid? : Bool + case @code + when 200, 204 + true + else + false + end + end + + def message : String + Constants::STATUS_CODES[@code] + end + end + end +end diff --git a/drivers/cisco/webex/utils.cr b/drivers/cisco/webex/utils.cr new file mode 100644 index 00000000000..1dc7c015cc1 --- /dev/null +++ b/drivers/cisco/webex/utils.cr @@ -0,0 +1,23 @@ +module Cisco + module Webex + module Utils + def self.hash_from_items_with_values(**kwargs) + kwargs = kwargs.map { |k, v| + if v != nil && v != "" + {"#{k}" => v} + end + } + + kwargs.reject!(nil) + kwargs = kwargs.reduce { |acc, i| acc.try(&.merge(i.not_nil!)) } + + kwargs + end + + def self.named_tuple_from_hash(hash) + named_tuple = NamedTuple.new(roomId: String, text: String) + named_tuple.from(hash) + end + end + end +end diff --git a/drivers/clipsal/c_bus.cr b/drivers/clipsal/c_bus.cr new file mode 100644 index 00000000000..e93e2d92b1f --- /dev/null +++ b/drivers/clipsal/c_bus.cr @@ -0,0 +1,252 @@ +require "placeos-driver" +require "placeos-driver/interface/lighting" + +# Documentation: https://aca.im/driver_docs/Clipsal/CBUS-Lighting-Application.pdf +# and https://aca.im/driver_docs/Clipsal/CBUS-Trigger-Control-Application.pdf + +class Clipsal::CBus < PlaceOS::Driver + include Interface::Lighting::Scene + include Interface::Lighting::Level + alias Area = Interface::Lighting::Area + + # Discovery Information + descriptive_name "Clipsal CBus Lighting" + generic_name :Lighting + tcp_port 10001 + + default_settings({ + trigger_groups: [0xCA], + }) + + @trigger_groups : Array(UInt8) = [0xCA_u8] + + def on_load + queue.wait = false + queue.delay = 100.milliseconds + transport.tokenizer = Tokenizer.new("\r") + + on_update + end + + def on_update + @trigger_groups = setting?(Array(UInt8), :trigger_groups) || [0xCA_u8] + end + + def disconnected + schedule.clear + end + + def connected + # Ensure we are in smart mode + send("|||\r", priority: 99) + + # maintain the connection + schedule.every(1.minute) do + logger.debug { "maintaining connection" } + send("|||\r", priority: 0) + end + end + + def set_lighting_scene(scene : UInt32, area : Area? = nil, fade_time : UInt32 = 1000_u32) + application, group = get_application_group(area, 0xCA) + action = scene & 0xFF + command = Bytes[0x05, application, 0x00, 0x02, group, action.to_u8] + + self[area] = action + + do_send(command) + end + + def lighting_scene?(area : Area? = nil) + _application, group = get_application_group(area, 0xCA) + self[Area.new(group.to_u32)]? + end + + RAMP_RATES = { + (...2_000_u32) => 0b0000_u8, # instant + (2_000_u32...6_000_u32) => 0b0001_u8, # 4s + (6_000_u32...10_000_u32) => 0b0010_u8, # 8s + (10_000_u32...15_000_u32) => 0b0011_u8, # 12s + (15_000_u32...25_000_u32) => 0b0100_u8, # 20s + (25_000_u32...35_000_u32) => 0b0101_u8, # 30s + (35_000_u32...50_000_u32) => 0b0110_u8, # 40s + (50_000_u32...75_000_u32) => 0b0111_u8, # 1m + (75_000_u32...105_000_u32) => 0b1000_u8, # 1m 30s + (105_000_u32...150_000_u32) => 0b1001_u8, # 2m + (150_000_u32...240_000_u32) => 0b1010_u8, # 3m + (240_000_u32...360_000_u32) => 0b1011_u8, # 5m + (360_000_u32...510_000_u32) => 0b1100_u8, # 7m + (510_000_u32...720_000_u32) => 0b1101_u8, # 10m + (720_000_u32...960_000_u32) => 0b1110_u8, # 15m + (960_000_u32...) => 0b1111_u8, # 17m + } + + def lookup_ramp_rate(fade_time : UInt32) : UInt8 + range = RAMP_RATES.keys.find(&.includes?(fade_time)) + rate = RAMP_RATES[range] + + # The command is structured as: 0b0xxxx010 where xxxx == rate + ((rate & 0x0F_u8) << 3) | 0b010_u8 + end + + LEVEL_PERCENTAGE = 0xFF / 100 + + def set_lighting_level(level : Float64, area : Area? = nil, fade_time : UInt32 = 1000_u32) + application, group = get_application_group(area, 0x38) + + level = level.clamp(0.0, 100.0) + level_byte = (level * LEVEL_PERCENTAGE).to_u8 + group = (group & 0xFF).to_u8 + rate = lookup_ramp_rate(fade_time) + + # stop_fading(group) + stop_f = cmd_string(Bytes[0x05, 0x38, 0x00, 0x09, group]) + command = stop_f + cmd_string(Bytes[0x05, application, 0x00, rate, group, level_byte]) + + self["#{area}_level"] = level + + send(command, name: "level_#{application}_#{group}") + end + + def stop_fading(group : UInt8) + do_send(Bytes[0x05, 0x38, 0x00, 0x09, group]) + end + + # return the current level + def lighting_level?(area : Area? = nil) + _application, group = get_application_group(area, 0x38) + self[Area.new(group.to_u32, append: "level")]? + end + + def received(data, task) + # extract the hex string encoded bytes + payload = String.new(data) + logger.debug { "CBus sent: #{payload}" } + data = payload[1..-2].hexbytes + + if !check_checksum(data) + return task.try(&.abort("CBus checksum failed")) + end + + # We are only looking at Point -> MultiPoint commands + # 0x03 == Point -> Point -> MultiPoint + # 0x06 == Point -> Point + if data[0] != 0x05 + logger.debug { "was not a Point -> MultiPoint response: type 0x#{data[0].to_s(16)}" } + return + end + + application = data[1] # The application being referenced + commands = data[3..-2].to_a # Remove the header + checksum + + while commands.size > 0 + current = commands.shift + + case application + when .in?(@trigger_groups) # Trigger group + area = if application == 0xCA_u8 + Area.new(commands.shift.to_u32) + else + Area.new(commands.shift.to_u32, channel: application.to_u32) + end + + case current + when 0x02 # Trigger Event (ex: 0504CA00 020101 29) + self[area] = commands.shift # Action selector + when 0x01 # Trigger Min + self[area] = 0 + when 0x79 # Trigger Max + self[area] = 0xFF + when 0x09 # Indicator Kill (ex: 0504CA00 0901 23) + logger.debug { "trigger kill request: grp 0x#{commands[0].to_s(16)}" } + # Group (turns off indicators of all scenes triggered by this group) + else + logger.debug { "unknown trigger group request 0x#{current.to_s(16)}" } + break # We don't know what data is here + end + when 0x30..0x5F # Lighting group + case current + when 0x01 # Group off (ex: 05043800 0101 0102 0103 0104 7905 33) + self[Area.new(commands.shift.to_u32, append: "level")] = 0.0 + when 0x79 # Group on (ex: 05013800 7905 44) + self[Area.new(commands.shift.to_u32, append: "level")] = 100.0 + when 0x02 # Blinds up or stop + # Example: 05083000022FFF93 + group = commands.shift + value = commands.shift + area = Area.new(group.to_u32, append: "blind") + + if value == 0xFF + self[area] = :up + elsif value == 5 + self[area] = :stopped + end + when 0x1A # Blinds down + # Example: 050830001A2F007A + group = commands.shift + value = commands.shift + self[Area.new(group.to_u32, append: "blind")] = :down if value == 0x00 + when 0x09 # Terminate Ramp + logger.debug { "terminate ramp request: grp 0x#{commands[0].to_s(16)}" } + commands.shift # Group address + else + # Ramp to level (ex: 05013800 0205FF BC) + # Header cmd cksum + if ((current & 0b10000101) == 0) && commands.size > 1 + logger.debug { "ramp request: grp 0x#{commands[0].to_s(16)} - level 0x#{commands[1].to_s(16)}" } + commands.shift(2) # Group address, level + else + logger.debug { "unknown lighting request 0x#{current.to_s(16)}" } + break # We don't know what data is here + end + end + else + logger.debug { "unknown application request app 0x#{application.to_s(16)}" } + break # We haven't programmed this application + end + end + + task.try &.success + end + + protected def get_application_group(area : Area?, app_default = 0x38) + group = area.try &.id + raise "area (cbus group) id required" unless group + application = (area.try(&.channel) || app_default).to_u8 + + {application, group.to_u8 & 0xFF_u8} + end + + protected def checksum(data : Bytes) : Bytes + check = 0 + data.each do |byte| + check += byte + end + check = check % 0x100 + check = ((check ^ 0xFF) + 1) & 0xFF + Bytes[check.to_u8] + end + + protected def check_checksum(data : Bytes) : Bool + check = 0 + data.each do |byte| + check += byte + end + (check % 0x100) == 0x00 + end + + protected def cmd_string(command : Bytes) : String + String.build do |str| + str << "\\" + str << command.hexstring.upcase + str << checksum(command).hexstring.upcase + str << "\r" + end + end + + protected def do_send(command : Bytes, **options) + cmd = cmd_string(command) + logger.debug { "Requesting CBus: #{cmd}" } + send(cmd, **options) + end +end diff --git a/drivers/clipsal/c_bus_spec.cr b/drivers/clipsal/c_bus_spec.cr new file mode 100644 index 00000000000..bf3eb128707 --- /dev/null +++ b/drivers/clipsal/c_bus_spec.cr @@ -0,0 +1,12 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Clipsal::CBus" do + should_send("|||\r") + + transmit "\\05CA0002250109\r" + status["area37"]?.should eq 1 + + exec :set_lighting_scene, 2, {id: 37} + should_send "\\05CA0002250208\r" + status["area37"]?.should eq 2 +end diff --git a/drivers/comm_box/v3x_v4.cr b/drivers/comm_box/v3x_v4.cr new file mode 100644 index 00000000000..ba001a56a4b --- /dev/null +++ b/drivers/comm_box/v3x_v4.cr @@ -0,0 +1,172 @@ +require "placeos-driver" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" + +class CommBox::V3X_V4 < PlaceOS::Driver + include Interface::Powerable + include Interface::Muteable + + enum Input + Vga = 111 # pc in manual + Dvi = 221 + Hdmi = 211 + Hdmi2 = 212 + Hdmi3 = 213 + Hdmi4 = 214 + DisplayPort = 231 + Dtv = 250 + Media = 310 + end + include Interface::InputSelection(Input) + + # Discovery Information + tcp_port 4660 + descriptive_name "CommBox V3, V3X and V4 Display " + generic_name :Display + + DELIMITER = "\r" + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds) { power? } + end + + def disconnected + schedule.clear + end + + def switch_to(input : Input, **options) + send "!200INPT #{input.value}\r", name: "input" + end + + def volume(value : Int32 | Float64, **options) + data = value.to_f.clamp(0.0, 100.0).round_away.to_i + send "!200VOLM #{data}\r", name: "volume" + end + + # Mutes both audio/video + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + mute_audio(state) if layer.audio? || layer.audio_video? + end + + # Emulate audio mute + def mute_audio(state : Bool = true) + send "!200MUTE #{state ? 1 : 0}\r", name: "mute" + end + + def power? + send "!200POWR ?\r" + end + + def input? + send "!200INPT ?\r" + end + + def volume? + send "!200VOLM ?\r" + end + + def toggle_mute + send "!200MUTE 2\r", name: "toggle_mute" + end + + def freeze_screen + send "!200FREZ 1\r", name: "freeze_on" + end + + def toggle_freeze_screen + send "!200FREZ 2\r", name: "togge_freeze" + end + + def freeze_screen? + send "!200FREZ ?\r" + end + + def power(state : Bool) + if state + send "!200POWR 1\r", name: "power" + else + send "!200POWR 0\r", name: "power" + end + end + + def age_mode(state : Bool) + if state + send "!200AGEM 1\r", name: "age_mode" + else + send "!200AGEMR 0\r", name: "age_mode" + end + end + + def toggle_age_mode + send "!200AGEM 2\r", name: "age_mode" + end + + def age_mode? + send "!200AGEM ?\r" + end + + @[Security(Level::Administrator)] + def custom_send(raw : String) + send "#{raw}\r" + end + + # Command is in format of header, version, ID, command name, separator, parameters, terminator + # A common received function for handling responses + def received(data, task) + data = String.new(data).strip + logger.debug { "received data: #{data}" } + + cmd, value = data.split("=", 2) + return task.try &.abort if error_check(cmd, value) + + case cmd + when "!201VOLM" + self[:volume_level] = value.to_i + when "!201MUTE" + self[:mute] = value == "1" + when "!201INPT" + self[:input] = Input.from_value(value.to_i) + when "!201POWR" + self[:power] = value == "1" + when "!201FREZ" + self[:freeze] = value + when "!201AGEM" + self[:age_mode] = value + else + logger.debug { "Unknown Output: #{cmd} with value #{value}" } + end + + task.try &.success + end + + def error_check(data : String, value : String) : Bool + return false unless value.starts_with?("ERR") + + case value + when "ERR1" + logger.warn { "ERR1 - The command is invalid" } + true + when "ERR2" + logger.warn { "ERR2 - The parameter is out of range or not supported." } + true + when "ERR3" + logger.warn { "ERR3 - The command is unavailable at this time." } + true + when "ERR4" + logger.warn { "ERR4 - General failure - all other errors." } + true + else + logger.warn { "Unknown Error : #{value}" } + true + end + end +end diff --git a/drivers/comm_box/v3x_v4_spec.cr b/drivers/comm_box/v3x_v4_spec.cr new file mode 100644 index 00000000000..27b083d2cbe --- /dev/null +++ b/drivers/comm_box/v3x_v4_spec.cr @@ -0,0 +1,34 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "CommBox::V3X_V4" do + exec(:power, true) + should_send("!200POWR 1\r") + responds("!201POWR=1\r") + status[:power].should eq(true) + + exec(:power?) + should_send("!200POWR ?\r") + responds("!201POWR=1\r") + + exec(:volume, 24) + should_send("!200VOLM 24\r") + responds("!201VOLM=24\r") + + exec(:volume, 6) + should_send("!200VOLM 6\r") + responds("!201VOLM=6\r") + + exec(:mute, true) + # Audio mute + should_send("!200MUTE 1\r") + responds("!201MUTE=1\r") + + exec(:mute, false) + # Audio mute + should_send("!200MUTE 0\r") + responds("!201MUTE=0\r") + + exec(:switch_to, "hdmi") + should_send("!200INPT 211\r") + responds("!201INPT=211\r") +end diff --git a/drivers/company_3m/displays/wall_display.cr b/drivers/company_3m/displays/wall_display.cr new file mode 100644 index 00000000000..5f31f6e37dc --- /dev/null +++ b/drivers/company_3m/displays/wall_display.cr @@ -0,0 +1,314 @@ +require "placeos-driver" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "bindata" + +# Protocol: https://aca.im/driver_docs/X3M/RS-232%20Instructions.pdf + +class Company3M::Displays::WallDisplay < PlaceOS::Driver + include Interface::Powerable + include Interface::AudioMuteable + + enum Input + VGA = 0 + DVI = 1 + HDMI = 2 + DisplayPort = 3 + end + + include Interface::InputSelection(Input) + + # Discovery Information + descriptive_name "3M Wall Display" + generic_name :Display + description <<-DESC + Display control is via RS-232 only. Ensure IP -> RS-232 converter has + been configured to provide comms at 9600,N,8,1. + DESC + + # Global Cache Port + tcp_port 4999 + + default_settings({ + # 0 == all monitors + monitor_id: "all", + }) + + @monitor_id : MonitorID = MonitorID::All + @power_target : Bool? = nil + + def on_load + transport.tokenizer = Tokenizer.new("\r") + on_update + end + + def on_update + @monitor_id = setting?(MonitorID, :monitor_id) || MonitorID::All + end + + def connected + schedule.every(15.seconds) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + logger.debug { "Polling device for connectivity heartbeat" } + + # The device does not provide any query only methods for interaction. + # Re-apply the current known power state to provide a comms heartbeat + # if we can do it safely. + target = @power_target + power(target, priority: 0) unless target.nil? + end + + # =================== + # Powerable Interface + # =================== + + def power(state : Bool, **options) + if state != @power_target + # Define setting for polling + self[:power_target] = @power_target = state + end + set :power, state, **options + end + + # ==================== + # Audio Mute Interface + # ==================== + + def mute_audio(state : Bool = true, index : Int32 | String = 0) + set :audio_mute, state + end + + # ========================= + # Input selection Interface + # ========================= + + def switch_to(input : Input) + set :input, input + end + + # ============== + # End Interfaces + # ============== + + protected def in_range(level : Int32 | Float64) : Int32 + level = level.to_f.clamp(0.0, 100.0) + level.round.to_i + end + + def volume(level : Int32 | Float64) + percentage = in_range(level) / 100.0 + adjusted = (percentage * 30.0).round_away.to_i + set :volume, adjusted + end + + def brightness(value : Int32 | Float64) + value = in_range value + set :brightness, value + end + + def contrast(value : Int32 | Float64) + value = in_range value + set :contrast, value + end + + def sharpness(value : Int32 | Float64) + value = in_range value + set :sharpness, value + end + + enum ColourTemp + K9300 = 0 + K6500 = 1 + User = 2 + end + + def colour_temp(value : ColourTemp) + set :colour_temp, value + end + + protected def set(command : Command, param, **opts) + logger.debug { "Setting #{command} -> #{param}" } + + request = new_request command, param + packet = build_packet request + send packet, **opts, name: command.to_s + end + + def received(bytes, task) + response = begin + parse_response bytes + rescue parse_error + logger.warn(exception: parse_error) { "failed to parse 3M packet" } + return task.try &.abort + end + + unless response.success? + logger.warn { "Device error: #{response.inspect}" } + return task.try &.abort + end + + logger.debug { "Device response received: #{response.inspect}" } + + self[response.command.to_s.underscore] = response.value + task.try &.success + end + + enum MonitorID : UInt8 + All = 0x2a + A = 0x41 + B = 0x42 + C = 0x43 + D = 0x44 + E = 0x45 + F = 0x46 + G = 0x47 + H = 0x48 + I = 0x49 + end + + enum MessageSender + PC = 0x30 + end + + enum MessageType : UInt8 + Command = 0x45 + Reply = 0x46 + end + + enum Command : UInt16 + Brightness = 0x0110 + Contrast = 0x0112 + Sharpness = 0x018c + ColourTemp = 0x0254 + Volume = 0x0062 + AudioMute = 0x008d + Input = 0x02cb + Power = 0x0003 + end + + enum ResultCode : UInt16 + Success = 0x3030 + Unsupported = 0x3031 + end + + class RequestPacket < BinData + endian big + + uint8 :header_start, value: ->{ 0x01_u8 } + uint8 :reserved, value: ->{ 0x30_u8 } + enum_field UInt8, monitor_id : MonitorID = MonitorID::All + enum_field UInt8, sender : MessageSender = MessageSender::PC + enum_field UInt8, message_type : MessageType = MessageType::Command + string :message_length, value: ->{ 10.to_s(16).upcase.rjust(2, '0') }, length: ->{ 2 } + + uint8 :message_start, value: ->{ 0x02_u8 } + string :op_code_page, length: ->{ 2 } + string :op_code, length: ->{ 2 } + string :set_value, length: ->{ 4 } + uint8 :message_end, value: ->{ 0x03_u8 } + + def command=(command : Command) + code = command.value.to_s(16).upcase.rjust(4, '0') + self.op_code_page = code[0..1] + self.op_code = code[2..3] + command + end + + def value=(val : Int32) + self.set_value = val.to_s(16).upcase.rjust(4, '0') + end + end + + class ResponsePacket < BinData + endian big + + uint8 :header_start + uint8 :reserved + enum_field UInt8, receiver : MessageSender = MessageSender::PC + enum_field UInt8, monitor_id : MonitorID = MonitorID::All + enum_field UInt8, message_type : MessageType = MessageType::Reply + string :message_length, length: ->{ 2 } + + uint8 :message_start, value: ->{ 0x02_u8 } + enum_field UInt16, result_code : ResultCode = ResultCode::Success + string :op_code_page, length: ->{ 2 } + string :op_code, length: ->{ 2 } + string :reply_type, length: ->{ 2 } + string :max_value, length: ->{ 4 } + string :current_value, length: ->{ 4 } + uint8 :message_end + uint8 :bcc + uint8 :delimiter + + getter command : Command do + Command.from_value "#{op_code_page}#{op_code}".to_i(16) + end + + def success? + self.result_code.success? + end + + def value + raw_val = self.current_value.to_i(16) + case self.command + in .brightness?, .contrast?, .sharpness? + raw_val + in .volume? + # adjust back into 0-100 range + (raw_val / 30.0) * 100.0 + in .audio_mute?, .power? + raw_val == 1 + in .colour_temp? + ColourTemp.from_value raw_val + in .input? + Input.from_value raw_val + end + end + end + + # Map a symbolic command and parameter value to an [op_code, value] + protected def new_request(command : Command, param : Bool | Enum | Int32) + value = case param + in Bool + param ? 1 : 0 + in Enum + param.to_i + in Int32 + param + end + + request = RequestPacket.new + request.command = command + request.value = value + request + end + + # Build a "set_parameter_command" packet ready for transmission + protected def build_packet(request : RequestPacket) : Bytes + request.monitor_id = @monitor_id + io = IO::Memory.new + io.write_bytes(request) + + bytes = io.to_slice + io.write_byte(bytes[1..-1].reduce { |acc, i| acc ^ i }) + io << "\r" + io.to_slice + end + + protected def parse_response(packet : Bytes) : ResponsePacket + io = IO::Memory.new(packet) + response = io.read_bytes(ResponsePacket) + + bcc = packet[1..-3].reduce { |acc, i| acc ^ i } + raise "invalid checksum" if bcc != response.bcc + + response + end +end diff --git a/drivers/company_3m/displays/wall_display_spec.cr b/drivers/company_3m/displays/wall_display_spec.cr new file mode 100644 index 00000000000..9eb2f66a4a3 --- /dev/null +++ b/drivers/company_3m/displays/wall_display_spec.cr @@ -0,0 +1,13 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Company3M::Displays::WallDisplay" do + exec(:power, true) + should_send("\x010*0E0A\x0200030001\x03\x1d\r") + responds("\x0100*F12\x020000030000010001\x03\x6d\r") + status[:power].should be_true + + exec(:power, false) + should_send("\x010*0E0A\x0200030000\x03\x1c\r") + responds("\x0100*F12\x020000030000010000\x03\x6c\r") + status[:power].should be_false +end diff --git a/drivers/crestron/cres_next.cr b/drivers/crestron/cres_next.cr new file mode 100644 index 00000000000..3c95fa9a39f --- /dev/null +++ b/drivers/crestron/cres_next.cr @@ -0,0 +1,126 @@ +require "placeos-driver" +require "json" +require "path" +require "uri" +require "./nvx_models" +require "./cres_next_auth" + +# Documentation: https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Prerequisites-Assumptions.htm +# inspecting request - response packets from the device webui is also useful + +# Parent module for Crestron DM NVX devices. +abstract class Crestron::CresNext < PlaceOS::Driver + include Crestron::CresNextAuth + + def websocket_headers + authenticate + + headers = HTTP::Headers.new + transport.cookies.add_request_headers(headers) + headers["CREST-XSRF-TOKEN"] = @xsrf_token unless @xsrf_token.empty? + headers["User-Agent"] = "advanced-rest-client" + headers + end + + def connected + schedule.every(10.minutes) { maintain_session } + end + + def disconnected + schedule.clear + end + + def tokenize(path : String) + path.split('/').reject(&.empty?) + end + + # ============================================ + # websocket for state changes and get requests + # ============================================ + protected def query(path : String, **options, &block : (JSON::Any, ::PlaceOS::Driver::Task) -> Nil) + request_path = Path["/Device"].join(path).to_s + tokens = tokenize(request_path) + parts = tokens.map { |part| %("#{part}":) } + + send(request_path, **options) do |data, task| + raw_json = String.new(data) + logger.debug { "Crestron sent: #{raw_json}" } + + # check if the response path is included + unless parts.map(&.in?(raw_json)).includes?(false) + # Just grab the relevant data as the response is deeply nested + json = JSON.parse(raw_json) + tokens.each { |key| json = json[key] } + block.call json, task + task.success json + end + end + end + + protected def ws_update(path : String, value, **options) + request_path = Path["/Device"].join(path).to_s + + # expands into object that we need to post + components = tokenize(request_path).map { |part| %({"#{part}") } + payload = %(#{components.join(':')}:#{value.to_json}#{"}" * components.size}) + + apply_ws_changes(payload, **options) + end + + private def apply_ws_changes(payload : String, **options) + send(payload, **options) do |data, task| + raw_json = String.new(data) + logger.debug { "Crestron sent: #{raw_json}" } + + if raw_json.includes? %("Results":) + task.success JSON.parse(raw_json) + end + end + end + + @[PlaceOS::Driver::Security(Level::Support)] + def manual_send(payload : JSON::Any) + data = payload.to_json + logger.debug { "Sending: #{data}" } + send data, wait: false + end + + def received(data, task) + raw_json = String.new data + logger.debug { "Crestron sent: #{raw_json}" } + end + + # ======================================== + # HTTP for updates and session maintenance + # ======================================== + def maintain_session + response = get("/Device/DeviceInfo") + return logout unless response.success? + logger.debug { "Maintaining Session:\n#{response.body}" } + end + + # payload is expected to be a hash or named tuple + protected def update(path : String, value, **options) + request_path = Path["/Device"].join(path).to_s + + # expands into object that we need to post + components = tokenize(request_path).map { |part| %({"#{part}") } + payload = %(#{components.join(':')}:#{value.to_json}#{"}" * components.size}) + + apply_http_changes(request_path, payload, **options) + end + + private def apply_http_changes(request_path : String, payload : String, **options) + queue(**options) do |task| + response = post request_path, body: payload, headers: HTTP::Headers{"CREST-XSRF-TOKEN" => @xsrf_token} + logger.debug { "updated requested for #{request_path}, response was #{response.body}" } + + # no real need to parse the responses as the changes will be sent down the websocket + if response.success? + task.success JSON.parse(response.body) + else + task.abort "crestron failed to apply changes to: #{request_path}\n#{response.body}" + end + end + end +end diff --git a/drivers/crestron/cres_next_auth.cr b/drivers/crestron/cres_next_auth.cr new file mode 100644 index 00000000000..1efc33f0e87 --- /dev/null +++ b/drivers/crestron/cres_next_auth.cr @@ -0,0 +1,60 @@ +require "uri" + +module Crestron::CresNextAuth + protected getter xsrf_token : String = "" + + def authenticate + logger.debug { "Authenticating" } + + # some devices require referer and origin to accept the login + uri = URI.parse config.uri.not_nil! + host = uri.host + + response = post("/userlogin.html", headers: { + "Content-Type" => "application/x-www-form-urlencoded", + "Referer" => "https://#{host}/userlogin.html", + "Origin" => "https://#{host}", + }, body: URI::Params.build { |form| + form.add("login", setting(String, :username)) + form.add("passwd", setting(String, :password)) + }) + + case response.status_code + when 200, 302 + auth_cookies = %w(AuthByPasswd iv tag userid userstr) + if (auth_cookies - response.cookies.to_h.keys).empty? + @xsrf_token = response.headers["CREST-XSRF-TOKEN"]? || "" + logger.debug { "Authenticated" } + else + error = "Device did not return all auth information" + end + when 403 + error = "Invalid credentials" + else + error = "Unexpected response (HTTP #{response.status})" + end + + if error + logger.error { error } + raise error + end + end + + def logout + response = post "/logout" + + case response.status + when 302 + logger.debug { "Logout successful" } + true + else + logger.warn { "Unexpected response (HTTP #{response.status})" } + false + end + ensure + @xsrf_token = "" + transport.cookies.clear + schedule.clear + disconnect + end +end diff --git a/drivers/crestron/fusion.cr b/drivers/crestron/fusion.cr new file mode 100644 index 00000000000..8d69bb81d64 --- /dev/null +++ b/drivers/crestron/fusion.cr @@ -0,0 +1,183 @@ +require "placeos-driver" +require "xml" +require "json" +require "uri" + +# TODO: add handling of security level 2 +# TODO: parse returend results into models +# +# Documentation: https://sdkcon78221.crestron.com/sdk/Fusion_APIs/Content/Topics/Default.htm +class Crestron::Fusion < PlaceOS::Driver + descriptive_name "Crestron Fusion" + generic_name :CrestronFusion + description <<-DESC + Crestron Fusion + DESC + + uri_base "https://fusion.myorg.com/fusion/apiservice/" + + default_settings({ + # Security level: 0 (No Security), 1 (Clear Text), 2 (Encrypted) + security_level: 1, + + user_id: "FUSION_USER_ID", + + # Should be the same as set in the Fusion configuration client + api_pass_code: "FUSION_API_PASS_CODE", + + # xml or json + content_type: "json", + + # uses old ciphers + https_insecure: true, + }) + + @security_level : Int32 = 1 + @user_id : String = "" + @api_pass_code : String = "" + @content_type : String = "" + + def on_update + @security_level = setting(Int32, :security_level) + @user_id = setting(String, :user_id) + @api_pass_code = setting(String, :api_pass_code) + @content_type = "application/" + setting(String, :content_type) + end + + ########### + # Actions # + ########### + + def get_actions(name : String?, room_id : String? = nil, page : Int32? = nil) + params = URI::Params.new + params["search"] = name if name + params["room"] = room_id if room_id + params["page"] = page.to_s if page + + response = perform_request("GET", "/actions", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_action(action_id : String) + response = perform_request("GET", "/actions/#{action_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def send_action(action_id : String?, room_id : String? = nil, node_id : String? = nil) + params = URI::Params.new + params["room"] = room_id if room_id + params["node"] = node_id if node_id + + path = if (id = action_id) && !id.empty? + "/actions/#{id}" + else + "/actions" + end + + response = perform_request("POST", path, params) + JSON.parse(response.body) + end + + ########## + # Alerts # + ########## + + # Severity should be in the range 1-4 + def get_alerts(node_ids : Array(String)? = nil, room_ids : Array(String)? = nil, start_time : String? = nil, end_time : String? = nil, severity : Int32? = nil, active_alerts : Bool = true) + params = URI::Params.new + params["nodes"] = node_ids.join(',') if node_ids + params["rooms"] = room_ids.join(',') if room_ids + params["start"] = start_time if start_time + params["end"] = end_time if end_time + params["severity"] = severity.to_s if severity + params["activeAlerts"] = active_alerts.to_s if active_alerts + + response = perform_request("GET", "/rooms", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ######### + # Rooms # + ######### + + def post_room(room_xml_or_json : String) + response = perform_request("POST", "/rooms", body: room_xml_or_json) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_rooms(name : String?, node_id : String? = nil, page : Int32? = nil) + params = URI::Params.new + params["search"] = name if name + params["node"] = node_id if node_id + params["page"] = page.to_s if page + + response = perform_request("GET", "/rooms", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_room(room_id : String) + response = perform_request("GET", "/rooms/#{room_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def put_room(room_id : String, room_xml_or_json : String) + response = perform_request("PUT", "/rooms/#{room_id}", body: room_xml_or_json) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def delete_room(room_id : String) + response = perform_request("DELETE", "/rooms/#{room_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ################# + # Signal Values # + ################# + + def get_signal_values(symbol_id : String) + response = perform_request("GET", "/signalvalues/#{symbol_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_signal_value(symbol_id : String, attribute_id : String) + response = perform_request("GET", "/signalvalues/#{symbol_id}/#{attribute_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def put_signal_value(symbol_id : String, attribute_id : String, value : String) + params = URI::Params.new + params["value"] = value + + response = perform_request("PUT", "/signalvalues/#{symbol_id}/#{attribute_id}", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ########### + # Helpers # + ########### + + private def perform_request(method : String, path : String, params : URI::Params = URI::Params.new, body : String? = nil) + if @security_level == 1 + params["auth"] = "#{@api_pass_code} #{@user_id}" + elsif @security_level == 2 + params["auth"] = encrypted_token + end + + headers = HTTP::Headers.new + headers["Content-Type"] = @content_type + headers["Accept"] = @content_type + + response = http(method, path, body, params, headers) + if response.status_code == 200 + response + else + raise "Fusion API request failed. Status code: #{response.status_code}" + end + end + + private def encrypted_token + # TODO: encrypt this + # "#{Time.utc.to_rfc3339} #{@user_id}" + raise "Fusion API security level 2 not supported" + end +end diff --git a/drivers/crestron/fusion_spec.cr b/drivers/crestron/fusion_spec.cr new file mode 100644 index 00000000000..230f29a90bb --- /dev/null +++ b/drivers/crestron/fusion_spec.cr @@ -0,0 +1,75 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::Fusion" do + settings({ + security_level: 0, + user_id: "spec-user-id", + api_pass_code: "spec-api-pass-code", + service_url: "/RoomViewSE/APIService/", + content_type: "xml", + }) + + resp = exec(:get_rooms, "Meeting Room A") + expect_http_request do |_request, response| + response.status_code = 200 + response << rooms.to_json + end + resp.get + + resp = exec(:get_room, "room-id") + expect_http_request do |_request, response| + response.status_code = 200 + response << room.to_json + end + resp.get + + resp = exec(:get_signal_value, "symbol-id", "attribute-id") + expect_http_request do |_request, response| + response.status_code = 200 + response << signal_value.to_json + end + resp.get + + resp = exec(:put_signal_value, "symbol-id", "attribute-id", "start") + expect_http_request do |_request, response| + response.status_code = 200 + response << put_signal_value_response.to_json + end + resp.get +end + +########### +# Helpers # +########### + +private def rooms + { + "rooms" => [ + room, + ], + } +end + +private def room + { + "RoomName" => "Meeting Room A", + } +end + +private def signal_value + { + "API_Signals" => [{ + "AttributeID" => "attribute-id", + "AttributeName" => "PlaceOS_Enabled", + "RawValue" => "False", + "SymbolID" => "symbol-id", + }], + "Status" => "Success", + } +end + +private def put_signal_value_response + { + "Status" => "Success", + } +end diff --git a/drivers/crestron/nvx_address_manager.cr b/drivers/crestron/nvx_address_manager.cr new file mode 100644 index 00000000000..0a31eb96a7e --- /dev/null +++ b/drivers/crestron/nvx_address_manager.cr @@ -0,0 +1,72 @@ +require "placeos-driver" +require "./nvx_models" + +class Crestron::NvxAddressManager < PlaceOS::Driver + descriptive_name "Crestron NVX Address Manager" + generic_name :NvxAddressManager + + description <<-DESC + Simplified management of NVX encoder multicast addressing. + + Allows a subnet to be assigned with sequential, blocked address + allocation to all NVX encoders appearing alongside instances of this + module. + + This is intended to be instantiated in systems containing all NVX + encoders that share a multicast subnet. + DESC + + default_settings({ + base_address: "239.8.0.2", + block_size: 8, + }) + + # https://github.com/Sija/ipaddress.cr + MULTICAST_ADDRESSES = ::IPAddress::IPv4.new("224.0.0.0/4") + + @base_address : UInt32 = 0_u32 + @block_size : Int32 = 8 + + def on_update + addr = setting(String, :base_address) + base_addr = ::IPAddress::IPv4.new addr + @base_address = base_addr.to_u32 + logger.warn { "#{addr} is not a valid multicast address" } unless MULTICAST_ADDRESSES.includes? base_addr + @block_size = setting(Int32, :block_size) + end + + def readdress_streams + logger.debug { "readdressing devices" } + + address_pairs = encoders.zip(addresses) + + interactions = address_pairs.map_with_index(1) do |(mod, ip_u32), idx| + ip = ::IPAddress::IPv4.parse_u32(ip_u32) + logger.debug { "setting encoder #{idx} to #{ip}" } + mod.multicast_address ip.to_s + end + + failed = 0 + interactions.each do |request| + begin + request.get + rescue error + failed += 1 + logger.warn(exception: error) { "addressing NVX devices" } + end + end + + raise "#{failed} failed to set stream address" unless failed == 0 + interactions.size + end + + protected def encoders + system.implementing(Crestron::Transmitter) + end + + # returns an iterator of IPv4 addresses represented as 32bit numbers + protected def addresses + address_range = (@base_address..MULTICAST_ADDRESSES.last.to_u32) + address_range.step by: @block_size + end +end diff --git a/drivers/crestron/nvx_address_manager_spec.cr b/drivers/crestron/nvx_address_manager_spec.cr new file mode 100644 index 00000000000..bc92e0b00a7 --- /dev/null +++ b/drivers/crestron/nvx_address_manager_spec.cr @@ -0,0 +1,22 @@ +require "placeos-driver/spec" +require "./nvx_models" + +DriverSpecs.mock_driver "Crestron::NvxAddressManager" do + system({ + Encoder: {NvxEncoderMock, NvxEncoderMock}, + }) + + exec(:readdress_streams).get.should eq 2 + + system(:Encoder_1)[:address].should eq "239.8.0.2" + system(:Encoder_2)[:address].should eq "239.8.0.10" +end + +# :nodoc: +class NvxEncoderMock < DriverSpecs::MockDriver + include Crestron::Transmitter + + def multicast_address(address : String) + self[:address] = address + end +end diff --git a/drivers/crestron/nvx_models.cr b/drivers/crestron/nvx_models.cr new file mode 100644 index 00000000000..15ba209b7aa --- /dev/null +++ b/drivers/crestron/nvx_models.cr @@ -0,0 +1,61 @@ +require "json" + +module Crestron + # Interface for enumerating devices + module Transmitter + end + + module Receiver + end + + enum AspectRatio + MaintainAspectRatio + StretchToFit + end + + enum SourceType + Audio + Video + end + + enum Location + CenterLeft + CenterRight + Custom + LowerLeft + LowerRight + UpperLeft + UpperRight + end + + struct OSD + include JSON::Serializable + + @[JSON::Field(key: "IsEnabled")] + property is_enabled : Bool? + + @[JSON::Field(key: "Location")] + property location : String? + + @[JSON::Field(key: "XPosition")] + property x_position : Int32? = 0 + + @[JSON::Field(key: "YPosition")] + property y_position : Int32? = 0 + + @[JSON::Field(key: "Text")] + property text : String? + + @[JSON::Field(key: "FontColor")] + property font_color : String? + + @[JSON::Field(key: "BackgroundTransparency")] + property background_transparency : String? + + @[JSON::Field(key: "Version")] + property version : String? = "2.0.0" + + def initialize(@text, @is_enabled, @location, @background_transparency) + end + end +end diff --git a/drivers/crestron/nvx_rx.cr b/drivers/crestron/nvx_rx.cr new file mode 100644 index 00000000000..e33dcd01dea --- /dev/null +++ b/drivers/crestron/nvx_rx.cr @@ -0,0 +1,281 @@ +require "./cres_next" +require "placeos-driver/interface/switchable" + +class Crestron::NvxRx < Crestron::CresNext # < PlaceOS::Driver + alias Input = String + alias Output = Int32 + include Interface::Switchable(Input, Output) + include Interface::InputSelection(Input) + include Crestron::Receiver + + descriptive_name "Crestron NVX Receiver" + generic_name :Decoder + description <<-DESC + Crestron NVX network media decoder. + DESC + + uri_base "wss://192.168.0.5/websockify" + + default_settings({ + username: "admin", + password: "admin", + }) + + @subscriptions : Hash(String, JSON::Any) = {} of String => JSON::Any + @audio_follows_video : Bool = true + + def connected + super + audio_follows_video = setting?(Bool, :audio_follows_video) + @audio_follows_video = audio_follows_video.nil? ? true : audio_follows_video + + # NVX hardware can be confiured a either a RX or TX unit - check this + # device is in the correct mode. + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/DeviceSpecific.htm?Highlight=DeviceMode + query("/DeviceSpecific/DeviceMode") do |mode| + # "DeviceMode":"Transmitter|Receiver", + next if mode == "Receiver" + logger.warn { "device configured as a #{mode}" } + self[:WARN] = "device configured as a #{mode}. Expecting Receiver" + end + + # Get the registered subscriptions for index based switching. + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + query("/XioSubscription/Subscriptions") do |subs| + self[:subscriptions] = @subscriptions = subs.as_h + end + + # Background poll for subscription changes. + schedule.every(1.hour) do + query("/XioSubscription/Subscriptions", priority: 5) do |subs| + self[:subscriptions] = @subscriptions = subs.as_h + end + end + + # Background poll to remain in sync with any external routing changes + schedule.every(5.minutes, immediate: true) { update_source_info } + end + + def switch_to(input : Input) + switch_layer input + end + + protected def switch_layer(input : Input, layer : SwitchLayer? = nil) + layer ||= SwitchLayer::All + + do_switch = case input.downcase + when "none", "break", "clear", "blank", "black" + blank layer + when "input1", "hdmi", "hdmi1" + switch_local "Input1", layer + when "input2", "hdmi2" + switch_local "Input2", layer + else + switch_stream input, layer + end + + do_switch.try &.get + update_source_info + end + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + switch_layer map.keys.first, layer + end + + def output(state : Bool) + logger.debug { "#{state ? "enabling" : "disabling"} output sync" } + + ws_update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + Hdmi: {IsOutputDisabled: !state}, + }], + }], + name: :output + ) + end + + # aspect ratio defined in nvx_rx_models + def aspect_ratio(mode : AspectRatio) + logger.debug { "setting output aspect ratio mode: #{mode}" } + + ws_update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + AspectRatioMode: mode, + }], + }], + name: :aspect_ratio + ) + end + + protected def query_device_name + query("/Localization/Name", name: "device_name") do |name| + self["device_name"] = name + end + end + + def query_osd_text + query("/Osd/Text", name: "osd_text") do |text| + self[:osd_text] = text + end + end + + def set_osd_text(text : String, enabled : Bool = true) + ws_update "/Osd/Text", text, name: :set_osd_text + ws_update "/Osd/IsEnabled", enabled, name: :set_osd_enabled + end + + protected def switch_stream(stream_reference : String | Int32, layer : SwitchLayer) + uuid = uuid_for stream_reference + + logger.debug do + subscription = @subscriptions[uuid].as_h + id, name = subscription.values_at "Position", "SessionName" + "switching to Stream#{id} (#{name}) on layer #{layer}" + end + + if layer.all? || layer.video? + ws_update "/DeviceSpecific/VideoSource", "Stream", name: :input_video + resp = ws_update "/AvRouting/Routes", { {VideoSource: uuid} }, name: :switch_video + end + + if @audio_follows_video + ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: uuid} }, name: :switch_audio + elsif layer.all? || layer.audio? + ws_update "/DeviceSpecific/AudioSource", "Stream", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: uuid} }, name: :switch_audio + end + + resp + end + + protected def switch_local(input, layer : SwitchLayer) + logger.debug { "switching to #{input}" } + + if layer.all? || layer.video? + resp = ws_update "/DeviceSpecific/VideoSource", input, name: :input_video + end + + if @audio_follows_video + resp = ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio + elsif layer.all? || layer.audio? + resp = ws_update "/DeviceSpecific/AudioSource", input, name: :input_audio + end + + resp + end + + protected def blank(layer : SwitchLayer) + logger.debug { "blanking output" } + + if layer.all? || layer.video? + ws_update "/DeviceSpecific/VideoSource", "None", name: :input_video + resp = ws_update "/AvRouting/Routes", { {VideoSource: ""} }, name: :switch_video + end + + if @audio_follows_video + ws_update "/DeviceSpecific/AudioSource", "AudioFollowsVideo", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: ""} }, name: :switch_audio + elsif layer.all? || layer.audio? + ws_update "/DeviceSpecific/AudioSource", "None", name: :input_audio + resp = ws_update "/AvRouting/Routes", { {AudioSource: ""} }, name: :switch_audio + end + + resp + end + + # Decoders must first subscribe to encoders they need to receive signals + # from. Switching is then based on device UUID's. + # + # The deivce web UI's (and presumbly XIO director) show these as selectable + # 'inputs' - this mapping allows sources to either be specified as a UUID, + # or their 'input number' as displayed with Crestron tooling. + # + # Alternatively, if a string is provided the list of search props will be + # searched for a match. + protected def uuid_for(reference : String) + if /Stream(\d+)/i =~ reference + # grab the matching data https://crystal-lang.org/api/latest/Regex.html + id = $~[1].to_i + return uuid_for id + end + + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + subscriptions = @subscriptions + + if subscriptions.has_key? reference + uuid = reference + else + {"MulticastAddress", "SessionName"}.each do |prop| + if result = subscriptions.find { |_, x| x.as_h[prop]? == reference } + uuid = result[0] + end + break if uuid + end + end + + raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil? + + uuid + end + + protected def uuid_for(reference : Int32) + subscriptions = @subscriptions + + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + if result = subscriptions.find { |_, x| x.as_h["Position"]? == reference } + uuid = result[0] + end + + raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil? + + uuid + end + + enum SourceType + Audio + Video + end + + # Build friendly source names based on a device state. + # + # Maps all streams into `Stream1`...`StreamN` style names based on + # subscriptions. Local inputs (`Input1`, `Input2`, `AnalogueAudio` etc) are + # left untouched. + protected def query_source_name_for(type : SourceType) + type_downcase = type.to_s.downcase + + # "ActiveAudioSource":"Input1|Input2|Analog|PrimaryAudio|SecondaryAudio", + # "ActiveVideoSource":"None|Input1|Input2|Stream", + query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source", priority: 0) do |source_name| + if source_name.as_s.includes? "Stream" + # "Routes": [{ + # "AudioSource": "07147488-9e0b-11e7-abc4-cec278b6b50a", + # "AutomaticStreamRoutingEnabled": false, + # "Name": "PrimaryStream", + # "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c", + # "VideoSource": "07147488-9e0b-11e7-abc4-cec278b6b50a" + # }] + query("/AvRouting/Routes", name: :routes, priority: 1) do |routes| + uuid = routes.dig?(0, "#{type}Source").try &.as_s? + # FIXME: provide 'Stream1..n' rather than uuids + self["#{type_downcase}_source"] = uuid.presence ? "Stream-#{uuid}" : "None" + end + else + self["#{type_downcase}_source"] = source_name + end + end + end + + # Query the device for the current source state and update status vars. + protected def update_source_info + query_source_name_for(:video) + query_source_name_for(:audio) + query_device_name + query_osd_text + end +end diff --git a/drivers/crestron/nvx_rx_spec.cr b/drivers/crestron/nvx_rx_spec.cr new file mode 100644 index 00000000000..adcf3a2868e --- /dev/null +++ b/drivers/crestron/nvx_rx_spec.cr @@ -0,0 +1,90 @@ +require "placeos-driver/spec" +require "uri" + +DriverSpecs.mock_driver "Crestron::NvxRx" do + # Connected callback makes some queries + should_send "/Device/DeviceSpecific/DeviceMode" + responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Receiver"}}}) + + should_send "/Device/XioSubscription/Subscriptions" + responds %({"Device": {"XioSubscription": {"Subscriptions": { + "00000000-0000-4002-0054-018a0089fd1c": { + "Address": "https://10.254.47.133/onvif/services", + "AudioChannels": 0, + "AudioFormat": "No Audio", + "Bitrate": 750, + "Encryption": true, + "Fps": 0, + "MulticastAddress": "228.228.228.224", + "Position": 2, + "Resolution": "0x0", + "RtspUri": "rtsp://10.254.47.133:554/live.sdp", + "SessionName": "DM-NVX-E30-DEADBEEF1234", + "SnapshotUrl": "", + "Transport": "TS/RTP", + "UniqueId": "00000000-0000-4002-0054-018a0089fd1c", + "VideoFormat": "Pixel", + "IsSyncDetected": false, + "Status": "SUBSCRIBED" + } + }}}}) + + should_send "/Device/Localization/Name" + responds %({"Device": {"Localization": {"Name": "projector"}}}) + + should_send "/Device/Osd/Text" + responds %({"Device": {"Osd": {"Text": "Hearing Loop"}}}) + + should_send "/Device/DeviceSpecific/ActiveVideoSource" + responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Stream"}}}) + + should_send "/Device/AvRouting/Routes" + responds %({"Device": {"AvRouting": {"Routes": [ + { + "Name": "Routing0", + "AudioSource": "00000000-0000-4002-0054-018a0089fd1c", + "VideoSource": "00000000-0000-4002-0054-018a0089fd1c", + "UsbSource": "00000000-0000-4002-0054-018a0089fd1c", + "AutomaticStreamRoutingEnabled": false, + "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c" + } + ]}}}) + + should_send "/Device/DeviceSpecific/ActiveAudioSource" + responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}}) + + status[:video_source].should eq("Stream-00000000-0000-4002-0054-018a0089fd1c") + status[:audio_source].should eq("Input1") + status[:device_name].should eq("projector") + status[:osd_text].should eq("Hearing Loop") + + # we call this manually as the driver isn't loaded in websocket mode + exec :authenticate + + # We expect the first thing it to do is authenticate + auth = URI::Params.build { |form| + form.add("login", "admin") + form.add("passwd", "admin") + } + + expect_http_request do |request, response| + io = request.body + if io + request_body = io.gets_to_end + if request_body == auth + response.status_code = 200 + response.headers["CREST-XSRF-TOKEN"] = "1234" + cookies = response.cookies + cookies["AuthByPasswd"] = "true" + cookies["iv"] = "true" + cookies["tag"] = "true" + cookies["userid"] = "admin" + cookies["userstr"] = "admin" + else + response.status_code = 401 + end + else + raise "expected request to include login form #{request.inspect}" + end + end +end diff --git a/drivers/crestron/nvx_scaler_control.cr b/drivers/crestron/nvx_scaler_control.cr new file mode 100644 index 00000000000..2e34b62734f --- /dev/null +++ b/drivers/crestron/nvx_scaler_control.cr @@ -0,0 +1,67 @@ +require "placeos-driver" +require "./nvx_models" + +class Crestron::NvxScalerControl < PlaceOS::Driver + descriptive_name "Crestron NVX Scaler Control" + generic_name :NvxAddressManager + + description <<-DESC + Synconisation tool for managing scaling settings on NVX decoders based + on window aspect ratios of a videowall processor. + + To enable flexible / user selectable distribution of both 16:9 and 21:9 + signals, aspect ratio control across both the videowall processor and + NVX decoders is exploited to keep things looking nice. + + In the case a decoder is being displayed on a 16:9 window it is set to + scale-to-fit, enabling ultrawide signals to be letterboxed. When a + signal is being send to an ultrawide window it is instead set to + scale-to-fill (stretch) on the NVX, then a second level of distortion + is applied on the videowall processor to convert this back to it's + original aspect. + + This approach keeps all components of the signal chain at 1080p / 4K and + enables live switching all all sources without EDID re-negotation. + DESC + + default_settings({ + # Mapping of { : } + link_scalers: { + window_1: "Decoder_1", + window_2: "Decoder_2", + }, + }) + + @links : Hash(String, String) = {} of String => String + + # Window of aspect ratio's to detect as 16:9 - allows for +/-5% for + # slightly off-shape windows + SCALE_TO_FIT_BOUNDS = (16 / 9 * 0.95)..(16 / 9 * 1.05) + + def on_update + @links = setting?(Hash(String, String), :link_scalers) || {} of String => String + end + + bind VideoWall_1, :windows, :videowall_windows_changed + + private def videowall_windows_changed(_subscription, new_value) + windows = Hash(String, NamedTuple(canwidth: Float64, canheight: Float64)).from_json new_value + windows.each do |id, props| + next unless @links.has_key? id + + nvx = system.get @links[id] + + aspect_ratio = props[:canwidth] / props[:canheight] + + if aspect_ratio.nan? + logger.debug { "#{id} not positioned on canvas, skipping" } + elsif SCALE_TO_FIT_BOUNDS.includes? aspect_ratio + logger.debug { "detected #{id} as 16:9, maintaining aspect" } + nvx.aspect_ratio AspectRatio::MaintainAspectRatio + else + logger.debug { "detected #{id} as ultrawide, filling window" } + nvx.aspect_ratio AspectRatio::StretchToFit + end + end + end +end diff --git a/drivers/crestron/nvx_scaler_control_spec.cr b/drivers/crestron/nvx_scaler_control_spec.cr new file mode 100644 index 00000000000..06e402ae452 --- /dev/null +++ b/drivers/crestron/nvx_scaler_control_spec.cr @@ -0,0 +1,37 @@ +require "placeos-driver/spec" +require "./nvx_models" + +DriverSpecs.mock_driver "Crestron::NvxScalerControl" do + system({ + Decoder: {NvxDecoderMock, NvxDecoderMock}, + VideoWall: {VideoWallMock}, + }) + + sleep 1 + + system(:Decoder_1)[:aspect_ratio].should eq "MaintainAspectRatio" + system(:Decoder_2)[:aspect_ratio].should eq "StretchToFit" +end + +# :nodoc: +class NvxDecoderMock < DriverSpecs::MockDriver + def aspect_ratio(mode : Crestron::AspectRatio) + self[:aspect_ratio] = mode + end +end + +# :nodoc: +class VideoWallMock < DriverSpecs::MockDriver + def on_load + self[:windows] = { + "window_1" => { + canwidth: 1920, + canheight: 1080, + }, + "window_2" => { + canwidth: 1080, + canheight: 1080, + }, + } + end +end diff --git a/drivers/crestron/nvx_tx.cr b/drivers/crestron/nvx_tx.cr new file mode 100644 index 00000000000..3ae11c1de3b --- /dev/null +++ b/drivers/crestron/nvx_tx.cr @@ -0,0 +1,150 @@ +require "./cres_next" +require "placeos-driver/interface/switchable" + +class Crestron::NvxTx < Crestron::CresNext # < PlaceOS::Driver + enum Input + None + Input1 + Input2 + end + include PlaceOS::Driver::Interface::InputSelection(Input) + include Crestron::Transmitter + + descriptive_name "Crestron NVX Transmitter" + generic_name :Encoder + description <<-DESC + Crestron NVX network media encoder + DESC + + uri_base "wss://192.168.0.5/websockify" + + def connected + super + + # NVX hardware can be confiured a either a RX or TX unit - check this + # device is in the correct mode. + query("/DeviceSpecific/DeviceMode") do |mode| + # "DeviceMode":"Transmitter|Receiver", + next if mode == "Transmitter" + logger.warn { "device configured as a #{mode}" } + self[:WARN] = "device configured as a #{mode}. Expecting Transmitter" + end + + # Background poll to remain in sync with any external routing changes + schedule.every(5.minutes, immediate: true) { update_source_info } + end + + def switch_to(input : Input) + logger.debug { "switching to #{input}" } + update( + "/DeviceSpecific", + {VideoSource: input, AudioSource: "AudioFollowsVideo"}, + name: :switch + ).get + update_source_info + end + + def output(state : Bool) + logger.debug { "#{state ? "enabling" : "disabling"} output sync" } + + update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + Hdmi: {IsOutputDisabled: !state}, + }], + }], + name: :output + ) + end + + def multicast_address(address : String) + logger.debug { "setting multicast address to #{address}" } + update("/StreamTransmit/Streams", [{MulticastAddress: address}], name: :multicast_address) + end + + def emulate_input_sync(state : Bool = true, idx : Int32 = 1) + self["input_#{idx}_sync"] = state + end + + # Build friendly source names based on a device state. + protected def query_source_name_for(type : SourceType) + type_downcase = type.to_s.downcase + query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source") do |source_name| + self["#{type_downcase}_source"] = source_name + end + end + + protected def query_multicast_address + query("/StreamTransmit/Streams", name: "streams") do |streams| + self["multicast_address"] = streams.dig(0, "MulticastAddress") + end + end + + # this is the audio AES67 address + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/NaxAudio.htm + protected def query_nax_address + query("/NaxAudio/NaxTx/NaxTxStreams/Stream01/SessionNameStatus", name: "audio_name") do |stream| + self["nax_address"] = stream + end + end + + protected def query_stream_name + query("/Localization/Name", name: "stream_name") do |name| + self["stream_name"] = name + end + end + + # Query the device for the current source state and update status vars. + protected def update_source_info + query_stream_name + query_nax_address + query_multicast_address + query_source_name_for(:video) + query_source_name_for(:audio) + end + + def received(data, task) + raw_json = String.new data + logger.debug { "Crestron sent: #{raw_json}" } + + return unless raw_json.includes? "AudioVideoInputOutput" + raw_json.lines.each do |line| + next if line.empty? + + begin + payload = JSON.parse(line) + + # we're checking if a device is plugged into a port + # Device/AudioVideoInputOutput/Inputs/0/Ports/0/IsSyncDetected + if av_inputs = payload.dig?("Device", "AudioVideoInputOutput", "Inputs").try &.as_a? + av_inputs.each do |input| + name = input["Name"]?.try(&.as_s) || "" + + # Device returns inputs as "input0", "input1" ... "inputN" within + # long poll responses, but appears to reference these same inputs + # as "input-1", "input-2" ... "input-N" within direct state queries. + idx = case name + when /input(\d+)/ + # increment by 1 + $~[1].to_i.succ + when /input-(\d+)/ + $~[1].to_i + else + # There also appears to be situations where no name is + # returned. As only the first input is in use across all + # encoders, default to input 1 as a nasty hack around + # this craziness. + 1 + end + + sync = input.dig?("Ports", 0, "IsSyncDetected").try &.as_bool? + self["input_#{idx}_sync"] = sync unless sync.nil? + end + end + rescue error + logger.warn(exception: error) { "error parsing JSON:\n#{line}" } + end + end + end +end diff --git a/drivers/crestron/nvx_tx_spec.cr b/drivers/crestron/nvx_tx_spec.cr new file mode 100644 index 00000000000..bbe720e3551 --- /dev/null +++ b/drivers/crestron/nvx_tx_spec.cr @@ -0,0 +1,36 @@ +require "placeos-driver/spec" +require "uri" + +DriverSpecs.mock_driver "Crestron::NvxRx" do + # Connected callback makes some queries + should_send "/Device/DeviceSpecific/DeviceMode" + responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Transmitter"}}}) + + should_send "/Device/Localization/Name" + responds %({"Device": {"Localization": {"Name": "pc-in-rack"}}}) + + should_send "/Device/NaxAudio/NaxTx/NaxTxStreams/Stream01/SessionNameStatus" + responds %({"Device": {"NaxAudio": {"NaxTx": {"NaxTxStreams": {"Stream01": {"SessionNameStatus": "pc-in-rack"}}}}}}) + + should_send "/Device/StreamTransmit/Streams" + responds %({"Device": {"StreamTransmit": {"Streams": [{"MulticastAddress": "192.168.0.2"}]}}}) + + should_send "/Device/DeviceSpecific/ActiveVideoSource" + responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Input1"}}}) + + should_send "/Device/DeviceSpecific/ActiveAudioSource" + responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}}) + + status[:stream_name].should eq("pc-in-rack") + status[:multicast_address].should eq("192.168.0.2") + status[:audio_source].should eq("Input1") + status[:audio_source].should eq("Input1") + + transmit %({"Device": {"AudioVideoInputOutput": {"Inputs": [ + {"Name": "input0", "Ports": [{"IsSyncDetected": true}]}, + {"Name": "input-2", "Ports": [{"IsSyncDetected": false}]} + ]}}}).gsub(/\s/, "") + + status["input_1_sync"].should eq(true) + status["input_2_sync"].should eq(false) +end diff --git a/drivers/crestron/occupancy_sensor.cr b/drivers/crestron/occupancy_sensor.cr new file mode 100644 index 00000000000..adc74d39c56 --- /dev/null +++ b/drivers/crestron/occupancy_sensor.cr @@ -0,0 +1,175 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "./cres_next_auth" + +# This device doesn't seem to support a websocket interface +# and relies on long polling + +class Crestron::OccupancySensor < PlaceOS::Driver + include Crestron::CresNextAuth + include Interface::Sensor + + descriptive_name "Crestron Occupancy Sensor" + generic_name :Occupancy + + uri_base "https://192.168.0.5" + + default_settings({ + username: "admin", + password: "admin", + + http_keep_alive_seconds: 600, + http_max_requests: 1200, + }) + + @mac : String = "" + @name : String? = nil + @occupied : Bool = false + @connected : Bool = false + getter last_update : Int64 = 0_i64 + getter poll_counter : UInt64 = 0_u64 + + @sensor_data : Array(Interface::Sensor::Detail) = Array(Interface::Sensor::Detail).new(1) + + def on_load + spawn { event_monitor } + schedule.every(10.minutes) { authenticate } + schedule.every(1.hour) { poll_device_state } + end + + def connected + @connected = true + + authenticate + poll_device_state + end + + def disconnected + @connected = false + end + + def poll_device_state : Nil + response = get("/Device", concurrent: true) + raise "unexpected response code: #{response.status_code}" unless response.success? + payload = JSON.parse(response.body) + + @last_update = Time.utc.to_unix + self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool + self[:presence] = @occupied ? 1.0 : 0.0 + self[:mac] = @mac = format_mac payload.dig("Device", "DeviceInfo", "MacAddress").as_s + self[:name] = @name = payload.dig("Device", "DeviceInfo", "Name").as_s? + + update_sensor + + # Start long polling once we have state + @poll_counter += 1 + end + + protected def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + def event_monitor + loop do + break if terminated? + if @connected + # sleep if long poll failed + sleep 1.second unless long_poll + else + # sleep if not connected + sleep 1.second + end + end + end + + # NOTE:: /Device/Longpoll + # 200 == check data + # when nothing new: {"Device":"Response Timeout"} + # when update: {"Device":{"SystemClock":{"CurrentTime":"2022-10-22T20:29:03Z","CurrentTimeWithOffset":"2022-10-22T20:29:03+09:30"}}} + # 301 == authentication required + # could auth every so often to prevent hitting this too + protected def long_poll : Bool + response = get("/Device/Longpoll") + + # retry after authenticating + if response.status_code == 301 + authenticate + response = get("/Device/Longpoll") + end + raise "unexpected response code: #{response.status_code}" unless response.success? + + raw_json = response.body + logger.debug { "long poll sent: #{raw_json}" } + + return true unless raw_json.includes? "IsRoomOccupied" + payload = JSON.parse(raw_json) + + @last_update = Time.utc.to_unix + self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool + self[:presence] = @occupied ? 1.0 : 0.0 + update_sensor + + true + rescue timeout : IO::TimeoutError + logger.debug { "timeout waiting for long poll to complete" } + false + rescue error + logger.warn(exception: error) { "during long polling" } + false + end + + @update_lock = Mutex.new + + protected def update_sensor + @update_lock.synchronize do + if sensor = @sensor_data[0]? + sensor.value = @occupied ? 1.0 : 0.0 + sensor.last_seen = @connected ? Time.utc.to_unix : @last_update + sensor.mac = @mac + sensor.name = @name + sensor.status = @connected ? Status::Normal : Status::Fault + else + @sensor_data << Detail.new( + type: :presence, + value: @occupied ? 1.0 : 0.0, + last_seen: @connected ? Time.utc.to_unix : @last_update, + mac: @mac, + id: nil, + name: @name, + module_id: module_id, + binding: "presence", + status: @connected ? Status::Normal : Status::Fault, + ) + end + end + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::Presence} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if mac && mac != @mac + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + + @sensor_data + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless @mac == mac + @sensor_data[0]? + end + + def get_sensor_details + @sensor_data[0]? + end +end diff --git a/drivers/crestron/occupancy_sensor_spec.cr b/drivers/crestron/occupancy_sensor_spec.cr new file mode 100644 index 00000000000..857298c8760 --- /dev/null +++ b/drivers/crestron/occupancy_sensor_spec.cr @@ -0,0 +1,114 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::OccupancySensor" do + full_query = %({ + "Device": { + "DeviceInfo": { + "BuildDate": "May 23 2022 (461338)", + "Category": "Linux Device", + "DeviceId": "@E-00107fec2d72", + "DeviceVersion": "3.0000.00002", + "Devicekey": "No SystemKey Server", + "MacAddress": "00.10.7f.ec.2d.72", + "Manufacturer": "Crestron", + "Model": "CEN-ODT-C-POE", + "Name": "Room1-Sensor", + "PufVersion": "3.0000.00002", + "RebootReason": "poweron", + "SerialNumber": "2027NEJ00064", + "Version": "2.1.0" + }, + "OccupancySensor": { + "ForceOccupied": "GET Not Supported, Write Only Property", + "ForceVacant": "GET Not Supported, Write Only Property", + "IsGraceOccupancyDetected": false, + "IsLedFlashEnabled": true, + "IsRoomOccupied": false, + "IsShortTimeoutEnabled": false, + "IsSingleSensorDeterminingOccupancy": true, + "IsSingleSensorDeterminingVacancy": true, + "Pir": { + "IsSensor1Enabled": true, + "OccupiedSensitivity": "Low", + "VacancySensitivity": "Low" + }, + "RawStates": { + "IsRawEnabled": false, + "RawOccupancy": false, + "RawPir": false, + "RawUltrasonic": false + }, + "TimeoutSeconds": 120, + "Ultrasonic": { + "IsSensor1Enabled": true, + "IsSensor2Enabled": true, + "OccupiedSensitivity": "Medium", + "VacancySensitivity": "Medium" + }, + "Version": "1.0.2" + } + } + }) + + # expect authentication + expect_http_request do |request, response| + data = request.body.try(&.gets_to_end) + if data == "login=admin&passwd=admin" + response.status_code = 200 + response.headers.add("Set-Cookie", [ + "userstr=61646d696e00;Path=/;Secure;HttpOnly;", + "userid=483d71e5ce65e6a6689a0e95adb3e2c5ff75ca5582c2f13d669e9213c0eeb9771a6923bf7c1aa1cef460ebf266f3231d;Path=/;Secure;HttpOnly;", + "iv=6023331f67beb11c89bb515f87580a6a;Path=/;Secure;HttpOnly;", + "tag=3877a0be20e70900c0ffb6b620e70900;Path=/;Secure;HttpOnly;", + "AuthByPasswd=crypt:36c319e11b69d1853c6d7070d3da33ec9f3194c840cbbb578f9690a4e9baf7da;Path=/;Secure;HttpOnly;", + "redirectCookie=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure;HttpOnly;", + ]) + else + response.status_code = 401 + response << "bad password" + end + end + + # expect a complete poll + expect_http_request do |request, response| + if request.path == "/Device" + response.status_code = 200 + response << full_query + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 0.5 + status[:occupied].should be_false + status[:name].should eq "Room1-Sensor" + status[:mac].should eq "00107fec2d72" + + # followed by a series of long polls + expect_http_request do |request, response| + if request.path == "/Device/Longpoll" + response.status_code = 200 + response << %({"Device": {"OccupancySensor": {"IsRoomOccupied": true}}}) + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 0.5 + status[:occupied].should be_true + + resp = exec(:get_sensor_details).get.not_nil! + resp.should eq({ + "status" => "normal", + "type" => "presence", + "value" => 1.0, + "last_seen" => resp["last_seen"].as_i64, + "mac" => "00107fec2d72", + "name" => "Room1-Sensor", + "module_id" => "spec_runner", + "binding" => "presence", + "location" => "sensor", + }) +end diff --git a/drivers/crestron/series4.cr b/drivers/crestron/series4.cr new file mode 100644 index 00000000000..762b1dc0c4f --- /dev/null +++ b/drivers/crestron/series4.cr @@ -0,0 +1,47 @@ +require "placeos-driver" +require "./cres_next_auth" + +class Crestron::Series4 < PlaceOS::Driver + include Crestron::CresNextAuth + + descriptive_name "Crestron Series4 Controller" + generic_name :AVController + + uri_base "https://192.168.0.5" + + default_settings({ + username: "admin", + password: "admin", + + http_keep_alive_seconds: 600, + http_max_requests: 1200, + }) + + getter last_update : Int64 = 0_i64 + getter poll_counter : UInt64 = 0_u64 + + @time_zone : Time::Location = Time::Location.load("UTC") + + def on_update + time_zone = setting?(String, :calendar_time_zone).presence || config.control_system.not_nil!.timezone.presence + @time_zone = Time::Location.load(time_zone) if time_zone + end + + def connected + schedule.every(10.minutes, immediate: true) { authenticate } + schedule.every(1.hour, immediate: true) { get_device_info } + end + + def disconnected + schedule.clear + end + + def get_device_info : Nil + response = get("/Device/DeviceInfo/") + raise "unexpected response code: #{response.status_code}" unless response.success? + + payload = JSON.parse(response.body) + self[:last_updated] = Time.local(@time_zone) + self[:info] = payload.dig("Device", "DeviceInfo") + end +end diff --git a/drivers/crestron/series4_spec.cr b/drivers/crestron/series4_spec.cr new file mode 100644 index 00000000000..8b8444cd7e1 --- /dev/null +++ b/drivers/crestron/series4_spec.cr @@ -0,0 +1,56 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::OccupancySensor" do + full_query = %({ + "Device":{ + "DeviceInfo":{ + "Model":"", + "Category":"", + "Manufacturer":"Crestron", + "DeviceId":"TSID or UUID", + "SerialNumber":"12345", + "Name":"Friendly Name", + "DeviceVersion":"1.2.3", + "PufVersion":"1.3454.00040.001", + "BuildDate":"May 13 2016", + "DeviceKey":"54857", + "MacAddress":"", + "RebootReason": "poweron", + "Version": "2.1.0" + } + } + }) + + # expect authentication + expect_http_request do |request, response| + data = request.body.try(&.gets_to_end) + if data == "login=admin&passwd=admin" + response.status_code = 200 + response.headers.add("Set-Cookie", [ + "userstr=61646d696e00;Path=/;Secure;HttpOnly;", + "userid=483d71e5ce65e6a6689a0e95adb3e2c5ff75ca5582c2f13d669e9213c0eeb9771a6923bf7c1aa1cef460ebf266f3231d;Path=/;Secure;HttpOnly;", + "iv=6023331f67beb11c89bb515f87580a6a;Path=/;Secure;HttpOnly;", + "tag=3877a0be20e70900c0ffb6b620e70900;Path=/;Secure;HttpOnly;", + "AuthByPasswd=crypt:36c319e11b69d1853c6d7070d3da33ec9f3194c840cbbb578f9690a4e9baf7da;Path=/;Secure;HttpOnly;", + "redirectCookie=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure;HttpOnly;", + ]) + else + response.status_code = 401 + response << "bad password" + end + end + + # expect a complete poll + expect_http_request do |request, response| + if request.path == "/Device/DeviceInfo/" + response.status_code = 200 + response << full_query + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 200.milliseconds + status[:info]["Manufacturer"].should eq "Crestron" +end diff --git a/drivers/crestron/virtual_switcher.cr b/drivers/crestron/virtual_switcher.cr new file mode 100644 index 00000000000..2769631b826 --- /dev/null +++ b/drivers/crestron/virtual_switcher.cr @@ -0,0 +1,196 @@ +require "placeos-driver" +require "placeos-driver/interface/switchable" +require "placeos-driver/interface/muteable" +require "./nvx_models" + +class Crestron::VirtualSwitcher < PlaceOS::Driver + descriptive_name "Crestron Virtual Switcher" + generic_name :Switcher + description <<-DESC + Enumerates the Creston Transmitters and Receivers in a system and provides + a simple interface for switching between avaiable streams + DESC + + include Interface::Switchable(String, Int32 | String) + include Interface::Muteable + + default_settings({ + audio_sink: { + module_id: "Mixer_1", + function_name: "set_string", + arguments: ["aes67_control_id"], + named_args: {} of String => JSON::Any, + }, + }) + + class AudioSink + include JSON::Serializable + + getter module_id : String + getter function_name : String + getter arguments : Array(JSON::Any) { [] of JSON::Any } + getter named_args : Hash(String, JSON::Any) { {} of String => JSON::Any } + end + + @audio : AudioSink? = nil + + def on_update + @audio = setting?(AudioSink, :audio_sink) + end + + # dummy to supress errors in routing + def power(state : Bool) + state + end + + protected def switch_audio_to(address : JSON::Any?) + return unless address + if sink = @audio + args = sink.arguments + [address] + system[sink.module_id].__send__(sink.function_name, args, sink.named_args) + end + end + + def transmitters + system.implementing(Crestron::Transmitter) + end + + def receivers + system.implementing(Crestron::Receiver) + end + + protected def get_streams(input : Input, layer : SwitchLayer = SwitchLayer::All) + if int_input = input.to_i? + if int_input == 0 + {"none", JSON::Any.new("")} # disconnected + else + # Subtract one as Encoder_1 on the system would be encoder[0] here + if tx = transmitters[int_input - 1]? + {tx[:stream_name], tx[:nax_address]?} + else + logger.warn { "could not find Encoder_#{input}" } + nil + end + end + else + return {input, nil} if layer.video? + if tx = transmitters.find { |sender| sender[:stream_name]? == input } + {input, tx[:nax_address]?} + else + {input, nil} + end + end + rescue ex + logger.warn { "could not find Encoder_#{input}, due to '#{ex.message}'" } + {input, nil} + end + + # only support muting the outputs, no unmuting + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + return unless state + switch_layer = case layer + in MuteLayer::Audio then SwitchLayer::Audio + in MuteLayer::Video then SwitchLayer::Video + in MuteLayer::AudioVideo then SwitchLayer::All + end + switch({"none" => [index]}, switch_layer) + end + + def switch_to(input : Input) + # lookup the input stream + stream = get_streams(input) + return unless stream + + switch_audio_to stream[1] + receivers.switch_to(stream[0]) + end + + def available_inputs + encoder_name_map.keys + end + + def available_outputs + decoder_name_map.keys + end + + protected def decoder_name_map + name_map = {} of String => PlaceOS::Driver::Proxy::Driver + # map-reduce for speed + Promise.all(receivers.map { |rx| + Promise.defer { name_map[rx["device_name"].as_s] = rx rescue nil } + }).get + name_map + end + + protected def encoder_name_map + name_map = {} of String => PlaceOS::Driver::Proxy::Driver + # map-reduce for speed + Promise.all(transmitters.map { |tx| + Promise.defer { name_map[tx["stream_name"].as_s] = tx rescue nil } + }).get + name_map + end + + DUMMY_OUTPUT = [] of Int32 + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + layer ||= SwitchLayer::All + + return unless layer.all? || layer.video? || layer.audio? + + logger.debug { "switching #{layer}: #{map}" } + + connect(map, layer) do |mod, (video, audio)| + case layer + in .all? + switch_audio_to audio + mod.switch_to(video) + in .audio? + switch_audio_to audio + in .video? + # the NVX RX implements this interface but the output is ignored + inp = case video + in JSON::Any + video.as_s? || video.as_i + in String + video + end + mod.switch({inp => DUMMY_OUTPUT}, layer) + in .data?, .data2? + end + end + end + + private def connect(inouts : Hash(Input, Array(Output)), layer : SwitchLayer, &) + inouts.each do |input, outputs| + stream = get_streams(input, layer) + next unless stream + + outputs = outputs.is_a?(Array) ? outputs : [outputs] + decoders = receivers + device_names = nil + outputs.each do |output| + case output + in Int32 + # Subtract one as Decoder_1 on the system would be decoder[0] here + if decoder = decoders[output - 1]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder_#{output}" } + end + in String + device_names = decoder_name_map unless device_names + if decoder = device_names[output]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder with name: #{output}" } + end + end + end + end + end +end diff --git a/drivers/delta/api.cr b/drivers/delta/api.cr new file mode 100644 index 00000000000..a281299d347 --- /dev/null +++ b/drivers/delta/api.cr @@ -0,0 +1,113 @@ +require "placeos-driver" +require "./models/**" + +class Delta::API < PlaceOS::Driver + descriptive_name "Delta API Gateway" + generic_name :Delta + uri_base "https://example.delta.io" + + default_settings({ + basic_auth: { + username: "srvc_acct", + password: "password!", + }, + user_agent: "PlaceOS", + debug: false, + }) + + @user_agent : String = "PlaceOS" + @debug : Bool = false + + def on_update + @user_agent = setting?(String, :user_agent) || "PlaceOS" + @debug = setting?(Bool, :debug) || false + end + + private def fetch(path : String, skip : Int32 = 0, max_results : Int32 = 1000) + logger.debug { config.uri } if @debug + request = "#{path}?alt=json&skip=#{skip}&max-results=#{max_results}" + logger.debug { request } if @debug + + response = get(request, headers: HTTP::Headers{ + "User-Agent" => @user_agent, + "Accept" => "*/*", + }) + + logger.debug { response.headers } if @debug + logger.debug { response.body } if @debug + + response + end + + # list all sites + def list_sites + response = Models::ListSitesResponse.from_json(fetch("/api/.bacnet").body) + response.json_unmapped.keys + end + + # list devices for site + def list_devices(site_name : String) + skip = 0 + devices = [] of Models::Device + path = URI.encode_path("/api/.bacnet/#{site_name}") + + loop do + response = fetch(path, skip) + + raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? + logger.debug { "response body:\n#{response.body}" } + + # returns this when there are no more results + # {"Collection":""} + + body = Models::ListDevicesBySiteNameResponse.from_json(response.body) + body.json_unmapped.keys.each do |key| + value = body.json_unmapped[key].as_h + devices.push(Models::Device.new(id: key.to_u32, base: value["$base"].to_s, node_type: value["nodeType"].to_s, display_name: value["displayName"].to_s)) + end + + break unless body.next_req.presence + skip += 1000 + end + + devices + end + + # list objects from device resource + def list_device_objects(site_name : String, device_number : String | UInt32) + skip = 0 + objects = [] of Models::Object + path = URI.encode_path("/api/.bacnet/#{site_name}/#{device_number}") + + loop do + response = fetch(path, skip) + + raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? + logger.debug { "response body:\n#{response.body}" } + + body = Models::ListObjectsByDeviceNumber.from_json(response.body) + body.json_unmapped.each do |key, obj| + value = obj.as_h + object_type, instance = key.split(',', 2) + objects.push(Models::Object.new(object_type, instance, base: value["$base"].to_s, display_name: value["displayName"].to_s)) + end + + break unless body.next_req.presence + skip += 1000 + end + + objects + end + + # get value of property from object through instance + def get_object_value(site_name : String, device_number : String | UInt32, object_type : String, instance : String | UInt32) + path = URI.encode_path("/api/.bacnet/#{site_name}/#{device_number}/#{object_type},#{instance}") + + response = fetch(path) + + raise "unexpected response #{response.status_code}\n#{response.body}" unless response.success? + logger.debug { "response body:\n#{response.body}" } + + Models::ValueProperty.from_json(response.body) + end +end diff --git a/drivers/delta/api_spec.cr b/drivers/delta/api_spec.cr new file mode 100644 index 00000000000..61e7a8e037a --- /dev/null +++ b/drivers/delta/api_spec.cr @@ -0,0 +1,226 @@ +require "placeos-driver/spec" +require "./models/**" + +DriverSpecs.mock_driver "Delta::API" do + # List sites + + list_sites = exec :list_sites + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Collection", + "nodeType": "PROTOCOL", + "Random Name": { + "$base": "Collection", + "nodeType": "NETWORK", + "truncated": "true" + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + sites = Array(String).from_json(list_sites.get.not_nil!.to_json) + sites.first.should eq "Random Name" + + # List devices by site name + + list_devices_by_site_name = exec(:list_devices, "Random Name") + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet/Random%20Name?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Collection", + "251": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "200": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "253": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "254": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + }, + "260": { + "$base": "Collection", + "displayName": "Test", + "nodeType": "DEVICE", + "truncated": "true" + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + devices = Array(Delta::Models::Device).from_json(list_devices_by_site_name.get.not_nil!.to_json) + devices.first.display_name.should eq "Test" + + # List objects by device number + list_objects_by_device_number = exec(:list_device_objects, "Random Name", "200") + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet/Random%20Name/200?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Collection", + "nodeType": "Device", + "analog-input,83": { + "$base": "Object", + "displayName": "CPU Board Temperature", + "truncated": "true" + }, + "analog-input,84": { + "$base": "Object", + "displayName": "APU Board Temperature", + "truncated": "true" + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + devices = Array(Delta::Models::Object).from_json(list_objects_by_device_number.get.not_nil!.to_json) + devices.first.display_name.should eq "CPU Board Temperature" + + # Get value property by object type through instance + get_value_property_by_object_type_through_instance = exec(:get_object_value, "Random Name", "200", "A", 3) + + expect_http_request do |request, response| + case "#{request.path}?#{request.query}" + when "/api/.bacnet/Random%20Name/200/A%2C3?alt=json&skip=0&max-results=1000" + response.status_code = 200 + response << %({ + "$base": "Object", + "displayName": "DEL1__AI1_85", + "object-identifier": { + "$base": "ObjectIdentifier", + "value": "del,1" + }, + "object-type": { + "$base": "Enumerated", + "value": "data-exchange-local-data" + }, + "object-name": { + "$base": "String", + "value": "DEL1__AI1_85" + }, + "exchange-flags": { + "$base": "BitString", + "value": "" + }, + "exchange-type": { + "$base": "Enumerated", + "value": "optimized-broadcast" + }, + "last-error": { + "$base": "Signed", + "value": 0 + }, + "local-ref": { + "$base": "Sequence", + "type": "0-BACnetDeviceObjectPropertyReference", + "deviceIdentifier": { + "$base": "ObjectIdentifier", + "value": "device,251" + }, + "objectIdentifier": { + "$base": "ObjectIdentifier", + "value": "analog-input,1" + }, + "propertyIdentifier": { + "$base": "Enumerated", + "value": "present-value", + "type": "0-BACnetPropertyIdentifier" + } + }, + "local-flags": { + "$base": "BitString", + "value": "not-commissioned" + }, + "local-value": { + "$base": "Choice", + "real": { + "$base": "Real", + "value": "1" + } + }, + "subscribers": { + "$base": "Array", + "1": { + "$base": "Sequence", + "subscriber": { + "$base": "Choice", + "device": { + "$base": "ObjectIdentifier", + "value": "200" + } + }, + "id": { + "$base": "Unsigned", + "value": 0 + }, + "useConfirmed": { + "$base": "Boolean", + "value": 0 + }, + "flags": { + "$base": "BitString", + "value": "" + }, + "refreshTimer": { + "$base": "Choice", + "refreshTimer": { + "$base": "Unsigned", + "value": "478568103" + } + } + } + }, + "last-sent": { + "$base": "Unsigned", + "value": 0 + }, + "send-frequency": { + "$base": "Unsigned", + "value": 0 + }, + "cov-increment": { + "$base": "Real", + "value": 0 + } + }) + else + response.status_code = 500 + response << "expected token request" + end + end + + value_property = Delta::Models::ValueProperty.from_json(get_value_property_by_object_type_through_instance.get.not_nil!.to_json) + value_property.display_name.should eq "DEL1__AI1_85" +end diff --git a/drivers/delta/models/device.cr b/drivers/delta/models/device.cr new file mode 100644 index 00000000000..9582479d437 --- /dev/null +++ b/drivers/delta/models/device.cr @@ -0,0 +1,24 @@ +require "json" + +module Delta + module Models + struct Device + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : UInt32 + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "nodeType")] + property node_type : String + + @[JSON::Field(key: "displayName")] + property display_name : String + + def initialize(@id : UInt32, @base : String, @node_type : String, @display_name : String) + end + end + end +end diff --git a/drivers/delta/models/generic_value.cr b/drivers/delta/models/generic_value.cr new file mode 100644 index 00000000000..cd0292ed0c8 --- /dev/null +++ b/drivers/delta/models/generic_value.cr @@ -0,0 +1,15 @@ +require "json" + +module Delta + module Models + struct GenericValue + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "value")] + property value : JSON::Any + end + end +end diff --git a/drivers/delta/models/list_devices_by_site_name_response.cr b/drivers/delta/models/list_devices_by_site_name_response.cr new file mode 100644 index 00000000000..6eabb088393 --- /dev/null +++ b/drivers/delta/models/list_devices_by_site_name_response.cr @@ -0,0 +1,20 @@ +require "json" + +module Delta + module Models + struct ListDevicesBySiteNameResponse + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(key: "$base")] + property base : String? = nil + + # returns this when there are no more results + @[JSON::Field(key: "Collection")] + property collection : String? = nil + + @[JSON::Field(key: "next")] + property next_req : String? = nil + end + end +end diff --git a/drivers/delta/models/list_objects_by_device_number_response.cr b/drivers/delta/models/list_objects_by_device_number_response.cr new file mode 100644 index 00000000000..84c04bbd05b --- /dev/null +++ b/drivers/delta/models/list_objects_by_device_number_response.cr @@ -0,0 +1,19 @@ +require "json" + +module Delta + module Models + struct ListObjectsByDeviceNumber + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "nodeType")] + property node_type : String + + @[JSON::Field(key: "next")] + property next_req : String? = nil + end + end +end diff --git a/drivers/delta/models/list_sites_response.cr b/drivers/delta/models/list_sites_response.cr new file mode 100644 index 00000000000..171a0b942d7 --- /dev/null +++ b/drivers/delta/models/list_sites_response.cr @@ -0,0 +1,16 @@ +require "json" + +module Delta + module Models + struct ListSitesResponse + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "nodeType")] + property node_type : String + end + end +end diff --git a/drivers/delta/models/local_value.cr b/drivers/delta/models/local_value.cr new file mode 100644 index 00000000000..0aaba970812 --- /dev/null +++ b/drivers/delta/models/local_value.cr @@ -0,0 +1,15 @@ +require "json" + +module Delta + module Models + struct LocalValue + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "real")] + property real : GenericValue + end + end +end diff --git a/drivers/delta/models/object.cr b/drivers/delta/models/object.cr new file mode 100644 index 00000000000..65821d5c618 --- /dev/null +++ b/drivers/delta/models/object.cr @@ -0,0 +1,22 @@ +require "json" + +module Delta + module Models + struct Object + include JSON::Serializable + + property object_type : String + property instance : UInt32 + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "displayName")] + property display_name : String + + def initialize(@object_type : String, instance : String, @base : String, @display_name : String) + @instance = instance.to_u32 + end + end + end +end diff --git a/drivers/delta/models/property_identifier.cr b/drivers/delta/models/property_identifier.cr new file mode 100644 index 00000000000..1735720feac --- /dev/null +++ b/drivers/delta/models/property_identifier.cr @@ -0,0 +1,18 @@ +require "json" + +module Delta + module Models + struct PropertyIdentifier + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "value")] + property value : JSON::Any + + @[JSON::Field(key: "type")] + property type : String + end + end +end diff --git a/drivers/delta/models/reference.cr b/drivers/delta/models/reference.cr new file mode 100644 index 00000000000..d4ef5f6c822 --- /dev/null +++ b/drivers/delta/models/reference.cr @@ -0,0 +1,24 @@ +require "json" + +module Delta + module Models + struct Reference + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String + + @[JSON::Field(key: "type")] + property type : String + + @[JSON::Field(key: "deviceIdentifier")] + property device_identifier : GenericValue + + @[JSON::Field(key: "objectIdentifier")] + property object_identifier : GenericValue + + @[JSON::Field(key: "propertyIdentifier")] + property property_identifier : PropertyIdentifier + end + end +end diff --git a/drivers/delta/models/value_property.cr b/drivers/delta/models/value_property.cr new file mode 100644 index 00000000000..d373ef5ee5d --- /dev/null +++ b/drivers/delta/models/value_property.cr @@ -0,0 +1,79 @@ +require "./**" +require "json" + +module Delta + module Models + struct ValueProperty + include JSON::Serializable + + @[JSON::Field(key: "$base")] + property base : String? + + @[JSON::Field(key: "displayName")] + property display_name : String? + + @[JSON::Field(key: "object-identifier")] + property object_identifier : GenericValue? + + @[JSON::Field(key: "object-type")] + property object_type : GenericValue? + + @[JSON::Field(key: "object-name")] + property object_name : GenericValue? + + @[JSON::Field(key: "exchange-flags")] + property exchange_flags : GenericValue? + + @[JSON::Field(key: "exchange-type")] + property exchange_type : GenericValue? + + @[JSON::Field(key: "last-error")] + property last_error : GenericValue? + + @[JSON::Field(key: "local-ref")] + property local_ref : Reference? + + @[JSON::Field(key: "local-flags")] + property local_flags : GenericValue? + + @[JSON::Field(key: "local-value")] + property local_flags : LocalValue? + + @[JSON::Field(key: "subscribers")] + property subscribers : Hash(String, JSON::Any)? + + @[JSON::Field(key: "last-sent")] + property last_sent : GenericValue? + + @[JSON::Field(key: "send-frequency")] + property send_frequency : GenericValue? + + @[JSON::Field(key: "cov-increment")] + property cov_increment : GenericValue? + + @[JSON::Field(key: "present-value")] + property present_value : GenericValue? + + @[JSON::Field(key: "status-flags")] + property status_flags : GenericValue? + + @[JSON::Field(key: "event-state")] + property event_state : GenericValue? + + @[JSON::Field(key: "out-of-service")] + property out_of_service : GenericValue? + + @[JSON::Field(key: "present-value")] + property present_value : GenericValue? + + @[JSON::Field(key: "units")] + property units : GenericValue? + + @[JSON::Field(key: "description")] + property description : GenericValue? + + @[JSON::Field(key: "reliability")] + property reliability : GenericValue? + end + end +end diff --git a/drivers/delta/uno_next.cr b/drivers/delta/uno_next.cr new file mode 100644 index 00000000000..3506c10936c --- /dev/null +++ b/drivers/delta/uno_next.cr @@ -0,0 +1,196 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "./models/**" + +# documentation: https://isdweb.deltaww.com/resources/files/UNOnext_bacnet_user_guide.pdf + +class Delta::UNOnext < PlaceOS::Driver + include Interface::Sensor + + descriptive_name "Delta UNOnext Indoor Air Monitor" + generic_name :UNOnext + description %(collects sensor data from UNOnext sensors) + + default_settings({ + site_name: "My Office", + manager_mappings: [{ + building_zone: "zone_id_here", + level_zone: "zone_id_here", + managers: [107100, 107300], + }], + # seconds between polling + poll_every: 10, + }) + + accessor delta_api : Delta_1 + + record ManMap, building_zone : String, level_zone : String, managers : Array(UInt32) do + include JSON::Serializable + end + + def on_update + @site_name = setting(String, :site_name) + @manager_mappings = setting(Array(ManMap), :manager_mappings) + + poll_every = setting?(Int32, :poll_every) || 10 + + @cached_data = Hash(String, Array(Detail)).new { |hash, key| hash[key] = [] of Detail } + schedule.clear + schedule.every(poll_every.seconds) { cache_sensor_data } + end + + getter site_name : String = "My Office" + getter manager_mappings : Array(ManMap) = [] of ManMap + getter cached_data : Hash(String, Array(Detail)) = {} of String => Array(Detail) + + # =================================== + # Sensor Interface functions + # =================================== + def sensor(mac : String, id : String? = nil) : Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id && mac.starts_with?("unonext-") + + device_id = mac.lchop("unonext-").to_u32? + index = id.to_u32? + return nil unless device_id && index + + build_sensor_details(device_id, index) + rescue error + logger.warn(exception: error) { "checking for sensor" } + nil + end + + SENSOR_TYPES = { + 0 => SensorType::Temperature, + 1 => SensorType::Humidity, + 2 => SensorType::AirQuality, # PM2.5 (particles smaller than 2.5) + 4 => SensorType::PPM, # CO2 + 5 => SensorType::Illuminance, + # 9 => SensorType::PPM, # O3 + } + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + # skip processing where possible + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.values.includes?(sensor_type) + end + + if mac + return NO_MATCH unless mac.starts_with?("unonext-") + end + + # grab the relevant values + result = if zone_id + cached_data[zone_id]? || [] of Detail + else + manager_mappings.flat_map do |man_map| + cached_data[man_map.level_zone]? || [] of Detail + end + end + + # filter them based on the request + if sensor_type && mac + result.reject! { |details| details.type != sensor_type || details.mac != mac } + elsif sensor_type + result.reject! { |details| details.type != sensor_type } + elsif mac + result.reject! { |details| details.mac != mac } + end + + result + end + + # =================================== + # Helper functions + # =================================== + + protected def build_sensor_details(device_id : UInt32, index : UInt32, building : String? = nil, level : String? = nil) : Detail? + prop = Models::ValueProperty.from_json delta_api.get_object_value(@site_name, device_id, "analog-value", index).get.to_json + return nil if (prop.out_of_service.try(&.value.as_i?) || 1) != 0 + + value = prop.present_value.try do |pv| + if string = pv.value.as_s? + string.to_f? + elsif int = pv.value.as_i? + int.to_f + end + end + return nil unless value + + case prop.units.try &.value + when "°C" + unit = "Cel" + sensor = SensorType::Temperature + when "%RH" + sensor = SensorType::Humidity + when "µg/m³" + sensor = SensorType::AirQuality + when "lx" + unit = "lx" + sensor = SensorType::Illuminance + when "ppm" + modifier = "CO2" + sensor = SensorType::PPM + end + return nil unless sensor + + Detail.new( + modifier: modifier, + type: sensor, + value: value, + last_seen: Time.utc.to_unix, + mac: "unonext-#{device_id}", + id: index.to_s, + name: "UNONext #{device_id}.#{index} #{prop.display_name} #{prop.units.try &.value}", + binding: "#{device_id}.#{index}", + module_id: module_id, + unit: unit, + building: building, + level: level, + ) + rescue error + logger.warn(exception: error) { "error requesting object value from #{device_id}.#{index}" } + nil + end + + NO_OBJECTS = [] of Models::Object + + protected def cache_sensor_data : Nil + logger.debug { "caching sensor data" } + + local_cache = Hash(String, Array(Detail)).new { |hash, key| hash[key] = [] of Detail } + + # grab all the UNONext manager objects + site = site_name + all_objects = manager_mappings.each do |man_map| + man_map.managers.each do |id| + begin + Array(Models::Object).from_json(delta_api.list_device_objects(site, id).get.to_json) + .select(&.display_name.includes?("UnoNext")) + .each do |object| + # skip the data points we don't care about + next if object.display_name.includes?("PM10") + next if object.display_name.includes?("TVOC") + + if details = build_sensor_details(id, object.instance, man_map.building_zone, man_map.level_zone) + self[details.binding] = details + + local_cache[man_map.building_zone] << details + local_cache[man_map.level_zone] << details + end + end + rescue error + logger.warn(exception: error) { "error requesting objects from manager #{id}" } + NO_OBJECTS + end + end + end + + logger.debug { "updating sensor cache" } + @cached_data = local_cache + end +end diff --git a/drivers/delta/uno_next_spec.cr b/drivers/delta/uno_next_spec.cr new file mode 100644 index 00000000000..0670934b069 --- /dev/null +++ b/drivers/delta/uno_next_spec.cr @@ -0,0 +1,5 @@ +require "placeos-driver/spec" +require "./models/**" + +DriverSpecs.mock_driver "Delta::UNOnext" do +end diff --git a/drivers/delta/zen_pir_location.cr b/drivers/delta/zen_pir_location.cr new file mode 100644 index 00000000000..8f05945de81 --- /dev/null +++ b/drivers/delta/zen_pir_location.cr @@ -0,0 +1,157 @@ +require "placeos-driver" +require "placeos-driver/interface/locatable" + +require "./models/**" + +class Delta::ZenPIRLocation < PlaceOS::Driver + include Interface::Locatable + + descriptive_name "Zen PIR Locations" + generic_name :PIR_Locations + description %(maps zen control pir locations to map areas) + + accessor delta_api : Delta_1 + + default_settings({ + site_name: "My Office", + zen_id: 12345, + pir_mappings: [{ + building_zone: "building_zone_id", + level_zone: "level_zone_id", + pirs: [{ + pir: 1234, + map: "area-1234", + }], + }], + # seconds between polling + poll_every: 10, + }) + + record PIR, pir : UInt32, map : String do + include JSON::Serializable + end + + record PIRMap, building_zone : String, level_zone : String, pirs : Array(PIR) do + include JSON::Serializable + end + + def on_update + @site_name = setting(String, :site_name) + @zen_id = setting(UInt32, :zen_id) + @pir_mappings = setting(Array(PIRMap), :pir_mappings) + + poll_every = setting?(Int32, :poll_every) || 10 + + @cached_data = Hash(String, Array(Location)).new { |hash, key| hash[key] = [] of Location } + schedule.clear + schedule.every(poll_every.seconds) { cache_sensor_data } + end + + getter site_name : String = "My Office" + getter zen_id : UInt32 = 1234_u32 + getter pir_mappings : Array(PIRMap) = [] of PIRMap + getter cached_data : Hash(String, Array(Location)) = {} of String => Array(Location) + + # =================================== + # Locatable Interface functions + # =================================== + def locate_user(email : String? = nil, username : String? = nil) + logger.debug { "sensor incapable of locating #{email} or #{username}" } + [] of Nil + end + + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + logger.debug { "sensor incapable of tracking #{email} or #{username}" } + [] of String + end + + def check_ownership_of(mac_address : String) : OwnershipMAC? + logger.debug { "sensor incapable of tracking #{mac_address}" } + nil + end + + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "searching locatable in zone #{zone_id}" } + return [] of Location if location.presence && location != "area" + @cached_data[zone_id]? || [] of Location + end + + # =================================== + # Caching functions + # =================================== + + struct Location + include JSON::Serializable + + getter location : Symbol = :area + property level : String + property map_id : String + property area_id : String + property capacity : Int32 + property at_location : Int32 + + property zen_device_id : UInt32 + property zen_object_id : UInt32 + + def initialize( + @level, @map_id, @area_id, @capacity, @at_location, + @zen_device_id, @zen_object_id + ) + end + end + + protected def cache_sensor_data : Nil + logger.debug { "caching sensor data" } + + # grab all the zen pir objects + site = site_name + device_id = zen_id + cached_count = 0 + cached_data = Hash(String, Array(Location)).new { |hash, key| hash[key] = [] of Location } + + all_objects = pir_mappings.each do |pir_map| + pir_map.pirs.each do |pir| + begin + prop = Models::ValueProperty.from_json delta_api.get_object_value(site, device_id, "binary-value", pir.pir).get.to_json + next if (prop.out_of_service.try(&.value.as_i?) || 1) != 0 + + state = prop.present_value.try do |pv| + if string = pv.value.as_s? + string.downcase + end + end + + next unless state.presence + at_location = case state + when "inactive", "off" + 0 + when "active", "on" + 1 + else + logger.warn { "unexpected PIR value: #{state} for object #{pir.pir}.#{device_id}" } + next + end + + loc = Location.new( + level: pir_map.level_zone, + area_id: pir.map, + map_id: pir.map, + capacity: 1, + at_location: at_location, + zen_device_id: device_id, + zen_object_id: pir.pir + ) + + cached_data[pir_map.building_zone] << loc + cached_data[pir_map.level_zone] << loc + cached_count += 1 + rescue error + logger.warn(exception: error) { "error requesting object #{pir.pir} from zen #{device_id}" } + end + end + end + + @cached_data = cached_data + logger.debug { "cached #{cached_count} PIR objects" } + end +end diff --git a/drivers/delta/zen_pir_location_spec.cr b/drivers/delta/zen_pir_location_spec.cr new file mode 100644 index 00000000000..8b3235e1e41 --- /dev/null +++ b/drivers/delta/zen_pir_location_spec.cr @@ -0,0 +1,4 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Delta::ZenPIRLocation" do +end diff --git a/drivers/denon/amplifier/av_receiver.cr b/drivers/denon/amplifier/av_receiver.cr new file mode 100644 index 00000000000..d2d98469ced --- /dev/null +++ b/drivers/denon/amplifier/av_receiver.cr @@ -0,0 +1,216 @@ +require "digest/md5" +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/switchable" + +# +module Denon; end + +module Denon::Amplifier; end + +# Protocol: https://aca.im/driver_docs/Denon/Denon%20AVR%20PROTOCOL%20V7.5.0.pdf +# +# NOTE:: Denon doesn't respond to commands that request the current state +# (ie if the volume is 100 and you request 100 it will not respond) +# + +class Denon::Amplifier::AvReceiver < PlaceOS::Driver + include PlaceOS::Driver::Interface::Powerable + include PlaceOS::Driver::Utilities::Transcoder + + @channel : Channel(String) = Channel(String).new + @stable_power : Bool = true + + COMMANDS = { + power: :PW, + power_query: :PW?, + mute: :MU, + mute_query: :MU?, + volume: :MV, + volume_query: :MV?, + input: :SI, + input_query: :SI?, + } + COMMANDS.to_h.merge!(COMMANDS.to_h.invert) + + @volume_range = 0..196 + + default_settings({ + max_waits: 10, + timeout: 3000, + }) + # Discovery Information + tcp_port 23 # Telnet + descriptive_name "Denon AVR (Switcher Amplifier)" + generic_name :Switcher + + # Denon requires some breathing room + # delay between_sends: 30 + # delay on_receive: 30 + + def on_load + # transport.tokenizer = Tokenizer.new(Bytes[0x0D]) + transport.tokenizer = Tokenizer.new("\r") + self[:volume_min] = 0 + self[:volume_max] = @volume_range.max # == 98 * 2 - Times by 2 so we can account for the half steps + on_update + end + + def on_update + self[:max_waits] = 10 + self[:timeout] = 3000 + end + + def connected + # + # Get state + # + # power? + # input? + # mute? + + schedule.every(60.seconds) do + logger.info { "-- Polling Denon AVR" } + power? + do_send(:input, priority: 0, name: :input) + end + end + + def disconnected + schedule.clear + end + + def power(state : Bool = false) + # self[:power] is current as we would be informed otherwise + if state && (self[:power] == "OFF" || self[:power] == "STANDBY") # Request to power on if off + do_send(:power, "ON", delay: 3.milliseconds, name: :power) # Manual states delay for 1 second, just to be safe + elsif !state && self[:power] == "ON" # Request to power off if on + do_send(:power, "STANDBY", delay: 3.milliseconds, name: :power) + end + end + + def power? + # def power?(**options) + # options[:emit] = {:power => block} unless block.nil? + do_send(:power_query, priority: 0, name: :power_query) + end + + def mute? + self[:mute] = "OFF" + do_send(:mute_query, priority: 0, name: :mute_query) + end + + def mute(state : Bool = true) + req = state ? "ON" : "OFF" + return if self[:mute] == req + do_send(:mute, req, name: :mute) + end + + def mute_audio(state : Bool = true) + mute state + end + + def unmute + mute false + end + + def unmute_audio + unmute + end + + def volume(level : Float64 | Int32 = 0) + level = level.to_f.clamp(0.0, 100.0) + return if self[:volume] == level + + percentage = level / 100.0 + value = (percentage * @volume_range.end.to_f).round_away.to_i + + # The denon is weird 99 is volume off, + # 99.5 is the minimum volume, + # 0 is the next lowest volume and 985 is the loudest volume + # => So we are treating 99, 995 and 0 as 0 + step = value % 2 + actual = value / 2 + req = actual.to_s.rjust(2, '0') + req += "5" if step != 0 + + do_send(:volume, req, name: :volume) # Name prevents needless queuing of commands + end + + def volume? + do_send(:volume_query, priority: 0, name: :volume_query) + end + + # Just here for documentation (there are many more) + # + # INPUTS = [:cd, :tuner, :dvd, :bd, :tv, :"sat/cbl", :dvr, :game, :game2, :"v.aux", :dock] + def input(input : String = "") + status = input.upcase # .downcase.to_sym + if status != self[:input] + input = input.to_s.upcase + do_send(:input, input, name: :input) + end + end + + def input? + do_send(:input_query, priority: 0, name: :input_query) + end + + def received(data, task) + data = String.new(data) + logger.info { "Denon sent #{data.inspect}" } + + return unless task + + # Process the response + cmd = data[0..1] # first 2 chars are the key / command + val = data[2..-2] # anything following the above and before \r is a response value + + case cmd + when "PW" + self[:power] = val + when "SI" + self[:input] = val + when "MV" + # return :ignore if val.chars.size > 3 # May send 'MVMAX 98' after volume command + # self[:volume] = 0 + # vol = val.to_i32 + # self[:volume] = val unless val.to_i32 > @volume_range.max + vol_percent = ((val.to_f * 2) / @volume_range.end.to_f) * 100.0 + self[:volume] = vol_percent + # return :ignore if param.length > 3 # May send 'MVMAX 98' after volume command + # vol = param[0..1].to_i * 2 + # vol += 1 if param.length == 3 + # vol == 0 if vol > @volume_range.max # this means the volume was 99 or 995 + # self[:volume] = vol + + when "MU" + self[:mute] = val + else + return :ignore + end + + task.try &.success + end + + protected def do_send(command, param = nil, **options) + # prepare the command + cmd = if param.nil? + "#{COMMANDS[command]}" + else + "#{COMMANDS[command]}#{param}" + end + logger.info { "Queing: #{cmd}" } + + # queue the request + queue(**({ + name: command, + }.merge(options))) do + @channel = Channel(String).new + # send the request + logger.info { " Sending: #{cmd}" } + transport.send(cmd) + end + end +end diff --git a/drivers/denon/amplifier/av_receiver_spec.cr b/drivers/denon/amplifier/av_receiver_spec.cr new file mode 100644 index 00000000000..fa81e17bd9e --- /dev/null +++ b/drivers/denon/amplifier/av_receiver_spec.cr @@ -0,0 +1,73 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Denon::Amplifier::AvReceiver" do + #### + # POWER + # + sleep 1.second + # query power + exec(:power?) + should_send("PW?") + responds("PWOFF\r") + status[:power].should eq("OFF") + # turn power on + exec(:power, true) + should_send("PWON") + responds("PWON\r") + status[:power].should eq("ON") + # power off turns amp to STANDBY not actually OFF + exec(:power, false) + should_send("PWSTANDBY") + responds("PWSTANDBY\r") + status[:power].should eq("STANDBY") + + #### + # INPUT + # + sleep 1.second + # query input > DVD + exec(:input?) + should_send("SI?") + responds("SIDVD\r") + status[:input].should eq("DVD") + # chaange input to tuner + exec(:input, "TUNER") + should_send("SITUNER") + responds("SITUNER\r") + status[:input].should eq("TUNER") + + #### + # VOLUME + # + sleep 1.second + # query + exec(:volume?) + should_send("MV?") + responds("MV49\r") + status[:volume].should eq(50.0) + # change volume + exec(:volume, 100) + should_send("MV98.0") + responds("MV98.0\r") + status[:volume].should eq(100.0) + + #### + # MUTE + # + sleep 1.second + # query + exec(:mute?) + should_send("MU?") + responds("MUOFF\r") + status[:mute].should eq("OFF") + # mute on + exec(:mute, true) + should_send("MUON") + responds("MUON\r") + status[:mute].should eq("ON") + # mute off + exec(:mute, false) + should_send("MUOFF") + responds("MUOFF\r") + status[:mute].should eq("OFF") +end diff --git a/drivers/echo360/device_capture.cr b/drivers/echo360/device_capture.cr new file mode 100644 index 00000000000..9b1b6a50ca6 --- /dev/null +++ b/drivers/echo360/device_capture.cr @@ -0,0 +1,167 @@ +require "placeos-driver" +require "oq" + +# Documentation: https://aca.im/driver_docs/Echo360/EchoSystemCaptureAPI_v301.pdf + +class Echo360::DeviceCapture < PlaceOS::Driver + # Discovery Information + generic_name :Capture + descriptive_name "Echo365 Device Capture" + uri_base "https://echo.server" + + default_settings({ + basic_auth: { + username: "srvc_acct", + password: "password!", + }, + }) + + def on_update + schedule.clear + schedule.every(15.seconds) do + logger.debug { "-- Polling Capture" } + system_status + capture_status + end + end + + STATUS_CMDS = { + system_status: :system, + capture_status: :captures, + next: :next_capture, + current: :current_capture, + state: :monitoring, + } + + {% begin %} + {% for function, route in STATUS_CMDS %} + {% path = "/status/#{route.id}" %} + def {{function.id}} + response = get({{path}}) + process_status check(response) + end + {% end %} + {% end %} + + @[Security(PlaceOS::Driver::Level::Support)] + def restart_application + post("/diagnostics/restart_all").success? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def reboot + post("/diagnostics/reboot").success? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def captures + response = get("/diagnostics/recovery/saved-content") + self[:captures] = check(response)["captures"]["capture"] + end + + @[Security(PlaceOS::Driver::Level::Support)] + def upload(id : String) + response = post("/diagnostics/recovery/#{id}/upload") + raise "upload request failed with #{response.status_code}\n#{response.body}" unless response.success? + response.body + end + + # This will auto-start a recording + def capture(name : String, duration : Int32, profile : String? = nil) + profile ||= self[:capture_profiles][0].as_s + response = post("/capture/new_capture", body: URI::Params.build { |form| + form.add("description", name) + form.add("duration", duration.to_s) + form.add("capture_profile_name", profile) + }) + check(response)["ok"]["#text"].as_s + end + + def test_capture(name : String, duration : Int32, profile : String? = nil) + profile ||= self[:capture_profiles][0].as_s + response = post("/capture/confidence_monitor", body: URI::Params.build { |form| + form.add("description", name) + form.add("duration", duration.to_s) + form.add("capture_profile_name", profile) + }) + check(response)["ok"]["#text"].as_s + end + + def extend(duration : Int32) + response = post("/capture/confidence_monitor", body: URI::Params.build { |form| + form.add("duration", duration.to_s) + }) + check(response)["ok"]["#text"].as_s + end + + def pause + response = post("/capture/pause") + check(response)["ok"]["#text"].as_s + end + + def start + response = post("/capture/record") + check(response)["ok"]["#text"].as_s + end + + def resume + start + end + + def record + start + end + + def stop + response = post("/capture/stop") + check(response)["ok"]["#text"].as_s + end + + # Converts the response into the appropriate format and indicates success / failure + protected def check(response) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + + # Convert the XML to JSON for simple parsing + # https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html + input_io = IO::Memory.new response.body + output_io = IO::Memory.new + OQ::Converters::XML.deserialize input_io, output_io + + output_io.rewind + json = JSON.parse(output_io) + logger.debug { "response was\n#{json.pretty_inspect}" } + json + end + + CHECK = {"next", "current"} + + # generic function for processing status and exposing the state + protected def process_status(data) + if results = data["status"]?.try(&.as_h) + results.each do |key, value| + if key.in?(CHECK) && (value.as_s?.try(&.strip.empty?) || value["schedule"]?.try(&.as_s?.try(&.strip.empty?))) + # next / current recordings are not present + self[key] = nil + elsif key[-1] == 's' && (hash = value.as_h?) + # This handles `{"api-versions" => {"api-version" => "3.0"}}` + inner = hash[key[0..-2]]? + if inner + self[key] = inner + else + self[key] = hash + end + elsif str_val = value.as_s?.try(&.strip) + # cleanup whitespace around string values + self[key] = str_val + else + # otherwise we don't manipulate the value and expose it for use + self[key] = value + end + end + results + else + logger.debug { "namespace 'status' not found, ignoring payload" } + data + end + end +end diff --git a/drivers/echo360/device_capture_spec.cr b/drivers/echo360/device_capture_spec.cr new file mode 100644 index 00000000000..aec824dcce2 --- /dev/null +++ b/drivers/echo360/device_capture_spec.cr @@ -0,0 +1,182 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Echo360::DeviceCapture" do + retval = exec(:system_status) + expect_http_request do |request, response| + if request.path == "/status/system" + response.status_code = 200 + response << SYSTEM_STATUS + else + puts "unexpected path #{request.path}" + response.status_code = 404 + end + end + retval.get + + status["api-versions"].should eq "3.0" + + retval = exec(:captures) + expect_http_request do |_request, response| + response.status_code = 200 + response << CAPTURE_STATUS + end + retval.get + status[:captures].as_a.size.should eq(2) +end + +CAPTURE_STATUS = <<-HEREDOC + + + Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014 + 2014-02-12T15:30:00.000Z + 3000 +
Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ + + John Doe + + +
+ + Some other capture + 2014-02-13T15:30:00.000Z + 1500 +
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..4b62145a83b --- /dev/null +++ b/drivers/embedia/control_point.cr @@ -0,0 +1,94 @@ +require "placeos-driver" + +# Documentation: https://aca.im/driver_docs/Embedia/Embedia%20Control%20Point%20rev2013.pdf +# RS232 Gateway. Baud Rate 9600,8,N,1 + +class Embedia::ControlPoint < PlaceOS::Driver + # Discovery Information + descriptive_name "Embedia Control Point Blinds" + generic_name :Blinds + + # Global Cache Port + tcp_port 4999 + + def on_load + queue.wait = false + queue.delay = 200.milliseconds + transport.tokenizer = Tokenizer.new("\r\n") + end + + def connected + schedule.every(1.minute) do + logger.debug { "Maintaining connection" } + query_sensor 0 + end + end + + def disconnected + schedule.clear + end + + COMMANDS = { + stop: 0x28, + down: 0x4e, # Also extend + up: 0x4b, # Also retract + next_extent_preset: 0x4f, + previous_extent_preset: 0x50, + + close: 0x16, + open: 0x1a, + next_tilt_preset: 0x07, + previous_tilt_preset: 0x04, + + clear_override: 0x4c, + } + + {% begin %} + {% for command, value in COMMANDS %} + def {{command.id}}(address : UInt8, **options) + do_send Bytes[address, 0x06, 0, 1, 0, {{value}}], **options + end + {% end %} + {% end %} + + def extent_preset(address : UInt8, number : UInt8, **options) + num = 0x1D + number.clamp(1, 10) + do_send Bytes[address, 0x06, 0, 1, 0, num], **options, name: "extent_preset#{address}" + end + + def tilt_preset(address : UInt8, number : UInt8, **options) + num = 0x39 + number.clamp(1, 10) + do_send Bytes[address, 0x06, 0, 1, 0, num], **options, name: "tilt_preset#{address}" + end + + def query_sensor(address : UInt8, **options) + do_send Bytes[address, 0x03, 0, 1, 0, 1], **options + end + + protected def do_send(data : Bytes, **options) + sending = data.hexstring.upcase + logger.debug { "sending :#{sending}--" } + send ":#{sending}--\r\n", **options + end + + def received(bytes, task) + logger.debug { + # remove the newline chars + raw_data = String.new(bytes).strip + + # strip the padding ':' and The LRC checksum + data = raw_data[1..-3].hexbytes + address = data[0] + func = data[1] + + case func + when 3 # Sensor level + "sensor response #{raw_data} on address 0x#{address.to_s(16)}" + else + "sent #{raw_data} on address 0x#{address.to_s(16)}" + end + } + + task.try &.success + end +end diff --git a/drivers/embedia/control_point_spec.cr b/drivers/embedia/control_point_spec.cr new file mode 100644 index 00000000000..6eef94e4905 --- /dev/null +++ b/drivers/embedia/control_point_spec.cr @@ -0,0 +1,10 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Embedia::ControlPoint" do + exec(:down, 0xFF) + should_send(":FF060001004E--\r\n") + + exec(:query_sensor, 0xFF) + should_send(":FF0300010001--\r\n") + responds(":FF0300010001AA\r\n") +end diff --git a/drivers/epson/projector/esc_vp21.cr b/drivers/epson/projector/esc_vp21.cr new file mode 100644 index 00000000000..1b9df5ae1ee --- /dev/null +++ b/drivers/epson/projector/esc_vp21.cr @@ -0,0 +1,262 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/switchable" + +class Epson::Projector::EscVp21 < PlaceOS::Driver + include Interface::Powerable + include Interface::Muteable + + enum Input + HDMI = 0x30 + HDBaseT = 0x80 + end + + include Interface::InputSelection(Input) + + # Discovery Information + tcp_port 3629 + descriptive_name "Epson Projector" + generic_name :Display + + default_settings({ + epson_projectors_poll_video_mute: true, + epson_projectors_poll_volume: true, + epson_projectors_disable_muting: false + }) + + @poll_video_mute : Bool = true + @poll_volume : Bool = true + + # Mute commands appear to cause significant problems in some Epson models. + # meet.cr appears to send mute commands to outputs of unjoined joinable rooms, causing disturbance to other meetings (especially since the UI currently does not have mute/unmute buttons). + # The below is a workaround until the meeting rm logic driver is fixed (don't mute other (unjoined) rooms on shutdown/unroute) + @muting_disabled : Bool = false + + @ready : Bool = false + + getter power_actual : Bool? = nil # actual power state + getter? power_stable : Bool = true # are we in a stable state? + getter? power_target : Bool = true # what is the target state? + + @unmute_volume : Float64 = 60.0 + + def on_load + transport.tokenizer = Tokenizer.new("\r") + self[:type] = :projector + on_update + end + + def on_update + @poll_video_mute = setting?(Bool, :epson_projectors_poll_video_mute) || true + @poll_volume = setting?(Bool, :epson_projectors_poll_volume) || true + @muting_disabled = setting?(Bool, :epson_projectors_disable_muting) || false + end + + def connected + # Have to init comms + send("ESC/VP.net\x10\x03\x00\x00\x00\x00") + schedule.every(52.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def power(state : Bool) + if state + @power_target = true + logger.debug { "-- epson Proj, requested to power on" } + do_send(:power, "ON", delay: 40.seconds, name: "power", priority: 99) + else + @power_target = false + logger.debug { "-- epson Proj, requested to power off" } + do_send(:power, "OFF", delay: 10.seconds, name: "power", priority: 99) + end + @power_stable = false + self[:power] = state + power? + end + + def power?(**options) : Bool + do_send(:power, **options).get + @power_actual || false + end + + def switch_to(input : Input) + logger.debug { "-- epson Proj, requested to switch to: #{input}" } + do_send(:input, input.value.to_s(16), name: :input) + + # for a responsive UI + self[:input] = input # for a responsive UI + self[:video_mute] = false + input? + end + + def input? + do_send(:input, priority: 0).get + self[:input] + end + + # Volume commands are sent using the inpt command + def volume(vol : Float64 | Int32, **options) + vol = vol.to_f.clamp(0.0, 100.0) + percentage = vol / 100.0 + vol_actual = (percentage * 255.0).round_away.to_i + + @unmute_volume = self[:volume].as_f if (muted = vol == 0.0) && self[:volume]? + do_send(:volume, vol_actual, **options, name: :volume) + + # for a responsive UI + self[:volume] = vol + self[:audio_mute] = muted + volume? unless @muting_disabled # Affected projectors support volume setting, but not volume query + end + + def volume? + return if @muting_disabled + do_send(:volume, priority: 0).get + self[:volume]?.try(&.as_f) + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + return if @muting_disabled + case layer + when .audio_video? + do_send(:av_mute, state ? "ON" : "OFF", name: :mute) + do_send(:av_mute, name: :mute?, priority: 0) + when .video? + do_send(:video_mute, state ? "ON" : "OFF", name: :video_mute) + video_mute? + when .audio? + val = state ? 0.0 : @unmute_volume + volume(val) + end + end + + def video_mute? + return if @muting_disabled + do_send(:video_mute, priority: 0).get + !!self[:video_mute]?.try(&.as_bool) + end + + ERRORS = [ + "00: no error", + "01: fan error", + "03: lamp failure at power on", + "04: high internal temperature", + "06: lamp error", + "07: lamp cover door open", + "08: cinema filter error", + "09: capacitor is disconnected", + "0A: auto iris error", + "0B: subsystem error", + "0C: low air flow error", + "0D: air flow sensor error", + "0E: ballast power supply error", + "0F: shutter error", + "10: peltiert cooling error", + "11: pump cooling error", + "12: static iris error", + "13: power supply unit error", + "14: exhaust shutter error", + "15: obstacle detection error", + "16: IF board discernment error", + ] + + def inspect_error + do_send(:error, priority: 0) + end + + COMMAND = { + power: "PWR", + input: "SOURCE", + volume: "VOL", + av_mute: "MUTE", + video_mute: "MSEL", + error: "ERR", + lamp: "LAMP", + } + RESPONSE = COMMAND.to_h.invert + + def received(data, task) + return task.try(&.success) if data.size <= 2 + data = String.new(data[1..-2]) + logger.debug { "<< Received from Epson Proj: #{data}" } + + data = data.split('=') + case RESPONSE[data[0].lstrip(':')] # Because we see sometimes see responses like ':::PWR' + when :error + if data[1]? + code = data[1].to_i(16) + self[:last_error] = ERRORS[code]? || "#{data[1]}: unknown error code #{code}" + return task.try(&.success("Epson PJ error was #{self[:last_error]}")) + else # Lookup error! + return task.try(&.abort("Epson PJ sent error response for #{task.not_nil!.name || "unknown"}")) + end + when :power + state = data[1].to_i + @power_actual = powered = state < 3 + warming = state == 2 + cooling = state == 3 + + if warming || cooling + schedule.in(5.seconds) { power?(priority: 10) } + elsif !@power_stable + if @power_actual == @power_target + @power_stable = true + else + power(@power_target) + end + end + + self[:power] = powered if @power_stable + self[:warming] = warming + self[:cooling] = cooling + + if powered == @power_target + self[:video_mute] = false unless powered + end + when :av_mute + self[:video_mute] = self[:audio_mute] = data[1] == "ON" + self[:volume] = 0.0 + when :video_mute + self[:video_mute] = data[1] == "ON" + when :volume + # convert to a percentage + vol = data[1].to_i + vol_percent = (vol.to_f / 255.0) * 100.0 + self[:volume] = vol_percent + + mute = vol == 0 + self[:audio_mute] = mute if mute + @unmute_volume ||= vol_percent unless mute + when :lamp + self[:lamp_usage] = data[1].split(" ")[0].to_i #split added as we see responses like "LAMP=1633 1633" + when :input + self[:input] = Input.from_value(data[1].to_i(16)) || "unknown" + end + + task.try(&.success) + end + + def do_poll + if power?(priority: 20) && @power_stable + input? + video_mute? if @poll_video_mute + volume? if @poll_volume + end + do_send(:lamp, priority: 20) + end + + private def do_send(command, param = nil, **options) + command = COMMAND[command] + cmd = param ? "#{command} #{param}\r" : "#{command}?\r" + logger.debug { ">> Sending to Epson Proj: #{command}: #{cmd}" } + send(cmd, **options) + end +end diff --git a/drivers/epson/projector/esc_vp21_spec.cr b/drivers/epson/projector/esc_vp21_spec.cr new file mode 100644 index 00000000000..726d7b36df1 --- /dev/null +++ b/drivers/epson/projector/esc_vp21_spec.cr @@ -0,0 +1,61 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Epson::Projector::EscVp21" do + # connected + should_send("ESC/VP.net\x10\x03\x00\x00\x00\x00") + responds(":\r") + # do_poll + # power? + should_send("PWR?\r") + responds(":PWR=01\r") + status[:power].should eq(true) + # input? + should_send("SOURCE?\r") + responds(":SOURCE=30\r") + status[:input].should eq("HDMI") + # video_mute? + should_send("MSEL?\r") + responds(":MSEL=0\r") + status[:video_mute].should eq(false) + # volume? + should_send("VOL?\r") + responds(":VOL=10\r") + status[:volume].should eq(10) + # lamp + should_send("LAMP?\r") + responds(":LAMP=20\r") + status[:lamp_usage].should eq(20) + + exec(:mute) + should_send("MUTE ON\r") + responds(":\r") + should_send("MUTE?\r") + responds(":MUTE=ON\r") + status[:video_mute].should eq(true) + status[:audio_mute].should eq(true) + status[:volume].should eq(0) + + exec(:switch_to, "HDBaseT") + should_send("SOURCE 80\r") + responds(":\r") + should_send("SOURCE?\r") + responds(":SOURCE=80\r") + status[:input].should eq("HDBaseT") + status[:video_mute].should eq(false) + + exec(:mute_audio, false) + should_send("VOL 10\r") + responds(":\r") + should_send("VOL?\r") + responds(":VOL=10\r") + status[:volume].should eq(10) + status[:audio_mute].should eq(false) + + exec(:volume, 50) + should_send("VOL 50\r") + responds(":\r") + should_send("VOL?\r") + responds(":VOL=50\r") + status[:volume].should eq(50) + status[:audio_mute].should eq(false) +end diff --git a/drivers/exterity/avedia_player/m93xx.cr b/drivers/exterity/avedia_player/m93xx.cr new file mode 100644 index 00000000000..6ccc64796eb --- /dev/null +++ b/drivers/exterity/avedia_player/m93xx.cr @@ -0,0 +1,163 @@ +require "telnet" +require "placeos-driver" + +class Exterity::AvediaPlayer::R93xx < PlaceOS::Driver + descriptive_name "Exterity Avedia Player (M93xx)" + generic_name :IPTV + tcp_port 22 + + default_settings({ + ssh: { + username: :ctrl, + password: :labrador, + term: :xterm, + }, + max_waits: 100, + }) + + @ready : Bool = false + + def connected + self[:ready] = @ready = false + + schedule.every(59.seconds) do + logger.debug { "-- Polling Exterity Player" } + current_channel + current_channel_name + end + + schedule.every(1.hour) do + logger.debug { "-- Polling Exterity Player" } + dump + end + end + + def disconnected + self[:ready] = @ready = false + transport.tokenizer = nil + schedule.clear + end + + def channel(number : Int32 | String) + if number.is_a? Number + set :playChannelNumber, number, name: :channel + else + stream number + end + end + + def channel_name(name : String) + set(:currentChannel_name, name, name: :name).get + current_channel_name + end + + def stream(uri : String) : Nil + set(:playChannelUri, uri, name: :channel).get + schedule.in(2.second) do + current_channel + current_channel_name + end + end + + def current_channel + get :currentChannel + end + + def current_channel_name + get :currentChannel_name + end + + def dump + do_send "^dump!\r", name: :dump + end + + def help + do_send "^help!\r", name: :help + end + + def reboot + remote :reboot + end + + def tv_info + get :tv_info + end + + def version + get :SoftwareVersion + end + + @[Security(Level::Support)] + def manual(cmd : String) + do_send cmd + end + + def received(data, task) + data = String.new(data).strip + + logger.debug { "Exterity sent #{data}" } + + if !@ready + if data =~ /Run a Shell/i + logger.info { "-- starting the command interface" } + + # select open shell option + do_send "6\r", wait: false, delay: 2.seconds, priority: 96 + + # launch command processor + do_send "/usr/bin/serialCommandInterface\r", wait: false, delay: 200.milliseconds, priority: 95 + + # we need to disconnect if we don't see the serialCommandInterface after a certain amount of time + schedule.in(20.seconds) do + if !@ready + logger.error { "Exterity connection failed to be ready after 20 seconds." } + disconnect + end + end + elsif data =~ /Terminal Control Interface/i + logger.info { "-- got the control interface message, we're READY now" } + transport.tokenizer = Tokenizer.new("!") + self[:ready] = @ready = true + dump + return + end + end + + # Extract response between the ^ and ! + resp = data.split("^")[1][0..-2] + process_resp resp, task + end + + protected def process_resp(data, task) + logger.debug { "Resp details #{data}" } + + parts = data.split ':', 2 + + case parts[0] + when "error" + message = task ? "Error when requesting: #{task.try &.name}" : "Error response received" + logger.warn { message } + task.try &.abort(message) + else + self[parts[0].underscore] = parts[1] + task.try &.success(parts[1]) + end + end + + protected def do_send(command, **options) + logger.debug { "requesting #{command}" } + send command, **options + end + + protected def set(command, data, **options) + do_send "^set:#{command}:#{data}!\r", **options.merge({wait: false}) + end + + protected def remote(cmd, **options) + do_send "^send:#{cmd}!\r", **options + end + + protected def get(status, **options) + do_send "^get:#{status}!\r", **options + end +end diff --git a/drivers/exterity/avedia_player/m93xx_spec.cr b/drivers/exterity/avedia_player/m93xx_spec.cr new file mode 100644 index 00000000000..d8940064e13 --- /dev/null +++ b/drivers/exterity/avedia_player/m93xx_spec.cr @@ -0,0 +1,31 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Exterity::AvediaPlayer::R92xx" do + # this lets the driver know it's successfully connected + sleep 1 + status[:ready].should eq(false) + responds("Terminal Control Interface\r") + status[:ready].should eq(true) + + should_send("^dump!\r").responds %(^currentChannel:udp://239.193.3.169:5000?hwchan=4! +^currentChannel_name:SBS ONE HD! +^currentChannel_number:30! +^currentAVChannel:udp://239.193.3.169:5000?hwchan=4! +^new_channel:NO VALUE! +^currentChannel:udp://239.193.3.169:5000?hwchan=4!) + + sleep 100.milliseconds + + status[:current_channel].should eq "udp://239.193.3.169:5000?hwchan=4" + status[:current_channel_name].should eq "SBS ONE HD" + + resp = exec(:version) + responds("^SoftwareVersion:123!\r") + resp.get + status[:software_version].should eq("123") + + resp = exec(:tv_info) + responds("^tv_info:a,b,c,d,e,f,g!\r") + resp.get + status[:tv_info].should eq("a,b,c,d,e,f,g") +end diff --git a/drivers/exterity/avedia_player/r92xx.cr b/drivers/exterity/avedia_player/r92xx.cr new file mode 100644 index 00000000000..78bee328f70 --- /dev/null +++ b/drivers/exterity/avedia_player/r92xx.cr @@ -0,0 +1,167 @@ +require "telnet" +require "placeos-driver" + +class Exterity::AvediaPlayer::R92xx < PlaceOS::Driver + descriptive_name "Exterity Avedia Player (R92xx)" + generic_name :IPTV + tcp_port 23 + + default_settings({ + max_waits: 100, + username: "admin", + password: "labrador", + }) + + @ready : Bool = false + @telnet : Telnet? = nil + + def on_load + new_telnet_client + transport.pre_processor { |bytes| @telnet.try &.buffer(bytes) } + end + + def connected + @ready = false + self[:ready] = false + + schedule.every(60.seconds) do + logger.info { "-- Polling Exterity Player" } + tv_info + end + end + + def disconnected + # ensures the buffer is cleared + new_telnet_client + + schedule.clear + end + + def channel(number : Int32 | String) + if number.is_a? Number + set :playChannelNumber, number + else + stream number + end + end + + def stream(uri : String) + set :playChannelUri, uri + end + + def dump + do_send "^dump!", name: :dump + end + + def help + do_send "^help!", name: :help + end + + def reboot + remote :reboot + end + + def tv_info + get :tv_info + end + + def version + get :SoftwareVersion + end + + def manual(cmd : String) + do_send cmd + end + + def received(data, task) + data = String.new(data).strip + + logger.info { "Exterity sent #{data}" } + + if @ready + # Detect if logged out of serialCommandInterface + if data =~ /sh: .* not found/i + # Launch command processor + do_send "/usr/bin/serialCommandInterface", wait: false, delay: 2.seconds, priority: 95 + return :failure + end + + # Extract response + data.split("!").map(&.strip("^")).each do |resp| + process_resp(resp, task) + end + elsif data =~ /Exterity Control Interface| Exit/i + logger.info { "-- got the control interface message, we're READY now" } + @ready = true + self[:ready] = true + version + elsif data =~ /login:/i + logger.info { "-- got the login: prompt" } + transport.tokenizer = Tokenizer.new("\r") + + # login + do_send setting(String, :username), wait: false, delay: 200.milliseconds, priority: 98 + do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97 + + # select open shell option + do_send "6", wait: false, delay: 2.seconds, priority: 96 + + # launch command processor + do_send "/usr/bin/serialCommandInterface", wait: false, delay: 200.milliseconds, priority: 95 + + # we need to disconnect if we don't see the serialCommandInterface after a certain amount of time + schedule.in(20.seconds) do + if !@ready + logger.error { "Exterity connection failed to be ready after 5 seconds. Check username and password." } + disconnect + end + end + elsif logger.info { "Somehow we got here #{data}" } + end + + task.try &.success + end + + protected def process_resp(data, task) + logger.info { "Resp details #{data}" } + + parts = data.split ':' + + case parts[0].to_s + when "error" + if task != nil + logger.warn { "Error when requesting: #{task.try &.name}" } + else + logger.warn { "Error response received" } + end + when "tv_info" + self[:tv_info] = parts[1] + when "SoftwareVersion" + self[:version] = parts[1] + end + end + + protected def new_telnet_client + @telnet = Telnet.new do |data| + transport.send(data) + end + end + + protected def do_send(command, **options) + logger.info { "requesting #{command}" } + send @telnet.not_nil!.prepare(command), **options + end + + protected def set(command, data, **options) + # options[:name] = :"set_#{command}" unless options[:name] + do_send "^set:#{command}:#{data}!", **options + end + + protected def remote(cmd, **options) + do_send "^send:#{cmd}!", **options + end + + protected def get(status, **options) + do_send "^get:#{status}!", **options + end +end diff --git a/drivers/exterity/avedia_player/r92xx_protocol.md b/drivers/exterity/avedia_player/r92xx_protocol.md new file mode 100644 index 00000000000..fdad269cab1 --- /dev/null +++ b/drivers/exterity/avedia_player/r92xx_protocol.md @@ -0,0 +1,415 @@ + +# Exterity AvediaPlayer r9200 Control Protocol. + +NOTE:: All information in this document was obtained via exploration of the R9200 device. +No information here was provided by Exterity during this process + + +## Connecting + +* Telnet Protocol (port 23) +* `telnet 192.168.1.13` +* Default username: `admin` +* Default password: `labrador` +* Select option `6` to run a shell + + +## Shell Navigation + +Once in the shell you can use following tools to read files: + +* `less` for scanning through files +* `cat` for dumping files +* `ps aux` for viewing processes +* `ls` for listing files + +The file system is readonly so moving files to `/usr/local/www` for downloading was not possible. + + +## Applications + +* Application are installed at: `/usr/bin` + * `serialCommandInterface` allows programmatic control of the device + * `irsend` for sending IR commands +* Configuration is at: `/etc` + * `lircd.conf` contains the human readable names of all the IR commands + +``` +begin remote + + name exterity_remote_2 + + bits 16 + flags SPACE_ENC + eps 20 + aeps 200 + + header 8800 4400 + one 550 1650 + zero 550 550 + ptrail 550 + repeat 8800 2200 + pre_data_bits 16 + pre_data 0xB5B7 + gap 38500 + toggle_bit 0 + frequency 38000 + +#! exterity_bit_period 560 +#! exterity_aeps 500 +#! exterity_rmpower_len 66 +#! exterity_rmpower_pattern 16 8 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 1 1 3 1 1 1 1 1 3 1 1 + + begin codes + rm_1 0x45ba + rm_2 0x35ca + rm_3 0x6d92 + rm_4 0xc53a + rm_5 0xb54a + rm_6 0xed12 + rm_7 0x25da + rm_8 0x758a + rm_9 0x1de2 + rm_cancel 0x03fc + rm_0 0xf50a + rm_menu 0xa55a + rm_power 0xad52 + rm_chup 0x0df2 + rm_chdown 0x8d72 + rm_volup 0x5da2 + rm_voldown 0xdd22 + rm_up 0x4db2 + rm_left 0x956a + rm_enter 0xcd32 + rm_right 0xbd42 + rm_down 0x2dd2 + rm_mute 0xa35c + rm_red 0x837c + rm_green 0x43bc + rm_yellow 0xc33c + rm_blue 0x23dc + rm_rewind 0x15ea + rm_play 0x55aa + rm_pause 0xe51a + rm_ff 0x3dc2 + rm_skipback 0x639c + rm_skipfwd 0xe31c + rm_stop 0x7d82 + rm_record 0x659a + rm_exterity 0x13ec + rm_fn_tv 0x936c + rm_fn_home 0x53ac + rm_guide 0xd32c + rm_subtitle 0x857A + rm_info 0x33CC + rm_help 0xB34C + rm_audio 0x9D62 + rm_teletext 0xD52A + rm_av 0xFD02 + end codes + +end remote + +``` + + +## Serial Command Interface + +* All lines start with `^` +* All lines end with `!` + +Dump of the help text: + + +``` +^help! +To display a value: ^get: