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

the HTML version of the email

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

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

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

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

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

+ + Please reach out to your workplace support team should you have any other queries, + otherwise, we look forward to seeing you soon + + + +``` diff --git a/docs/http-api.md b/docs/http-api.md new file mode 100644 index 00000000000..9e908404b81 --- /dev/null +++ b/docs/http-api.md @@ -0,0 +1,154 @@ +# HTTP API + +Primarily for development. + + +## GET /build + +Returns the list of available drivers + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `compiled=true` (optional) if you only want the list of compiled drivers + +```json + +["drivers/place/spec_helper.cr", "..."] +``` + + +### GET /build/repositories + +Returns the list of 3rd party repositories + +```json + +["private_drivers", "..."] +``` + + +### GET /build/repository_commits + +Returns the list of available commits at the repository level + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### GET /build/{{escaped driver path}} + +Returns the list of compiled versions of the specified file are available + +```json + +["private_drivers_cr_01519d6", "..."] +``` + + +### GET /build/{{escaped driver path}}/commits + +Returns the list of available commits for the current driver + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### POST /build + +compiles a driver + +* `driver=drivers/path.cr` (required) the path to the driver +* `commit=01519d6` (optional) defaults to head + + +### DELETE /build/{{escaped driver path}} + +deletes compiled versions of a driver + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `commit=01519d6` (optional) deletes all versions of a driver if not specified + + +## GET /test + +Lists the available specs + +```json + +["drivers/place/spec_helper_spec.cr", "..."] +``` + + +### GET /test/{{escaped spec path}}/commits + +Returns the list of available commits for the specified spec + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `count=50` (optional) if you want more or less commits + +```json + +{ + "commit": "01519d6", + "date": "2019-06-02T23:59:22+10:00", + "author": "Stephen von Takach", + "subject": "implement websocket spec runner" +} +``` + + +### POST /test + +Compiles and runs a spec and returns the output + +* `repository=folder_name` (optional) if you wish to specify a third party repository +* `driver=drivers/path/to/file.cr` (required) the driver you want to test +* `spec=drivers/path/to/file_spec.cr` (required) the spec you want to run on the driver +* `commit=01519d6` (optional) the commit you would like the driver to be running at +* `spec_commit=01519d6` (optional) the commit you would like the spec to be running at +* `force=true` (optional) forces a re-compilation of the driver and spec +* `debug=true` (optional) compiles the files with debugging symbols + +```text +Launching spec runner +Launching driver: /Users/steve/Documents/projects/placeos/drivers/bin/drivers/drivers_place_private_helper_cr_4f6e0cd +... starting driver IO services +... starting module +... waiting for module +... module connected +... enabling debug output +... starting spec +... spec complete +... terminating driver gracefully +Driver terminated with: 0 + + +Finished in 15.65 milliseconds +0 examples, 0 failures, 0 errors, 0 pending + +spec runner exited with 0 +``` + + +### WebSocket /test/run_spec + +Same requirements as `POST /test` above however it streams the response diff --git a/docs/runtime-debugging.md b/docs/runtime-debugging.md new file mode 100644 index 00000000000..9fe51783978 --- /dev/null +++ b/docs/runtime-debugging.md @@ -0,0 +1,195 @@ +# Runtime Debugging + +This is supported via VS Code on OSX or Linux platforms. +It might be possible to do remote debugging on Windows in conjunction with the Linux Layer. + +* Requires [VS Code](https://code.visualstudio.com/) + * install [Crystal Lang](https://marketplace.visualstudio.com/items?itemName=faustinoaq.crystal-lang) extension + * install [Native Debug](https://marketplace.visualstudio.com/items?itemName=webfreak.debug) extension +* Requires [GDB](https://www.gnu.org/software/gdb/) + * On OSX install using [Homebrew](https://brew.sh/) + * Then code sign the executable: https://sourceware.org/gdb/wiki/PermissionsDarwin + * The `gdb-entitlement.xml` file is in this folder + * When creating the signing certificate follow [this guide](https://apple.stackexchange.com/questions/309017/unknown-error-2-147-414-007-on-creating-certificate-with-certificate-assist) + +This should also work with [LLDB](https://lldb.llvm.org/) on OSX however [has issues](https://github.com/crystal-lang/crystal/issues/4457). + + +## Debug on VSCode + +By convention the project directory name is the same as your application name, if you have changed it, please update `${workspaceFolderBasename}` with the name configured inside `shards.yml` + +### 1. `tasks.json` configuration to compile a crystal project + +```javascript +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Compile", + "command": "shards build --debug ${workspaceFolderBasename}", + "type": "shell" + } + ] +} +``` + +### 2. `launch.json` configuration to debug a binary + +#### Using GDB + +```javascript +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "gdb", + "request": "launch", + "target": "./bin/${workspaceFolderBasename}", + "cwd": "${workspaceRoot}", + "preLaunchTask": "Compile" + } + ] +} +``` + +#### Using LLDB + +```javascript +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug", + "type": "lldb-mi", + "request": "launch", + "target": "./bin/${workspaceFolderBasename}", + "cwd": "${workspaceRoot}", + "preLaunchTask": "Compile" + } + ] +} +``` + +### 3. Then hit the DEBUG green play button + +![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..1547493f1f7 --- /dev/null +++ b/drivers/amber_tech/grandview.cr @@ -0,0 +1,151 @@ +require "placeos-driver" +require "placeos-driver/interface/moveable" +require "placeos-driver/interface/stoppable" + +# Documentation: https://aca.im/driver_docs/AmberTech/grandview-screen.pdf +# https://www.ambertech.com.au/Documents/GV_IP%20CONTROL_Smart%20Screen_Trifold_Manual_April2020.pdf +require "./grandview_models" + +class AmberTech::Grandview < PlaceOS::Driver + include Interface::Moveable + include Interface::Stoppable + + # Discovery Information + generic_name :Screen + descriptive_name "Ambertech Grandview Projector Screen" + uri_base "http://192.168.0.2" + + # The device requires the HTTP port closed after every request + # (even though it responds with HTTP1.1 and doesn't return any headers) + default_settings({ + http_max_requests: 1, + }) + + def on_load + queue.delay = 2.seconds + schedule.every(1.minute) { status } + end + + # moveable interface + def move(position : MoveablePosition, index : Int32 | String = 0) + command = case position + when .up?, .close?, .in? + "/Close.js?a=100" + when .down?, .open?, .out? + "/Open.js?a=100" + else + raise "unsupported move option: #{position}" + end + + queue(name: "move") do |task| + response = get(command, headers: build_headers) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + self[:status] = status = parse_state StatusResp.from_json(response.body).status + task.success status + end + end + + # stoppable interface + def stop(index : Int32 | String = 0, emergency : Bool = false) + queue(name: "stop", priority: 999, clear_queue: emergency) do |task| + response = get("/Stop.js?a=100", headers: build_headers) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + + self[:status] = status = parse_state StatusResp.from_json(response.body).status + task.success status + end + end + + def status + if queue.online + queue(name: "status", priority: 0) do |task| + response = perform_status_request + if response.success? + task.success parse_status(response) + else + task.abort "request failed with #{response.status_code}\n#{response.body}" + end + end + else + response = perform_status_request + parse_status(response) if response.success? + end + end + + protected def perform_status_request + get("/GetDevInfoList.js", headers: build_headers) + end + + protected def build_headers + { + "Host" => URI.parse(config.uri.not_nil!).host.not_nil!, + "Connection" => "keep-alive", + } + end + + protected def parse_status(response) + info = AmberTech::Devices.from_json(response.body) + state = info.device_info.first + + self[:ver] = state.ver + self[:id] = state.id + self[:ip] = state.ip + self[:ip_subnet] = state.ip_subnet + self[:ip_gateway] = state.ip_gateway + self[:name] = state.name + self[:status] = parse_state state.status + info + end + + # compatibility with Screen Technics + def up(index : Int32 = 0) + move :up + end + + def up? + {"opened", "opening"}.includes?(self["status"]?) + end + + def down(index : Int32 = 0) + move :down + end + + def down? + {"closed", "closing"}.includes?(self["status"]?) + end + + protected def parse_state(state : AmberTech::Status) + case state + in .stop? + self[:moving0] = false + self[:position0] = nil + self[:screen0] = "stopped" + in .opening? + self[:moving0] = true + self[:position0] = MoveablePosition::Open + self[:screen0] = "moving_bottom" + poll_state + in .opened? + self[:moving0] = false + self[:position0] = MoveablePosition::Open + self[:screen0] = "at_bottom" + in .closing? + self[:moving0] = true + self[:position0] = MoveablePosition::Close + self[:screen0] = "moving_top" + poll_state + in .closed? + self[:moving0] = false + self[:position0] = MoveablePosition::Close + self[:screen0] = "at_top" + end + + state.to_s.downcase + end + + protected def poll_state + schedule.clear + schedule.every(1.minute) { status; nil } + schedule.in(2.seconds) { status; nil } + end +end diff --git a/drivers/amber_tech/grandview_models.cr b/drivers/amber_tech/grandview_models.cr new file mode 100644 index 00000000000..2467fb80bfa --- /dev/null +++ b/drivers/amber_tech/grandview_models.cr @@ -0,0 +1,45 @@ +require "json" + +module AmberTech + enum Status + Stop + Opening + Opened + Closing + Closed + end + + class DevInfo + include JSON::Serializable + + getter ver : String + getter id : String + getter ip : String + + @[JSON::Field(key: "sub")] + getter ip_subnet : String + + @[JSON::Field(key: "gw")] + getter ip_gateway : String + getter name : String + getter pass : String? + getter pass2 : String? + getter status : Status + end + + class Devices + include JSON::Serializable + + @[JSON::Field(key: "devInfo")] + getter device_info : Array(DevInfo) + + @[JSON::Field(key: "currentIp")] + getter current_ip : String + end + + class StatusResp + include JSON::Serializable + + getter status : Status + end +end diff --git a/drivers/amber_tech/grandview_spec.cr b/drivers/amber_tech/grandview_spec.cr new file mode 100644 index 00000000000..aea60dd10fd --- /dev/null +++ b/drivers/amber_tech/grandview_spec.cr @@ -0,0 +1,40 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "AmberTech::Grandview" do + retval = exec(:status) + + expect_http_request do |request, response| + raise "unexpected path #{request.path} for info" unless request.path == "/GetDevInfoList.js" + response.status_code = 200 + response << %({ + "currentIp":"10.142.196.27", + "devInfo":[ + { + "ver":"1.0", + "id":"1015095851", + "ip":"10.142.196.27", + "sub":"255.255.255.128", + "gw":"10.142.196.1", + "name":"CII_Scrn", + "pass":"admin", + "pass2":"config", + "status":"Closed" + } + ] + }) + end + + retval.get + sleep 1 + status[:status].should eq "closed" + + retval = exec(:move, "down") + sleep 2 + expect_http_request do |request, response| + raise "unexpected path #{request.path} for move down" unless request.path == "/Open.js" + response.status_code = 200 + response << %({"status":"Opening"}) + end + retval.get.should eq "opening" + status[:status].should eq "opening" +end diff --git a/drivers/amx/svsi/n_series_decoder.cr b/drivers/amx/svsi/n_series_decoder.cr new file mode 100644 index 00000000000..9b326c6e5a6 --- /dev/null +++ b/drivers/amx/svsi/n_series_decoder.cr @@ -0,0 +1,218 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "inactive-support/mapped_enum" + +# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf + +class Amx::Svsi::NSeriesDecoder < PlaceOS::Driver + include Interface::Muteable + include PlaceOS::Driver::Interface::InputSelection(Int32) + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Decoder" + generic_name :Decoder + + @previous_stream : Int32? = nil + @mute : Bool = false + @stream : Int32? = nil + + private DELIMITER = "\r" + + mapped_enum Command do + GetStatus = "getStatus" + Set = "set" + SetSettings = "setSettings" + SwitchKVM = "KVMMasterIP" + Mute = "mute" + Unmute = "unmute" + SetAudio = "seta" + Live = "live" + Local = "local" + ScalerEnable = "scalerenable" + ScalerDisable = "scalerdisable" + ModeSet = "modeset" + end + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + do_send(Command::GetStatus, priority: 0) + end + + def switch_to(input : Int32) + switch_video(input) + switch_audio(0) # enable AFV + end + + def switch_video(stream_id : Int32) + do_send(Command::Set, stream_id) + end + + def switch_audio(stream_id : Int32) + @previous_stream = stream_id + unmute + end + + def switch_kvm(ip_address : String, video_follow : Bool = true) + host = "#{ip_address},#{video_follow ? 1 : 0}" + do_send(Command::SwitchKVM, host) + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + if state + do_send(Command::Mute, name: :mute) + do_send(Command::SetAudio, 0) + else + do_send(Command::SetAudio, @previous_stream || 0) + do_send(Command::Unmute, name: :mute) + end + end + + def live(state : Bool = true) + state ? do_send(Command::Live) : local(self[:playlist].as_i) + end + + def local(playlist : Int32 = 0) + do_send(Command::Local, playlist) + end + + def scaler(state : Bool) + action = state ? Command::ScalerEnable : Command::ScalerDisable + do_send(action, name: :scaler) + end + + OutputModes = [ + "auto", + "1080p59.94", + "1080p60", + "720p60", + "4K30", + "4K25", + ] + + def output_resolution(mode : String) + unless OutputModes.includes?(mode) + logger.error { "\"#{mode}\" is not a valid resolution" } + return + end + do_send(Command::ModeSet, mode) + end + + def videowall( + width : Int32, + height : Int32, + x_pos : Int32, + y_pos : Int32, + scale : VideowallScalingMode = VideowallScalingMode::Auto + ) + if width > 1 && height > 1 + videowall_size(width, height) + videowall_position(x_pos, y_pos) + videowall_scaling(scale) + videowall_enable + else + videowall_disable + end + end + + def videowall_enable(state : Bool = true) + state = state ? "on" : "off" + do_send(Command::SetSettings, "wallEnable", state) + end + + def videowall_disable + videowall_enable(false) + end + + def videowall_size(width : Int32, height : Int32) + do_send(Command::SetSettings, "wallHorMons", width) + do_send(Command::SetSettings, "wallVerMons", height) + end + + def videowall_position(x : Int32, y : Int32) + do_send(Command::SetSettings, "wallMonPosV", x) + do_send(Command::SetSettings, "wallMonPosH", y) + end + + enum VideowallScalingMode + Auto # decoder decides best method + Fit # aspect distort + Stretch # fill and crop + end + + def videowall_scaling(scaling_mode : VideowallScalingMode) + do_send(Command::SetSettings, "wallStretch", scaling_mode) + end + + mapped_enum Response do + Stream = "stream" + StreamAudio = "streamaudio" + Name = "name" + Playmode = "playmode" + Playlist = "playlist" + Mute = "mute" + ScalerBypass = "scalerbypass" + Mode = "mode" + InputRes = "inputres" + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + prop, value = data.split(':') + + case Response.from_mapped_value?(prop.downcase) + in Response::Stream + self[:video] = @stream = value.to_i + in Response::StreamAudio + stream_id = value.to_i + self[:audio_actual] = stream_id + self[:audio] = stream_id == 0 ? (@mute ? 0 : @stream) : stream_id + in Response::Name + self[:device_name] = value + in Response::Playmode + self[:local_playback] = value == "local" + in Response::Playlist + self[:playlist] = value.to_i + in Response::Mute + self[:mute] = @mute = value == "1" + in Response::ScalerBypass + self[:scaler_active] = value != "no" + in Response::Mode + self[:output_res] = value + in Response::InputRes + self[:input_res] = value + in Nil + raise "Unexpected response: #{prop}" + end + + task.try(&.success) + end + + def do_send(command : Command, *args, **options) + arguments = [command.mapped_value] + + unless (splat = args.to_a).is_a? Array(NoReturn) + arguments += splat + end + + request = "#{arguments.join(':')}#{DELIMITER}" + send(request, **options) + end +end diff --git a/drivers/amx/svsi/n_series_encoder.cr b/drivers/amx/svsi/n_series_encoder.cr new file mode 100644 index 00000000000..54f0593835a --- /dev/null +++ b/drivers/amx/svsi/n_series_encoder.cr @@ -0,0 +1,121 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/switchable" +require "inactive-support/mapped_enum" + +# Documentation: https://aca.im/driver_docs/AMX/SVSIN1000N2000Series.APICommandList.pdf + +class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver + include Interface::Muteable + + enum Input + Hdmionly + Vgaonly + Hdmivga + Vgahdmi + end + + include Interface::InputSelection(Input) + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Encoder" + generic_name :Encoder + + private DELIMITER = "\r" + + mapped_enum Command do + GetStatus = "getStatus" + VideoSource = "vidsrc" + Live = "live" + Local = "local" + Disable = "txdisable" + Mute = "mute" + Unmute = "unmute" + end + + def on_load + transport.tokenizer = Tokenizer.new(DELIMITER) + end + + def connected + schedule.every(50.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def do_poll + do_send(Command::GetStatus, priority: 0) + end + + def switch_to(input : Input, **options) + do_send(Command::VideoSource, input, **options) + end + + Modes = (1..8).map &.to_s + + def media_source(mode : String) + if mode == "live" + do_send(Command::Live) + elsif Modes.includes?(mode) + do_send(Command::Local, mode) + else + raise("invalid mode #{mode}") + end + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + if state + do_send(Command::Disable) if layer.audio_video? || layer.video? + do_send(Command::Mute) if layer.audio_video? || layer.audio? + else + do_send(Command::Disable) if layer.audio_video? || layer.video? + do_send(Command::Unmute) if layer.audio_video? || layer.audio? + end + end + + enum Response + Name + Stream + Playmode + Mute + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + prop, value = data.split(':') + + case Response.parse? prop + in Response::Name + self[:device_name] = value + in Response::Stream + self[:stream_id] = value.to_i + in Response::Playmode + self[:mute] = value == "off" + in Response::Mute + self[:audio_mute] = value == "1" + in Nil + raise "Invalid response: #{prop}" + end + + task.try(&.success) + end + + def do_send(command : Command, *args, **options) + arguments = [command.mapped_value] + + unless (splat = args.to_a).is_a? Array(NoReturn) + arguments += splat + end + + request = "#{arguments.join(':')}#{DELIMITER}" + send(request, **options) + end +end diff --git a/drivers/amx/svsi/n_series_switcher.cr b/drivers/amx/svsi/n_series_switcher.cr new file mode 100644 index 00000000000..88ff69d7d3d --- /dev/null +++ b/drivers/amx/svsi/n_series_switcher.cr @@ -0,0 +1,228 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" + +# require "placeos-driver/interface/switchable" + +# Documentation: https://aca.im/driver_docs/AMX/N8000SeriesAPICommandListRev1.1.pdf + +class Amx::Svsi::NSeriesEncoder < PlaceOS::Driver + include Interface::Muteable + + tcp_port 50002 + descriptive_name "AMX SVSI N-Series Switcher" + generic_name :Switcher + + alias InOut = String | Int32 + + @inputs : Hash(String, String) = {} of String => String + @outputs : Hash(String, String) = {} of String => String + @encoders = [] of String + @decoders = [] of String + @lookup : Hash(String, String) = {} of String => String + @list = [] of String + + def on_load + transport.tokenizer = Tokenizer.new("") + on_update + end + + def on_update + @inputs = setting?(Hash(String, String), :inputs) || {} of String => String + @outputs = setting?(Hash(String, String), :outputs) || {} of String => String + + @encoders = @inputs.keys + @decoders = @outputs.keys + + @lookup = @inputs.merge(@outputs) + @list = @encoders + @decoders + end + + def connected + @lookup.each_key do |ip_address| + monitor(ip_address, priority: 0) + monitornotify(ip_address, priority: 0) + end + + schedule.every(50.seconds) { + logger.debug { "-- Maintaining Connection --" } + monitornotify(@list.first, priority: 0) + } + end + + def disconnected + schedule.clear + end + + CommonCommands = [ + :monitor, :monitornotify, + :live, :local, :serial, :readresponse, :sendir, :sendirraw, :audioon, :audiooff, + :enablehdmiaudio, :disablehdmiaudio, :autohdmiaudio, + # recorder commands + :record, :dsrecord, :dvrswitch1, :dvrswitch2, :mpeg, :mpegall, :deletempegfile, + :play, :stop, :pause, :unpause, :fastforward, :rewind, :deletefile, :stepforward, + :stepreverse, :stoprecord, :recordhold, :recordrelease, :playhold, :playrelease, + :deleteallplaylist, :deleteallmpegs, :remotecopy, + # window processor commands + :wpswitch, :wpaudioin, :wpactive, :wpinactive, :wpaudioon, :wpaudiooff, :wpmodeon, + :wpmodeoff, :wparrange, :wpbackground, :wpcrop, :wppriority, :wpbordon, :wpbordoff, + :wppreset, + # audio transceiver commands + :atrswitch, :atrmute, :atrunmute, :atrtxmute, :atrtxunmute, :atrhpvol, :atrlovol, + :atrlovolup, :atrlovoldown, :atrhpvolup, :atrhpvoldown, :openrelay, :closerelay, + # video wall commands + :videowall, + # miscellaneous commands + :script, :goto, :tcpclient, :udpclient, :reboot, :gc_serial, :gc_openrelay, + :gc_closerelay, :gc_ir, + ] + + {% for name in CommonCommands %} + def {{name.id}}(ip_address : String, *args, **options) + do_send({{name.id.stringify}}, ip_address, *args, **options) + end + {% end %} + + def serialhex(ip_address : String, wait_time : Int32 = 1, *data, **options) + do_send("serialhex", wait_time, ip_address, *data, **options) + end + + # Encoder Commands + {% for name in [:modeoff, :enablecc, :disablecc, :autocc, :uncompressedoff] %} + def {{name.id}}(input : InOut, *args, **options) + do_send({{name.id.stringify}}, get_input(input), *args, **options) + end + {% end %} + + # Decoder Commands + {% for name in [:audiofollow, :volume, :dvion, :dvioff, :cropref, :getStatus] %} + def {{name.id}}(output : InOut, *args, **options) + do_send({{name.id.stringify}}, get_output(output), *args, **options) + end + {% end %} + + def switch(inouts : Hash(Int32, InOut | Array(InOut)), **options) + inouts.each do |input, output| + outputs = output.is_a?(InOut) ? [output] : output + if input != 0 + # 'in_ip' => ['ip1', 'ip2'] etc + input_actual = get_input(input) + outputs.each do |o| + output_actual = get_output(o) + + dvion(output_actual, **options) + audioon(output_actual, **options) + audiofollow(output_actual, **options) + + self["video#{output_actual}"] = input_actual + self["audio#{output_actual}"] = input_actual + do_send(:switch, output_actual, input_actual, **options) + end + else + # nil => ['ip1', 'ip2'] etc + outputs.each do |o| + output_actual = get_output(o) + dvioff(output_actual, **options) + audiooff(output_actual, **options) + end + end + end + end + + def switch_audio(inouts : Hash(Int32, InOut | Array(InOut)), **options) + inouts.each do |input, output| + outputs = output.is_a?(InOut) ? [output] : output + if input != 0 + # 'in_ip' => ['ip1', 'ip2'] etc + input_actual = get_input(input) + outputs.each do |o| + output_actual = get_output(o) + + audioon(input_actual, **options) + audioon(output_actual, **options) + + self["audio#{output_actual}"] = input_actual + do_send(:switchaudio, output_actual, input_actual, **options) + end + else + # nil => ['ip1', 'ip2'] etc + outputs.each do |o| + audiooff(get_output(o), **options) + end + end + end + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + address = index.is_a?(Int32) && (val = @encoders[index]? || @decoders[index]?) ? val : index.as(String) + if state + dvioff(address) if layer.audio_video? || layer.video? + audiooff(address) if layer.audio_video? || layer.audio? + else + dvion(address) if layer.audio_video? || layer.video? + audioon(address) if layer.audio_video? || layer.audio? + end + end + + def received(data, task) + data = String.new(data) + logger.debug { "Received: #{data}" } + + resp = data.split(':') + + case resp.size + when 13 # Encoder or decoder status + self[resp[0]] = { + communications: resp[1] == "1", + dvioff: resp[2] == "1", + scaler: resp[3] == "1", + source_detected: resp[4] == "1", + mode: resp[5], + audio_enabled: resp[6] == "1", + video_stream: resp[7].to_i, + audio_stream: resp[8] == "follow video" ? resp[8] : resp[8].to_i, + playlist: resp[9], + colorspace: resp[10], + hdmiaudio: resp[11], + resolution: resp[12], + } + when 10 # Audio Transceiver or window processor status + self[resp[0]] = resp + else + logger.warn { "unknown response type: #{resp}" } + end + + task.try(&.success) + end + + def do_send(*args, **options) + cmd = args.join(' ') + logger.debug { "sending #{cmd}" } + send("#{cmd}\r\n", **options) + end + + private def get_input(address : InOut) : String + if address.is_a?(String) && @inputs[address]? + address + elsif address.is_a?(Int32) && (input = @encoders[address]?) + input + else + logger.warn { "unknown address #{address}" } + address.to_s + end + end + + private def get_output(address : InOut) : String + if address.is_a?(String) && @outputs[address]? + address + elsif address.is_a?(Int32) && (output = @decoders[address]?) + output + else + logger.warn { "unknown address #{address}" } + address.to_s + end + end +end diff --git a/drivers/amx/svsi/virtual_switcher.cr b/drivers/amx/svsi/virtual_switcher.cr new file mode 100644 index 00000000000..4ed0a1169c3 --- /dev/null +++ b/drivers/amx/svsi/virtual_switcher.cr @@ -0,0 +1,55 @@ +require "placeos-driver" +require "placeos-driver/interface/switchable" + +# This driver provides an abstraction layer for systems using SVSI based signal +# distribution. In place of referencing specific decoders and stream id's, +# this may be used to enable all endpoints associated with a system to be +# grouped as a virtual matrix switcher and a familiar switcher API used. + +class Amx::Svsi::VirtualSwitcher < PlaceOS::Driver + include PlaceOS::Driver::Interface::Switchable(Int32, Int32) + + descriptive_name "AMX SVSI Virtual Switcher" + generic_name :Switcher + + accessor encoders : Array(Encoder), implementing: InputSelection + accessor decoders : Array(Decoder), implementing: InputSelection + + def switch_to(input : Int32) + decoders.each(&.switch_to(input)) + end + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + layer ||= SwitchLayer::All + connect(map) do |mod, stream| + mod.switch_audio(stream) if layer.all? || layer.audio? + mod.switch_video(stream) if layer.all? || layer.video? + end + end + + private def connect(inouts : Hash(Input, Array(Output)), &) + inouts.each do |input, outputs| + if input == 0 + stream = 0 # disconnected + else + # Subtract one as Encoder_1 on the system would be encoder[0] here + if encoder = encoders[input - 1]? + stream = encoder[:stream_id] + else + logger.warn { "could not find Encoder_#{input}" } + break + end + end + + outputs = outputs.is_a?(Array) ? outputs : [outputs] + outputs.each do |output| + # Subtract one as Decoder_1 on the system would be decoder[0] here + if decoder = decoders[output - 1]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder_#{output}" } + end + end + end + end +end diff --git a/drivers/ashrae/bacnet.cr b/drivers/ashrae/bacnet.cr new file mode 100644 index 00000000000..740998a1960 --- /dev/null +++ b/drivers/ashrae/bacnet.cr @@ -0,0 +1,634 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "socket" +require "./bacnet_models" + +class Ashrae::BACnet < PlaceOS::Driver + include Interface::Sensor + + generic_name :BACnet + descriptive_name "BACnet Connector" + description %(makes BACnet data available to other drivers in PlaceOS) + + # Hookup dispatch to the BACnet BBMD device + uri_base "ws://dispatch/api/dispatch/v1/udp_dispatch?port=47808&accept=192.168.0.1" + + default_settings({ + dispatcher_key: "secret", + bbmd_ip: "192.168.0.1", + known_devices: [{ + ip: "192.168.86.25", + id: 389999, + net: 0x0F0F, + addr: "0A", + }], + verbose_debug: false, + poll_period: 3, + }) + + def websocket_headers + dispatcher_key = setting?(String, :dispatcher_key) + HTTP::Headers{ + "Authorization" => "Bearer #{dispatcher_key}", + "X-Module-ID" => module_id, + } + end + + protected getter! udp_server : UDPSocket + protected getter! bacnet_client : ::BACnet::Client::IPv4 + protected getter! device_registry : ::BACnet::Client::DeviceRegistry + + alias DeviceInfo = ::BACnet::Client::DeviceRegistry::DeviceInfo + + @packets_processed : UInt64 = 0_u64 + @verbose_debug : Bool = false + @bbmd_ip : Socket::IPAddress = Socket::IPAddress.new("127.0.0.1", 0xBAC0) + @devices : Hash(UInt32, DeviceInfo) = {} of UInt32 => DeviceInfo + @mutex : Mutex = Mutex.new(:reentrant) + @bbmd_forwarding : Array(UInt8) = [] of UInt8 + @seen_devices : Hash(UInt32, DeviceAddress) = {} of UInt32 => DeviceAddress + + protected def get_device(device_id : UInt32) + @mutex.synchronize { @devices[device_id]? } + end + + def on_load + # We only use dispatcher for broadcast messages, a local port for primary comms + server = UDPSocket.new + server.bind "0.0.0.0", 0xBAC0 + server.write_timeout = 200.milliseconds + @udp_server = server + + queue.timeout = 2.seconds + + # Hook up the client to the transport + client = ::BACnet::Client::IPv4.new(0, 2.seconds) + client.on_transmit do |message, address| + if address.address == Socket::IPAddress::BROADCAST + if @bbmd_forwarding.size == 4 + message.data_link.request_type = ::BACnet::Message::IPv4::Request::ForwardedNPDU + message.data_link.address.ip1 = @bbmd_forwarding[0] + message.data_link.address.ip2 = @bbmd_forwarding[1] + message.data_link.address.ip3 = @bbmd_forwarding[2] + message.data_link.address.ip4 = @bbmd_forwarding[3] + message.data_link.address.port = 47808_u16 + end + + logger.debug { "sending broadcase message #{message.inspect}" } + + # send to the known devices (in case BBMD does not forward message) + devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress + devices.each do |dev| + begin + server.send message, to: dev.address + rescue error + logger.warn(exception: error) { "error sending message to #{dev.address}" } + end + end + + # Send this message to the BBMD + message.data_link.request_type = ::BACnet::Message::IPv4::Request::DistributeBroadcastToNetwork + payload = DispatchProtocol.new + payload.message = DispatchProtocol::MessageType::WRITE + payload.ip_address = @bbmd_ip.address + payload.id_or_port = @bbmd_ip.port.to_u64 + payload.data = message.to_slice + transport.send payload.to_slice + else + server.send message, to: address + end + end + @bacnet_client = client + + # Track the discovery of devices + registry = ::BACnet::Client::DeviceRegistry.new(client, logger) + registry.on_new_device { |device| new_device_found(device) } + @device_registry = registry + + spawn { process_data(server, client) } + on_update + end + + # This is our input read loop, grabs the incoming data and pumps it to our client + protected def process_data(server, client) + loop do + break if server.closed? + bytes, client_addr = server.receive + + begin + message = IO::Memory.new(bytes).read_bytes(::BACnet::Message::IPv4) + client.received message, client_addr + @packets_processed += 1_u64 + rescue error + logger.warn(exception: error) { "error parsing BACnet packet from #{client_addr}: #{bytes.to_slice.hexstring}" } + end + end + end + + def on_unload + udp_server.close + end + + def on_update + bbmd_ip = setting?(String, :bbmd_ip) || "" + bbmd_forwarding = setting?(String, :bbmd_forwarding) || "" + + @bbmd_forwarding = bbmd_forwarding.strip.split(".").select(&.presence).map(&.to_u8) + @bbmd_ip = Socket::IPAddress.new(bbmd_ip, 0xBAC0) if bbmd_ip.presence + @verbose_debug = setting?(Bool, :verbose_debug) || false + + schedule.clear + schedule.in(5.seconds) { query_known_devices } + + poll_period = setting?(UInt32, :poll_period) || 3 + schedule.every(poll_period.minutes) do + logger.debug { "--- Polling all known bacnet devices" } + keys = @mutex.synchronize { @devices.keys } + keys.each { |device_id| poll_device(device_id) } + end + + perform_discovery if bbmd_ip.presence + end + + def packets_processed + @packets_processed + end + + def connected + bbmd_ip = setting?(String, :bbmd_ip) + perform_discovery if bbmd_ip.presence + end + + protected def object_value(obj) + val = obj.value.try &.value + case val + in ::BACnet::Time, ::BACnet::Date + val.value + in ::BACnet::BitString, BinData + nil + in ::BACnet::PropertyIdentifier + val.property_type + in ::BACnet::ObjectIdentifier + {val.object_type, val.instance_number} + in Nil, Bool, UInt64, Int64, Float32, Float64, String + val + end + rescue + nil + end + + protected def device_details(device) + { + name: device.name, + model_name: device.model_name, + vendor_name: device.vendor_name, + + ip_address: device.ip_address.to_s, + network: device.network, + address: device.address, + id: device.object_ptr.instance_number, + + objects: device.objects.map { |obj| + { + name: obj.name, + type: obj.object_type, + id: obj.instance_id, + + unit: obj.unit, + value: object_value(obj), + seen: obj.changed, + } + }, + } + end + + def device(device_id : UInt32) + device_details get_device(device_id).not_nil! + end + + def devices + device_registry.devices.map { |device| device_details device } + end + + def query_known_devices + sent = [] of UInt32 + @seen_devices.each_value do |info| + sent << info.id.not_nil! + logger.debug { "inspecting #{info.address} - #{info.id}" } + device_registry.inspect_device(info.address, info.identifier, info.net, info.addr) + end + devices = setting?(Array(DeviceAddress), :known_devices) || [] of DeviceAddress + devices.each do |info| + if id = info.id + next if id.in? sent + sent << id + logger.debug { "inspecting #{info.address} - #{info.id}" } + device_registry.inspect_device(info.address, info.identifier, info.net, info.addr) + end + end + "inspected #{sent.size} devices" + end + + def poll_device(device_id : UInt32) + device = get_device(device_id) + return false unless device + + client = bacnet_client + objects = @mutex.synchronize { device.objects.dup } + objects.each do |obj| + next unless obj.object_type.in?(::BACnet::Client::DeviceRegistry::OBJECTS_WITH_VALUES) + name = object_binding(device_id, obj) + queue(name: name, priority: 0, timeout: 500.milliseconds) do |task| + spawn_action(task) do + obj.sync_value(client) + self[name] = object_value(obj) + end + end + Fiber.yield + end + true + end + + protected def spawn_action(task, &block : -> Nil) + spawn(same_thread: true) { task.success block.call } + Fiber.yield + end + + # Performs a WhoIs discovery against the BACnet network + def perform_discovery : Nil + bacnet_client.who_is + end + + alias ObjectType = ::BACnet::ObjectIdentifier::ObjectType + + def update_value(device_id : UInt32, instance_id : UInt32, object_type : ObjectType) + obj = get_object_details(device_id, instance_id, object_type) + name = object_binding(device_id, obj) + + queue(name: name, priority: 50) do |task| + spawn_action(task) do + obj.sync_value(bacnet_client) + self[name] = object_value(obj) + end + end + end + + protected def get_object_details(device_id : UInt32, instance_id : UInt32, object_type : ObjectType) + device = get_device(device_id).not_nil! + device.objects.find { |obj| obj.object_ptr.object_type == object_type && obj.object_ptr.instance_number == instance_id }.not_nil! + end + + def write_real(device_id : UInt32, instance_id : UInt32, value : Float32, object_type : ObjectType = ObjectType::AnalogValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_double(device_id : UInt32, instance_id : UInt32, value : Float64, object_type : ObjectType = ObjectType::LargeAnalogValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_unsigned_int(device_id : UInt32, instance_id : UInt32, value : UInt64, object_type : ObjectType = ObjectType::PositiveIntegerValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_signed_int(device_id : UInt32, instance_id : UInt32, value : Int64, object_type : ObjectType = ObjectType::IntegerValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_string(device_id : UInt32, instance_id : UInt32, value : String, object_type : ObjectType = ObjectType::CharacterStringValue) + object = get_object_details(device_id, instance_id, object_type) + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + ::BACnet::Object.new.set_value(value), + network: object.network, + address: object.address + ) + end + end + value + end + + def write_binary(device_id : UInt32, instance_id : UInt32, value : Bool, object_type : ObjectType = ObjectType::BinaryValue) + val = value ? 1 : 0 + object = get_object_details(device_id, instance_id, object_type) + val = ::BACnet::Object.new.set_value(val) + val.short_tag = 9_u8 + + queue(priority: 99) do |task| + spawn_action(task) do + bacnet_client.write_property( + object.ip_address, + ::BACnet::ObjectIdentifier.new(object_type, instance_id), + ::BACnet::PropertyType::PresentValue, + val, + network: object.network, + address: object.address + ) + end + end + value + end + + protected def new_device_found(device) + logger.debug { "new device found: #{device.name}, #{device.model_name} (#{device.vendor_name}) with #{device.objects.size} objects" } + logger.debug { device.inspect } if @verbose_debug + + @mutex.synchronize { @devices[device.object_ptr.instance_number] = device } + + device_id = device.object_ptr.instance_number + device.objects.each { |obj| self[object_binding(device_id, obj)] = object_value(obj) } + end + + protected def object_binding(device_id, obj) + "#{device_id}.#{obj.object_type}[#{obj.instance_id}]" + end + + def received(data, task) + # we should only be receiving broadcasted messages here + protocol = IO::Memory.new(data).read_bytes(DispatchProtocol) + + logger.debug { "received message: #{protocol.message} #{protocol.ip_address}:#{protocol.id_or_port} (size #{protocol.data_size})" } + + if protocol.message.received? + message = IO::Memory.new(protocol.data).read_bytes(::BACnet::Message::IPv4) + logger.debug { "dispatch sent:\n#{message.inspect}" } if @verbose_debug + bacnet_client.received message, @bbmd_ip + + app = message.application + + is_iam = false + is_cov = case app + when ::BACnet::ConfirmedRequest + app.service.cov_notification? + when ::BACnet::UnconfirmedRequest + is_iam = app.service.i_am? + app.service.cov_notification? + else + false + end + network = message.network + + if network && is_cov + ip = if message.data_link.request_type.forwarded_npdu? + ip_add = message.data_link.address + "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}" + else + protocol.ip_address + end + if network.source_specifier + addr = network.source_address + net = network.source.network + end + device = message.objects.find { |obj| obj.tag == 1 }.not_nil!.to_object_id.instance_number + # prop = message.objects.find { |obj| obj.tag == 2 } + @seen_devices[device] = DeviceAddress.new(ip, device, net, addr) + end + + if network && is_iam + ip = if message.data_link.request_type.forwarded_npdu? + ip_add = message.data_link.address + "#{ip_add.ip1}.#{ip_add.ip2}.#{ip_add.ip3}.#{ip_add.ip4}" + else + protocol.ip_address + end + details = ::BACnet::Client::Message::IAm.parse(message) + device = details[:object_id].instance_number + @seen_devices[device] = DeviceAddress.new(ip, device, details[:network], details[:address]) + end + end + + task.try &.success + end + + def seen_devices + @seen_devices + end + + # ====================== + # Sensor interface + # ====================== + + protected def to_sensor(device_id, device, object, filter_type = nil) : Interface::Sensor::Detail? + sensor_type = case object.unit + when Nil + # required for case statement to work + if object.name.includes? "count" + SensorType::Counter + end + when .degrees_fahrenheit?, .degrees_celsius?, .degrees_kelvin? + SensorType::Temperature + when .percent_relative_humidity? + SensorType::Humidity + when .pounds_force_per_square_inch? + SensorType::Pressure + # when + # SensorType::Presence + when .volts?, .millivolts?, .kilovolts?, .megavolts? + SensorType::Voltage + when .milliamperes?, .amperes? + SensorType::Current + when .millimeters_of_water?, .centimeters_of_water?, .inches_of_water?, .cubic_feet?, .cubic_meters?, .imperial_gallons?, .milliliters?, .liters?, .us_gallons? + SensorType::Volume + when .milliwatts?, .watts?, .kilowatts?, .megawatts?, .watt_hours?, .kilowatt_hours?, .megawatt_hours? + SensorType::Power + when .hertz?, .kilohertz?, .megahertz? + SensorType::Frequency + when .cubic_feet_per_second?, .cubic_feet_per_minute?, .cubic_feet_per_hour?, .cubic_meters_per_second?, .cubic_meters_per_minute?, .cubic_meters_per_hour?, .imperial_gallons_per_minute?, .milliliters_per_second?, .liters_per_second?, .liters_per_minute?, .liters_per_hour?, .us_gallons_per_minute?, .us_gallons_per_hour? + SensorType::Flow + when .percent? + SensorType::Level + when .no_units? + if object.name.includes? "count" + SensorType::Counter + end + end + return nil unless sensor_type + return nil if filter_type && sensor_type != filter_type + + unit = case object.unit + when Nil + when .degrees_fahrenheit? then "[degF]" + when .degrees_celsius? then "Cel" + when .degrees_kelvin? then "K" + when .pounds_force_per_square_inch? then "[psi]" + when .volts? then "V" + when .millivolts? then "mV" + when .kilovolts? then "kV" + when .megavolts? then "MV" + when .milliamperes? then "mA" + when .amperes? then "A" + when .cubic_feet? then "[cft_i]" + when .cubic_meters? then "m3" + when .imperial_gallons? then "[gal_br]" + when .milliliters? then "ml" + when .liters? then "l" + when .us_gallons? then "[gal_us]" + when .milliwatts? then "mW" + when .watts? then "W" + when .kilowatts? then "kW" + when .megawatts? then "MW" + when .watt_hours? then "Wh" + when .kilowatt_hours? then "kWh" + when .megawatt_hours? then "MWh" + when .hertz? then "Hz" + when .kilohertz? then "kHz" + when .megahertz? then "MHz" + when .cubic_feet_per_second? then "[cft_i]/s" + when .cubic_feet_per_minute? then "[cft_i]/min" + when .cubic_feet_per_hour? then "[cft_i]/h" + when .cubic_meters_per_second? then "m3/s" + when .cubic_meters_per_minute? then "m3/min" + when .cubic_meters_per_hour? then "m3/h" + when .imperial_gallons_per_minute? then "[gal_br]/min" + when .milliliters_per_second? then "ml/s" + when .liters_per_second? then "l/s" + when .liters_per_minute? then "l/min" + when .liters_per_hour? then "l/h" + when .us_gallons_per_minute? then "[gal_us]/min" + when .us_gallons_per_hour? then "[gal_us]/h" + end + + obj_value = object_value(object) + value = case obj_value + in String, Nil, ::Time, ::BACnet::PropertyIdentifier::PropertyType, Tuple(ObjectType, UInt32) + nil + in Bool + obj_value ? 1.0 : 0.0 + in UInt64, Int64, Float32, Float64 + obj_value.to_f64 + end + return nil if value.nil? + + Interface::Sensor::Detail.new( + type: sensor_type, + value: value, + last_seen: object.changed.to_unix, + mac: device_id.to_s, + id: "#{object.object_type}[#{object.instance_id}]", + name: "#{device.name}: #{object.name}", + module_id: module_id, + binding: object_binding(device_id, object), + unit: unit + ) + end + + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + filter = type ? Interface::Sensor::SensorType.parse?(type) : nil + + if mac + device_id = mac.to_u32? + return NO_MATCH unless device_id + device = get_device device_id + return NO_MATCH unless device + return device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) } + end + + matches = @mutex.synchronize do + @devices.map do |(device_id, device)| + device.objects.compact_map { |obj| to_sensor(device_id, device, obj, filter) } + end + end + matches.flatten + rescue error + logger.warn(exception: error) { "searching for sensors" } + NO_MATCH + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + device_id = mac.to_u32? + return nil unless device_id + device = get_device device_id + return nil unless device + + # id should be in the format "object_type[instance_id]" + obj_type_string, instance_id_string = id.split('[', 2) + instance_id = instance_id_string.rchop.to_u32? + return nil unless instance_id + + object_type = ObjectType.parse?(obj_type_string) + return nil unless object_type + + object = get_object_details(device_id, instance_id, object_type) + + if object.changed < 1.minutes.ago + begin + object.sync_value(bacnet_client) + rescue error + logger.warn(exception: error) { "failed to obtain latest value for sensor at #{mac}.#{id}" } + end + end + + to_sensor(device_id, device, object) + end + + @[Security(Level::Support)] + def save_seen_devices + define_setting(:known_devices, @seen_devices.values) + end +end diff --git a/drivers/ashrae/bacnet_datapoints.cr b/drivers/ashrae/bacnet_datapoints.cr new file mode 100644 index 00000000000..53100498216 --- /dev/null +++ b/drivers/ashrae/bacnet_datapoints.cr @@ -0,0 +1,30 @@ +require "placeos-driver" +require "json" + +class Ashrae::BACnetDataPoints < PlaceOS::Driver + descriptive_name "BACnet Data Points" + generic_name :DataPoints + + default_settings({ + points: { + "power" => "101003.AnalogValue[45]", + "humidity" => "101005.AnalogValue[4]", + }, + }) + + accessor bacnet : BACnet_1 + + def on_load + on_update + end + + def on_update + subscriptions.clear + points = setting(Hash(String, String), :points) + points.each do |(key, status)| + bacnet.subscribe(status) do |_sub, payload| + self[key] = JSON.parse(payload) + end + end + end +end diff --git a/drivers/ashrae/bacnet_datapoints_spec.cr b/drivers/ashrae/bacnet_datapoints_spec.cr new file mode 100644 index 00000000000..05559525362 --- /dev/null +++ b/drivers/ashrae/bacnet_datapoints_spec.cr @@ -0,0 +1,20 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Qbic::TouchPanel" do + system({ + BACnet: {BACnetMock}, + }) + + sleep 0.2 + + status["power"].should eq true + status["humidity"].should eq 34.4 +end + +# :nodoc: +class BACnetMock < DriverSpecs::MockDriver + def on_load + self["101003.AnalogValue[45]"] = true + self["101005.AnalogValue[4]"] = 34.4 + end +end diff --git a/drivers/ashrae/bacnet_models.cr b/drivers/ashrae/bacnet_models.cr new file mode 100644 index 00000000000..dc0d0a1d672 --- /dev/null +++ b/drivers/ashrae/bacnet_models.cr @@ -0,0 +1,42 @@ +require "bacnet" +require "json" + +module Ashrae + class DeviceAddress + include JSON::Serializable + + def initialize(@ip, @id, @net, @addr) + end + + getter ip : String + getter id : UInt32? + getter net : UInt16? + getter addr : String? + + def address + Socket::IPAddress.new(@ip, 0xBAC0) + end + + def identifier + ::BACnet::ObjectIdentifier.new :device, @id.not_nil! + end + end + + class DispatchProtocol < BinData + endian big + + enum MessageType + OPENED + CLOSED + RECEIVED + WRITE + CLOSE + end + + enum_field UInt8, message : MessageType = MessageType::RECEIVED + string :ip_address + uint64 :id_or_port + uint32 :data_size, value: ->{ data.size } + bytes :data, length: ->{ data_size }, default: Bytes.new(0) + end +end diff --git a/drivers/ashrae/bacnet_spec.cr b/drivers/ashrae/bacnet_spec.cr new file mode 100644 index 00000000000..e6d768eca87 --- /dev/null +++ b/drivers/ashrae/bacnet_spec.cr @@ -0,0 +1,8 @@ +require "placeos-driver/spec" + +# NOTE:: this spec only works if there is a BACnet network configured locally +# such as https://github.com/chipkin/BACnetServerExampleCPP/releases +DriverSpecs.mock_driver "Ashrae::BACnet" do + exec(:query_known_devices).get + (exec(:devices).get.not_nil!.size > 0).should be_true +end diff --git a/drivers/aver/cam520_pro.cr b/drivers/aver/cam520_pro.cr new file mode 100644 index 00000000000..eefd6007461 --- /dev/null +++ b/drivers/aver/cam520_pro.cr @@ -0,0 +1,350 @@ +require "placeos-driver" +require "placeos-driver/interface/camera" +require "placeos-driver/interface/powerable" +require "./cam520_pro_models" + +class Aver::Cam520Pro < PlaceOS::Driver + include Interface::Powerable + include Interface::Camera + + # Discovery Information. + generic_name :Camera + descriptive_name "Aver 520 Pro Camera" + + # note: wss port is 9188 + uri_base "ws://10.110.144.40:9187/ws" + + default_settings({ + username: "spec", + password: "Aver", + + zoom_max: 28448, + invert_controls: false, + }) + + protected getter bearer_token : String = "" + @username : String = "" + @zoom_max : Int32 = 28448 + @invert : Bool = false + + def on_load + queue.wait = false + transport.before_request do |request| + logger.debug { "performing request: #{request.method} #{request.path}\n#{String.new(request.body.as(IO::Memory).to_slice)}" } + if request.path != "/login_name" + bearer = bearer_token.presence || authenticate + request.headers["Authorization"] = "Bearer #{bearer}" + end + end + on_update + end + + def on_update + @username = setting(String, :username) + if @username != "spec" + device_host = URI.parse(config.uri.not_nil!) + device_host.port = nil + transport.http_uri_override = device_host + end + + @zoom_max = setting(Int32, :zoom_max) + @presets = setting?(Presets, :camera_presets) || @presets + self[:presets] = @presets.keys + self[:inverted] = @invert = setting?(Bool, :invert_controls) || false + end + + def connected + send "token:#{authenticate}" + schedule.clear + schedule.every(10.minutes) { authenticate } + schedule.every(1.minutes) { keep_alive } + + pan? + tilt? + zoom? + end + + def disconnected + schedule.clear + end + + protected def check_success(response) : Bool + logger.debug { "http response #{response.status_code}: #{response.body}" } + return true if response.success? + @bearer_token = "" if response.status_code == 401 + details = HttpResponse(Nil?).from_json(response.body.not_nil!) + raise "unexpected response #{details.code} - #{details.msg}" + end + + macro parse(response, klass = Nil?) + check_success({{response}}) + HttpResponse({{klass}}).from_json({{response}}.body.not_nil!).data + end + + protected def authenticate + logger.debug { "Authenticating" } + + response = post("/login_name", body: { + name: setting(String, :username), + password: setting(String, :password), + }.to_json) + + @bearer_token = parse(response, Auth).token + end + + def keep_alive + send("alive") + end + + getter pan_pos : Int32 = 0 + getter tilt_pos : Int32 = 0 + getter zoom_pos : Int32 = 0 + + def received(data, task) : Nil + data = String.new(data) + logger.debug { "Camera sent: #{data}" } + + payload = Event.from_json(data).data + case payload + in Option + value = payload.value.to_i + case payload.option + in .ptz_ps? + @pan_pos = value + in .ptz_ts? + @tilt_pos = value + in .ptz_zs? + @zoom_pos = value + self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f) + end + in Event + raise "not possible" + end + ensure + task.try &.success + end + + # ====== Camera Interface ====== + + def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0) + tilt_speed = -tilt_speed if @invert + + if pan_speed.abs >= tilt_speed.abs + axis = AxisSelect::Pan + stop = AxisSelect::Tilt + dir = pan_speed >= 0.0 ? 0 : 1 + cmd = pan_speed.zero? ? 2 : 1 + else + stop = AxisSelect::Pan + axis = AxisSelect::Tilt + dir = tilt_speed >= 0.0 ? 0 : 1 + cmd = tilt_speed.zero? ? 2 : 1 + end + + # stop any previous move + spawn(same_thread: true) do + post("/camera_move", body: { + method: "SetPtzf", + axis: stop.to_i, + dir: dir, + cmd: 2, + }.to_json) + end + + Fiber.yield + + # start moving in the desired direction + response = post("/camera_move", body: { + method: "SetPtzf", + axis: axis.to_i, + dir: dir, + cmd: cmd, + }.to_json) + + parse(response, Nil) + end + + alias Presets = Hash(String, Tuple(Int32, Int32, Int32)) + @presets : Presets = {} of String => Tuple(Int32, Int32, Int32) + + def recall(position : String, index : Int32 | String = 0) + if pos = @presets[position]? + pan_pos, tilt_pos, zoom_pos = pos + zoom_native(zoom_pos) + pan_direct(pan_pos) + tilt_direct(tilt_pos) + else + raise "unknown preset #{position}" + end + end + + def save_position(name : String, index : Int32 | String = 0) + @presets[name] = {@pan_pos, @tilt_pos, @zoom_pos} + save_presets + end + + def remove_position(name : String, index : Int32 | String = 0) + @presets.delete(name) + save_presets + end + + protected def save_presets + define_setting(:camera_presets, @presets) + self[:presets] = @presets.keys + end + + def pan_direct(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_p", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def tilt_direct(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_t", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def pan? + response = post("/get_option", body: { + method: "Get", + option: "ptz_p_s", + }.to_json) + + @pan_pos = parse(response, Int32) + end + + def tilt? + response = post("/get_option", body: { + method: "Get", + option: "ptz_t_s", + }.to_json) + + @tilt_pos = parse(response, Int32) + end + + # ====== Zoomable Interface ====== + + # Zooms to an absolute position + def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0) + position = position.clamp(0.0, 100.0) + percentage = position / 100.0 + zoom_native (percentage * @zoom_max.to_f).to_i + end + + def zoom(direction : ZoomDirection, index : Int32 | String = 0) + case direction + in .stop? + dir = 0 + cmd = 2 + in .out? + dir = 1 + cmd = 1 + in .in? + dir = 0 + cmd = 1 + end + + response = post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Zoom.to_i, + dir: dir, + cmd: cmd, + }.to_json) + + parse(response, Nil) + end + + def zoom_native(position : Int32) + response = post("/set_option", body: { + method: "Set", + option: "ptz_z", + value: position, + }.to_json) + + parse(response, Nil) || position + end + + def zoom? + response = post("/get_option", body: { + method: "Get", + option: "ptz_z_s", + }.to_json) + + @zoom_pos = value = parse(response, Int32) + self[:zoom] = value.to_f * (100.0 / @zoom_max.to_f) + end + + # ====== Moveable Interface ====== + + # moves at 50% of max speed + def move(position : MoveablePosition, index : Int32 | String = 0) + case position + in .up? + joystick(pan_speed: 0.0, tilt_speed: 50.0) + in .down? + joystick(pan_speed: 0.0, tilt_speed: -50.0) + in .left? + joystick(pan_speed: -50.0, tilt_speed: 0.0) + in .right? + joystick(pan_speed: 50.0, tilt_speed: 0.0) + in .in? + zoom(:in) + in .out? + zoom(:out) + in .open?, .close? + # not supported + end + end + + # ====== Stoppable Interface ====== + + def stop(index : Int32 | String = 0, emergency : Bool = false) + # tilt + spawn(same_thread: true) do + post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Tilt.to_i, + dir: 0, + cmd: 2, + }.to_json) + end + + # pan + spawn(same_thread: true) do + post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Pan.to_i, + dir: 0, + cmd: 2, + }.to_json) + end + + Fiber.yield + + # zoom + response = post("/camera_move", body: { + method: "SetPtzf", + axis: AxisSelect::Zoom.to_i, + dir: 0, + cmd: 2, + }.to_json) + + parse(response, Nil) + end + + # ====== Powerable Interface ====== + + # dummy interface as no power command, camera is always on + def power(state : Bool) + state + end +end diff --git a/drivers/aver/cam520_pro_models.cr b/drivers/aver/cam520_pro_models.cr new file mode 100644 index 00000000000..473cdce22fa --- /dev/null +++ b/drivers/aver/cam520_pro_models.cr @@ -0,0 +1,53 @@ +require "json" + +module Aver + enum AxisSelect + Pan = 0 + Tilt + Zoom + Focus + end + + struct Auth + include JSON::Serializable + + getter token : String + end + + struct HttpResponse(Data) + include JSON::Serializable + + getter code : Int32 + getter msg : String + getter data : Data + end + + abstract struct Event + include JSON::Serializable + + getter event : String + + use_json_discriminator "event", { + "option" => EventOption, + } + end + + enum OptionType + PtzPS + PtzTS + PtzZS + end + + struct Option + include JSON::Serializable + + getter option : OptionType + getter value : String + end + + struct EventOption < Event + include JSON::Serializable + + getter data : Option + end +end diff --git a/drivers/aver/cam520_pro_spec.cr b/drivers/aver/cam520_pro_spec.cr new file mode 100644 index 00000000000..f3272673bb0 --- /dev/null +++ b/drivers/aver/cam520_pro_spec.cr @@ -0,0 +1,173 @@ +require "placeos-driver/spec" +require "./cam520_pro_models" + +DriverSpecs.mock_driver "Aver::Cam520Pro" do + # ==================== + # should send an authentication request + # ==================== + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2Njc3ODE0OTR9.blGZUSAekKJVi4VoOAEg9fARCOhyIMNiu_37L3Jv070" + expect_http_request do |request, response| + io = request.body + if io + data = io.gets_to_end + request = JSON.parse(data) + if request["name"] == "spec" && request["password"] == "Aver" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: { + token: token, + }, + }.to_json + else + response.status_code = 401 + end + else + raise "expected request to include dialing details #{request.inspect}" + end + end + + should_send "token:#{token}" + + # ====================== + # query state on connect + # ====================== + + # query pan? + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["method"] == "Get" && request["option"] == "ptz_p_s" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: 200, + }.to_json + else + response.status_code = 400 + end + end + + # query tilt? + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["method"] == "Get" && request["option"] == "ptz_t_s" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: 100, + }.to_json + else + response.status_code = 400 + end + end + + # query zoom? + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["method"] == "Get" && request["option"] == "ptz_z_s" + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: 0, + }.to_json + else + response.status_code = 400 + end + end + + sleep 0.2 + + status[:zoom].should eq(0.0) + exec(:pan_pos).get.should eq(200) + exec(:tilt_pos).get.should eq(100) + + # ==================== + # test zoom value parsing + # ==================== + transmit({ + event: "option", + data: { + option: "ptz_z_s", + value: "28448", + }, + }.to_json) + + sleep 0.2 + + status[:zoom].should eq(100.0) + + # ==================== + # check zoom interface + # ==================== + resp = exec(:zoom_to, 0.0) + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["option"] == "ptz_z" && request["value"] == 0 + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: nil, + }.to_json + else + response.status_code = 400 + end + end + resp.get + + transmit({ + event: "option", + data: { + option: "ptz_z_s", + value: "0", + }, + }.to_json) + + sleep 0.2 + + status[:zoom].should eq(0.0) + + # ====================== + # check camera interface + # ====================== + resp = exec(:joystick, 80.0, 10.0) + # Stop tilt + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["axis"] == 1 && request["cmd"] == 2 + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: nil, + }.to_json + else + raise "stop move failed in joystick request" + end + end + # Move pan + expect_http_request do |request, response| + data = request.body.not_nil!.gets_to_end + request = JSON.parse(data) + if request["axis"] == 0 && request["cmd"] == 1 + response.status_code = 200 + response << { + code: 200, + msg: "ok", + data: nil, + }.to_json + else + response.status_code = 400 + end + end + resp.get +end diff --git a/drivers/aws/sns_sms.cr b/drivers/aws/sns_sms.cr new file mode 100644 index 00000000000..451e0c9fae2 --- /dev/null +++ b/drivers/aws/sns_sms.cr @@ -0,0 +1,78 @@ +require "placeos-driver" +require "placeos-driver/interface/sms" +require "awscr-signer" +require "uri/params" + +# Documentation: https://docs.aws.amazon.com/sns/latest/api/API_Publish.html + +class AWS::SnsSms < PlaceOS::Driver + include Interface::SMS + + # Discovery Information + generic_name :SMS + descriptive_name "Amazon SNS - SMS service" + uri_base "https://sns.us-west-2.amazonaws.com" + + default_settings({ + aws_access_key: "12345", + aws_secret: "random", + }) + + def on_load + on_update + end + + getter! signer : Awscr::Signer::Signers::V4 + + def on_update + access_key = setting(String, :aws_access_key) + secret = setting(String, :aws_secret) + + # grab the bits required for the signer + uri_parts = URI.parse(config.uri.not_nil!).host.not_nil!.split('.') + service = uri_parts[0] + region = uri_parts[1] + + @signer = Awscr::Signer::Signers::V4.new(service, region, access_key, secret) + transport.before_request { |request| signer.sign(request) } + end + + def send_sms( + phone_numbers : String | Array(String), + message : String, + format : String? = "SMS", + source : String? = nil + ) + phone_numbers = [phone_numbers] unless phone_numbers.is_a?(Array) + + responses = phone_numbers.map do |number| + params = URI::Params.build do |form| + form.add "Action", "Publish" + form.add "PhoneNumber", number + form.add "Message", message + + if source + if source =~ /^\+?\d{5,14}$/ + form.add "MessageAttributes.entry.1.Name", "AWS.MM.SMS.OriginationNumber" + form.add "MessageAttributes.entry.1.Value.DataType", "String" + form.add "MessageAttributes.entry.1.Value.StringValue", source + else + form.add "MessageAttributes.entry.1.Name", "AWS.SNS.SMS.SenderID" + form.add "MessageAttributes.entry.1.Value.DataType", "String" + form.add "MessageAttributes.entry.1.Value.StringValue", source.gsub(' ', '-') + end + end + end + + post("/?#{params}", headers: HTTP::Headers{ + "Accept" => "application/json", + }) + end + + responses.each do |response| + raise "request failed with #{response.status_code}: #{response.body}" unless response.success? + end + + nil + end +end diff --git a/drivers/aws/sns_sms_spec.cr b/drivers/aws/sns_sms_spec.cr new file mode 100644 index 00000000000..932a6227789 --- /dev/null +++ b/drivers/aws/sns_sms_spec.cr @@ -0,0 +1,24 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "AWS::SnsSms" do + # Send the request + retval = exec(:send_sms, + phone_numbers: "61418419954", + message: "hello steve" + ) + + # sms should send a HTTP request + expect_http_request do |request, response| + params = request.query_params + if {params["Action"], params["PhoneNumber"], params["Message"]} == {"Publish", "61418419954", "hello steve"} + response.status_code = 200 + response << "{\"PublishResponse\":{\"PublishResult\":{\"MessageId\":\"0b486c18-fa23-5f82-a5a0-35200c5f3d96\",\"SequenceNumber\":null},\"ResponseMetadata\":{\"RequestId\":\"6710f384-1a8a-56e3-8b63-aabcecf664f7\"}}}" + else + response.status_code = 400 + response << "{}" + end + end + + # What the sms function should return + retval.get.should eq(nil) +end diff --git a/drivers/biamp/nexia.cr b/drivers/biamp/nexia.cr new file mode 100644 index 00000000000..bd76ef17c1a --- /dev/null +++ b/drivers/biamp/nexia.cr @@ -0,0 +1,154 @@ +require "placeos-driver" +require "inactive-support/mapped_enum" +require "./ntp" + +class Biamp::Nexia < PlaceOS::Driver + include Biamp::NTP + + tcp_port 23 + descriptive_name "Biamp Nexia/Audia" + generic_name :Mixer + + protected property device_id = 0 + + def on_load + queue.delay = 30.milliseconds + transport.tokenizer = Tokenizer.new("\r\n", "\xFF\xFE\x01") + end + + def connected + send Bytes[0xFF, 0xFE, 0x01], wait: false # Echo off + schedule.every(60.seconds, true) do + query_device_id + end + end + + def disconnected + schedule.clear + end + + def query_device_id + send Command[:GETD, 0, "DEVID"] + end + + def preset(number : Int32) + send Command[:RECALL, 0, "PRESET", number], name: "preset_#{number}" + end + + mapped_enum Mixer do + Matrix = "MMMUTEXP" + Standard = "SMMUTEXP" + Auto = "AMMUTEXP" + end + + def mixer(id : Int32, inouts : Hash(Int32, Array(Int32)) | Array(Int32), mute : Bool = false, type : Mixer = Mixer::Matrix) + value = mute ? 0 : 1 + + if inouts.is_a? Hash + inouts.each do |input, outputs| + outputs.each do |output| + send Command[:SET, device_id, type.mapped_value, id, input, output, value] + end + end + else + inouts.each do |input| + send Command[:SET, device_id, Mixer::Auto.mapped_value, id, input, value] + end + end + end + + mapped_enum Faders do + Fader = "FDRLVL" + MatrixIn = "MMLVLIN" + MatrixOut = "MMLVLOUT" + MatrixCrosspoint = "MMLVLXP" + StdmatrixIn = "SMLVLIN" + StdmatrixOut = "SMLVLOUT" + AutoIn = "AMLVLIN" + AutoOut = "AMLVLOUT" + IoIn = "INPLVL" + IoOut = "OUTLVL" + end + + protected def get_range(type : Faders) + return -100..0 if type.matrix_crosspoint? + -100..12 + end + + def fader(id : Int32, level : Float64 | Int32, index : Int32 = 1, type : Faders = Faders::Fader) + level = level.to_f.clamp(0.0, 100.0) + percentage = level / 100.0 + range = get_range type + + # adjust into range + level_actual = percentage * (range.size - 1).to_f + level_actual = level_actual + range.begin.to_f + + send Command[:SETD, device_id, type.mapped_value, id, index, level_actual], name: "fader_#{id}" + end + + def query_fader(id : Int32, index : Int32 = 1, type : Faders = Faders::Fader) + send Command[:GETD, device_id, type.mapped_value, id, index] + end + + mapped_enum Mutes do + Fader = "FDRMUTE" + MatrixIn = "MMMUTEIN" + MatrixOut = "MMMUTEOUT" + AutoIn = "AMMUTEIN" + AutoOut = "AMMUTEOUT" + StdmatrixIn = "SMMUTEIN" + StdmatrixOut = "SMOUTMUTE" + IoIn = "INPMUTE" + IoOut = "OUTMUTE" + end + + def mute(id : Int32, state : Bool = true, index : Int32 = 1, type : Mutes = Mutes::Fader) + value = state ? 1 : 0 + send Command[:SETD, device_id, type.mapped_value, id, index, value], name: "mute_#{id}" + end + + def unmute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader) + mute(id, false, index, type) + end + + def query_mute(id : Int32, index : Int32 = 1, type : Mutes = Mutes::Fader) + send Command[:GETD, device_id, type.mapped_value, id, index] + end + + def received(data, task) + case response = Response.parse data + in Response::FullPath + logger.debug { "Device responded #{response.message}" } + result = process_full_path_response response + task.try &.success result + in Response::OK + logger.info { "OK" } + task.try &.success + in Response::Error + logger.warn { "Device error: #{data}" } + task.try &.abort(response.message) + in Response::Invalid + logger.error { "Invalid response structure" } + task.try &.abort(response.data) + end + end + + protected def process_full_path_response(response) + case response.attribute + when "DEVID" + self["device_id"] = self.device_id = response.value.to_i + else + if mute = Mutes.from_mapped_value? response.attribute + id, index = response.params + self["#{mute.to_s.underscore}#{id}_#{index}_mute"] = response.value == "1" + elsif fader = Faders.from_mapped_value? response.attribute + range = get_range fader + vol_percent = ((response.value.to_f - range.begin.to_f) / (range.size - 1).to_f) * 100.0 + + id, index = response.params + self["#{fader.to_s.underscore}#{id}_#{index}"] = vol_percent + end + end + end +end diff --git a/drivers/biamp/nexia_spec.cr b/drivers/biamp/nexia_spec.cr new file mode 100644 index 00000000000..c690a28c6b4 --- /dev/null +++ b/drivers/biamp/nexia_spec.cr @@ -0,0 +1,48 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Biamp::Nexia" do + should_send "\xFF\xFE\x01" + + should_send("GETD 0 DEVID\n") + responds("#GETD 0 DEVID 1\r\n") + status["device_id"].should eq(1) + + exec(:preset, 1001) + should_send("RECALL 0 PRESET 1001\n") + responds("#RECALL 0 PRESET 1001 +OK\r\n") + + exec(:fader, 1, 0.0) + should_send("SETD 1 FDRLVL 1 1 -100.0\n") + responds("#SETD 1 FDRLVL 1 1 -100.0 +OK\r\n") + status["fader1_1"].should eq(0.0) + + exec(:fader, 1, 100.0, 2, "matrix_in") + should_send("SETD 1 MMLVLIN 1 2 12.0\n") + responds("#SETD 1 MMLVLIN 1 2 12.0 +OK\r\n") + status["matrix_in1_2"].should eq(100.0) + + exec(:mute, 1234, false, 3) + should_send("SETD 1 FDRMUTE 1234 3 0\n") + responds("#SETD 1 FDRMUTE 1234 3 0 +OK\r\n") + status["fader1234_3_mute"].should eq(false) + + exec(:mute, 1234, true, 5, "auto_in") + should_send("SETD 1 AMMUTEIN 1234 5 1\n") + responds("#SETD 1 AMMUTEIN 1234 5 1 +OK\r\n") + status["auto_in1234_5_mute"].should eq(true) + + exec(:unmute, 111) + should_send("SETD 1 FDRMUTE 111 1 0\n") + responds("#SETD 1 FDRMUTE 111 1 0 +OK\r\n") + status["fader111_1_mute"].should eq(false) + + exec(:query_fader, 133) + should_send("GETD 1 FDRLVL 133 1\n") + responds("#GETD 1 FDRLVL 133 1 -100.0\r\n") + status["fader133_1"].should eq(0.0) + + exec(:query_mute, 155) + should_send("GETD 1 FDRMUTE 155 1\n") + responds("#GETD 1 FDRMUTE 155 1 0\r\n") + status["fader155_1_mute"].should eq(false) +end diff --git a/drivers/biamp/ntp.cr b/drivers/biamp/ntp.cr new file mode 100644 index 00000000000..236772fdfec --- /dev/null +++ b/drivers/biamp/ntp.cr @@ -0,0 +1,80 @@ +# Biamp ATP/NTP protocol utilities. +# https://support.biamp.com/Audia-Nexia/Control/Audia-Nexia_Text_Protocol +module Biamp::NTP + record Command, + type : Type, + device : Int32, + attribute : String, + instance : Int32? = nil, + index_1 : Int32? = nil, + index_2 : Int32? = nil, + value : String | Int32 | Float64 | Nil = nil do + macro [](type, *params) + {% if type == :GET || type == :GETD %} + {{@type.name}}.new({{type}}, {{params.splat}}) + {% else %} + {{@type.name}}.new({{type}}, {{params[0...-1].splat}}, value: {{params[-1]}}) + {% end %} + end + + enum Type + SET + SETD + GET + GETD + INC + INCD + DEC + DECD + RECALL + DIAL + end + + def to_io(io : IO, format = nil) + io << type + {device, attribute, instance, index_1, index_2, value}.each do |field| + next if field.nil? + io << ' ' << field + end + io << '\n' + end + end + + module Response + record FullPath, + message : String, + type : Command::Type, + device : Int32, + attribute : String, + params : Array(String), + value : String + record OK + record Error, message : String + record Invalid, data : Bytes + + def self.parse(data : Bytes) + case data[0] + when '#' + response = String.new data + if response.includes? " -ERR" + Error.new response + else + fields = response[1..].split + type = Command::Type.parse fields[0] + device = fields[1].to_i + attribute = fields[2] + params = fields[3..] + # All responses except GETD provide an "+OK" in the last field + value = type.getd? ? fields[-1] : fields[-2] + FullPath.new response, type, device, attribute, params, value + end + when '+' + OK.new + when '-' + Error.new String.new data + else + Invalid.new data + end + end + end +end diff --git a/drivers/biamp/tesira.cr b/drivers/biamp/tesira.cr new file mode 100644 index 00000000000..da5dc03d9ea --- /dev/null +++ b/drivers/biamp/tesira.cr @@ -0,0 +1,222 @@ +require "placeos-driver" +require "telnet" + +module Biamp; end + +class Biamp::Tesira < PlaceOS::Driver + # Discovery Information + tcp_port 23 # Telnet + descriptive_name "Biamp Tesira" + generic_name :Mixer + + default_settings({ + no_password: true, + username: "default", + password: "default", + }) + + alias Num = Int32 | Float64 + alias Ids = String | Array(String) + + def on_load + # Nexia requires some breathing room + queue.wait = false + queue.delay = 30.milliseconds + end + + def connected + @telnet = telnet = Telnet.new do |telnet_response| + transport.send telnet_response + end + transport.pre_processor { |bytes| telnet.buffer(bytes) } + + if setting(Bool, :no_password) + do_send setting(String, :username) || "admin", wait: false, delay: 200.milliseconds, priority: 98 + do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97 + end + do_send "SESSION set verbose false", priority: 96 + + schedule.clear + schedule.every(60.seconds) do + do_send "DEVICE get serialNumber", priority: 95 + end + end + + def disconnected + transport.tokenizer = nil + schedule.clear + end + + def preset(number_or_name : String | Int32) + if number_or_name.is_a? Int32 + do_send "DEVICE recallPreset #{number_or_name}", priority: 30, name: "preset_#{number_or_name}" + else + do_send build(:DEVICE, :recallPresetByName, number_or_name), priority: 30, name: "preset_#{number_or_name}" + end + end + + def start_audio + do_send "DEVICE startAudio" + end + + def reboot + do_send "DEVICE reboot" + end + + def get_aliases + do_send "SESSION get aliases" + end + + MIXERS = { + "matrix" => "crosspointLevelState", + "mixer" => "crosspoint", + } + + def mixer(id : String, inouts : Hash(Int32, Int32 | Array(Int32)) | Array(Int32), mute : Bool = false, type : String = "matrix") + mixer_type = MIXERS[type] || type + + if inouts.is_a? Hash + inouts.each do |input, outs| + outputs = ensure_array(outs) + outputs.each do |output| + do_send build(id, :set, mixer_type, input, output, mute), priority: 30, name: "mixmute_#{input}_#{output}" + end + end + else # assume array (auto-mixer) + inouts.each do |input| + do_send build(id, :set, mixer_type, input, mute), priority: 30, name: "mixmute_#{input}" + end + end + end + + FADERS = { + "fader" => "level", + "matrix_in" => "inputLevel", + "matrix_out" => "outputLevel", + "matrix_crosspoint" => "crosspointLevel", + "level" => "fader", + "inputLevel" => "matrix_in", + "outputLevel" => "matrix_out", + "crosspointLevel" => "matrix_crosspoint", + } + + def fader(fader_id : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + # value range: -100 ~ 12 + fader_type = FADERS[type] || type + + fader_ids = ensure_array(fader_id) + indicies = ensure_array(index) + fader_ids.each do |fad| + indicies.each do |i| + do_send build(fad, :set, fader_type, i, level), priority: 30, name: "fade_#{fad}_#{i}" + self["#{fader_type}_#{fad}_#{i}"] = level + end + end + end + + # Named params version + def faders(ids : Ids, level : Num | Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + fader(ids, level, index, type) + end + + MUTES = { + "fader" => "mute", + "matrix_in" => "inputMute", + "matrix_out" => "outputMute", + "mute" => "fader", + "inputMute" => "matrix_in", + "outputMute" => "matrix_out", + } + + def mute(fader_id : Ids, value : Bool = true, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute_type = MUTES[type] || type + + fader_ids = ensure_array(fader_id) + indicies = ensure_array(index) + fader_ids.each do |fad| + indicies.each do |i| + do_send build(fad, :set, mute_type, i, value), priority: 30, name: "mute_#{fad}_#{i}" + self["#{mute_type}_#{fad}_#{i}_mute"] = value + end + end + end + + # Named params version + def mutes(ids : Ids, muted : Bool, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute(ids, muted, index, type) + end + + def unmute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute(fader_id, false, index, type) + end + + def query_fader(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + fad_type = FADERS[type] || type + fader_id = ensure_array(fader_id)[0] + index = ensure_array(index)[0] + + do_send build(fader_id, :get, fad_type, index) + end + + # Named params version + def query_faders(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + query_fader(ids, index, type) + end + + def query_mute(fader_id : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + mute_type = MUTES[type] || type + fader_id = ensure_array(fader_id)[0] + index = ensure_array(index)[0] + + do_send build(fader_id, :get, mute_type, index) + end + + # Named params version + def query_mutes(ids : Ids, index : Int32 | Array(Int32) = 1, type : String = "fader") + query_mute(ids, index, type) + end + + def received(data, task) + data = String.new(data).strip + + logger.debug { "Tesira responded -> data: #{data}" } + result = data.split(" ") + + if result[0] == "-" + task.try(&.abort) + end + + if data =~ /login:|server/i + transport.tokenizer = Tokenizer.new "\r\n" + end + + task.try(&.success) + end + + private def build(*args) + cmd = "" + args.each do |arg| + data = arg.to_s + next if data.blank? + cmd = cmd + " " if cmd.size > 0 + + if data.includes? " " + cmd = cmd + "\"" + cmd = cmd + data + cmd = cmd + "\"" + else + cmd = cmd + data + end + end + cmd + end + + private def do_send(command, **options) + logger.debug { "requesting #{command}" } + send @telnet.not_nil!.prepare(command), **options + end + + private def ensure_array(object) + object.is_a?(Array) ? object : [object] + end +end diff --git a/drivers/biamp/tesira_spec.cr b/drivers/biamp/tesira_spec.cr new file mode 100644 index 00000000000..52a6afd2d03 --- /dev/null +++ b/drivers/biamp/tesira_spec.cr @@ -0,0 +1,39 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Biamp::Tesira" do + transmit "login: " + should_send "default\r\n" + should_send "default\r\n" + should_send "SESSION set verbose false\r\n" + + exec(:preset, 1001) + should_send "DEVICE recallPreset 1001" + + exec(:preset, "1001-test") + should_send "DEVICE recallPresetByName 1001-test" + + exec(:start_audio) + should_send "DEVICE startAudio" + + exec(:reboot) + should_send "DEVICE reboot" + + exec(:get_aliases) + should_send "SESSION get aliases" + + exec(:mixer, "123", [1]) + should_send "123 set crosspointLevelState 1 false" + + exec(:fader, "Fader123", 11) + should_send "Fader123 set level 1 11" + responds("+OK\r\n") + status["level_Fader123_1"] = 11 + + exec(:mute, "Fader123") + should_send "Fader123 set mute 1 true" + responds("+OK\r\n") + status["level_Fader123_1_mute"] = true + + exec(:query_fader, "Fader123") + should_send "Fader123 get level 1" +end diff --git a/drivers/bose/control_space_serial.cr b/drivers/bose/control_space_serial.cr new file mode 100644 index 00000000000..2044a9712bd --- /dev/null +++ b/drivers/bose/control_space_serial.cr @@ -0,0 +1,58 @@ +require "placeos-driver" + +# Documentation: https://aca.im/driver_docs/Bose/Bose-ControlSpace-SerialProtocol-v5.pdf + +class Bose::ControlSpaceSerial < PlaceOS::Driver + # Discovery Information + tcp_port 10055 + descriptive_name "Bose ControlSpace Serial Protocol" + generic_name :Mixer + + def on_load + # 0x0D ( carriage return \r) + transport.tokenizer = Tokenizer.new(Bytes[0x0D]) + on_update + end + + def on_update + end + + def connected + schedule.every(60.seconds) do + logger.debug { "-- maintaining connection" } + do_send "GS", priority: 99 + end + end + + def disconnected + schedule.clear + end + + private def do_send(data, **options) + logger.debug { "requesting: #{data}" } + send "#{data}\x0D", **options + end + + def set_parameter_group(id : UInt8) + do_send("SS #{id.to_s(16).upcase}", wait: false, name: "set_pgroup").get + self[:parameter_group] = id + end + + def get_parameter_group + do_send "GS" + end + + def received(data, task) + # Ignore the framing bytes + data = String.new(data).rchop + logger.debug { "ControlSpace sent: #{data}" } + + parts = data.split(" ") + case parts[0] + when "S" + self[:parameter_group] = parts[1].to_i(16) + end + + task.try &.success + end +end diff --git a/drivers/bose/control_space_serial_spec.cr b/drivers/bose/control_space_serial_spec.cr new file mode 100644 index 00000000000..939b6d6848e --- /dev/null +++ b/drivers/bose/control_space_serial_spec.cr @@ -0,0 +1,12 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Bose::ControlSpaceSerial" do + exec(:set_parameter_group, 12) + should_send("SS C\r") + status[:parameter_group].should eq(12) + + exec(:get_parameter_group) + should_send("GS\r") + responds("S FF\r") + status[:parameter_group].should eq(255) +end diff --git a/drivers/build.cr b/drivers/build.cr new file mode 100644 index 00000000000..84cf25664f1 --- /dev/null +++ b/drivers/build.cr @@ -0,0 +1,10 @@ +{% if env("COMPILE_DRIVER") %} + {% if env("COMPILE_DRIVER").ends_with?("_spec.cr") %} + require "placeos-driver/spec" + {% else %} + require "placeos-driver" + {% end %} + + # Dynamically require the desired driver + {{ ("require \"../" + env("COMPILE_DRIVER") + "\"").id }} +{% end %} diff --git a/drivers/cisco/collaboration_endpoint.cr b/drivers/cisco/collaboration_endpoint.cr new file mode 100644 index 00000000000..a490d169391 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint.cr @@ -0,0 +1,505 @@ +require "placeos-driver" +require "promise" +require "uuid" + +module Cisco::CollaborationEndpoint + macro included + @@status_mappings = {} of Symbol => String + + def self.map_status(**opts) + @@status_mappings.merge! opts.to_h + end + end + + # used by many of the commands + enum Toogle + On + Off + end + + getter peripheral_id : String do + uuid = generate_request_uuid + @ignore_update = true + define_setting(:peripheral_id, uuid) + uuid + end + + protected getter feedback : Feedback = Feedback.new + @ready : Bool = false + @init_called : Bool = false + + # Camera idx => Preset name => Preset id + alias Presets = Hash(Int32, Hash(String, Int32)) + @presets : Presets = {} of Int32 => Hash(String, Int32) + getter feedback_paths : Array(String) = [] of String + + def on_load + # NOTE:: on_load doesn't call on_update as on_update disconnects + queue.delay = 80.milliseconds + queue.timeout = 3.seconds + @peripheral_id = setting?(String, :peripheral_id) + @presets = setting?(Presets, :camera_presets) || @presets + self[:camera_presets] = @presets.transform_values { |val| val.keys } + driver = self + driver.load_settings if driver.responds_to?(:load_settings) + end + + # used when saving settings from the driver + # this prevents needless disconnects + @ignore_update : Bool = false + + def on_update + if @ignore_update + @ignore_update = false + return + end + @presets = setting?(Presets, :camera_presets) || @presets + self[:camera_presets] = @presets.transform_values { |val| val.keys } + driver = self + driver.load_settings if driver.responds_to?(:load_settings) + + # Force a reconnect and event resubscribe following module updates. + disconnect + end + + @last_received : Int64 = 0_i64 + + protected def reset_connection_flags + @ready = false + @init_called = false + @feedback_paths = [] of String + transport.tokenizer = nil + end + + def connected + reset_connection_flags + schedule.every(2.minutes) { ensure_feedback_registered } + schedule.every(30.seconds) do + if @last_received > 40.seconds.ago.to_unix + heartbeat timeout: 35 + else + disconnect + end + end + schedule.in(10.seconds) do + init_connection unless @ready || @init_called + schedule.in(15.seconds) { disconnect if !@ready || self["configuration"]?.nil? } + end + begin + transport.send "xPreferences OutputMode JSON\n" + rescue + end + queue.clear abort_current: true + end + + def disconnected + schedule.clear + reset_connection_flags + clear_feedback_subscriptions(false) + queue.clear abort_current: true + self[:ready] = false + end + + def generate_request_uuid + UUID.random.to_s + end + + def ensure_feedback_registered + send "xPreferences OutputMode JSON\n", priority: 0, wait: false, name: "output_json" + results = @feedback_paths.map do |path| + request = XAPI.xfeedback :register, path + # Always returns an empty response, nothing special to handle + do_send(request, priority: 0, name: path) + end + spawn(same_thread: true) do + success = 0 + results.each do |task| + begin + success += 1 if task.get.state.success? + rescue + end + end + logger.debug { "FEEDBACK REGISTERED #{success}" } + disconnect unless success > 0 + end + @feedback_paths.size + end + + # ------------------------------ + # Exec methods + + alias JSONBasic = Enumerable::JSONBasic + alias Config = Hash(String, Hash(String, JSONBasic)) + + # Push a configuration settings to the device. + def xconfigurations(config : Config) + config.each { |path, settings| xconfiguration(path, settings) } + end + + # Execute an xCommand on the device. + def xcommand( + command : String, + multiline_body : String? = nil, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + **kwargs + ) + request = XAPI.xcommand(command, **kwargs.merge({hash_args: hash_args})) + name = if kwargs.empty? + command + elsif kwargs.size == 1 + "#{command} #{kwargs.keys.to_a.first}" + end + + # use default queue priority is not specified + priority = kwargs[:priority]? || queue.priority + + do_send request, multiline_body, name: name, priority: priority do |response| + # The result keys are a little odd: they're a concatenation of the + # last two command elements and 'Result', unless the command + # failed in which case it's just 'Result'. + # For example: + # xCommand Video Input SetMainVideoSource ... + # becomes: + # InputSetMainVideoSourceResult + result_key = command.split(' ').last(2).join("") + "Result" + command_result = response["CommandResponse/#{result_key}/status"]? + failure_result = response["CommandResponse/Result/Reason"]? + + result = command_result || failure_result + + if result + if result == "OK" + result + else + failure_result ||= response["CommandResponse/#{result_key}/Reason"]? + logger.error { failure_result.inspect } + :abort + end + else + logger.warn { "Unexpected response format" } + :abort + end + end + end + + # Apply a single configuration on the device. + def xconfiguration( + path : String, + hash_args : Hash(String, JSONBasic) = {} of String => JSONBasic, + **kwargs + ) + promises = hash_args.map do |setting, value| + apply_configuration(path, setting, value) + end + kwargs.each do |setting, value| + promise = apply_configuration(path, setting, value) + promises << promise + end + Promise.all(promises).get.first + end + + protected def apply_configuration(path : String, setting : String, value : JSONBasic) + request = XAPI.xconfiguration(path, setting, value) + promise = Promise.new(Bool) + + task = do_send request, name: "#{path} #{setting}" do |response| + result = response["CommandResponse/Configuration/status"]? + + if result == "Error" + reason = response["CommandResponse/Configuration/Reason"]? + xpath = response["CommandResponse/Configuration/XPath"]? + + error_msg = "#{reason} (#{xpath})" + promise.reject(RuntimeError.new error_msg) + logger.error { error_msg } + :abort + else + promise.resolve true + true + end + end + + spawn(same_thread: true) do + task.get + promise.reject(RuntimeError.new "failed to set configuration: #{path} #{setting}: #{value}") if task.state == :abort + end + promise + end + + def xstatus(path : String) + request = XAPI.xstatus path + promise = Promise.new(Hash(String, Enumerable::JSONComplex)) + + task = do_send request do |response| + prefix = "Status/#{XAPI.tokenize(path).join('/')}" + results = {} of String => Enumerable::JSONComplex + response.each do |key, value| + results[key] = value if key.starts_with?(prefix) + end + + if !results.empty? + promise.resolve results + results + elsif error = response["Status/status"]? || response["CommandResponse/Status/status"]? + reason = response["Status/Reason"]? || response["CommandResponse/Status/Reason"]? + xpath = response["Status/XPath"]? || response["CommandResponse/Status/XPath"]? + error_msg = "#{reason} (#{xpath})" + promise.reject(RuntimeError.new error_msg) + logger.error { error_msg } + :abort + else + results[prefix] = nil + promise.resolve results + results + end + end + + spawn(same_thread: true) do + task.get + promise.reject(RuntimeError.new "failed to obtain status: #{path}") if task.state == :abort + end + promise.get + end + + # ------------------------------ + # Base comms + + protected def init_connection + @init_called = true + transport.tokenizer = Tokenizer.new do |io| + raw = io.gets_to_end + data = raw.lstrip + index = if data.starts_with?("{") + count = 0 + pos = 0 + data.each_char_with_index do |char, i| + pos = i + count += 1 if char == '{' + count -= 1 if char == '}' + break if count.zero? + end + pos if count.zero? + else + data =~ XAPI::COMMAND_RESPONSE + end + + if index + message = data[0..index] + index += raw.byte_index_to_char_index(raw.byte_index(message).not_nil!).not_nil! + index = raw.char_index_to_byte_index(index + 1) + end + + index || -1 + end + + raise "failed to register control system" unless register_control_system.get.state.success? + self[:ready] = @ready = true + + push_config + sync_config + @@status_mappings.each do |key, path| + begin + bind_status(path, key.to_s) + rescue error + logger.warn(exception: error) { "failed to bind status #{path} (#{key})" } + end + end + + driver = self + driver.connection_ready if driver.responds_to?(:connection_ready) + rescue error + @init_called = false + logger.warn(exception: error) { "error configuring xapi transport" } + end + + protected def do_send(command, multiline_body = nil, **options) + do_send(command, multiline_body, **options) { true } + end + + protected def do_send(command, multiline_body = nil, **options, &callback : ::PlaceOS::Driver::Task::ResponseCallback) + request_id = generate_request_uuid + request = "#{command} | resultId=\"#{request_id}\"\n" + + logger.debug { "-> #{request}" } + request = "#{request}#{multiline_body}\n.\n" if multiline_body + + task = send request, **options + task.xapi_request_id = request_id + task.xapi_callback = callback + task + end + + def received(data, task) + @last_received = Time.utc.to_unix + payload = String.new(data) + logger.debug { "<- #{payload}" } + + if transport.tokenizer.nil? && payload =~ XAPI::LOGIN_COMPLETE + queue.clear abort_current: true + sleep 500.milliseconds + transport.send "xPreferences OutputMode JSON\n" + logger.info { "initializing connection" } + spawn(same_thread: true) { init_connection } + return + end + + response = XAPI.parse payload + + return feedback.notify(response) if task.nil? + + if task.xapi_request_id == response["ResultId"]? + command_result = task.xapi_callback.try &.call(response) + + feedback.notify(response) if command_result.nil? + command_result == :abort ? task.abort : task.success(command_result) + else + feedback.notify(response) + end + rescue error : JSON::ParseException + payload = String.new(data).strip + case payload + when "OK" + task.try &.success payload + when "Command not recognized." + logger.error { "Command not recognized: `#{task.try &.request_payload}`" } + task.try &.abort payload + else + logger.debug { "Malformed device response: #{error}\n#{payload}" } + task.try &.abort "Malformed device response: #{error}" + end + end + + # ------------------------------ + # Event subscription + + # Subscribe to feedback from the device. + def register_feedback(path : String, &update_handler : Proc(String, Enumerable::JSONComplex, Nil)) + if !@ready + unless feedback.contains? path + @feedback_paths << path + @feedback_paths.uniq! + feedback.insert(path, &update_handler) + end + return true + end + + logger.debug { "Subscribing to device feedback for #{path}" } + + unless feedback.contains? path + @feedback_paths << path + @feedback_paths.uniq! + request = XAPI.xfeedback :register, path + # Always returns an empty response, nothing special to handle + result = do_send request, name: path + end + + feedback.insert path, &update_handler + + result.try(&.get) || true + end + + def unregister_feedback(path : String) + return clear_feedback_subscriptions if path == "/" + logger.debug { "Unsubscribing feedback for #{path}" } + feedback.remove path + @feedback_paths.delete path + do_send XAPI.xfeedback(:deregister, path) + end + + def clear_feedback_subscriptions(connected : Bool = true) + logger.debug { "Unsubscribing all feedback" } + @status_keys.clear + feedback.clear + @feedback_paths.clear + do_send XAPI.xfeedback(:deregister_all) if connected + end + + # ------------------------------ + # Module status + + @status_keys = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key| + hash[key] = {} of String => Enumerable::JSONComplex + end + + # Bind arbitary device feedback to a status variable. + def bind_feedback(path : String, status_key : String) + register_feedback path do |value_path, value| + if value_path == path + self[status_key] = value + else + key_path = value_path.sub(path, "") + hash = @status_keys[status_key] + hash[key_path] = value + self[status_key] = hash + end + end + end + + # Bind device status to a module status variable. + def bind_status(path : String, status_key : String) + bind_path = "Status/#{path.tr " ", "/"}" + bind_feedback "/#{bind_path}", status_key + payload = xstatus(path) + + # single value? + if payload.size == 1 && payload.has_key?(bind_path) + self[status_key] = payload[bind_path] + else + self[status_key] = @status_keys[status_key] = payload.transform_keys do |key| + key.sub(path, "") + end + end + payload + end + + def push_config + if config = setting?(Config, :configuration) + xconfigurations config + end + end + + def sync_config + bind_feedback "/Configuration", "configuration" + send "xConfiguration *\n", wait: false + end + + # ------------------------------ + # External feedback subscriptions + + # Subscribe another module to async device events. + # Callback methods must be of arity 1 and public. + def on_event(path : String, mod_id : String, channel : String) + logger.debug { "Registering callback for #{path} to #{mod_id}/#{channel}" } + register_feedback path do |event_path, value| + event_json = {event_path => value}.to_json + logger.debug { "Publishing #{path} event to #{mod_id}/#{channel} with payload #{event_json}" } + publish("#{mod_id}/#{channel}", event_json) + end + end + + # Clear external event subscribtions for a specific device path. + def clear_event(path : String) + logger.debug { "Clearing event subscription for #{path}" } + unregister_feedback path + end + + # ------------------------------ + # Connectivity management + + protected def register_control_system + xcommand "Peripherals Connect", + hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id}, + name: "PlaceOS", + type: :ControlSystem + end + + protected def heartbeat(timeout : Int32) + # high priority as otherwise the VC will indicate we've disconnected + xcommand "Peripherals HeartBeat", + hash_args: Hash(String, JSON::Any::Type){"ID" => self.peripheral_id}, + timeout: timeout, + priority: 99 + end +end + +require "./collaboration_endpoint/xapi" diff --git a/drivers/cisco/collaboration_endpoint/cameras.cr b/drivers/cisco/collaboration_endpoint/cameras.cr new file mode 100644 index 00000000000..a8ae810b212 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/cameras.cr @@ -0,0 +1,193 @@ +require "placeos-driver/interface/camera" +require "./xapi" + +module Cisco::CollaborationEndpoint::Cameras + include PlaceOS::Driver::Interface::Camera + include Cisco::CollaborationEndpoint::XAPI + + alias Interface = PlaceOS::Driver::Interface + + protected def save_presets + @ignore_update = true + define_setting(:camera_presets, @presets) + self[:camera_presets] = @presets.transform_values { |val| val.keys } + end + + command({"Camera Preset Activate" => :camera_preset}, + preset_id: 1..35) + command({"Camera Preset Store" => :camera_store_preset}, + camera_id: 1..2, + preset_id: 1..35, # Optional - codec will auto-assign if omitted + name_: String, + take_snapshot_: Bool, + default_position_: Bool) + command({"Camera Preset Remove" => :camera_remove_preset}, + preset_id: 1..35) + + enum CameraAxis + All + Focus + PanTilt + Zoom + end + + enum FocusDirection + Far + Near + Stop + end + + command({"Camera PositionReset" => :camera_position_reset}, + camera_id: 1..2, + axis_: CameraAxis) + command({"Camera Ramp" => :camera_move}, + camera_id: 1..2, + pan_: Interface::Camera::PanDirection, + pan_speed_: 1..15, + tilt_: Interface::Camera::TiltDirection, + tilt_speed_: 1..15, + zoom_: Interface::Zoomable::ZoomDirection, + zoom_speed_: 1..15, + focus_: FocusDirection) + + # Camera Interface + # ================ + + def stop(index : Int32 | String = 0, emergency : Bool = false) + cam = index.to_i + cam = 1 if cam.zero? + + camera_move( + camera_id: cam, + pan: PanDirection::Stop, + tilt: TiltDirection::Stop, + zoom: ZoomDirection::Stop + ) + end + + def move(position : MoveablePosition, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + case position + in .open?, .close? + # iris not supported + in .down?, .up? + joystick( + pan_speed: 0.0, + tilt_speed: position.down? ? -50.0 : 50.0, + index: cam + ) + in .left?, .right? + joystick( + pan_speed: position.left? ? -50.0 : 50.0, + tilt_speed: 0.0, + index: cam + ) + in .in?, .out? + zoom(position.in? ? ZoomDirection::In : ZoomDirection::Out, cam) + end + end + + def zoom_to(position : Float64, auto_focus : Bool = true, index : Int32 | String = 0) + raise "direct zoom unsupported on this camera" + end + + def zoom(direction : ZoomDirection, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + camera_move( + camera_id: cam, + zoom: direction, + zoom_speed: 6 + ) + end + + def joystick(pan_speed : Float64, tilt_speed : Float64, index : Int32 | String = 0) + pan_speed = pan_speed.clamp(-100.0, 100.0) + tilt_speed = tilt_speed.clamp(-100.0, 100.0) + + pan = if pan_speed.zero? + pan_speed = nil + PanDirection::Stop + else + pan_speed.negative? ? PanDirection::Left : PanDirection::Right + end + + tilt = if tilt_speed.zero? + tilt_speed = nil + TiltDirection::Stop + else + tilt_speed.negative? ? TiltDirection::Down : TiltDirection::Up + end + + cam = index.to_i + cam = 1 if cam.zero? + + if pan_speed + percentage = pan_speed.abs / 100.0 + pan_speed_actual = (percentage * 15.0).round.to_i + end + + if tilt_speed + percentage = tilt_speed.abs / 100.0 + tilt_speed_actual = (percentage * 15.0).round.to_i + end + + camera_move( + camera_id: cam, + pan: pan, + pan_speed: pan_speed_actual, + tilt: tilt, + tilt_speed: tilt_speed_actual, + zoom: ZoomDirection::Stop + ) + end + + def recall(position : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + preset = presets[position]? + raise "preset '#{position}' not found on camera #{index}" unless preset + + camera_preset(preset_id: preset) + end + + def save_position(name : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + in_use = @presets.values.flat_map(&.values) + next_available = ((1..35).to_a - in_use).first + presets[name] = next_available + + camera_store_preset( + camera_id: cam, + preset_id: next_available, # Optional - codec will auto-assign if omitted + name: name + ).get + + @presets[cam] = presets + save_presets + true + end + + def remove_position(name : String, index : Int32 | String = 0) + cam = index.to_i + cam = 1 if cam.zero? + + presets = @presets[cam]? || {} of String => Int32 + presets.delete(name) + if presets.empty? + @presets.delete(cam) + else + @presets[cam] = presets + end + save_presets + true + end +end diff --git a/drivers/cisco/collaboration_endpoint/feedback.cr b/drivers/cisco/collaboration_endpoint/feedback.cr new file mode 100644 index 00000000000..0cf25f4333e --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/feedback.cr @@ -0,0 +1,49 @@ +class Cisco::CollaborationEndpoint::Feedback + def initialize + @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k| + h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil) + end + end + + # Nuke a subtree below the path + def remove(path : String) + remove = [] of String + @callbacks.each_key { |key| remove << key if key.starts_with?(path) } + remove.each { |key| @callbacks.delete(key) } + self + end + + # Insert a response handler block to be notified of updates effecting the + # specified feedback path. + def insert(path : String, &handler : Proc(String, Enumerable::JSONComplex, Nil)) + @callbacks[path] << handler + self + end + + def contains?(path : String) + found = false + @callbacks.each_key do |key| + if path.starts_with? key + found = true + break + end + end + found + end + + def notify(path : String, value : Enumerable::JSONComplex) + @callbacks.each do |key, callbacks| + callbacks.each &.call(path, value) if path.starts_with? key + end + end + + def notify(payload : Hash(String, Enumerable::JSONComplex)) + payload.each { |key, value| notify("/#{key}", value) } + end + + def clear + @callbacks = Hash(String, Array(Proc(String, Enumerable::JSONComplex, Nil))).new do |h, k| + h[k] = [] of Proc(String, Enumerable::JSONComplex, Nil) + end + end +end diff --git a/drivers/cisco/collaboration_endpoint/powerable.cr b/drivers/cisco/collaboration_endpoint/powerable.cr new file mode 100644 index 00000000000..18962590cad --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/powerable.cr @@ -0,0 +1,40 @@ +require "placeos-driver/interface/powerable" +require "./xapi" + +module Cisco::CollaborationEndpoint::Powerable + include PlaceOS::Driver::Interface::Powerable + include Cisco::CollaborationEndpoint::XAPI + + alias Interface = PlaceOS::Driver::Interface + + # Powerable Interface: + # ==================== + + command({"Standby Deactivate" => :powerup}) + command({"Standby HalfWake" => :half_wake}) + command({"Standby Activate" => :standby}) + command({"Standby ResetTimer" => :reset_standby_timer}, delay: 1..480) + + def power(state : Bool) + state ? powerup : half_wake + self[:power] = state + end + + def power_state(state : Interface::Powerable::PowerState) + case state + in .on? + power true + in .off? + power false + in .full_off? + standby + self[:power] = false + end + self[:power_state] = state + end + + enum PowerOff + Restart + Shutdown + end +end diff --git a/drivers/cisco/collaboration_endpoint/presentation.cr b/drivers/cisco/collaboration_endpoint/presentation.cr new file mode 100644 index 00000000000..15e6ea535a7 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/presentation.cr @@ -0,0 +1,62 @@ +require "placeos-driver/interface/switchable" +require "./xapi" + +module Cisco::CollaborationEndpoint::Presentation + enum PresentationInputs + None + Input1 + Input2 + Input3 + Input4 + end + + include PlaceOS::Driver::Interface::InputSelection(PresentationInputs) + include Cisco::CollaborationEndpoint::XAPI + + enum SendingMode + LocalRemote + LocalOnly + end + + @sending_mode : SendingMode = SendingMode::LocalRemote + @presenting_input : Int32? = nil + + command({"Presentation Start" => :presentation_start}, + presentation_source_: 1..2, + sending_mode_: SendingMode, + connector_id_: 1..2, + instance_: 1..6) # TODO:: support "New" + command({"Presentation Stop" => :presentation_stop}, + instance_: 1..6, + presentation_source_: 1..4) + + # Provide compatabilty with the router module for activating presentation. + def switch_to(input : PresentationInputs) + if input.none? + @presenting_input = nil + presentation_stop + else + source = input.to_s[5..-1].to_i + @presenting_input = source + + presentation_start( + presentation_source: source, + sending_mode: @sending_mode + ) + end + + self[:presenting_input] = @presenting_input + end + + def send_presentation_to(remote : Bool) + @sending_mode = remote ? SendingMode::LocalRemote : SendingMode::LocalOnly + self[:present_to_remote] = remote + + if input = @presenting_input + presentation_start( + presentation_source: input, + sending_mode: @sending_mode + ) + end + end +end diff --git a/drivers/cisco/collaboration_endpoint/response.cr b/drivers/cisco/collaboration_endpoint/response.cr new file mode 100644 index 00000000000..46b73e4dcb9 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/response.cr @@ -0,0 +1,81 @@ +require "json" + +module Cisco::CollaborationEndpoint::XAPI + TRUTHY = {"true", "available", "standby", "on", "active"} + FALSEY = {"false", "unavailable", "off", "inactive"} + BOOLEAN = ->(val : String) { TRUTHY.includes?(val.downcase) } + BOOL_OR = ->(term : String) { ->(val : String) { val == term ? term : BOOLEAN.call(val) } } + PARSERS = { + "TTPAR_OnOff" => BOOLEAN, + "TTPAR_OnOffAuto" => BOOL_OR.call("Auto"), + "TTPAR_OnOffCurrent" => BOOL_OR.call("Current"), + "TTPAR_MuteEnabled" => BOOLEAN, + } + + def self.value_convert(value : String, valuespace : String? = nil) + parser = PARSERS[valuespace]? + return value.to_i64 unless parser + parser.call(value) + rescue + check = value.downcase + # probably wasn't an integer + if check.in? TRUTHY + true + elsif check.in? FALSEY + false + else + value + end + end + + def self.parse(data : String) + JSON.parse(data).as_h.flatten_xapi_json + end +end + +module Enumerable + alias JSONBasic = Bool | Float64 | Int64 | String | Nil + alias JSONComplex = JSONBasic | Hash(String, JSONComplex) + + def flatten_xapi_json(parent_prefix : String? = nil, delimiter : String = "/") + res = {} of String => JSONComplex + + self.each_with_index do |elem, i| + if elem.is_a?(Tuple) + k, v = elem + else + # this is an Array + k, v = i, elem + + # check if there is an ID element in the child + if id = v.as_h?.try &.delete("id") + k = id + end + end + + # assign key name for result hash + key = parent_prefix ? "#{parent_prefix}#{delimiter}#{k}" : k.to_s + raw = v.raw + + case raw + in Array(JSON::Any) + # recursive call to flatten child elements + res.merge!(raw.flatten_xapi_json(key, delimiter)) + in Hash(String, JSON::Any) + value = raw["Value"]? + if value && value.as_h?.nil? + valuespaceref = raw["valueSpaceRef"]?.try &.as_s.split('/').last + res[key] = Cisco::CollaborationEndpoint::XAPI.value_convert(value.as_s, valuespaceref) + elsif id + res[key] = raw.flatten_xapi_json(delimiter: delimiter) + else + res.merge!(raw.flatten_xapi_json(key, delimiter)) + end + in JSONBasic + res[key] = raw + end + end + + res + end +end diff --git a/drivers/cisco/collaboration_endpoint/ui_extensions.cr b/drivers/cisco/collaboration_endpoint/ui_extensions.cr new file mode 100644 index 00000000000..972923e91d8 --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/ui_extensions.cr @@ -0,0 +1,76 @@ +require "./xapi" + +module Cisco::CollaborationEndpoint::UIExtensions + include Cisco::CollaborationEndpoint::XAPI + + command({"UserInterface Message Alert Clear" => :msg_alert_clear}) + command({"UserInterface Message Alert Display" => :msg_alert}, + text: String, + title_: String, + duration_: 0..3600) + + command({"UserInterface Message Prompt Clear" => :msg_prompt_clear}) + + def msg_prompt(text : String, options : Array(JSON::Any::Type), title : String? = nil, feedback_id : String? = nil, duration : Int64? = nil) + # TODO: return a promise, then prepend a async traffic monitor so it + # can be resolved with the response, or rejected after the timeout. + option_map = {} of String => JSON::Any::Type + ("Option.1".."Option.5").each_with_index do |key, i| + break if i >= options.size + option_map[key] = options[i] + end + + xcommand "UserInterface Message Prompt Display", + hash_args: Hash(String, JSON::Any::Type){ + "text" => text, + "title" => title, + "feedback_id" => feedback_id, + "duration" => duration, + }.merge(option_map) + end + + enum TextInputType + SingleLine + Numeric + Password + PIN + end + + enum TextKeyboardState + Open + Closed + end + + command({"UserInterface Message TextInput Clear" => :msg_text_clear}) + command({"UserInterface Message TextInput Display" => :msg_text}, + text: String, + feedback_id: String, + title_: String, + duration_: 0..3600, + input_type_: TextInputType, + keyboard_state_: TextKeyboardState, + place_holder_: String, + submit_text_: String) + + def ui_set_value(widget : String, value : JSON::Any::Type? = nil) + if value.nil? + xcommand "UserInterface Extensions Widget UnsetValue", + widget_id: widget + else + xcommand "UserInterface Extensions Widget SetValue", + value: value, widget_id: widget + end + end + + def ui_extensions_deploy(id : String, xml_def : String) + xcommand "UserInterface Extensions Set", xml_def, config_id: id + end + + def ui_extensions_list + xcommand "UserInterface Extensions List" + end + + def ui_extensions_clear + xcommand "UserInterface Extensions Clear" + end +end diff --git a/drivers/cisco/collaboration_endpoint/xapi.cr b/drivers/cisco/collaboration_endpoint/xapi.cr new file mode 100644 index 00000000000..5189c9164ef --- /dev/null +++ b/drivers/cisco/collaboration_endpoint/xapi.cr @@ -0,0 +1,150 @@ +require "json" +require "./response" +require "./feedback" + +# monkey patching task is how we attach custom data +# request_payload is set by send if it's defined +class ::PlaceOS::Driver::Task + getter request_payload : String? = nil + + def request_payload=(payload : String) + @request_payload = payload.split("\n")[0] + end + + alias ResponseCallback = Proc(Hash(String, Enumerable::JSONComplex), Hash(String, Enumerable::JSONComplex) | Enumerable::JSONComplex | Symbol) + property xapi_request_id : String? = nil + property xapi_callback : ResponseCallback? = nil +end + +module Cisco::CollaborationEndpoint::XAPI + # Regexp's for tokenizing the xAPI command and response structure. + INVALID_COMMAND = /(?<=Command not recognized\.)[\r\n]+/ + + SUCCESS = /(?<=OK)[\r\n]+/ + + COMMAND_RESPONSE = Regex.union(INVALID_COMMAND, SUCCESS) + + LOGIN_COMPLETE = /Login successful/ + + enum ActionType + XConfiguration + XCommand + XStatus + XFeedback + XPreferences + end + + enum FeedbackAction + Register + Deregister + DeregisterAll + List + end + + # Serialize an xAPI action into transmittable command. + def self.create_action( + __action__ : ActionType, + *args, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + priority : Int32? = nil, # we want to ignore this param, hence we specified it here + **kwargs + ) + [ + __action__.to_s.camelcase(lower: true), + args.compact_map(&.to_s), + hash_args.map { |key, value| + if value + value = "\"#{value}\"" if value.is_a? String + "#{key.to_s.camelcase}: #{value}" + end + }, + kwargs.map { |key, value| + if value + value = "\"#{value}\"" if value.is_a? String + "#{key.to_s.camelcase}: #{value}" + end + }.to_a.compact!, + ].flatten.join " " + end + + # Serialize an xCommand into transmittable command. + def self.xcommand( + path : String, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type, + **kwargs + ) + create_action ActionType::XCommand, path, **kwargs.merge({hash_args: hash_args}) + end + + # Serialize an xConfiguration action into a transmittable command. + def self.xconfiguration(path : String, setting : String, value : JSON::Any::Type) + create_action ActionType::XConfiguration, path, hash_args: { + setting => value, + } + end + + # Serialize an xStatus request into transmittable command. + def self.xstatus(path : String) + create_action ActionType::XStatus, path + end + + # Serialize a xFeedback subscription request. + def self.xfeedback(action : FeedbackAction, path : String? = nil) + if path + xpath = tokenize path + create_action ActionType::XFeedback, action, "/#{xpath.join('/')}" + else + create_action ActionType::XFeedback, action + end + end + + def self.tokenize(path : String) + # Allow space or slash seperated paths + path.split(/[\s\/\\]/).reject(&.empty?) + end + + macro command(cmd_name, **params) + {% for cmd, name in cmd_name %} + def {{name.id}}( + {% for param, klass in params %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + + {% if klass.is_a?(RangeLiteral) %} + {{param.id}} : Int32{% if optional %}? = nil{% end %}, + {% else %} + {{param.id}} : {{klass}}{% if optional %}? = nil{% end %}, + {% end %} + {% end %} + ) + {% for param, klass in params %} + {% if klass.is_a?(RangeLiteral) %} + {% optional = false %} + {% if param.stringify.ends_with?("_") %} + {% optional = true %} + {% param = param.stringify[0..-2] %} + {% end %} + {% if optional %} if {{param.id}}{% end %} + raise ArgumentError.new("#{ {{param.stringify}} } must be within #{ {{klass}} }, was #{ {{param.id}} }") unless ({{klass}}).includes?({{param.id}}) + {% if optional %}end{% end %} + {% end %} + {% end %} + + # send the command + xcommand( + {{cmd}}, + {% for param, klass in params %} + {% if param.stringify.ends_with?("_") %} + {% param = param.stringify[0..-2] %} + {% end %} + + {{param.id}}: {{param.id}}, + {% end %} + ) + end + {% end %} + end +end diff --git a/drivers/cisco/dna_spaces.cr b/drivers/cisco/dna_spaces.cr new file mode 100644 index 00000000000..3ffec6acf6d --- /dev/null +++ b/drivers/cisco/dna_spaces.cr @@ -0,0 +1,637 @@ +require "set" +require "jwt" +require "s2_cells" +require "simple_retry" +require "placeos-driver" +require "placeos-driver/interface/locatable" + +class Cisco::DNASpaces < PlaceOS::Driver + include Interface::Locatable + + # Discovery Information + descriptive_name "Cisco DNA Spaces" + generic_name :DNA_Spaces + uri_base "https://partners.dnaspaces.io" + + default_settings({ + dna_spaces_activation_key: "provide this and the API / tenant ids will be generated automatically", + dna_spaces_api_key: "X-API-KEY", + tenant_id: "sfdsfsdgg", + + # Time before a user location is considered probably too old (in minutes) + max_location_age: 10, + + floorplan_mappings: { + location_a4cb0: { + "level_name" => "optional name", + "building" => "zone-GAsXV0nc", + "level" => "zone-GAsmleH", + "offset_x" => 12.4, + "offset_y" => 5.2, + "map_width" => 50.3, + "map_height" => 100.9, + }, + }, + + debug_stream: false, + }) + + @streaming = false + @last_received = 0_i64 + @stream_active = false + + def on_load + on_update + if !@api_key.empty? + @streaming = true + spawn(same_thread: true) { start_streaming_events } + end + end + + def on_unload + @channel.close + @stream_active = false + update_monitoring_status(running: false) + end + + @activation_token : String = "" + @api_key : String = "" + @tenant_id : String = "" + @channel : Channel(String) = Channel(String).new + @max_location_age : Time::Span = 10.minutes + @s2_level : Int32 = 21 + @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new + @debug_stream : Bool = false + @events_received : UInt64 = 0_u64 + + def on_update + @max_location_age = (setting?(UInt32, :max_location_age) || 10).minutes + @s2_level = setting?(Int32, :s2_level) || 21 + @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings + @debug_stream = setting?(Bool, :debug_stream) || false + + schedule.clear + schedule.every(30.minutes) { cleanup_caches } + schedule.every(5.minutes) { update_monitoring_status } + schedule.in(5.seconds) { update_monitoring_status } + + @activation_token = setting?(String, :dna_spaces_activation_key) || "" + if @activation_token.empty? + @api_key = setting(String, :dna_spaces_api_key) + @tenant_id = setting(String, :tenant_id) + else + @api_key = setting?(String, :dna_spaces_api_key) || "" + @tenant_id = setting?(String, :tenant_id) || "" + + # Activate the API key using the activation_token + schedule.in(5.seconds) { activate } if @api_key.empty? + end + + if !@streaming && !@api_key.empty? + @streaming = true + spawn(same_thread: true) { start_streaming_events } + end + end + + @[Security(Level::Support)] + def activate + return if @activation_token.empty? + + response = get("/client/v1/partner/partnerPublicKey/") + raise "failed to obtain partner public key, code #{response.status_code}" unless response.success? + + logger.debug { "public key requested: #{response.body}" } + + payload = NamedTuple( + status: Bool, + message: String, + data: Array(ActivactionPublicKey)).from_json(response.body.not_nil!) + + raise "unexpected failure obtaining partner public key: #{payload[:message]}" unless payload[:status] + + public_key = payload[:data][0].public_key + payload, header = JWT.decode(@activation_token, public_key, JWT::Algorithm::RS256) + app_id = payload["appId"].as_s + ref_id = payload["activationRefId"].as_s + tenant_id = payload["tenantId"].as_i64.to_s + + response = post("/client/v1/partner/activateOnPremiseApp", headers: { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{@activation_token}", + }, body: { + appId: app_id, + activationRefId: ref_id, + }.to_json) + raise "failed to obtain API key, code #{response.status_code}\n#{response.body}" unless response.success? + + logger.debug { "application activated: #{response.body}" } + + payload = NamedTuple( + status: Bool, + message: String, + data: NamedTuple(apiKey: String)).from_json(response.body.not_nil!) + + raise "unexpected failure obtaining API key: #{payload[:message]}" unless payload[:status] + + api_key = payload[:data][:apiKey] + logger.debug { "saving API key: #{tenant_id}, #{api_key}" } + + define_setting(:tenant_id, tenant_id) + define_setting(:dna_spaces_api_key, api_key) + define_setting(:dna_spaces_activation_key, "") + + logger.debug { "settings saved! Starting stream" } + @api_key = api_key + @tenant_id = tenant_id + if !@streaming + @streaming = true + spawn(same_thread: true) { start_streaming_events } + end + end + + class LocationInfo + include JSON::Serializable + + getter location : Location + + @[JSON::Field(key: "locationDetails")] + getter details : LocationDetails + end + + def get_location_info(location_id : String) + response = get("/api/partners/v1/locations/#{location_id}?partnerTenantId=#{@tenant_id}", headers: { + "X-API-KEY" => @api_key, + }) + + raise "failed to obtain location id #{location_id}, code #{response.status_code}" unless response.success? + LocationInfo.from_json(response.body.not_nil!) + end + + @description_lock : Mutex = Mutex.new + @location_descriptions : Hash(String, String) = {} of String => String + + def seen_locations + @description_lock.synchronize { @location_descriptions.dup } + end + + # MAC Address => Location (including user) + @locations : Hash(String, DeviceLocationUpdate | IotTelemetry) = {} of String => DeviceLocationUpdate | IotTelemetry + @loc_lock : Mutex = Mutex.new + + def locations + @loc_lock.synchronize { yield @locations } + end + + @user_lookup : Hash(String, Set(String)) = {} of String => Set(String) + @user_loc : Mutex = Mutex.new + + def user_lookup + @user_loc.synchronize { yield @user_lookup } + end + + def user_lookup(user_id : String) + formatted_user = format_username(user_id) + user_lookup { |lookup| lookup[formatted_user]? } + end + + def locate_mac(address : String) + formatted_address = format_mac(address) + locations { |locs| locs[formatted_address]? } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_state + logger.debug { + "MAC Locations: #{locations &.keys}" + } + {tracking: locations &.size, events_received: @events_received} + end + + @map_details : Hash(String, Dimension) = {} of String => Dimension + @map_lock : Mutex = Mutex.new + + def get_map_details(map_id : String) + map = @map_lock.synchronize { @map_details[map_id]? } + if !map + response = get("/api/partners/v1/maps/#{map_id}?partnerTenantId=#{@tenant_id}", headers: { + "X-API-KEY" => @api_key, + }) + if !response.success? + message = "failed to obtain map id #{map_id}, code #{response.status_code}" + logger.warn { message } + return nil + end + map = MapInfo.from_json(response.body.not_nil!).dimension + @map_lock.synchronize { @map_details[map_id] = map } + end + map + end + + @[Security(PlaceOS::Driver::Level::Support)] + def cleanup_caches : Nil + logger.debug { "removing location data that is over 30 minutes old" } + + old = 30.minutes.ago.to_unix + remove_keys = [] of String + locations do |locs| + locs.each { |mac, location| remove_keys << mac if location.last_seen < old } + remove_keys.each { |mac| locs.delete(mac) } + end + + logger.debug { "removed #{remove_keys.size} MACs" } + nil + end + + # we want to stream events until driver is terminated + protected def start_streaming_events + @streaming = true + SimpleRetry.try_to( + base_interval: 10.milliseconds, + max_interval: 5.seconds + ) { stream_events unless terminated? } + ensure + @streaming = false + end + + # as sometimes the map id is missing, but in the same location + # location id => map id + @location_id_maps = {} of String => String + + # Processes events as they come in, forces a disconnect if no events are sent + # for a period of time as the remote should be sending them periodically + protected def process_events(client) + loop do + select + when data = @channel.receive + logger.debug { "received push #{data}" } if @debug_stream + @events_received = @events_received &+ 1_u64 + begin + event = Cisco::DNASpaces::Events.from_json(data) + payload = event.payload + case payload + when DeviceExit + device_mac = format_mac(payload.device.mac_address) + locations &.delete(device_mac) + when DeviceEntry + # This is used entirely for + @description_lock.synchronize { payload.location.descriptions(@location_descriptions) } + when DeviceLocationUpdate, IotTelemetry + if !payload.has_position? + iot_payload = payload.as(IotTelemetry) + # process other IoT telemetry such as presense or temperature etc + self[iot_payload.device.mac_address] = payload + next + end + + # Keep track of device location + device_mac = format_mac(payload.device.mac_address) + existing = nil + + # ignore locations where we don't have enough details to put the device on a map + if payload.map_id.presence + @location_id_maps[payload.location.location_id] = payload.map_id + elsif (level_data = @floorplan_mappings[payload.location.location_id]?) && level_data["map_width"]? && level_data["map_height"]? + # we don't need the map ID as the x, y coordinates are defined by us + else + found = false + payload.location_mappings.values.each do |loc_id| + if map_id = @location_id_maps[loc_id]? + payload.map_id = map_id + found = true + break + end + end + + if !found + logger.debug { "ignoring device #{device_mac} location as map_id is empty, location id #{payload.location.location_id}, visit #{payload.visit_id}" } + next + end + end + + payload.last_seen = payload.last_seen // 1000 + + locations do |loc| + existing = loc[device_mac]? + loc[device_mac] = payload + end + + # Maintain user lookup + if payload.raw_user_id.presence + user_id = format_username(payload.raw_user_id) + + if existing && payload.raw_user_id != existing.raw_user_id + old_user_id = format_username(existing.raw_user_id) + + user_lookup do |lookup| + lookup[old_user_id]?.try &.delete(device_mac) + devices = lookup[old_user_id]? || Set(String).new + devices.delete(device_mac) + lookup.delete(old_user_id) if devices.empty? + + devices = lookup[user_id]? || Set(String).new + devices << device_mac + lookup[user_id] = devices + end + else + user_lookup do |lookup| + devices = lookup[user_id]? || Set(String).new + devices << device_mac + lookup[user_id] = devices + end + end + end + + # payload.location_mappings => { "ZONE" => loc_id, "FLOOR" => loc_id, "BUILDING" => loc_id, "CAMPUS" => loc_id } + else + logger.debug { "ignoring event: #{payload ? payload.class : event.class}" } + end + rescue error + logger.error(exception: error) { "parsing DNA Spaces event: #{data}" } + end + when timeout(20.seconds) + logger.debug { "no events received for 20 seconds, expected heartbeat at 15 seconds" } + @channel.close + break + end + end + ensure + client.close + end + + protected def stream_events + client = HTTP::Client.new URI.parse(config.uri.not_nil!) + client.get("/api/partners/v1/firehose/events", HTTP::Headers{ + "X-API-KEY" => @api_key, + }) do |response| + if !response.success? + @stream_active = false + logger.warn { "failed to connect to firehose api #{response.status_code}" } + raise "failed to connect to firehose api #{response.status_code}" + end + + @stream_active = true + + # We use a channel for event processing so we can make use of timeouts + @channel = Channel(String).new + spawn(same_thread: true) { process_events(client) } + + begin + loop do + if response.body_io.closed? + @channel.close + break + end + + if data = response.body_io.gets + @last_received = Time.utc.to_unix_ms + @channel.send data + else + @channel.close + break + end + end + rescue IO::Error + @channel.close + end + end + + # Trigger the retry behaviour + @stream_active = false + raise "stream closed" + end + + # ============================= + # Locatable interface + # ============================= + def locate_user(email : String? = nil, username : String? = nil) + if macs = user_lookup(username.presence || email.presence.not_nil!) + location_max_age = @max_location_age.ago.to_unix + + macs.compact_map { |mac| + if location = locate_mac(mac) + if location.last_seen > location_max_age + # we update the mac_address to a formatted version + location.device.mac_address = mac + location + end + end + }.sort! { |a, b| + b.last_seen <=> a.last_seen + }.map { |location| + lat = location.latitude + lon = location.longitude + + loc = { + "location" => "wireless", + "coordinates_from" => "top-left", + "x" => location.x_pos, + "y" => location.y_pos, + "lon" => lon, + "lat" => lat, + "s2_cell_id" => S2Cells::LatLon.new(lat, lon).to_token(@s2_level), + "mac" => location.device.mac_address, + "variance" => location.unc, + "last_seen" => location.last_seen, + "dna_floor_id" => location.map_id, + "ssid" => location.ssid, + "manufacturer" => location.device.manufacturer, + "os" => location.device.os, + } + + map_width = 0.0 + map_height = 0.0 + offset_x = 0.0 + offset_y = 0.0 + + # Add our zone IDs to the response + location.location_mappings.each_value do |location_id| + if level_data = @floorplan_mappings[location_id]? + level_data.each do |key, value| + case key + when "offset_x" + offset_x = value.as(Float64) + loc["x"] = location.x_pos - offset_x + when "offset_y" + offset_y = value.as(Float64) + loc["y"] = location.y_pos - offset_y + when "map_width" + map_width = value.as(Float64) + when "map_height" + map_height = value.as(Float64) + else + loc[key] = value + end + end + break + end + end + + # Add map information to the response + if map_width > 0.0 && map_height > 0.0 + loc["map_width"] = map_width + loc["map_height"] = map_height + elsif map_size = get_map_details(location.map_id) + loc["map_width"] = map_width > 0.0 ? map_width : (map_size.length - offset_x) + loc["map_height"] = map_height > 0.0 ? map_height : (map_size.width - offset_y) + end + + loc + } + else + [] of Nil + end + end + + # Will return an array of MAC address strings + # lowercase with no seperation characters abcdeffd1234 etc + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + user_lookup(username.presence || email.presence.not_nil!).try(&.to_a) || [] of String + end + + # Will return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}` + def check_ownership_of(mac_address : String) : OwnershipMAC? + if location = locate_mac(mac_address) + { + location: "wireless", + assigned_to: format_username(location.raw_user_id), + mac_address: format_mac(mac_address), + } + end + end + + # Will return an array of devices and their x, y coordinates + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "looking up device locations in #{zone_id}" } + return [] of Nil if location.presence && location != "wireless" + + # Find the floors associated with the provided zone id + floors = [] of String + adjustments = {} of String => Tuple(Float64, Float64, Float64, Float64) + @floorplan_mappings.each do |floor_id, data| + if data.values.includes?(zone_id) + floors << floor_id + offset_x = (data["offset_x"]? || 0.0).as(Float64) + offset_y = (data["offset_y"]? || 0.0).as(Float64) + map_width = (data["map_width"]? || -1.0).as(Float64) + map_height = (data["map_height"]? || -1.0).as(Float64) + adjustments[floor_id] = {offset_x, offset_y, map_width, map_height} + end + end + logger.debug { "found matching meraki floors: #{floors}" } + return [] of Nil if floors.empty? + + checking_count = @locations.size + wrong_floor = 0 + too_old = 0 + + # Find the devices that are on the matching floors + oldest_location = @max_location_age.ago.to_unix + + matching = locations(&.compact_map { |mac, loc| + if loc.last_seen < oldest_location + too_old += 1 + next + end + if (floors & loc.location_mappings.values).empty? + wrong_floor += 1 + next + end + + # ensure the formatted mac is being used + loc.device.mac_address = mac + loc + }) + + logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" } + + matching.group_by(&.map_id).flat_map { |map_id, locations| + map_width = -1.0 + map_height = -1.0 + offset_x = 0.0 + offset_y = 0.0 + + # any adjustments required for these locations? + locations.first.location_mappings.each_value do |location_id| + if level_data = adjustments[location_id]? + offset_x, offset_y, map_width, map_height = level_data + break + end + end + + if map_width == -1.0 || map_height == -1.0 + if map_size = get_map_details(map_id) + map_width = map_width > -1.0 ? map_width : (map_size.length - offset_x) + map_height = map_height > -1.0 ? map_height : (map_size.width - offset_y) + end + end + + locations.map do |loc| + lat = loc.latitude + lon = loc.longitude + + { + location: :wireless, + coordinates_from: "top-left", + x: loc.x_pos - offset_x, + y: loc.y_pos - offset_y, + lon: lon, + lat: lat, + s2_cell_id: S2Cells::LatLon.new(lat, lon).to_token(@s2_level), + mac: loc.device.mac_address, + variance: loc.unc, + last_seen: loc.last_seen, + map_width: map_width, + map_height: map_height, + ssid: loc.ssid, + manufacturer: loc.device.manufacturer, + os: loc.device.os, + } + end + } + end + + def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + def format_username(user : String) + if user.includes? "@" + user = user.split("@")[0] + elsif user.includes? "\\" + user = user.split("\\")[1] + end + user.downcase + end + + # This provides the DNA Spaces dashboard with stream consumption status + @[Security(PlaceOS::Driver::Level::Administrator)] + def update_monitoring_status(running : Bool = true) : Nil + response = put("/api/partners/v1/monitoring/status", headers: { + "Content-Type" => "application/json", + "X-API-KEY" => @api_key, + }, body: { + data: { + overallStatus: { + status: running ? "up" : "down", + notices: [] of Nil, + }, + instanceDetails: { + ipAddress: "", + instanceId: module_id, + }, + cloudFirehose: { + status: @stream_active ? "connected" : "disconnected", + lastReceived: @last_received, + }, + localFirehose: { + status: "disconnected", + lastReceived: 0, + }, + subsystems: [] of Nil, + }, + }.to_json) + raise "failed to update status, code #{response.status_code}\n#{response.body}" unless response.success? + end +end + +require "./dna_spaces/events" diff --git a/drivers/cisco/dna_spaces/activation_publickey.cr b/drivers/cisco/dna_spaces/activation_publickey.cr new file mode 100644 index 00000000000..eb30ec06dd3 --- /dev/null +++ b/drivers/cisco/dna_spaces/activation_publickey.cr @@ -0,0 +1,14 @@ +require "./events" + +class Cisco::DNASpaces::ActivactionPublicKey + include JSON::Serializable + + getter version : String + + @[JSON::Field(key: "publicKey")] + getter public_key : String + + def public_key + "-----BEGIN PUBLIC KEY-----\n#{@public_key}\n-----END PUBLIC KEY-----\n" + end +end diff --git a/drivers/cisco/dna_spaces/app_activaction.cr b/drivers/cisco/dna_spaces/app_activaction.cr new file mode 100644 index 00000000000..36202b21424 --- /dev/null +++ b/drivers/cisco/dna_spaces/app_activaction.cr @@ -0,0 +1,21 @@ +require "./events" + +class Cisco::DNASpaces::AppActivaction + include JSON::Serializable + + @[JSON::Field(key: "spacesTenantName")] + getter spaces_tenant_name : String + + @[JSON::Field(key: "spacesTenantId")] + getter spaces_tenant_id : String + + @[JSON::Field(key: "partnerTenantId")] + getter partner_tenant_id : String + getter name : String + + @[JSON::Field(key: "referenceId")] + getter reference_id : String + + @[JSON::Field(key: "instanceName")] + getter instance_name : String +end diff --git a/drivers/cisco/dna_spaces/ble_rssi_update.cr b/drivers/cisco/dna_spaces/ble_rssi_update.cr new file mode 100644 index 00000000000..b9f25eac962 --- /dev/null +++ b/drivers/cisco/dna_spaces/ble_rssi_update.cr @@ -0,0 +1,49 @@ +require "./events" +require "./location" + +class Cisco::DNASpaces::BlePayload + include JSON::Serializable + + property timestamp : Int64 + property data : String +end + +class Cisco::DNASpaces::RssiMeasurement + include JSON::Serializable + + @[JSON::Field(key: "apMacAddress")] + property access_point_mac : String + + @[JSON::Field(key: "ifSlotId")] + property if_slot_id : Int32 + + @[JSON::Field(key: "bandId")] + property band_id : Int32 + + @[JSON::Field(key: "antennaId")] + property antenna_id : Int32 + + property rssi : Int32 + property timestamp : Int64 +end + +class Cisco::DNASpaces::RssiNotification + include JSON::Serializable + + @[JSON::Field(key: "macAddress")] + property mac_address : String + + @[JSON::Field(key: "apRssiMeasurements")] + property measurements : Array(RssiMeasurement) + + @[JSON::Field(key: "blePayload")] + property payload : BlePayload +end + +class Cisco::DNASpaces::BleRssiUpdate + include JSON::Serializable + + @[JSON::Field(key: "rssiNotification")] + getter notification : RssiNotification + getter location : Location +end diff --git a/drivers/cisco/dna_spaces/device.cr b/drivers/cisco/dna_spaces/device.cr new file mode 100644 index 00000000000..cb917bcda95 --- /dev/null +++ b/drivers/cisco/dna_spaces/device.cr @@ -0,0 +1,48 @@ +require "./events" + +class Cisco::DNASpaces::Device + include JSON::Serializable + + @[JSON::Field(key: "deviceId")] + getter device_id : String + + @[JSON::Field(key: "userId")] + getter user_id : String + + getter tags : Array(String) = [] of String + getter mobile : String? + getter email : String? + + def email + @email.try &.downcase + end + + def email_raw + @email + end + + getter gender : String? + + @[JSON::Field(key: "firstName")] + getter first_name : String? + + @[JSON::Field(key: "lastName")] + getter last_name : String? + + @[JSON::Field(key: "postalCode")] + getter postal_code : String? + + # optIns + # otherFields + # socialNetworkInfo + + # We make this editable so we can store the formatted version here + @[JSON::Field(key: "macAddress")] + property mac_address : String + getter manufacturer : String? + getter os : String? + + @[JSON::Field(key: "osVersion")] + getter os_version : String? + getter type : String +end diff --git a/drivers/cisco/dna_spaces/device_count.cr b/drivers/cisco/dna_spaces/device_count.cr new file mode 100644 index 00000000000..c799d9b8286 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_count.cr @@ -0,0 +1,22 @@ +require "./events" + +class Cisco::DNASpaces::DeviceCount + include JSON::Serializable + + getter location : Location + + @[JSON::Field(key: "associatedCount")] + getter associated_count : Int32 + + @[JSON::Field(key: "estimatedProbingCount")] + getter estimated_probing_count : Int32 + + @[JSON::Field(key: "probingRandomizedPercentage")] + getter probing_randomized_percentage : Float64 + + @[JSON::Field(key: "estimatedDensity")] + getter estimated_density : Float64 + + @[JSON::Field(key: "estimatedCapacityPercentage")] + getter estimated_capacity_percentage : Float64 +end diff --git a/drivers/cisco/dna_spaces/device_entry.cr b/drivers/cisco/dna_spaces/device_entry.cr new file mode 100644 index 00000000000..befd6dd137e --- /dev/null +++ b/drivers/cisco/dna_spaces/device_entry.cr @@ -0,0 +1,26 @@ +require "./events" + +class Cisco::DNASpaces::DeviceEntry + include JSON::Serializable + + getter device : Device + getter location : Location + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "daysSinceLastVisit")] + getter days_sinc_last_visit : Int32 +end diff --git a/drivers/cisco/dna_spaces/device_exit.cr b/drivers/cisco/dna_spaces/device_exit.cr new file mode 100644 index 00000000000..151ef54cd5f --- /dev/null +++ b/drivers/cisco/dna_spaces/device_exit.cr @@ -0,0 +1,38 @@ +require "./events" + +class Cisco::DNASpaces::DeviceExit + include JSON::Serializable + + getter device : Device + getter location : Location + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_datetime : String + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "visitClassification")] + getter visit_classification : String +end diff --git a/drivers/cisco/dna_spaces/device_location_update.cr b/drivers/cisco/dna_spaces/device_location_update.cr new file mode 100644 index 00000000000..8be08d405f7 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_location_update.cr @@ -0,0 +1,55 @@ +require "./events" + +class Cisco::DNASpaces::DeviceLocationUpdate + include JSON::Serializable + + getter device : Device + getter location : Location + + getter ssid : String + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "lastSeen")] + property last_seen : Int64 + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "mapId")] + property map_id : String + + @[JSON::Field(key: "xPos")] + getter x_pos : Float64 + + @[JSON::Field(key: "yPos")] + getter y_pos : Float64 + + @[JSON::Field(key: "confidenceFactor")] + getter confidence_factor : Float64 + getter latitude : Float64 + getter longitude : Float64 + getter unc : Float64 + + def has_position? + true + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end +end diff --git a/drivers/cisco/dna_spaces/device_presence.cr b/drivers/cisco/dna_spaces/device_presence.cr new file mode 100644 index 00000000000..2c3973a4035 --- /dev/null +++ b/drivers/cisco/dna_spaces/device_presence.cr @@ -0,0 +1,54 @@ +require "./events" + +class Cisco::DNASpaces::DevicePresence + include JSON::Serializable + + @[JSON::Field(key: "presenceEventType")] + getter presence_event_type : String + + @[JSON::Field(key: "wasInActive")] + getter was_in_active : Bool + getter device : Device + getter location : Location + + getter ssid : String + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "daysSinceLastVisit")] + getter days_since_last_visit : Int32 + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_date_time : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "deviceClassification")] + getter device_classification : String + + @[JSON::Field(key: "visitClassification")] + getter visit_classification : String + + @[JSON::Field(key: "activeDevicesCount")] + getter active_devices_count : Int32 + + @[JSON::Field(key: "inActiveDevicesCount")] + getter inactive_devices_count : Int32 +end diff --git a/drivers/cisco/dna_spaces/events.cr b/drivers/cisco/dna_spaces/events.cr new file mode 100644 index 00000000000..1f67da326e6 --- /dev/null +++ b/drivers/cisco/dna_spaces/events.cr @@ -0,0 +1,134 @@ +require "json" +require "../dna_spaces" +require "./location" +require "./device" +require "./*" + +# This is used to map the various events into a simpler data structure +abstract class Cisco::DNASpaces::Events + include JSON::Serializable + + # event type hint + use_json_discriminator "eventType", { + "KEEP_ALIVE" => KeepAlive, + "DEVICE_ENTRY" => DeviceEntryWrapper, + "DEVICE_EXIT" => DeviceExitWrapper, + "PROFILE_UPDATE" => ProfileUpdateWrapper, + "LOCATION_CHANGE" => LocationChangeWrapper, + "DEVICE_LOCATION_UPDATE" => DeviceLocationUpdateWrapper, + "TP_PEOPLE_COUNT_UPDATE" => PeopleCountUpdateWrapper, + "DEVICE_PRESENCE" => DevicePresenceWrapper, + "USER_PRESENCE" => UserPresenceWrapper, + "APP_ACTIVATION" => AppActivactionWrapper, + "DEVICE_COUNT" => DeviceCountWrapper, + "BLE_RSSI_UPDATE" => BleRssiUpdateWrapper, + "IOT_TELEMETRY" => IotTelemetryWrapper, + } + + @[JSON::Field(key: "recordUid")] + getter record_uid : String + + @[JSON::Field(key: "recordTimestamp")] + getter record_timestamp : Int64 + + @[JSON::Field(key: "spacesTenantId")] + getter spaces_tenant_id : String + + @[JSON::Field(key: "spacesTenantName")] + getter spaces_tenant_name : String + + @[JSON::Field(key: "partnerTenantId")] + getter partner_tenant_id : String +end + +class Cisco::DNASpaces::KeepAlive < Cisco::DNASpaces::Events + getter eventType : String = "KEEP_ALIVE" + + def payload + nil + end +end + +class Cisco::DNASpaces::DeviceEntryWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_ENTRY" + + @[JSON::Field(key: "deviceEntry")] + getter payload : DeviceEntry +end + +class Cisco::DNASpaces::DeviceExitWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_EXIT" + + @[JSON::Field(key: "deviceExit")] + getter payload : DeviceExit +end + +class Cisco::DNASpaces::ProfileUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "PROFILE_UPDATE" + + @[JSON::Field(key: "deviceProfileUpdate")] + getter payload : Device +end + +class Cisco::DNASpaces::LocationChangeWrapper < Cisco::DNASpaces::Events + getter eventType : String = "LOCATION_CHANGE" + + @[JSON::Field(key: "locationHierarchyChange")] + getter payload : LocationChange +end + +class Cisco::DNASpaces::DeviceLocationUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_LOCATION_UPDATE" + + @[JSON::Field(key: "deviceLocationUpdate")] + getter payload : DeviceLocationUpdate +end + +class Cisco::DNASpaces::PeopleCountUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "TP_PEOPLE_COUNT_UPDATE" + + @[JSON::Field(key: "tpPeopleCountUpdate")] + getter payload : PeopleCountUpdate +end + +class Cisco::DNASpaces::DevicePresenceWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_PRESENCE" + + @[JSON::Field(key: "devicePresence")] + getter payload : DevicePresence +end + +class Cisco::DNASpaces::UserPresenceWrapper < Cisco::DNASpaces::Events + getter eventType : String = "USER_PRESENCE" + + @[JSON::Field(key: "userPresence")] + getter payload : UserPresence +end + +class Cisco::DNASpaces::AppActivactionWrapper < Cisco::DNASpaces::Events + getter eventType : String = "APP_ACTIVATION" + + @[JSON::Field(key: "appActivation")] + getter payload : AppActivaction +end + +class Cisco::DNASpaces::DeviceCountWrapper < Cisco::DNASpaces::Events + getter eventType : String = "DEVICE_COUNT" + + @[JSON::Field(key: "deviceCounts")] + getter payload : DeviceCount +end + +class Cisco::DNASpaces::BleRssiUpdateWrapper < Cisco::DNASpaces::Events + getter eventType : String = "BLE_RSSI_UPDATE" + + @[JSON::Field(key: "bleRssiUpdate")] + getter payload : BleRssiUpdate +end + +class Cisco::DNASpaces::IotTelemetryWrapper < Cisco::DNASpaces::Events + getter eventType : String = "IOT_TELEMETRY" + + @[JSON::Field(key: "iotTelemetry")] + getter payload : IotTelemetry +end diff --git a/drivers/cisco/dna_spaces/iot_telemetry.cr b/drivers/cisco/dna_spaces/iot_telemetry.cr new file mode 100644 index 00000000000..c39a646795c --- /dev/null +++ b/drivers/cisco/dna_spaces/iot_telemetry.cr @@ -0,0 +1,232 @@ +require "./events" +require "./location" + +class Cisco::DNASpaces::IotDeviceInfo + include JSON::Serializable + + @[JSON::Field(key: "deviceType")] + property type : String + + @[JSON::Field(key: "deviceId")] + property id : String + + @[JSON::Field(key: "deviceMacAddress")] + property mac_address : String + + @[JSON::Field(key: "deviceName")] + property device_name : String + + @[JSON::Field(key: "firmwareVersion")] + property firmware_version : String + + @[JSON::Field(key: "rawDeviceId")] + property raw_id : String + property manufacturer : String + + def os + type + end +end + +class Cisco::DNASpaces::IotPosition + include JSON::Serializable + + @[JSON::Field(key: "mapId")] + property map_id : String + + @[JSON::Field(key: "xPos")] + getter x_pos : Float64 + + @[JSON::Field(key: "yPos")] + getter y_pos : Float64 + + @[JSON::Field(key: "confidenceFactor")] + getter confidence_factor : Float64 + getter latitude : Float64 + getter longitude : Float64 + + @[JSON::Field(key: "locationId")] + property location_id : String + + @[JSON::Field(key: "lastLocatedTime")] + property time_located : Int64 +end + +class Cisco::DNASpaces::TpData + include JSON::Serializable + + @[JSON::Field(key: "peopleCount")] + property people_count : Int32 + + @[JSON::Field(key: "standbyState")] + property standby_state : Int32 + + @[JSON::Field(key: "ambientNoise")] + property ambient_noise : Int32 + + @[JSON::Field(key: "drynessScore")] + property dryness_score : Int32 + + @[JSON::Field(key: "activeCalls")] + property active_calls : Int32 + + @[JSON::Field(key: "presentationState")] + property presentation_state : Int32 + + @[JSON::Field(key: "timeStamp")] + property time_stamp : Int64 + + @[JSON::Field(key: "airQualityIndex")] + property air_quality_index : Float64 + + @[JSON::Field(key: "temperatureInCelsius")] + property temperature_in_celsius : Float64 + + @[JSON::Field(key: "humidityInPercentage")] + property humidity_in_percentage : Float64 + + getter presence : Bool +end + +class Cisco::DNASpaces::IotTelemetry + include JSON::Serializable + + @[JSON::Field(key: "deviceInfo")] + getter device : IotDeviceInfo + + @[JSON::Field(key: "detectedPosition")] + getter detected_position : IotPosition? + + @[JSON::Field(key: "placedPosition")] + getter placed_position : IotPosition? + + getter location : Location + + @[JSON::Field(key: "deviceRtcTime")] + getter device_rtc : Int64 + + @[JSON::Field(key: "rawHeader")] + getter raw_header : Int64 + + @[JSON::Field(key: "rawPayload")] + getter raw_payload : String + + @[JSON::Field(key: "sequenceNum")] + getter sequence_num : Int64 + + @[JSON::Field(key: "airQuality")] + getter air_quality_index : NamedTuple(airQualityIndex: Float64)? + + @[JSON::Field(key: "temperature")] + getter temperature_celsius : NamedTuple(temperatureInCelsius: Float64)? + + @[JSON::Field(key: "humidity")] + getter humidity_percent : NamedTuple(humidityInPercentage: Float64)? + + @[JSON::Field(key: "airPressure")] + getter air_pressure_actual : NamedTuple(pressure: Float64)? + + @[JSON::Field(key: "pirTrigger")] + getter pir_trigger : NamedTuple(timestamp: Int64)? + + @[JSON::Field(key: "tpData")] + getter tele_presence_data : TpData? + + def air_quality + if index = @air_quality_index + index[:airQualityIndex] + else + 0.0 + end + end + + def temperature + if temp = @temperature_celsius + temp[:temperatureInCelsius] + else + 0.0 + end + end + + def humidity + if humidity = @humidity_percent + humidity[:humidityInPercentage] + else + 0.0 + end + end + + def air_pressure + if pressure = @air_pressure_actual + pressure[:pressure] + else + 0.0 + end + end + + def pir_triggered + if pir_trigger = @pir_trigger + pir_trigger[:timestamp] + else + 0_i64 + end + end + + @[JSON::Field(ignore: true)] + @location_mappings : Hash(String, String)? = nil + + # Ensure we only process these once + def location_mappings : Hash(String, String) + if mappings = @location_mappings + mappings + else + mappings = location.details + @location_mappings = mappings + mappings + end + end + + def has_position? + !!(@detected_position || @placed_position) + end + + def position : IotPosition + (@detected_position || @placed_position).not_nil! + end + + # make this class quack like a wifi DeviceLocationUpdate + delegate latitude, to: position + delegate longitude, to: position + delegate confidence_factor, to: position + delegate x_pos, to: position + delegate y_pos, to: position + delegate map_id, to: position + + def map_id=(id) + position.map_id = id + end + + def visit_id + "unknown for IoT" + end + + def last_seen + position.time_located + end + + def last_seen=(time) + position.time_located = time + end + + def raw_user_id + "" + end + + def unc : Float64 + 3.0 + end + + def ssid + "IoT" + end +end diff --git a/drivers/cisco/dna_spaces/location.cr b/drivers/cisco/dna_spaces/location.cr new file mode 100644 index 00000000000..26cb978eef1 --- /dev/null +++ b/drivers/cisco/dna_spaces/location.cr @@ -0,0 +1,30 @@ +require "./events" + +class Cisco::DNASpaces::Location + include JSON::Serializable + + @[JSON::Field(key: "locationId")] + getter location_id : String + getter name : String + + # TODO:: this might be better as an enum + # if there are only limited types + @[JSON::Field(key: "inferredLocationTypes")] + getter tags : Array(String) = [] of String + + getter parent : Location? + + # Maps tag names to location_ids + def details(mappings = {} of String => String) + parent.try &.details(mappings) + tags.each { |tag| mappings[tag] = location_id } + mappings + end + + # Maps location_ids to location names + def descriptions(mappings = {} of String => String) + parent.try &.descriptions(mappings) + mappings[location_id] = name + mappings + end +end diff --git a/drivers/cisco/dna_spaces/location_change.cr b/drivers/cisco/dna_spaces/location_change.cr new file mode 100644 index 00000000000..6043e172f14 --- /dev/null +++ b/drivers/cisco/dna_spaces/location_change.cr @@ -0,0 +1,32 @@ +require "./events" + +class Cisco::DNASpaces::LocationChange + include JSON::Serializable + + @[JSON::Field(key: "changeType")] + getter change_type : String + getter location : Location + + class Metadata + include JSON::Serializable + + getter key : String + getter values : Array(String) + end + + class LocationDetails + include JSON::Serializable + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + getter city : String + getter state : String + getter country : String + getter category : String + + getter latitude : Float64 + getter longitude : Float64 + + getter metadata : Array(Metadata) + end +end diff --git a/drivers/cisco/dna_spaces/location_details.cr b/drivers/cisco/dna_spaces/location_details.cr new file mode 100644 index 00000000000..c69048af78f --- /dev/null +++ b/drivers/cisco/dna_spaces/location_details.cr @@ -0,0 +1,16 @@ +require "./events" + +class Cisco::DNASpaces::LocationDetails + include JSON::Serializable + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + getter city : String + getter state : String + getter country : String + getter category : String + + getter latitude : Float64 + getter longitude : Float64 +end diff --git a/drivers/cisco/dna_spaces/map_info.cr b/drivers/cisco/dna_spaces/map_info.cr new file mode 100644 index 00000000000..31be13841d7 --- /dev/null +++ b/drivers/cisco/dna_spaces/map_info.cr @@ -0,0 +1,30 @@ +require "./events" + +class Cisco::DNASpaces::Dimension + include JSON::Serializable + + getter length : Float64 + getter width : Float64 + getter height : Float64 + + @[JSON::Field(key: "offsetX")] + getter offset_x : Float64 + + @[JSON::Field(key: "offsetY")] + getter offset_y : Float64 +end + +class Cisco::DNASpaces::MapInfo + include JSON::Serializable + + @[JSON::Field(key: "mapId")] + getter id : String + + @[JSON::Field(key: "imageWidth")] + getter image_width : Float64 + + @[JSON::Field(key: "imageHeight")] + getter image_height : Float64 + + getter dimension : Cisco::DNASpaces::Dimension +end diff --git a/drivers/cisco/dna_spaces/people_count_update.cr b/drivers/cisco/dna_spaces/people_count_update.cr new file mode 100644 index 00000000000..99d8f35cd22 --- /dev/null +++ b/drivers/cisco/dna_spaces/people_count_update.cr @@ -0,0 +1,32 @@ +require "./events" + +# This is triggered from telepresence devices +class Cisco::DNASpaces::PeopleCountUpdate + include JSON::Serializable + + @[JSON::Field(key: "tpDeviceId")] + getter tp_device_id : String + getter location : Location + getter presence : Bool + + @[JSON::Field(key: "peopleCount")] + getter people_count : Int32 + + @[JSON::Field(key: "standbyState")] + getter standby_state : Int32 + + @[JSON::Field(key: "ambientNoise")] + getter ambient_noise : Int32 + + @[JSON::Field(key: "drynessScore")] + getter dryness_score : Int32 + + @[JSON::Field(key: "activeCalls")] + getter active_calls : Int32 + + @[JSON::Field(key: "presentationState")] + getter presentation_state : Int32 + + @[JSON::Field(key: "timeStamp")] + getter timestamp : Int64 +end diff --git a/drivers/cisco/dna_spaces/user_presence.cr b/drivers/cisco/dna_spaces/user_presence.cr new file mode 100644 index 00000000000..762c06de514 --- /dev/null +++ b/drivers/cisco/dna_spaces/user_presence.cr @@ -0,0 +1,83 @@ +require "./events" + +class Cisco::DNASpaces::UserPresence + include JSON::Serializable + + class User + include JSON::Serializable + + @[JSON::Field(key: "userId")] + getter user_id : String + + @[JSON::Field(key: "deviceIds")] + getter device_ids : Array(String) + getter tags : Array(String) = [] of String + getter mobile : String? + getter email : String? + getter gender : String? + + @[JSON::Field(key: "firstName")] + getter first_name : String? + + @[JSON::Field(key: "lastName")] + getter last_name : String? + + @[JSON::Field(key: "postalCode")] + getter postal_code : String? + + # otherFields + # socialNetworkInfo + end + + class UserCount + include JSON::Serializable + + @[JSON::Field(key: "usersWithUserId")] + getter users_with_user_id : Int64 + + @[JSON::Field(key: "usersWithoutUserId")] + getter users_without_user_id : Int64 + + @[JSON::Field(key: "totalUsers")] + getter total_users : Int64 + end + + @[JSON::Field(key: "presenceEventType")] + getter presence_event_type : String + + @[JSON::Field(key: "wasInActive")] + getter was_in_active : Bool + + getter user : User + getter location : Location + + @[JSON::Field(key: "rawUserId")] + getter raw_user_id : String + + @[JSON::Field(key: "visitId")] + getter visit_id : String + + @[JSON::Field(key: "entryTimestamp")] + getter entry_timestamp : Int64 + + @[JSON::Field(key: "entryDateTime")] + getter entry_datetime : String + + @[JSON::Field(key: "exitTimestamp")] + getter exit_timestamp : Int64 + + @[JSON::Field(key: "exitDateTime")] + getter exit_datetime : String + + @[JSON::Field(key: "visitDurationMinutes")] + getter visit_duration_minutes : Int32 + + @[JSON::Field(key: "timeZone")] + getter time_zone : String + + @[JSON::Field(key: "activeUsersCount")] + getter active_users_count : UserCount + + @[JSON::Field(key: "inActiveUsersCount")] + getter inactive_users_count : UserCount +end diff --git a/drivers/cisco/dna_spaces_spec.cr b/drivers/cisco/dna_spaces_spec.cr new file mode 100644 index 00000000000..d58064080a2 --- /dev/null +++ b/drivers/cisco/dna_spaces_spec.cr @@ -0,0 +1,18 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::DNASpaces" do + # The dashboard should request the streaming API + expect_http_request do |request, response| + headers = request.headers + if headers["X-API-KEY"]? == "X-API-KEY" + response.headers["Transfer-Encoding"] = "chunked" + response.status_code = 200 + response << %({"recordUid":"event-85b84f15","recordTimestamp":1605502585236,"spacesTenantId":"","spacesTenantName":"","partnerTenantId":"","eventType":"KEEP_ALIVE"}) + else + response.status_code = 401 + end + end + + # Should standardise the format of MAC addresses + exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b) +end diff --git a/drivers/cisco/ise/guests.cr b/drivers/cisco/ise/guests.cr new file mode 100644 index 00000000000..39ad954385f --- /dev/null +++ b/drivers/cisco/ise/guests.cr @@ -0,0 +1,219 @@ +require "placeos-driver" +require "./models/internal_user" + +# Tested with Cisco ISE API v3.1 +# https://developer.cisco.com/docs/identity-services-engine/v1/#!internaluser + +class Cisco::Ise::Guests < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco ISE REST API" + generic_name :Guests + uri_base "https://ise-pan:9060/ers/config" + + default_settings({ + username: "user", + password: "pass", + portal_id: "Required, ask cisco ISE admins", + timezone: "UTC", + guest_type: "Required, ask cisco ISE admins for valid subset of values", # e.g. Contractor + location: "Required for ISE v2.2, ask cisco ISE admins for valid value. Else, remove for ISE v1.4", # e.g. New York + custom_data: {} of String => String, + debug: false, + }) + + @basic_auth : String = "" + @portal_id : String = "" + @sms_service_provider : String? = nil + @guest_type : String = "default_guest_type" + @timezone : Time::Location = Time::Location.load("Australia/Sydney") + @location : String? = nil + @custom_data = {} of String => String + + TYPE_HEADER = "application/json" + TIME_FORMAT = "%m/%d/%Y %H:%M" + + def on_load + on_update + end + + def on_update + username = setting?(String, :username) + password = setting?(String, :password) + + @basic_auth = ["Basic", Base64.strict_encode([username, password].join(":"))].join(" ") + + @debug = setting?(Bool, :debug) || false + + @portal_id = setting?(String, :portal_id) || "portal101" + @guest_type = setting?(String, :guest_type) || "default_guest_type" + @location = setting?(String, :location) + @sms_service_provider = setting?(String, :sms_service_provider) + + time_zone = setting?(String, :timezone).presence + @timezone = Time::Location.load(time_zone) if time_zone + @custom_data = setting?(Hash(String, String), :custom_data) || {} of String => String + + logger.debug { "Basic auth details: #{@basic_auth}" } if @debug + end + + def create_internal( + event_start : Int64, + attendee_email : String, + attendee_name : String, + company_name : String? = nil, # Mandatory but driver will extract from email if not passed + phone_number : String = "0123456789", # Mandatory, use a fake value as default + sms_service_provider : String? = nil, # Use this param to override the setting + guest_type : String? = nil, # Mandatory but use this param to override the setting + portal_id : String? = nil # Mandatory but use this param to override the setting + ) + # Determine the name of the attendee for ISE + guest_names = attendee_name.split + first_name_index_end = guest_names.size > 1 ? -2 : -1 + first_name = guest_names[0..first_name_index_end].join(' ') + last_name = guest_names[-1] + username = genererate_username(first_name, last_name) + password = genererate_password(first_name, last_name) + + return {"username" => username, "password" => UUID.random.to_s[0..3]}.merge(@custom_data) if setting?(Bool, :test) + + sms_service_provider ||= @sms_service_provider + guest_type ||= @guest_type + portal_id ||= @portal_id + + time_object = Time.unix(event_start).in(@timezone) + from_date = time_object.at_beginning_of_day.to_s(TIME_FORMAT) + to_date = time_object.at_end_of_day.to_s(TIME_FORMAT) + + # If company_name isn't passed + # Hackily grab a company name from the attendee's email (we may be able to grab this from the signal if possible) + company_name ||= attendee_email.split('@')[1].split('.')[0].capitalize + + internal_user = Models::InternalUser.from_json(%({})) + + # These custom attributes and any custom attribute needs to be predefined + # in the ISE GUI. + # custom_attributes = { + # "fromDate" => from_date, + # "toDate" => to_date, + # "location" => @location.to_s, + # "companyName" => company_name, + # "phoneNumber" => phone_number, + # "smsServiceProvider" => sms_service_provider.to_s, + # "guestType" => guest_type, + # "portalId" => portal_id, + # } of String => String + + # custom_attributes.merge!(@custom_data) + + internal_user.name = username + internal_user.password = password + internal_user.first_name = first_name + internal_user.last_name = last_name + internal_user.email = attendee_email + + # internal_user.custom_attributes = custom_attributes + + logger.debug { "Internal user: #{internal_user.to_json}" } if @debug + + response = post("/internaluser/", body: {"InternalUser" => internal_user}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to create internal user, code #{response.status_code}\n#{response.body}" unless response.success? + + user = get_internal_user_by_name(username) + user.password = password + + user + end + + def get_internal_user_by_id(id : String) + response = get("/internaluser/#{id}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by id, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + internal_user + end + + def get_internal_user_by_name(name : String) + response = get("/internaluser/name/#{name}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by name, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + internal_user = Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + internal_user + end + + def get_internal_user_by_email(email : String) + response = get("/internaluser/?filter=email.CONTAINS.#{email}", headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + logger.debug { "Response: #{response.status_code}, #{response.body}" } if @debug + + raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success? + + parsed_body = JSON.parse(response.body) + + resources = parsed_body["SearchResult"].as_h.["resources"].as_a + + raise "returned body has no resources" if resources.empty? + + get_internal_user_by_id(resources.first.as_h.["id"].to_s) + end + + def update_internal_user_password_by_id(id : String, password : String) + internal_user = get_internal_user_by_id(id) + + response = put("/internaluser/#{internal_user.id}", body: {"InternalUser" => {"password" => password}}.to_json, headers: { + "Accept" => TYPE_HEADER, + "Content-Type" => TYPE_HEADER, + "Authorization" => @basic_auth, + }) + + raise "failed to get internal user by email, code #{response.status_code}\n#{response.body}" unless response.success? + + JSON.parse(response.body) + end + + def update_internal_user_password_by_email(email : String, password : String) + internal_user = get_internal_user_by_email(email) + + update_internal_user_password_by_id(internal_user.id.to_s, password) + end + + # Will be 9 characters in length until 2081-08-05 10:16:46.208000000 UTC + # when it will increase to 10 + private def genererate_username(firstname, lastname) + "#{firstname[0].downcase}#{lastname[0].downcase}#{Time.utc.to_unix_ms.to_s(62)}" + end + + # Will be 9 characters in length until 2081-08-05 10:16:46.208000000 UTC + # when it will increase to 10 + private def genererate_password(firstname, lastname) + "P!#{lastname[0].downcase}#{firstname[0].downcase}#{Time.utc.to_unix_ms.to_s(31)}" + end +end diff --git a/drivers/cisco/ise/guests_spec.cr b/drivers/cisco/ise/guests_spec.cr new file mode 100644 index 00000000000..0556830c34d --- /dev/null +++ b/drivers/cisco/ise/guests_spec.cr @@ -0,0 +1,45 @@ +require "placeos-driver" +require "./guests" +require "./models/internal_user" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Ise::Guests" do + portal = "portal101" + phone = "0123456789" + type = "Contractor" + lo = "New York" + + settings({ + portal_id: portal, + guest_type: type, + location: lo, + }) + + start_time = Time.local(Time::Location.load("Australia/Sydney")) + start_date = start_time.at_beginning_of_day.to_s(Cisco::Ise::Guests::TIME_FORMAT) + end_date = start_time.at_end_of_day.to_s(Cisco::Ise::Guests::TIME_FORMAT) + attendee_email = "attendee@test.com" + company_name = "PlaceOS" + + sms = "Global Default" + exec(:create_internal, start_time.to_unix, attendee_email, "First Last", company_name, phone, sms, "Daily") + + # POST to /internaluser/ + expect_http_request do |request, response| + parsed_body = JSON.parse(request.body.not_nil!) + internal_user = Cisco::Ise::Models::InternalUser.from_json(parsed_body["InternalUser"].to_json) + + email_address = internal_user.email + email_address.should eq attendee_email + + first_name = internal_user.first_name + first_name.should eq "First" + + last_name = internal_user.last_name + last_name.should eq "Last" + + response.status_code = 201 + response.headers["Location"] = "https://ise-pan:9060/ers/config/internaluser/e1bb8290-6ccb-11e3-8cdf-000c29c56fc7" + response.headers["Content-Type"] = "application/xml" + end +end diff --git a/drivers/cisco/ise/models/internal_user.cr b/drivers/cisco/ise/models/internal_user.cr new file mode 100644 index 00000000000..41c73a1d1e2 --- /dev/null +++ b/drivers/cisco/ise/models/internal_user.cr @@ -0,0 +1,38 @@ +require "json" + +class Cisco::Ise::Models::InternalUser + include JSON::Serializable + + @[JSON::Field(key: "name")] + property name : String = ["internalUser", UUID.random.to_s.split("-").last].join("-") + + @[JSON::Field(key: "id")] + property id : String? + + @[JSON::Field(key: "description")] + property description : String? + + @[JSON::Field(key: "changePassword")] + property change_password : Bool = false + + @[JSON::Field(key: "email")] + property email : String? + + @[JSON::Field(key: "enabled")] + property enabled : Bool = true + + @[JSON::Field(key: "customAttributes")] + property custom_attributes : Hash(String, String) = {} of String => String + + @[JSON::Field(key: "firstName")] + property first_name : String? + + @[JSON::Field(key: "lastName")] + property last_name : String? + + @[JSON::Field(key: "password")] + property password : String? + + @[JSON::Field(key: "passwordIDStore")] + property password_store : String = "Internal Users" +end diff --git a/drivers/cisco/meraki/captive_portal.cr b/drivers/cisco/meraki/captive_portal.cr new file mode 100644 index 00000000000..f42f0ad542e --- /dev/null +++ b/drivers/cisco/meraki/captive_portal.cr @@ -0,0 +1,142 @@ +require "json" +require "openssl" +require "placeos-driver" + +class Cisco::Meraki::CaptivePortal < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Meraki Captive Portal" + generic_name :CaptivePortal + description %( + for more information visit: https://meraki.cisco.com/lib/pdf/meraki_whitepaper_captive_portal.pdf + ) + + default_settings({ + wifi_secret: "anything really", + default_timezone: "Australia/Sydney", + date_format: "%Y%m%d", + # duration of access in hours + access_duration: 12, + # Length of the clients wifi code + code_length: 4, + success_url: "https://company.com/welcome", + }) + + def on_load + on_update + end + + @wifi_secret : String = "" + @date_format : String = "%Y%m%d" + @success_url : String = "https://place.technology/" + @default_timezone : Time::Location = Time::Location.load("Australia/Sydney") + @access_duration : Time::Span = 12.hours + @code_length : Int32 = 4 + + @denied : UInt64 = 0_u64 + @granted : UInt64 = 0_u64 + @errors : UInt64 = 0_u64 + + @guests : Hash(String, ChallengePayload) = {} of String => ChallengePayload + + def on_update + @wifi_secret = setting?(String, :wifi_secret) || "anything really" + @date_format = setting?(String, :date_format) || "%Y%m%d" + @success_url = setting?(String, :success_url) || "https://place.technology/" + @access_duration = (setting?(Int32, :access_duration) || 12).hours + @code_length = setting?(Int32, :code_length) || 4 + + time_zone = setting?(String, :default_timezone).presence + @default_timezone = Time::Location.load(time_zone) if time_zone + end + + @[Security(Level::Support)] + def guests + @guests + end + + @[Security(Level::Support)] + def lookup(mac : String) + @guests[format_mac(mac)] + end + + def generate_guest_data(email : String, time : Int64, time_zone : String? = nil) + time_zone = time_zone.presence ? Time::Location.load(time_zone.not_nil!) : @default_timezone + date = Time.unix(time).in(time_zone).to_s(@date_format) + guest_string = "#{email.downcase}-#{date}-#{@wifi_secret}" + + OpenSSL::Digest.new("SHA256").update(guest_string).final.hexstring + end + + # Splits the SHA256 into code length and then randomly selects one + def generate_guest_token(email : String, time : Int64, time_zone : String? = nil) + generate_guest_data(email, time, time_zone).scan(/.{#{@code_length}}/).sample(1)[0][0] + end + + class ChallengePayload + include JSON::Serializable + + property ap_mac : String + property client_ip : String + property client_mac : String + property base_grant_url : String + property user_continue : String? + + # key they were provided in their invite email + property code : String + property email : String + property timezone : String? + + property expires : Time? = nil + end + + EMPTY_HEADERS = {} of String => String + JSON_HEADERS = { + "Content-Type" => "application/json", + } + + # Webhook for providing guest access + def challenge(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "guest access attempt: #{method},\nheaders #{headers},\nbody #{body}" } + + challenge = ChallengePayload.from_json(body) + + check_code = challenge.code + guest_codes = generate_guest_data(challenge.email, Time.utc.to_unix, challenge.timezone) + matched = guest_codes.scan(/.{#{@code_length}}/).count { |code| code[0] == check_code } > 0 + + if matched + challenge.expires = @access_duration.from_now + @guests[format_mac(challenge.client_mac)] = challenge + @granted += 1_u64 + self[:granted_access] = @granted + + redirect_url = "#{challenge.base_grant_url}?duration=#{@access_duration.to_i}&continue_url=#{challenge.user_continue || @success_url}" + response = { + redirect_to: redirect_url, + }.to_json + + logger.debug { "successful joined network #{challenge.inspect}" } + + # Redirect to the success URL + {HTTP::Status::OK, JSON_HEADERS, response} + else + @denied += 1_u64 + self[:denied_access] = @denied + + logger.debug { "failed wifi access attempt by #{challenge.inspect}" } + + {HTTP::Status::NOT_ACCEPTABLE, JSON_HEADERS, "{}"} + end + rescue error + @errors += 1_u64 + self[:errors] = @errors + last_error = error.inspect_with_backtrace + self[:last_error] = last_error + logger.error { "failed to parse wifi challenge payload\n#{error}" } + {HTTP::Status::INTERNAL_SERVER_ERROR, EMPTY_HEADERS, nil} + end + + protected def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end +end diff --git a/drivers/cisco/meraki/captive_portal_spec.cr b/drivers/cisco/meraki/captive_portal_spec.cr new file mode 100644 index 00000000000..11b01cf020b --- /dev/null +++ b/drivers/cisco/meraki/captive_portal_spec.cr @@ -0,0 +1,16 @@ +require "openssl" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Meraki::CaptivePortal" do + date = Time.unix(1599477274).in(Time::Location.load("Australia/Sydney")).to_s("%Y%m%d") + hexdigest = OpenSSL::Digest.new("SHA256").update("guest@email.com-#{date}-anything really").final.hexstring + + # Check the hex codes match + retval = exec(:generate_guest_data, "guest@email.com", 1599477274, "Australia/Sydney") + retval.get.should eq hexdigest + + # check it matches on of the codes + codes = hexdigest.scan(/.{4}/).map { |code| code[0] } + retval = exec(:generate_guest_token, "guest@email.com", 1599477274, "Australia/Sydney") + codes.includes?(retval.get.not_nil!.as_s).should eq true +end diff --git a/drivers/cisco/meraki/dashboard.cr b/drivers/cisco/meraki/dashboard.cr new file mode 100644 index 00000000000..5869d1bfa7e --- /dev/null +++ b/drivers/cisco/meraki/dashboard.cr @@ -0,0 +1,218 @@ +require "uri" +require "json" +require "link-header" +require "placeos-driver" +require "./scanning_api" + +class Cisco::Meraki::Dashboard < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Meraki Dashboard" + generic_name :Dashboard + uri_base "https://api.meraki.com" + description %( + for more information visit: + * Dashboard API: https://documentation.meraki.com/zGeneral_Administration/Other_Topics/The_Cisco_Meraki_Dashboard_API + * Scanning API: https://developer.cisco.com/meraki/scanning-api/#!introduction/scanning-api + + NOTE:: API Call volume is rate limited to 5 calls per second per organization + ) + + default_settings({ + meraki_validator: "configure if scanning API is enabled", + meraki_secret: "configure if scanning API is enabled", + meraki_api_key: "configure for the dashboard API", + + # Max requests a second made to the dashboard + rate_limit: 4, + debug_payload: false, + + # filter message type + scanning_api_filter: "WiFi", + }) + + def on_load + spawn { rate_limiter } + on_update + end + + def on_unload + @channel.close + end + + @scanning_validator : String = "" + @scanning_secret : String = "" + @api_key : String = "" + @scanning_api_filter : MessageType = MessageType::WiFi + + @rate_limit : Int32 = 4 + @channel : Channel(Nil) = Channel(Nil).new(1) + @queue_lock : Mutex = Mutex.new + @queue_size = 0 + @wait_time : Time::Span = 300.milliseconds + + @debug_payload : Bool = false + + def on_update + @scanning_validator = setting?(String, :meraki_validator) || "" + @scanning_secret = setting?(String, :meraki_secret) || "" + @api_key = setting?(String, :meraki_api_key) || "" + @scanning_api_filter = setting?(MessageType, :scanning_api_filter) || MessageType::WiFi + + @rate_limit = setting?(Int32, :rate_limit) || 4 + @wait_time = 1.second / @rate_limit + + @debug_payload = setting?(Bool, :debug_payload) || false + end + + # Perform fetch with the required API request limits in place + @[Security(PlaceOS::Driver::Level::Support)] + def fetch(location : String) + req(location, &.body) + end + + @[Security(PlaceOS::Driver::Level::Support)] + def fetch_all(location : String) + responses = [] of String + req_all_pages(location) { |response| responses << response.body } + responses + end + + protected def req(location : String) + if (@wait_time * @queue_size) > 10.seconds + raise "wait time would be exceeded for API request, #{@queue_size} requests already queued" + end + + @queue_lock.synchronize { @queue_size += 1 } + @channel.receive + @queue_lock.synchronize { @queue_size -= 1 } + + headers = HTTP::Headers{ + "X-Cisco-Meraki-API-Key" => @api_key, + "Content-Type" => "application/json", + "Accept" => "application/json", + "User-Agent" => "PlaceOS/2.0 PlaceTechnology", + } + + uri = URI.parse(location) + response = if uri.host.nil? + get(location, headers: headers) + else + HTTP::Client.get(location, headers: headers) + end + + if response.success? + yield response + elsif response.status.found? + # Meraki might return a `302` on GET requests + response = HTTP::Client.get(response.headers["Location"], headers: headers) + if response.success? + yield response + else + raise "request #{location} failed with status: #{response.status_code}" + end + else + raise "request #{location} failed with status: #{response.status_code}" + end + end + + protected def req_all_pages(location : String) : Nil + next_page = location + + loop do + break unless next_page + + next_page = req(next_page) do |response| + yield response + LinkHeader.new(response)["next"]? + end + end + end + + EMPTY_HEADERS = {} of String => String + SUCCESS_RESPONSE = {HTTP::Status::OK, EMPTY_HEADERS, nil} + + @[Security(PlaceOS::Driver::Level::Support)] + def organizations + req("/api/v1/organizations?perPage=1000") do |response| + Array(Organization).from_json(response.body) + end + end + + @[Security(PlaceOS::Driver::Level::Support)] + def networks(organization_id : String) + nets = [] of Network + req_all_pages("/api/v1/organizations/#{organization_id}/networks?perPage=1000") do |response| + nets.concat Array(Network).from_json(response.body) + end + nets + end + + @[Security(PlaceOS::Driver::Level::Support)] + def poll_clients(network_id : String? = nil, timespan : UInt32 = 900_u32) + clients = [] of Client + req_all_pages "/api/v1/networks/#{network_id}/clients?perPage=1000×pan=#{timespan}" do |response| + clients.concat Array(Client).from_json(response.body) + end + clients + end + + def get_zones(camera_serial : String) + req("/api/v1/devices/#{camera_serial}/camera/analytics/zones") do |response| + Array(CameraZone).from_json(response.body) + end + end + + # Webhook endpoint for scanning API, expects version 3 + def scanning_api(method : String, headers : Hash(String, Array(String)), body : String) + logger.debug { "scanning API received: #{method},\nheaders #{headers},\nbody size #{body.size}" } + logger.debug { body } if @debug_payload + + # Return the scanning API validator code on a GET request + return {HTTP::Status::OK.to_i, EMPTY_HEADERS, @scanning_validator} if method == "GET" + + # Check the version matches + if !body.starts_with?(%({"version":"3.0")) + logger.warn { "unknown scanning API message received:\n#{body[0..96]}" } + return SUCCESS_RESPONSE + end + + # Parse the data posted + begin + seen = DevicesSeen.from_json(body) + logger.debug { "parsed meraki payload" } + + # filter out observations we're not interested in + if !@scanning_api_filter.none? && seen.message_type != @scanning_api_filter + logger.debug { "ignoring message type: #{seen.message_type}" } + return SUCCESS_RESPONSE + end + + # Check the secret matches + raise "secret mismatch, sent: #{seen.secret}" unless seen.secret == @scanning_secret + + self[seen.data.network_id] = seen.data.observations + rescue e + logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" } + logger.debug { "failed payload body was\n#{body}" } + end + + # Return a 200 response + SUCCESS_RESPONSE + end + + protected def rate_limiter + loop do + break if @channel.closed? + begin + @channel.send(nil) + rescue error + logger.error(exception: error) { "issue with rate limiter" } + ensure + sleep @wait_time + end + end + rescue + # Possible error with logging exception, restart rate limiter silently + spawn { rate_limiter } unless @channel.closed? + end +end diff --git a/drivers/cisco/meraki/dashboard_spec.cr b/drivers/cisco/meraki/dashboard_spec.cr new file mode 100644 index 00000000000..b5ca78a6486 --- /dev/null +++ b/drivers/cisco/meraki/dashboard_spec.cr @@ -0,0 +1,21 @@ +require "./scanning_api" +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Meraki::Dashboard" do + # Send the request + retval = exec(:fetch, "/api/v0/organizations") + + # The dashboard should send a HTTP request with the API key + expect_http_request do |request, response| + headers = request.headers + if headers["X-Cisco-Meraki-API-Key"]? == "configure for the dashboard API" + response.status_code = 202 + response << %([{"id":"org id","name":"place tech"}]) + else + response.status_code = 401 + end + end + + # Should return the payload + retval.get.should eq %([{"id":"org id","name":"place tech"}]) +end diff --git a/drivers/cisco/meraki/geo.cr b/drivers/cisco/meraki/geo.cr new file mode 100644 index 00000000000..cf2c419721c --- /dev/null +++ b/drivers/cisco/meraki/geo.cr @@ -0,0 +1,73 @@ +require "math" +require "json" + +module Cisco; end + +module Cisco::Meraki; end + +module Cisco::Meraki::Geo + struct Point + include JSON::Serializable + + def initialize(@lat, @lng) + end + + property lat : Float64 + property lng : Float64 + end + + struct Distance + include JSON::Serializable + + def initialize(@x, @y) + end + + property x : Float64 + property y : Float64 + end + + def self.calculate_xy(top_left : Point, bottom_left : Point, bottom_right : Point, position, distance : Distance) + y_base = geo_distance(top_left, bottom_left) + a = geo_distance(top_left, position) + c = geo_distance(bottom_left, position) + x_raw = triangle_height(a, y_base, c) + + x_base = geo_distance(bottom_left, bottom_right) + a = geo_distance(bottom_left, position) + c = geo_distance(bottom_right, position) + y_raw = triangle_height(a, x_base, c) + + # find the percentage distance from the origin + percentage_height = y_raw / y_base + percentage_width = x_raw / x_base + + # adjust into range provided by the original distances + Distance.new(distance.x * percentage_width, distance.y * percentage_height) + end + + # radius in meters, approx as we're using a perfect sphere the same volume as the earth + EarthRadiusApprox = 6371000.7900_f64 + Radians = Math::PI / 180_f64 + + # https://www.movable-type.co.uk/scripts/latlong.html + # returns the distance in meters + def self.geo_distance(start : Point, ending) + lat_diff = (ending.lat - start.lat) * Radians + lng_diff = (ending.lng - start.lng) * Radians + start_lat_radian = start.lat * Radians + end_lng_radian = ending.lng * Radians + + a = Math.sin(lat_diff / 2_f64) * Math.sin(lat_diff / 2_f64) + + Math.cos(start_lat_radian) * Math.cos(end_lng_radian) * + Math.sin(lng_diff / 2_f64) * Math.sin(lng_diff / 2_f64) + + c = 2_f64 * Math.atan2(Math.sqrt(a), Math.sqrt(1_f64 - a)) + + EarthRadiusApprox * c + end + + # https://www.omnicalculator.com/math/triangle-height + def self.triangle_height(a : Float64, base : Float64, c : Float64) + 0.5_f64 * Math.sqrt((a + base + c) * (base + c - a) * (a - base + c) * (a + base - c)) / base + end +end diff --git a/drivers/cisco/meraki/meraki_locations.cr b/drivers/cisco/meraki/meraki_locations.cr new file mode 100644 index 00000000000..e2f6c424f6b --- /dev/null +++ b/drivers/cisco/meraki/meraki_locations.cr @@ -0,0 +1,1151 @@ +require "placeos-driver" +require "json" +require "s2_cells" +require "./mqtt_models" +require "./scanning_api" +require "../../place/area_polygon" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" + +class Cisco::Meraki::Locations < PlaceOS::Driver + include Interface::Locatable + include Interface::Sensor + + # Discovery Information + descriptive_name "Meraki Location Service" + generic_name :MerakiLocations + + description %(requires meraki dashboard driver for API calls) + + accessor dashboard : Dashboard_1 + + default_settings({ + # We will always accept a reading with a confidence lower than this + acceptable_confidence: 5.0, + + # Max Uncertainty in meters - we don't accept positions that are less certain + maximum_uncertainty: 25.0, + + # For confident yet inaccurate location data/maps. If a location's variance is below this threshold, increase it to this value. + # 0.0 disables the override + override_min_variance: 0.0, + + # Optionally only store locations for devices whose "os" property matches this regex string. + regex_filter_device_os: nil, + + # can we use the meraki dashboard API for user lookups + default_network_id: "network_id", + + # Area index each point on a floor lands on + # 21 == ~4 meters squared, which given wifi variance is good enough for tracing + # S2 cell levels: https://s2geometry.io/resources/s2cell_statistics.html + s2_level: 21, + debug_payload: false, + debug_webhook: false, + + # Level mappings, level name for human readability + floorplan_mappings: { + "g_727894289773756672" => { + "building": "zone-12345", + "level": "zone-123456", + "level_name": "BUILDING - L1", + }, + }, + + # Time before a user location is considered probably too old + max_location_age: 10, + + # Ignore certain usernames from the dashboard + ignore_usernames: ["host/"], + + # Enable / Disable dashboard username lookup completely + disable_username_lookup: false, + + # Where desks have no occupancy + return_empty_spaces: true, + }) + + def on_load + # We want to store our user => mac_address mappings in redis + @user_mac_mappings = PlaceOS::Driver::RedisStorage.new(module_id, "user_macs") + on_update + end + + @acceptable_confidence : Float64 = 5.0 + @maximum_uncertainty : Float64 = 25.0 + @override_min_variance : Float64 = 0.0 + @regex_filter_device_os : String? = nil + + @time_multiplier : Float64 = 0.0 + @confidence_multiplier : Float64 = 0.0 + @max_location_age : Time::Span = 6.minutes + @drift_location_age : Time::Span = 4.minutes + @confidence_time : Time::Span = 2.minutes + + @storage_lock : Mutex = Mutex.new + @user_mac_mappings : PlaceOS::Driver::RedisStorage? = nil + @default_network : String = "" + @floorplan_mappings : Hash(String, Hash(String, String | Float64)) = Hash(String, Hash(String, String | Float64)).new + @floorplan_sizes = {} of String => FloorPlan + @network_devices = {} of String => NetworkDevice + + @s2_level : Int32 = 21 + @ignore_usernames : Array(String) = [] of String + @return_empty_spaces : Bool = true + + @debug_payload : Bool = false + @debug_webhook : Bool = false + + def on_update + @default_network = setting?(String, :default_network_id) || "" + @return_empty_spaces = setting?(Bool, :return_empty_spaces) || false + + @acceptable_confidence = setting?(Float64, :acceptable_confidence) || 5.0 + @maximum_uncertainty = setting?(Float64, :maximum_uncertainty) || 25.0 + @override_min_variance = setting?(Float64, :override_min_variance) || 0.0 + @regex_filter_device_os = setting?(String, :regex_filter_device_os) + + @max_location_age = (setting?(UInt32, :max_location_age) || 6).minutes + # Age we keep a confident value (without drifting towards less confidence) + @confidence_time = @max_location_age / 3 + # Age at which we discard a drifting value (accepting a less confident value) + @drift_location_age = @max_location_age - @confidence_time + + # How much confidence do we have in this new value, relative to an old confident value + @time_multiplier = 1.0_f64 / (@drift_location_age.to_i - @confidence_time.to_i).to_f64 + @confidence_multiplier = 1.0_f64 / (@maximum_uncertainty.to_i - @acceptable_confidence.to_i).to_f64 + + @floorplan_mappings = setting?(Hash(String, Hash(String, String | Float64)), :floorplan_mappings) || @floorplan_mappings + + @s2_level = setting?(Int32, :s2_level) || 21 + @debug_payload = setting?(Bool, :debug_payload) || false + @debug_webhook = setting?(Bool, :debug_webhook) || false + @ignore_usernames = setting?(Array(String), :ignore_usernames) || [] of String + disable_username_lookup = setting?(Bool, :disable_username_lookup) || false + + schedule.clear + if @default_network.presence + schedule.every(59.seconds) { update_sensor_cache } + schedule.every(2.minutes) { map_users_to_macs } unless disable_username_lookup + schedule.every(29.minutes) { sync_floorplan_sizes } + + schedule.in(30.milliseconds) do + sync_floorplan_sizes + update_sensor_cache + end + end + schedule.every(30.minutes) { cleanup_caches } + + subscriptions.clear + if @default_network.presence + dashboard.subscribe(@default_network) do |_subscription, new_value| + # values are always raw JSON strings + parse_new_locations(new_value) + end + end + + # Grab desk data from the MQTT connection + if system.exists? :MerakiMQTT + mqtt_module = system[:MerakiMQTT] + mqtt_module.subscribe(:floor_lookup) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + @floor_lookup = Hash(String, FloorMapping).from_json(new_value) + update_desk_mappings unless @zone_lookup.empty? + end + mqtt_module.subscribe(:zone_lookup) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + @zone_lookup = Hash(String, Array(String)).from_json(new_value) + update_desk_mappings unless @floor_lookup.empty? + end + schedule.every(10.minutes) { update_desk_mappings } + mqtt_module.subscribe(:camera_updated) do |_sub, new_value| + next if new_value.nil? || new_value == "null" + _time, camera_serial = Tuple(Int64, String).from_json(new_value) + + if @desk_mappings.has_key? camera_serial + check_camera_status(mqtt_module, camera_serial) + end + end + end + end + + protected def check_camera_status(mqtt_module, camera_serial) + detected_desks = mqtt_module.status(DetectedDesks, "camera_#{camera_serial}_desks") + desk_details[camera_serial] = detected_desks + average_results(camera_serial, detected_desks) + if lux_level = mqtt_module.status?(Float64, "camera_#{camera_serial}_lux") + lux[camera_serial] = lux_level + end + end + + # serial => desks detected + getter desk_details : Hash(String, DetectedDesks) = {} of String => DetectedDesks + + # serial => lux + getter lux : Hash(String, Float64) = {} of String => Float64 + + protected def user_mac_mappings + @storage_lock.synchronize { + yield @user_mac_mappings.not_nil! + } + end + + protected def req(location : String) + response = dashboard.fetch(location).get.as_s + begin + yield response + rescue error + logger.debug(exception: error) { "processing failed for #{location} with response: #{response}" } + raise error + end + end + + protected def req_all(location : String) + dashboard.fetch_all(location).get.as_a.each { |resp| yield resp.as_s } + end + + struct Lookup + include JSON::Serializable + + property time : Time + property mac : String + + def initialize(@time, @mac) + end + end + + # MAC Address => Location + @locations : Hash(String, DeviceLocation) = {} of String => DeviceLocation + @ip_lookup : Hash(String, Lookup) = {} of String => Lookup + + def lookup_ip(address : String) + @ip_lookup[address.downcase]? + end + + def locate_mac(address : String) + @locations[format_mac(address)]? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_foorplans + @floorplan_sizes + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_network_devices + @network_devices + end + + @[Security(PlaceOS::Driver::Level::Support)] + def inspect_state + logger.debug { + "IP Mappings: #{@ip_lookup.keys}\n\nMAC Locations: #{@locations.keys}\n\nClient Details: #{@client_details.keys}" + } + {ip_mappings: @ip_lookup.size, tracking: @locations.size, client_details: @client_details.size} + end + + # Returns the list of users who can be located + @[Security(PlaceOS::Driver::Level::Support)] + def locateable + too_old = @max_location_age.ago + @client_details.compact_map do |mac, client| + location = @locations[mac]? + client.user if location && ((location.time > too_old) || (client.time_added > too_old)) + end + end + + @[Security(PlaceOS::Driver::Level::Support)] + def poll_clients(network_id : String? = nil, timespan : UInt32 = 900_u32) + network_id = network_id.presence || @default_network + Array(Client).from_json dashboard.poll_clients(network_id, timespan).get.to_json + end + + @client_details : Hash(String, Client) = {} of String => Client + + @[Security(PlaceOS::Driver::Level::Support)] + def map_users_to_macs(network_id : String? = nil) + network_id = network_id.presence || @default_network + + logger.debug { "mapping users to device MACs" } + clients = poll_clients(network_id) + + new_devices = 0 + updated_dev = 0 + now = Time.utc + + logger.debug { "mapping found #{clients.size} devices" } + + user_mac_mappings do |storage| + clients.each do |client| + # So we can merge additional details into device location responses + user_mac = format_mac(client.mac) + client.time_added = now + + user_id = client.user + + if user_id + @ignore_usernames.each do |name| + if user_id.starts_with?(name) + client.user = user_id = nil + break + end + end + end + + # Attempt to lookup username via learning + if user_id.nil? + if known_id = storage[user_mac]? + client.user = known_id + end + end + + @client_details[user_mac] = client + next unless user_id + + was_update, was_new = map_user_mac(user_mac, user_id, storage) + updated_dev += 1 if was_update + new_devices += 1 if was_new + end + end + + logger.debug { "mapping assigned #{new_devices} new devices, #{updated_dev} user updated" } + nil + end + + protected def map_user_mac(user_mac, user_id, storage) + updated_dev = false + new_devices = false + user_id = format_username(user_id) + + # Check if mac mapping already exists + existing_user = storage[user_mac]? + return {false, false} if existing_user == user_id + + # Remove any pervious mappings + if existing_user + updated_dev = true + if user_macs = storage[existing_user]? + macs = Array(String).from_json(user_macs) + macs.delete(user_mac) + storage[existing_user] = macs.to_json + end + else + new_devices = true + end + + # Update the user mappings + storage[user_mac] = user_id + macs = if user_macs = storage[user_id]? + tmp_macs = Array(String).from_json(user_macs) + tmp_macs.unshift(user_mac) + tmp_macs.uniq! + tmp_macs[0...9] + else + [user_mac] + end + storage[user_id] = macs + + {updated_dev, new_devices} + end + + def format_username(user : String) + if user.includes? "@" + user = user.split("@")[0] + elsif user.includes? "\\" + user = user.split("\\")[1] + end + user.downcase + end + + def macs_assigned_to(email : String? = nil, username : String? = nil) : Array(String) + username = format_username(username.presence || email.presence.not_nil!) + if macs = user_mac_mappings(&.[username]?) + Array(String).from_json(macs) + else + [] of String + end + end + + def check_ownership_of(mac_address : String) : OwnershipMAC? + lookup = format_mac(mac_address) + if user = user_mac_mappings(&.[lookup]?) + { + location: "wireless", + assigned_to: user, + mac_address: lookup, + } + end + end + + # returns locations based on most recently seen + # versus most accurate location + def locate_user(email : String? = nil, username : String? = nil) + username = format_username(username.presence || email.presence.not_nil!) + + if macs = user_mac_mappings(&.[username]?) + location_max_age = @max_location_age.ago + + Array(String).from_json(macs).compact_map { |mac| + if location = locate_mac(mac) + client = @client_details[mac]? + + # If a filter is set, then ignore this device unless it matches + if @regex_filter_device_os + if client && client.os + unless /#{@regex_filter_device_os}/.match(client.os.not_nil!) + logger.debug { "[#{username}] IGNORING #{mac} as OS does not match regex filter" } + next + end + else + logger.debug { "[#{username}] IGNORING #{mac} as OS is UNKNOWN" } + next + end + end + + # We set these here to speed up processing + location.client = client + location.mac = mac + + if client && client.time_added > location_max_age + location + elsif location.time > location_max_age + location + end + end + }.sort! { |a, b| + b.time <=> a.time + }.map { |location| + lat = location.lat + lon = location.lng + + loc = { + "location" => "wireless", + "coordinates_from" => "bottom-left", + "x" => location.x, + "y" => location.y, + "lon" => lon, + "lat" => lat, + "s2_cell_id" => lat ? S2Cells::LatLon.new(lat.not_nil!, lon.not_nil!).to_token(@s2_level) : nil, + "mac" => location.mac, + "variance" => location.variance, + "last_seen" => location.time.to_unix, + "meraki_floor_id" => location.floor_plan_id, + "meraki_floor_name" => location.floor_plan_name, + } + + # Add our zone IDs to the response + if level_data = @floorplan_mappings[location.floor_plan_id]? + level_data.each { |k, v| loc[k] = v } + end + + # Add meraki map information to the response + if map_size = @floorplan_sizes[location.floor_plan_id]? + loc["map_width"] = map_size.width + loc["map_height"] = map_size.height + end + + # Add additional client information if it's available + if client = location.client + loc["manufacturer"] = client.manufacturer if client.manufacturer + loc["os"] = client.os if client.os + loc["ssid"] = client.ssid if client.ssid + end + + loc + } + else + [] of Nil + end + end + + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "looking up device locations in #{zone_id}" } + case location.presence + when "wireless" + wireless_locations(zone_id) + when "desk" + desk_locations(zone_id) + when nil + wireless_locs = wireless_locations(zone_id) + desk_locs = desk_locations(zone_id) + combind = Array(typeof(wireless_locs[0]) | typeof(desk_locs[0])).new(wireless_locs.size + desk_locs.size) + combind.concat(wireless_locs) + combind.concat(desk_locs) + else + [] of String + end + end + + def wireless_locations(zone_id : String) + # Find the floors associated with the provided zone id + floors = [] of String + @floorplan_mappings.each do |floor_id, data| + floors << floor_id if data.values.includes?(zone_id) + end + logger.debug { "found matching meraki floors: #{floors}" } + return [] of String if floors.empty? + + checking_count = @locations.size + wrong_floor = 0 + too_old = 0 + + # Find the devices that are on the matching floors + oldest_location = @max_location_age.ago + matching = @locations.compact_map do |mac, loc| + # We set this here to speed up processing + client = @client_details[mac]? + loc.client = client + + if loc.time < oldest_location + if client + if client.time_added < oldest_location + too_old += 1 + next + end + else + too_old += 1 + next + end + end + if !floors.includes?(loc.floor_plan_id) + wrong_floor += 1 + next + end + # ensure the formatted mac is being used + loc.mac = mac + loc + end + + logger.debug { "found #{matching.size} matching devices\nchecked #{checking_count} locations, #{wrong_floor} were on the wrong floor, #{too_old} were too old" } + + # Build the payload on the matching locations + matching.group_by(&.floor_plan_id).flat_map { |floor_id, locations| + map_width = -1.0 + map_height = -1.0 + + if map_size = @floorplan_sizes[floor_id]? + map_width = map_size.width + map_height = map_size.height + elsif mappings = @floorplan_mappings[floor_id]? + map_width = (mappings["width"]? || map_width).as(Float64) + map_height = (mappings["height"]? || map_width).as(Float64) + end + + locations.compact_map do |loc| + lat = loc.lat + lon = loc.lng + + # Add additional client information if it's available + if client = @client_details[loc.mac]? + manufacturer = client.manufacturer + os = client.os + ssid = client.ssid + end + + # Skip payloads with invalid coordinates + if (x = loc.x) && (y = loc.y) + if x.is_a?(Float64) && y.is_a?(Float64) + if loc.x.as(Float64).nan? || loc.y.as(Float64).nan? + logger.warn { "ignoring bad location for #{loc.mac}, NaN" } + next + end + else + logger.warn { "ignoring bad location for #{loc.mac}, unexpected value #{loc.x.inspect}" } + next + end + else + logger.warn { "ignoring bad location for #{loc.mac}, no coordinates provided" } + next + end + + { + location: :wireless, + coordinates_from: "bottom-left", + x: loc.x, + y: loc.y, + lon: lon, + lat: lat, + s2_cell_id: lat ? S2Cells::LatLon.new(lat.not_nil!, lon.not_nil!).to_token(@s2_level) : nil, + mac: loc.mac, + variance: loc.variance, + last_seen: loc.time.to_unix, + map_width: map_width, + map_height: map_height, + manufacturer: manufacturer, + os: os, + ssid: ssid, + } + end + } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def cleanup_caches : Nil + logger.debug { "removing IP and location data that is over 30 minutes old" } + + # IP => MAC mappings + old = 30.minutes.ago + remove_keys = [] of String + @ip_lookup.each { |ip, lookup| remove_keys << ip if lookup.time < old } + remove_keys.each { |ip| @ip_lookup.delete(ip) } + logger.debug { "removed #{remove_keys.size} IP => MAC mappings" } + + # IP => Username mappings + remove_keys.clear + @ip_usernames.each { |ip, lookup| remove_keys << ip if lookup.time < old } + remove_keys.each { |ip| @ip_usernames.delete(ip) } + logger.debug { "removed #{remove_keys.size} IP => Username mappings" } + + # Client details + remove_keys.clear + @client_details.each { |mac, client| remove_keys << mac if client.time_added < old } + remove_keys.each { |mac| @client_details.delete(mac) } + logger.debug { "removed #{remove_keys.size} client details" } + + # MACs + remove_keys.clear + @locations.each do |mac, location| + if location.time < old + if client = @client_details[mac]? + remove_keys << mac if client.time_added < old + else + remove_keys << mac + end + end + end + remove_keys.each { |mac| @locations.delete(mac) } + logger.debug { "removed #{remove_keys.size} MACs" } + end + + @[Security(PlaceOS::Driver::Level::Support)] + def sync_floorplan_sizes(network_id : String? = nil) + network_id = network_id.presence || @default_network + logger.debug { "syncing floor plan sizes for network #{network_id}" } + + floor_plans = {} of String => FloorPlan + + req_all("/api/v1/networks/#{network_id}/floorPlans?perPage=1000") { |response| + Array(FloorPlan).from_json(response).each do |plan| + floor_plans[plan.id] = plan + end + nil + } + + @floorplan_sizes = floor_plans + + # mac address => device location + network_devices = {} of String => NetworkDevice + cameras = [] of NetworkDevice + + req_all("/api/v1/networks/#{network_id}/devices?perPage=1000") { |response| + Array(NetworkDevice).from_json(response).each do |device| + cameras << device if device.firmware.starts_with?("cam") + next unless device.floor_plan_id + network_devices[format_mac(device.mac)] = device + end + nil + } + + @network_devices = network_devices + @cameras = cameras + + {floor_plans, network_devices} + end + + @[Security(PlaceOS::Driver::Level::Support)] + def camera_analytics(serial : String) + req("/api/v1/devices/#{serial}/camera/analytics/live") do |response| + CameraAnalytics.from_json(response) + end + end + + alias CamAnalytics = NamedTuple( + camera: NetworkDevice, + details: CameraAnalytics, + building: String?, + level: String?) + + @camera_analytics = {} of String => CamAnalytics + @cameras = [] of NetworkDevice + + getter cameras + + def update_sensor_cache + analytics = {} of String => CamAnalytics + cameras.each do |cam| + begin + mappings = @floorplan_mappings[cam.floor_plan_id]? + counts = camera_analytics(cam.serial) + mac = format_mac(cam.mac) + if mappings + analytics[mac] = { + camera: cam, + details: counts, + building: mappings["building"]?.as(String?), + level: mappings["level"]?.as(String?), + } + else + analytics[mac] = { + camera: cam, + details: counts, + building: nil.as(String?), + level: nil.as(String?), + } + end + + counts.zones.each do |area_id, count| + self["people-#{mac}-#{area_id}"] = count.person + self["presence-#{mac}-#{area_id}"] = count.person > 0 + end + rescue error + logger.debug(exception: error) { "failed to obtain analytics for #{cam.name} (serial: #{cam.serial})" } + end + end + @camera_analytics = analytics + end + + # Webhook endpoint for scanning API, expects version 3 + def parse_new_locations(payload : String) : Nil + logger.debug { payload } if @debug_payload + + locations_updated = 0 + + # Parse the data posted + begin + observations = Array(Observation).from_json(payload) + logger.debug { "parsed meraki payload" } + + ignore_older = @max_location_age.ago.in Time::Location::UTC + drift_older = @drift_location_age.ago.in Time::Location::UTC + current_time = Time.utc + + observations.each do |observation| + client_mac = format_mac(observation.client_mac) + existing = @locations[client_mac]? + logger.debug { "parsing new observation for #{client_mac}" } if @debug_webhook + + # If a filter is set, then ignore this device unless it matches + if @regex_filter_device_os + # client.os has more accurate data (observation.os is usually nil for iPhones) + client = @client_details[format_mac(observation.client_mac)]? + if client.nil? || /#{@regex_filter_device_os}/.match(client.os || "").nil? + logger.debug { "FILTERED OUT #{client_mac}: OS \"#{observation.os}\" did not match \"#{@regex_filter_device_os}\"" } if @debug_webhook + next + end + end + location = parse(existing, ignore_older, drift_older, observation) + if location + @locations[client_mac] = location + locations_updated += 1 + end + update_ipv4(observation.ipv4, client_mac, current_time) + update_ipv6(observation.ipv6.try(&.downcase), client_mac, current_time) + end + rescue e + logger.error { "failed to parse meraki scanning API payload\n#{e.inspect_with_backtrace}" } + logger.debug { "failed payload body was\n#{payload}" } + end + + logger.debug { "updated #{locations_updated} locations" } + end + + protected def parse(existing, ignore_older, drift_older, observation) : DeviceLocation? + locations_raw = observation.locations + + # We'll attempt to return a location based on the nearest WAP + if locations_raw.empty? + last_seen = observation.latest_record + if wap_device = @network_devices[format_mac(last_seen.nearest_ap_mac)]? + return wap_device.location unless wap_device.location.nil? + + if floor_plan = @floorplan_sizes[wap_device.floor_plan_id.not_nil!]? + return wap_device.location = DeviceLocation.calculate_location(floor_plan, wap_device, last_seen.time) + end + end + return nil + end + + # existing.time is our ajusted time + if existing_time = existing.try &.time + existing = nil if existing_time < ignore_older + end + + # remove locations that don't have an x,y or very uncertain or very old + locations = locations_raw.reject do |loc| + loc.get_x.nil? || loc.variance > @maximum_uncertainty + end + + if locations.empty? + logger.debug { + if locations_raw.empty? + "ignored as no location data provided" + else + "ignored as no location in observation met minimum requirements, had coordinates: #{!!locations_raw[0].get_x}, uncertainty: #{locations_raw[0].variance}" + end + } if @debug_webhook + return existing + end + + # ensure oldest -> newest (we adjusted these already) + locations = locations.sort { |a, b| a.time <=> b.time } + + # estimate the location given the current observations + location = existing || locations.shift + locations.each do |new_loc| + next unless new_loc.time >= location.time + + # If acceptable then this is newer + if new_loc.variance < @acceptable_confidence + location = new_loc + next + end + + # if more accurate and newer then we'll take this + if new_loc.variance < location.variance + location = new_loc + location.variance = @override_min_variance if location.variance < @override_min_variance + next + end + + # should we drift the older location towards a less accurate newer location + if location.time < drift_older + # has the floor changed, we should probably accept the newer less accurate location + if location.floor_plan_id != new_loc.floor_plan_id + location = new_loc + next + end + + new_uncertainty = new_loc.variance + old_uncertainty = location.variance + + confidence_factor = 1.0 - (@confidence_multiplier * (new_uncertainty - @acceptable_confidence)) + confidence_factor = 0.0 if confidence_factor < 0 + + time_diff = new_loc.time.to_unix - location.time.to_unix + time_factor = @time_multiplier * (time_diff - @confidence_time.to_i).to_f + time_factor = 0.0 if time_factor < 0 + + # Average of the confidence factors + average_multiplier = (confidence_factor + time_factor) / 2.0 + + new_x = new_loc.x! + new_y = new_loc.y! + old_x = location.x! + old_y = location.y! + + # 7.5 = 5 + (( 10 - 5 ) * 0.5) + new_x = old_x + ((new_x - old_x) * average_multiplier) + new_y = old_y + ((new_y - old_y) * average_multiplier) + new_uncertainty = old_uncertainty + ((new_uncertainty - old_uncertainty) * average_multiplier) + + new_loc.x = new_x + new_loc.y = new_y + new_loc.variance = new_uncertainty < @override_min_variance ? @override_min_variance : new_uncertainty + + location = new_loc + end + end + + location + end + + protected def update_ipv4(ipv4, client_mac, current_time) + return unless ipv4 + + lookup = @ip_lookup[ipv4]? || Lookup.new(current_time, client_mac) + lookup.time = current_time + lookup.mac = client_mac + @ip_lookup[ipv4] = lookup + + if lookup = @ip_usernames[ipv4]? + username = lookup.mac + user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) } + end + end + + protected def update_ipv6(ipv6, client_mac, current_time) + return unless ipv6 + + lookup = @ip_lookup[ipv6]? || Lookup.new(current_time, client_mac) + lookup.time = current_time + lookup.mac = client_mac + @ip_lookup[ipv6] = lookup + + if lookup = @ip_usernames[ipv6]? + username = lookup.mac + user_mac_mappings { |storage| map_user_mac(client_mac, username, storage) } + end + end + + def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + # ip => {username, time} + @ip_usernames : Hash(String, Lookup) = {} of String => Lookup + + @[Security(PlaceOS::Driver::Level::Administrator)] + def ip_username_mappings(ip_map : Array(Tuple(String, String, String, String?))) : Nil + now = Time.utc + user_mac_mappings do |storage| + ip_map.each do |(ip, username, domain, hostname)| + username = format_username(username) + @ip_usernames[ip] = Lookup.new(now, username) + + if lookup = @ip_lookup[ip]? + map_user_mac(lookup.mac, username, storage) + end + end + end + end + + @[Security(PlaceOS::Driver::Level::Administrator)] + def mac_address_mappings(username : String, macs : Array(String), domain : String = "") + username = format_username(username) + user_mac_mappings do |storage| + macs.each { |mac| map_user_mac(format_mac(mac), username, storage) } + end + end + + # ====================== + # Sensor interface: + # ====================== + + protected def to_sensors(zone_id, filter, camera, details, building, level) + sensors = [] of Interface::Sensor::Detail + return sensors if zone_id && (building || level) && !zone_id.in?({building, level}) + + formatted_mac = format_mac(camera.mac) + + {SensorType::PeopleCount, SensorType::Presence}.each do |type| + next if filter && filter != type + + time = details.ts.to_unix + type_indicator = type.to_s.underscore.split('_', 2)[0] + + details.zones.each do |area_id, count| + value = case type + when SensorType::PeopleCount + count.person.to_f + when SensorType::Presence + count.person > 0 ? 1.0 : 0.0 + else + # Will never make it here + raise "unknown sensor" + end + + sensor = Interface::Sensor::Detail.new( + type: type, + value: value, + last_seen: time, + mac: camera.mac, + id: "#{area_id}-#{type_indicator}", + name: "#{camera.name} Presence: #{camera.model} (#{camera.serial})", + + module_id: module_id, + binding: "#{type_indicator}-#{formatted_mac}-#{area_id}" + ) + + sensor.building = building + sensor.level = level + sensors << sensor + end + end + + sensors + end + + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if type && !type.in?({"Presence", "PeopleCount"}) + filter = type ? SensorType.parse(type) : nil + + if mac + cam_state = @camera_analytics[format_mac(mac)]? + return NO_MATCH unless cam_state + return to_sensors(zone_id, filter, **cam_state) + end + + @camera_analytics.values.flat_map { |cam_data| to_sensors(zone_id, filter, **cam_data) } + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + + return nil unless id + cam_state = @camera_analytics[format_mac(mac)]? + return nil unless cam_state + + # https://crystal-lang.org/api/1.1.0/String.html#rpartition(search:Char%7CString):Tuple(String,String,String)-instance-method + area_str, _, sensor_type = id.rpartition('-') + + filter = case sensor_type + when "people" + SensorType::PeopleCount + when "presence" + SensorType::Presence + else + return nil + end + + area_id = area_str.to_i64? + return nil unless area_id + + zone_count = cam_state[:details].zones[area_id]?.try &.person + return nil unless zone_count + + to_sensors(nil, filter, **cam_state).find { |sensor| sensor.id == id } + end + + # ========== + # Desk data: + # ========== + # desk_id => [{time, occupied}] + getter desk_occupancy : Hash(String, Array(Tuple(Int64, Bool))) + + @desk_occupancy : Hash(String, Array(Tuple(Int64, Bool))) = Hash(String, Array(Tuple(Int64, Bool))).new do |hash, key| + hash[key] = Array(Tuple(Int64, Bool)).new(4) + end + + protected def average_results(serial, detected) + desks = @desk_mappings[serial]? + return unless desks + + time = Time.utc.to_unix + past = desk_data_expiry_time + + # id => Array({distance, occupied}) + results = Hash(String, Array(Tuple(Float64, Bool))).new { |h, k| h[k] = [] of Tuple(Float64, Bool) } + + # we store the closest desk point to the line, + # as this detected desk might be the occupancy we care about + detected.desks.each do |(lx, ly, cx, cy, rx, ry, occupancy)| + desks.each { |desk| desk.distance = calculate_distance(lx, ly, cx, cy, rx, ry, desk) } + if desk = desks.sort! { |a, b| a.distance <=> b.distance }.first? + results[desk.label] << {desk.distance, !occupancy.zero?} + end + end + + # then for each desk id, we take the closest detected desk and use that + # occupancy value + results.each do |desk_id, distances| + _distance, occupancy = distances.sort! { |a, b| a[0] <=> b[0] }.first + desk_occupation = @desk_occupancy[desk_id] + desk_occupation << {time, occupancy} + cleanup_old_data(desk_occupation, past) + end + end + + # We want to find the line closest to the offical desk point + protected def calculate_distance(lx, ly, cx, cy, rx, ry, desk) + desk = Point.new(desk.x, desk.y) + { + Point.new(lx, ly).distance_to(desk), + Point.new(rx, ry).distance_to(desk), + Point.new(cx, cy).distance_to(desk), + }.sum + end + + protected def desk_data_expiry_time + 90.seconds.ago.to_unix + end + + protected def cleanup_old_data(desk_occupation, expiry_time) + desk_occupation.reject! { |(time, _occupancy)| time < expiry_time } + end + + # ====================== + # Desk location service: + # ====================== + # zone_id => array of camera serials + @zone_lookup : Hash(String, Array(String)) = {} of String => Array(String) + + # camera serial => level + building + @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping + + # Camera serial => [desk location] + getter desk_mappings : Hash(String, Array(CameraZone)) = {} of String => Array(CameraZone) + + def desk_locations(zone_id : String) + serials = @zone_lookup[zone_id]? + return [] of Nil if !serials || serials.empty? + + return_empty_spaces = @return_empty_spaces + expiry_time = desk_data_expiry_time + + serials.compact_map { |serial| + desks = @desk_mappings[serial]? + next unless desks + + # does data exist for the desks? + next unless desk_details[serial]? + + floor = @floor_lookup[serial] + illumination = lux[serial]? + + desks.compact_map do |desk| + desk_id = desk.label + occupied = is_occupied?(desk_id, expiry_time) + + # Do we want to return empty desks (depends on the frontend) + next if !return_empty_spaces && occupied == 0 + + { + location: "desk", + at_location: occupied, + map_id: desk_id, + level: floor.level_id, + building: floor.building_id, + capacity: 1, + + area_lux: illumination, + merakimv: serial, + } + end + }.flatten + end + + def update_desk_mappings + desk_mappings = Hash(String, Array(CameraZone)).new + @floor_lookup.keys.each do |serial| + begin + desk_mappings[serial] = Array(CameraZone).from_json(dashboard.get_zones(serial).get.to_json).reject!(&.id.==("0")) + rescue error + logger.warn(exception: error) { "fetching zones for camera: #{serial}" } + end + end + + @desk_mappings = desk_mappings + + mqtt_module = system[:MerakiMQTT] + desk_mappings.keys.each { |camera_serial| check_camera_status(mqtt_module, camera_serial) } + end + + protected def desk_data_expiry_time + 90.seconds.ago.to_unix + end + + protected def is_occupied?(desk_id, expiry_time) + desk_occupation = @desk_occupancy[desk_id]? + return 0 unless desk_occupation + + occupied = 0 + desk_occupation.reject! do |(time, occupancy)| + if time < expiry_time + next true + elsif occupancy + occupied += 1 + end + false + end + + size = desk_occupation.size + return 0 if size.zero? + + # We care if the desk basically had signs of life + (occupied / size) > 0.3 ? 1 : 0 + end +end diff --git a/drivers/cisco/meraki/meraki_locations_spec.cr b/drivers/cisco/meraki/meraki_locations_spec.cr new file mode 100644 index 00000000000..5941f8d81dc --- /dev/null +++ b/drivers/cisco/meraki/meraki_locations_spec.cr @@ -0,0 +1,187 @@ +require "./scanning_api" +require "placeos-driver/spec" + +# :nodoc: +class DashboardMock < DriverSpecs::MockDriver + def fetch(location : String) + logger.info { "fetching: #{location}" } + case location + when "/api/v1/networks/network_id/floorPlans" + %([{"floorPlanId":"floor-123","name":"Level 1","width":30.5,"height":20,"topLeftCorner":{"lat":0,"lng":0},"bottomLeftCorner":{"lat":0,"lng":0},"bottomRightCorner":{"lat":0,"lng":0}}]) + when "/api/v1/networks/network_id/devices" + %([]) + when "/api/v1/devices/Q2HV-KAM-ETSG/camera/analytics/live" + %({ + "ts": "2021-08-09T23:56:52.236Z", + "zones": { + "582653201791058186": { + "person": 0 + }, + "582653201791058185": { + "person": 0 + }, + "0": { + "person": 0 + } + } + }) + else + %([]) + end + end + + def fetch_all(location : String) + [fetch(location)] + end + + def get_zones(serial : String) + logger.info { "ZONE REQ: request made for camera '#{serial}'" } + [{ + zoneId: "ignored", + type: "something", + label: "desk-1234", + regionOfInterest: { + x0: "0.44", + y0: "0.56", + x1: "0.44", + y1: "0.56", + }, + }] + end +end + +# :nodoc: +class MQTTMock < DriverSpecs::MockDriver + def on_load + self["camera_camera_serial_desks"] = { + "_v" => 2, + "time" => "2022-01-20 02:14:00", + "desks" => [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0], + ], + } + + self["camera_updated"] = {0, "camera_serial"} + + self["floor_lookup"] = { + "camera_serial" => { + camera_serials: ["camera_serial"], + level_id: "zone-123", + building_id: "zone-456", + }, + } + + self["zone_lookup"] = { + "zone-456" => {"camera_serial"}, + } + end + + def trigger_update + self["camera_updated"] = {1, "camera_serial"} + end +end + +DriverSpecs.mock_driver "Cisco::Meraki::Locations" do + system({ + Dashboard: {DashboardMock}, + MerakiMQTT: {MQTTMock}, + }) + + sleep 0.5 + + # Should standardise the format of MAC addresses + exec(:format_mac, "0x12:34:A6-789B").get.should eq %(1234a6789b) + + floors_raw = %({"g_727894289773756676": { + "floorPlanId": "g_727894289773756676", + "width": 84.73653902424, + "height": 55.321510873304, + "topLeftCorner": { + "lat": 25.20105494120424, + "lng": 55.27527794417147 + }, + "bottomLeftCorner": { + "lat": 25.20128402691947, + "lng": 55.27478983574903 + }, + "bottomRightCorner": { + "lat": 25.200607564298647, + "lng": 55.27440203743774 + }, + "name": "BUILDING - L3" + }, + "g_727894289773756679": { + "floorPlanId": "g_727894289773756679", + "width": 82.037895885132, + "height": 48.035263155936, + "topLeftCorner": { + "lat": 25.201070920997147, + "lng": 55.27523029269689 + }, + "bottomLeftCorner": { + "lat": 25.20126383588677, + "lng": 55.274803104166594 + }, + "bottomRightCorner": { + "lat": 25.200603702563107, + "lng": 55.27443896882145 + }, + "name": "Building - GF" + }}) + floors = Hash(String, Cisco::Meraki::FloorPlan).from_json(floors_raw) + + macs_raw = %({"683a1e545b0c": { + "floorPlanId": "g_727894289773756676", + "lat": 25.2011012305148, + "lng": 55.2749184519053, + "mac": "68:3a:1e:54:5b:0c", + "name": "1F-07", + "model": "MV22", + "firmware": "camera-4-13", + "serial": "Q2HV-KAM-ETSG" + }, + "683a1e5474ed": { + "floorPlanId": "g_727894289773756679", + "lat": 25.2008175846893, + "lng": 55.2746475487948, + "mac": "68:3a:1e:54:74:ed", + "name": "GF-29", + "model": "MV22", + "firmware": "camera-4-13", + "serial": "Q2HV-KAM-ETSG" + }}) + macs = Hash(String, Cisco::Meraki::NetworkDevice).from_json(macs_raw) + + macs.each do |_mac, wap_device| + floor_plan = floors[wap_device.floor_plan_id] + # do some unit testing + loc = Cisco::Meraki::DeviceLocation.calculate_location(floor_plan, wap_device, Time.utc) + loc.to_json + end + + exec(:camera_analytics, "Q2HV-KAM-ETSG").get.should eq({ + "ts" => "2021-08-09T23:56:52.236+0000", + "zones" => { + "582653201791058186" => {"person" => 0}, + "582653201791058185" => {"person" => 0}, + "0" => {"person" => 0}, + }, + }) + + exec(:device_locations, "zone-456").get.should eq([{ + "location" => "desk", + "at_location" => 0, + "map_id" => "desk-1234", + "level" => "zone-123", + "building" => "zone-456", + "capacity" => 1, + "area_lux" => nil, + "merakimv" => "camera_serial", + }]) +end diff --git a/drivers/cisco/meraki/mqtt.cr b/drivers/cisco/meraki/mqtt.cr new file mode 100644 index 00000000000..5cfac1ce7b6 --- /dev/null +++ b/drivers/cisco/meraki/mqtt.cr @@ -0,0 +1,322 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "placeos-driver/interface/locatable" +require "../../place/mqtt_transport_adaptor" +require "./mqtt_models" + +# documentation: https://developer.cisco.com/meraki/mv-sense/#!mqtt +# Use https://www.desmos.com/calculator for plotting points (sample code for copy and paste) +# data = [[1,2,3,4,5,6, 0]] +# data.each do |d| +# puts "(#{d[0]}, #{d[1]}),(#{d[2]}, #{d[3]}),(#{d[4]}, #{d[5]})" +# end + +class Cisco::Meraki::MQTT < PlaceOS::Driver + include Interface::Sensor + + descriptive_name "Meraki MQTT" + generic_name :MerakiMQTT + + tcp_port 1883 + description %(subscribes to Meraki MV Sense camera data) + + default_settings({ + username: "user", + password: "pass", + keep_alive: 60, + client_id: "placeos", + + floor_mappings: [ + { + camera_serials: ["1234", "camera_serial"], + level_id: "zone-123", + building_id: "zone-456", + }, + ], + }) + + SUBS = { + # Meraki desk occupancy (coords and occupancy are floats) + # {ts: unix_time, desks: [[lx, ly, rx, ry, cx, cy, occupancy], [...]]} + "/merakimv/+/net.meraki.detector", + + # lux levels on a camera + # {lux: float} + "/merakimv/+/light", + + # Number of entrances in the camera’s complete field of view + # {ts: unix_time, counts: {person: number, vehicle: number}} + "/merakimv/+/0", + } + + @keep_alive : Int32 = 60 + @username : String? = nil + @password : String? = nil + @client_id : String = "placeos" + + @mqtt : ::MQTT::V3::Client? = nil + @subs : Array(String) = [] of String + @transport : Place::TransportAdaptor? = nil + @sub_proc : Proc(String, Bytes, Nil) = Proc(String, Bytes, Nil).new { |_key, _payload| nil } + + @floor_lookup : Hash(String, FloorMapping) = {} of String => FloorMapping + + def on_load + @sub_proc = Proc(String, Bytes, Nil).new { |key, payload| on_message(key, payload) } + on_update + end + + def on_unload + end + + def on_update + @username = setting?(String, :username) + @password = setting?(String, :password) + @keep_alive = setting?(Int32, :keep_alive) || 60 + @client_id = setting?(String, :client_id) || ::MQTT.generate_client_id("placeos_") + + # zone_id => camera serial + zone_lookup = Hash(String, Array(String)).new { |h, k| h[k] = [] of String } + # camera serial => level + building + floor_lookup = {} of String => FloorMapping + floor_mappings = setting?(Array(FloorMapping), :floor_mappings) || [] of FloorMapping + floor_mappings.each do |mapping| + mapping.camera_serials.each do |serial| + zone_lookup[mapping.level_id] << serial + zone_lookup[mapping.building_id.not_nil!] << serial if mapping.building_id + floor_lookup[serial] = mapping + end + end + self[:floor_lookup] = @floor_lookup = floor_lookup + self[:zone_lookup] = zone_lookup + + existing = @subs + @subs = SUBS.to_a + + schedule.clear + schedule.every((@keep_alive // 3).seconds) { ping } + + if client = @mqtt + unsub = existing - @subs + newsub = @subs - existing + + unsub.each do |sub| + logger.debug { "unsubscribing to #{sub}" } + client.unsubscribe(sub) + end + + newsub.each do |sub| + logger.debug { "subscribing to #{sub}" } + client.subscribe(sub, &@sub_proc) + end + end + end + + def connected + transp = Place::TransportAdaptor.new(transport, queue) + client = ::MQTT::V3::Client.new(transp) + @transport = transp + @mqtt = client + + logger.debug { "sending connect message" } + client.connect(@username, @password, @keep_alive, @client_id) + @subs.each do |sub| + logger.debug { "subscribing to #{sub}" } + client.subscribe(sub, &@sub_proc) + end + end + + def disconnected + @transport = nil + @mqtt = nil + end + + def ping + logger.debug { "sending ping" } + @mqtt.not_nil!.ping + end + + def received(data, task) + logger.debug { "received #{data.size} bytes: 0x#{data.hexstring}" } + @transport.try &.process(data) + task.try &.success + end + + getter people_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do + Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key| + hash[key] = {} of String => Tuple(Float64, Int64) + end + end + + getter vehicle_counts : Hash(String, Hash(String, Tuple(Float64, Int64))) do + Hash(String, Hash(String, Tuple(Float64, Int64))).new do |hash, key| + hash[key] = {} of String => Tuple(Float64, Int64) + end + end + + getter lux : Hash(String, Tuple(Float64, Int64)) = {} of String => Tuple(Float64, Int64) + + # this is where we do all of the MQTT message processing + protected def on_message(key : String, playload : Bytes) : Nil + json_message = String.new(playload) + key = key[1..-1] if key.starts_with?("/") + + logger.debug { "new message: #{key} = #{json_message}" } + _merakimv, serial_no, status = key.split("/") + + case status + when "net.meraki.detector" + # we assume version 3 of the API here for sanity reasons + detected_desks = DetectedDesks.from_json(json_message) + self["camera_#{serial_no}_desks"] = detected_desks + self["camera_updated"] = {Time.utc.to_unix, serial_no} + when "light" + light = LuxLevel.from_json(json_message) + lux[serial_no] = {light.lux, light.timestamp} + self["camera_#{serial_no}_lux"] = light.lux + else + # Everything else is a zone count + entry = Entrances.from_json json_message + case entry.count_type + in CountType::People + people_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix} + in CountType::Vehicles + vehicle_counts[serial_no][status] = {entry.count.to_f64, Time.unix_ms(entry.timestamp).to_unix} + in CountType::Unknown + # ignore + end + self["camera_#{serial_no}_zone#{status}_#{entry.count_type.to_s.downcase}"] = entry.count + end + end + + # ---------------- + # Sensor Interface + # ---------------- + + # return the specified sensor details + def sensor(mac : String, id : String? = nil) : Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + + if id == "lux" + add_lux_values([] of Detail, mac).first? + elsif id.starts_with? "zone" + zone, count_type = id.split('_', 2) + zone = zone[4..-1] # remove the word "zone" + + sensor_type = SensorType::PeopleCount + lookup = case count_type + when "people" + people_counts + when "vehicles" + sensor_type = SensorType::Counter + vehicle_counts + end + + if lookup + if counts = lookup[mac]? + if count = counts[zone]? + to_sensor(sensor_type, mac, "zone#{zone}_#{count_type}", count[0], count[1]) + end + end + end + else + nil + end + end + + NO_MATCH = [] of Interface::Sensor::Detail + LUX_ID = "lux" + + # return an array of sensor details + # zone_id can be ignored if location is unknown by the sensor provider + # mac_address can be used to grab data from a single device (basic grouping) + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + serial_filter = nil + if zone_id && !@floor_lookup.empty? + serial_filter = [] of String + @floor_lookup.each do |serial, floor| + serial_filter << serial if {floor.level_id, floor.building_id}.includes?(zone_id) + end + end + + sensors = [] of Detail + filter = type ? Interface::Sensor::SensorType.parse?(type) : nil + + case filter + when nil + add_lux_values(sensors, mac, serial_filter) + add_people_counts(sensors, mac, serial_filter) + add_vehicle_counts(sensors, mac, serial_filter) + when .people_count? + add_people_counts(sensors, mac, serial_filter) + when .counter? + add_vehicle_counts(sensors, mac, serial_filter) + when .illuminance? + add_lux_values(sensors, mac, serial_filter) + else + sensors + end + rescue error + logger.warn(exception: error) { "searching for sensors" } + NO_MATCH + end + + protected def add_people_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + people_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, mac, "zone#{zone_name}_people", count, time) } + else + people_counts.each do |serial, zones| + next if serial_filter && !serial_filter.includes?(serial) + zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::PeopleCount, serial, "zone#{zone_name}_people", count, time) } + end + end + sensors + end + + protected def add_vehicle_counts(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + vehicle_counts[mac]?.try &.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, mac, "zone#{zone_name}_vehicles", count, time) } + else + vehicle_counts.each do |serial, zones| + next if serial_filter && !serial_filter.includes?(serial) + zones.each { |zone_name, (count, time)| sensors << to_sensor(SensorType::Counter, serial, "zone#{zone_name}_vehicles", count, time) } + end + end + sensors + end + + protected def add_lux_values(sensors, mac : String? = nil, serial_filter : Array(String)? = nil) + if mac + return sensors if serial_filter && !serial_filter.includes?(mac) + if lux_val = lux[mac]? + level, time = lux_val + sensors << to_sensor(SensorType::Illuminance, mac, LUX_ID, level, time) + end + else + lux.each do |serial, (level, time)| + next if serial_filter && !serial_filter.includes?(serial) + sensors << to_sensor(SensorType::Illuminance, serial, LUX_ID, level, time) + end + end + sensors + end + + protected def to_sensor(sensor_type, serial, id, value, timestamp) : Interface::Sensor::Detail + Interface::Sensor::Detail.new( + type: sensor_type, + value: value, + last_seen: timestamp, + mac: serial, + id: id, + name: "Meraki Camera #{serial}: #{id}", + module_id: module_id, + binding: "camera_#{serial}_#{id}", + unit: sensor_type.illuminance? ? "lx" : nil + ) + end +end diff --git a/drivers/cisco/meraki/mqtt_models.cr b/drivers/cisco/meraki/mqtt_models.cr new file mode 100644 index 00000000000..b10de7d0521 --- /dev/null +++ b/drivers/cisco/meraki/mqtt_models.cr @@ -0,0 +1,74 @@ +require "json" + +# Meraki MQTT Data Models +module Cisco::Meraki + class FloorMapping + include JSON::Serializable + + getter camera_serials : Array(String) + getter level_id : String + getter building_id : String? + end + + class DetectedDesks + include JSON::Serializable + + @[JSON::Field(key: "_v")] + getter api_version : Int32 + + # Time in milliseconds v3, + @[JSON::Field(key: "ts")] + getter time_unix : Int64? + + @[JSON::Field(key: "time")] + getter time_string : String? + + getter desks : Array(Tuple(Float64, Float64, # left +Float64, Float64, # center +Float64, Float64, # right +Float64 # occupancy +)) + end + + class LuxLevel + include JSON::Serializable + + # Not actually provided for this message, but here for testing + @[JSON::Field(key: "ts")] + getter timestamp : Int64 { Time.utc.to_unix } + + getter lux : Float64 + end + + enum CountType + People + Vehicles + Unknown + end + + class Entrances + include JSON::Serializable + + @[JSON::Field(key: "ts")] + getter timestamp : Int64 + + getter counts : NamedTuple( + person: Int32?, + vehicle: Int32?, + ) + + @[JSON::Field(ignore: true)] + getter count_type : CountType do + if counts[:person] + CountType::People + elsif counts[:vehicle] + CountType::Vehicles + else + CountType::Unknown + end + end + + @[JSON::Field(ignore: true)] + getter count : Int32 { counts[:person] || counts[:vehicle] || 0 } + end +end diff --git a/drivers/cisco/meraki/mqtt_spec.cr b/drivers/cisco/meraki/mqtt_spec.cr new file mode 100644 index 00000000000..8692f91d7b4 --- /dev/null +++ b/drivers/cisco/meraki/mqtt_spec.cr @@ -0,0 +1,149 @@ +require "placeos-driver/spec" +require "mqtt" + +DriverSpecs.mock_driver "Place::MQTT" do + # ============================ + # CONNECTION + # ============================ + puts "===== CONNECTION NEGOTIATION =====" + connect = MQTT::V3::Connect.new + connect.id = MQTT::RequestType::Connect + connect.keep_alive_seconds = 60_u16 + connect.client_id = "placeos" + connect.clean_start = true + connect.username = "user" + connect.password = "pass" + connect.packet_length = connect.calculate_length + should_send(connect.to_slice) + + connack = MQTT::V3::Connack.new + connack.id = MQTT::RequestType::Connack + connack.packet_length = connack.calculate_length + responds(connack.to_slice) + + # ============================ + # SUBSCRIPTIONS + # ============================ + puts "===== CHECKING DESKS SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 2_u16 + packet.topic = "/merakimv/+/net.meraki.detector" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 2_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + puts "===== CHECKING LUX SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 4_u16 + packet.topic = "/merakimv/+/light" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 4_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + puts "===== CHECKING COUNTS SUBSCRIPTION =====" + packet = MQTT::V3::Subscribe.new + packet.id = MQTT::RequestType::Subscribe + packet.qos = MQTT::QoS::BrokerReceived + packet.message_id = 6_u16 + packet.topic = "/merakimv/+/0" + packet.packet_length = packet.calculate_length + should_send(packet.to_slice) + + suback = MQTT::V3::Suback.new + suback.id = MQTT::RequestType::Suback + suback.message_id = 6_u16 + suback.return_codes = [MQTT::QoS::FireAndForget] + suback.packet_length = suback.calculate_length + responds(suback.to_slice) + + # ============================ + # REMOTE PUBLISH + # ============================ + puts "===== REMOTE PUBLISH =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/1234/light" + publish.payload = %({"lux":33.2,"ts":1642564552}) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_1234_lux"].should eq(33.2) + + # ============================ + # CHECK SENSOR INTERFACE + # ============================ + lux_sensor = { + "status" => "normal", + "type" => "illuminance", + "value" => 33.2, + "last_seen" => 1642564552, + "mac" => "1234", + "id" => "lux", + "name" => "Meraki Camera 1234: lux", + "module_id" => "spec_runner", + "binding" => "camera_1234_lux", + "unit" => "lx", + "location" => "sensor", + } + exec(:sensors).get.should eq([lux_sensor]) + exec(:sensor, "1234", "lux").get.should eq(lux_sensor) + + # ============================ + # CHECK LOCATABLE INTERFACE + # ============================ + puts "===== CHECKING LOCATABLE INTERFACE =====" + publish = MQTT::V3::Publish.new + publish.id = MQTT::RequestType::Publish + publish.message_id = 8_u16 + publish.topic = "/merakimv/camera_serial/net.meraki.detector" + publish.payload = %({ + "_v": 2, + "time": "2022-01-20 02:14:00", + "coords":[], + "desks": [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0] + ] + }) + publish.packet_length = publish.calculate_length + + transmit publish.to_slice + sleep 0.1 # wait a bit for processing + status["camera_1234_lux"].should eq(33.2) + status["camera_camera_serial_desks"].should eq({ + "_v" => 2, + "time" => "2022-01-20 02:14:00", + "desks" => [ + [185, 282, 227, 211, 272, 158, 0], + [376, 197, 321, 268, 264, 365, 0], + [401, 450, 460, 355, 499, 273, 0], + [572, 348, 547, 414, 506, 483, 0], + [312, 571, 259, 546, 210, 515, 0], + [536, 492, 494, 529, 446, 560, 0], + [137, 542, 162, 573, 189, 597, 0], + ], + }) +end diff --git a/drivers/cisco/meraki/scanning_api.cr b/drivers/cisco/meraki/scanning_api.cr new file mode 100644 index 00000000000..ad1bbf6c9c4 --- /dev/null +++ b/drivers/cisco/meraki/scanning_api.cr @@ -0,0 +1,335 @@ +require "json" +require "./geo" + +module Cisco::Meraki + ISO8601 = "%FT%T%z" + + class Organization + include JSON::Serializable + + property id : String + property name : String + property url : String + property api : NamedTuple(enabled: Bool) + end + + class Network + include JSON::Serializable + + property id : String + + @[JSON::Field(key: "organizationId")] + property organization_id : String + + property name : String + + @[JSON::Field(key: "productTypes")] + property product_types : Array(String) + + @[JSON::Field(key: "timeZone")] + property time_zone : String + property tags : Array(String) + property url : String + + @[JSON::Field(key: "enrollmentString")] + property enrollment_string : String? + property notes : String? + end + + class CameraAnalytics + include JSON::Serializable + ISO8601_MS = "%FT%T.%3N%z" + + class PeopleCount + include JSON::Serializable + + property person : Int32 + end + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::CameraAnalytics::ISO8601_MS))] + property ts : Time + property zones : Hash(Int64, PeopleCount) + end + + class FloorPlan + include JSON::Serializable + + @[JSON::Field(key: "floorPlanId")] + property id : String + property width : Float64 + property height : Float64 + + @[JSON::Field(key: "topLeftCorner")] + property top_left : Geo::Point + + @[JSON::Field(key: "bottomLeftCorner")] + property bottom_left : Geo::Point + + @[JSON::Field(key: "bottomRightCorner")] + property bottom_right : Geo::Point + + # This is useful for when we have to map meraki IDs to our zones + property name : String? + + def to_distance + Geo::Distance.new(width, height) + end + end + + class FloorPlanLocation + include JSON::Serializable + + property id : String + property name : String + property x : Float64 + property y : Float64 + end + + class NetworkDevice + include JSON::Serializable + + # Used for caching the location calculated for this device + # where an observation doesn't have location values but has a closest WAP + @[JSON::Field(ignore: true)] + property location : DeviceLocation? + + @[JSON::Field(key: "floorPlanId")] + property floor_plan_id : String? + + property lat : Float64 + property lng : Float64 + property mac : String + + property serial : String + property model : String + property firmware : String + + # This is useful for when we have to map meraki IDs to our zones + property name : String? + end + + class Client + include JSON::Serializable + + property id : String + property mac : String + property description : String? + + property ip : String? + property ip6 : String? + + @[JSON::Field(key: "ip6Local")] + property ip6_local : String? + + property user : String? + + # 2020-09-29T07:53:08Z + @[JSON::Field(key: "firstSeen")] + property first_seen : String + + @[JSON::Field(key: "lastSeen")] + property last_seen : String + + property manufacturer : String? + property os : String? + + @[JSON::Field(key: "recentDeviceMac")] + property recent_device_mac : String? + property ssid : String? + property vlan : String? + property switchport : String? + property status : String + property notes : String? + + @[JSON::Field(ignore: true)] + property! time_added : Time + end + + class RSSI + include JSON::Serializable + + @[JSON::Field(key: "apMac")] + property access_point_mac : String + property rssi : Int32 + end + + class DeviceLocation + include JSON::Serializable + + def initialize(@x, @y, @lng, @lat, @variance, floor_plan_id, floor_plan_name, @time) + @wifi_floor_plan_name = floor_plan_name + @wifi_floor_plan_id = floor_plan_id + @mac = nil + @client = nil + @rssi_records = [] of RSSI + end + + def self.calculate_location(floor : FloorPlan, device : NetworkDevice, time : Time) : DeviceLocation + distance = Geo.calculate_xy(floor.top_left, floor.bottom_left, floor.bottom_right, device, floor.to_distance) + DeviceLocation.new(distance.x, distance.y, device.lng, device.lat, 25_f64, floor.id, floor.name, time) + end + + # NOTE:: This is not part of the location response, + # it is here to simplify processing + @[JSON::Field(ignore: true)] + property mac : String? + + # NOTE:: this is not part of the location response, + # it is here to speed up processing + @[JSON::Field(ignore: true)] + property client : Client? = nil + + # Multiple types as the location when parsed might include javascript `"NaN"` + property x : Float64 | String | Nil + property y : Float64 | String | Nil + property lng : Float64? + property lat : Float64? + property variance : Float64 + + @[JSON::Field(key: "floorPlanId")] + property wifi_floor_plan_id : String? + + @[JSON::Field(key: "floorPlanName")] + property wifi_floor_plan_name : String? + + @[JSON::Field(key: "floorPlan")] + property floor_plan : FloorPlanLocation? + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))] + property time : Time + + @[JSON::Field(key: "nearestApTags")] + property nearest_ap_tags : Array(String) { [] of String } + + @[JSON::Field(key: "rssiRecords")] + property rssi_records : Array(RSSI) + + def x! + get_x.not_nil! + end + + def y! + get_y.not_nil! + end + + def get_x : Float64? + if tmp = x || floor_plan.try(&.x) + if tmp.is_a?(Float64) + tmp + end + end + end + + def get_y : Float64? + if tmp = y || floor_plan.try(&.y) + if tmp.is_a?(Float64) + tmp + end + end + end + + def floor_plan_id + wifi_floor_plan_id || floor_plan.try(&.id) + end + + def floor_plan_name + wifi_floor_plan_name || floor_plan.try(&.name) + end + end + + class LatestRecord + include JSON::Serializable + + @[JSON::Field(key: "nearestApMac")] + property nearest_ap_mac : String + + @[JSON::Field(key: "nearestApRssi")] + property nearest_ap_rssi : Int32 + + @[JSON::Field(converter: Time::Format.new(Cisco::Meraki::ISO8601))] + property time : Time + end + + class Observation + include JSON::Serializable + + @[JSON::Field(key: "clientMac")] + property client_mac : String + + property manufacturer : String? + property ipv4 : String? + property ipv6 : String? + property ssid : String? + property os : String? + + @[JSON::Field(key: "latestRecord")] + property latest_record : LatestRecord + property locations : Array(DeviceLocation) + end + + class Data + include JSON::Serializable + + @[JSON::Field(key: "networkId")] + property network_id : String + property observations : Array(Observation) + end + + enum MessageType + None + WiFi + Bluetooth + end + + class DevicesSeen + include JSON::Serializable + + property version : String + property secret : String + + @[JSON::Field(key: "type")] + property message_type : MessageType + + property data : Data + end + + struct CameraZone + include JSON::Serializable + + struct Region + include JSON::Serializable + + getter x0 : String + getter y0 : String + getter x1 : String + getter y1 : String + end + + @[JSON::Field(key: "zoneId")] + getter id : String + getter type : String + getter label : String + + @[JSON::Field(key: "regionOfInterest")] + getter region : Region + + @[JSON::Field(ignore: true)] + property distance : Float64 = 0.0 + + def mid_point + mid_x = (region.x0.to_f64 + region.x1.to_f64) / 2.0 + mid_y = (region.y0.to_f64 + region.y1.to_f64) / 2.0 + {mid_x, mid_y} + end + + getter x : Float64 do + xpos, @y = mid_point + xpos + end + + getter y : Float64 do + @x, ypos = mid_point + ypos + end + end +end diff --git a/drivers/cisco/room_kit.cr b/drivers/cisco/room_kit.cr new file mode 100644 index 00000000000..ffd3f734255 --- /dev/null +++ b/drivers/cisco/room_kit.cr @@ -0,0 +1,385 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "promise" +require "uuid" + +require "./collaboration_endpoint" +require "./collaboration_endpoint/ui_extensions" +require "./collaboration_endpoint/presentation" +require "./collaboration_endpoint/powerable" +require "./collaboration_endpoint/cameras" + +class Cisco::RoomKit < PlaceOS::Driver + include Interface::Sensor + + # Discovery Information + descriptive_name "Cisco Room Kit" + generic_name :VidConf + tcp_port 22 + + description <<-DESC + Control of Cisco SX20 devices. + + API access requires a local user with the "admin" role to be + created on the codec. + DESC + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + peripheral_id: "uuid", + configuration: { + "RoomAnalytics" => { + "PeopleCountOutOfCall" => "On", + "PeoplePresenceDetector" => "On", + "WakeupOnMotionDetection" => "On", + }, + }, + presets: { + "Front Lecturn": 1, + }, + }) + + include Cisco::CollaborationEndpoint + include Cisco::CollaborationEndpoint::UIExtensions + include Cisco::CollaborationEndpoint::Presentation + include Cisco::CollaborationEndpoint::Powerable + include Cisco::CollaborationEndpoint::Cameras + + enum PresentationMode + None + Local + Remote + end + + @presentation_mode : PresentationMode = PresentationMode::None + @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new + + def connected + super + schedule.in(40.seconds) { disconnect if self["calls"]?.nil? } + end + + protected def connection_ready + subscriptions.clear + subscribe("presentation") do |_sub, state| + if state != "null" + # presentation is typically false or "Sending" + if state == "false" + self[:presentation_mode] = @presentation_mode + else + self[:presentation_mode] = PresentationMode::Remote + end + end + end + + register_feedback "/Event/PresentationPreviewStarted" do + self[:presentation_mode] = PresentationMode::Local + end + register_feedback "/Event/PresentationPreviewStopped" do + @presentation_mode = PresentationMode::None + self[:presentation_mode] = @presentation_mode if self[:presentation]? == false + end + + @calls = Hash(String, Hash(String, Enumerable::JSONComplex)).new do |hash, key| + hash[key] = {} of String => Enumerable::JSONComplex + end + self[:calls] = @calls + register_feedback "/Status/Call" do |value_path, value| + if value.is_a? Hash(String, Enumerable::JSONComplex) + if value["Status"]? == "Idle" || value["ghost"]? == "True" + @calls.delete value_path + else + @calls[value_path].merge! value + end + self[:calls] = @calls + else + logger.debug { "unexpected call status value #{value}" } + end + end + end + + map_status mic_mute: "Audio Microphones Mute" + map_status volume: "Audio Volume" + map_status speaker_track: "Cameras SpeakerTrack" + map_status presence_detected: "RoomAnalytics PeoplePresence" + map_status people_count: "RoomAnalytics PeopleCount Current" + map_status do_not_disturb: "Conference DoNotDisturb" + map_status presentation: "Conference Presentation Mode" + map_status peripherals: "Peripherals ConnectedDevice" + # selfview == camera pip + map_status selfview: "Video Selfview Mode" + map_status selfview_fullscreen: "Video Selfview FullScreenMode" + map_status video_input: "Video Input" + map_status video_output: "Video Output" + map_status video_layout: "Video Layout LayoutFamily Local" + map_status standby: "Standby State" + + command({"Audio Microphones Mute" => :mic_mute_on}) + command({"Audio Microphones Unmute" => :mic_mute_off}) + command({"Audio Microphones ToggleMute" => :mic_mute_toggle}) + + def mic_mute(state : Bool = true) + state ? mic_mute_on : mic_mute_off + end + + enum Toogle + On + Off + end + + enum Sound + Alert + Bump + Busy + CallDisconnect + CallInitiate + CallWaiting + Dial + KeyInput + KeyInputDelete + KeyTone + Nav + NavBack + Notification + OK + PresentationConnect + Ringing + SignIn + SpecialInfo + TelephoneCall + VideoCall + VolumeAdjust + WakeUp + end + + command({"Audio Sound Play" => :play_sound}, + sound: Sound, + loop_: Toogle) + command({"Audio Sound Stop" => :stop_sound}) + + command({"Bookings List" => :bookings}, + days_: 1..365, + day_offset_: 0..365, + limit_: Int32, + offset_: Int32) + + command({"Call Accept" => :call_accept}, call_id_: Int32) + command({"Call Reject" => :call_reject}, call_id_: Int32) + command({"Call Disconnect" => :hangup}, call_id_: Int32) + command({"Call Hold" => :call_place_on_hold}, call_id_: Int32) + command({"Call Resume" => :call_resume}, call_id_: Int32) + + command({"Call DTMFSend" => :dtmf_send}, + d_t_m_f_string: String, + call_id_: 0..65534) + + enum DialProtocol + H320 + H323 + Sip + Spark + end + + enum CallType + Audio + Video + end + + command({"Dial" => :dial}, + number: String, + protocol_: DialProtocol, + call_rate_: 64..6000, + call_type_: CallType) + + enum VideoLayout + Equal + PIP + end + + command({"Video Input SetMainVideoSource" => :camera_select}, + connector_id_: 1..3, # Source can either be specified as the + layout_: VideoLayout, # physical connector... + source_id_: 1..3) # ...or the logical source ID + + enum LayoutFamily + Auto + Equal + Overlay + Prominent + Single + end + + enum LayoutTarget + Local + Remote + end + + command({"Video Layout LayoutFamily Set" => :video_layout}, + layout_family: LayoutFamily, + target_: LayoutTarget) + + enum PiPPosition + CenterLeft + CenterRight + LowerLeft + LowerRight + UpperCenter + UpperLeft + UpperRight + end + + enum MonitorRole + First + Second + Third + Fourth + end + + command({"Video Selfview Set" => :selfview}, + mode_: Toogle, + full_screen_mode_: Toogle, + p_i_p_position_: PiPPosition, + on_monitor_role_: MonitorRole) + + @[Security(Level::Support)] + command({"Cameras AutoFocus Diagnostics Start" => :autofocus_diagnostics_start}, + camera_id: 1..1) + + @[Security(Level::Support)] + command({"Cameras AutoFocus Diagnostics Stop" => :autofocus_diagnostics_stop}, + camera_id: 1..1) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Diagnostics Start" => :speaker_track_diagnostics_start}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Diagnostics Stop" => :speaker_track_diagnostics_stop}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Activate" => :speaker_track_activate}) + + @[Security(Level::Support)] + command({"Cameras SpeakerTrack Deactivate" => :speaker_track_deactivate}) + + def speaker_track(state : Bool = true) + state ? speaker_track_activate : speaker_track_deactivate + end + + enum PhonebookType + Corporate + Local + end + + command({"Phonebook Search" => :phonebook_search}, + search_string: String, + phonebook_type_: PhonebookType, + limit_: Int32, + offset_: Int32) + + command({"UserInterface WebView Display" => :webview_display}, + url: String) + + command({"UserInterface WebView Clear" => :webview_clear}) + + @[Security(Level::Support)] + command({"SystemUnit Boot" => :reboot}, action_: PowerOff) + + # Helper methods + # ============== + + def show_camera_pip(visible : Bool) + mode = visible ? Toogle::On : Toogle::Off + selfview mode: mode + end + + def mic_mute(state : Bool = true) + state ? mic_mute_on : mic_mute_off + end + + def presentation_mode(value : PresentationMode) + case value + in .remote? + presentation_start sending_mode: :LocalRemote + in .local? + @presentation_mode = PresentationMode::Local + presentation_start sending_mode: :LocalOnly + in .none? + @presentation_mode = PresentationMode::None + presentation_stop + end + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::PeopleCount, SensorType::Presence} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if mac && mac != config.ip + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + + if sensor_type + sensor = build_sensor_details(sensor_type) + return NO_MATCH unless sensor + [sensor] + else + space_sensors + end + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless id + return nil unless mac == config.ip + + case id + when "people" + build_sensor_details(:people_count) + when "presence" + build_sensor_details(:presence) + end + end + + protected def build_sensor_details(sensor : SensorType) : Detail? + id = "people_count" + + value = case sensor + when .people_count? + self[:people_count].as_i.to_f64 + when .presence? + id = "presence_detected" + self[:presence_detected] == "No" ? 0.0 : 1.0 + else + raise "sensor type unavailable: #{sensor}" + end + return nil unless value + + Detail.new( + type: sensor, + value: value, + last_seen: Time.utc.to_unix, + mac: config.ip.as(String), + id: id, + name: "Cisco Room Kit (#{config.ip})", + module_id: module_id, + binding: id + ) + end + + protected def space_sensors + [ + build_sensor_details(:people_count), + build_sensor_details(:presence), + ].compact + end +end diff --git a/drivers/cisco/room_os.cr b/drivers/cisco/room_os.cr new file mode 100644 index 00000000000..ec3af9fa146 --- /dev/null +++ b/drivers/cisco/room_os.cr @@ -0,0 +1,42 @@ +require "placeos-driver" +require "promise" +require "uuid" + +require "./collaboration_endpoint" +require "./collaboration_endpoint/ui_extensions" + +class Cisco::RoomOS < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Room OS" + generic_name :RoomOS + tcp_port 22 + + description <<-DESC + Low level driver for any Cisco Room OS device. This may be used + if direct access is required to the device API, or a required feature + is not provided by the device specific implementation. + + Where possible use the implementation for room device in use + i.e. SX80, Room Kit etc. + DESC + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + peripheral_id: "uuid", + configuration: { + "Audio Microphones Mute" => {"Enabled" => "False"}, + "Audio Input Line 1 VideoAssociation" => { + "MuteOnInactiveVideo" => "On", + "VideoInputSource" => 2, + }, + }, + }) + + include Cisco::CollaborationEndpoint + include Cisco::CollaborationEndpoint::UIExtensions + + map_status volume: "Audio Volume" +end diff --git a/drivers/cisco/room_os_spec.cr b/drivers/cisco/room_os_spec.cr new file mode 100644 index 00000000000..9e15843da94 --- /dev/null +++ b/drivers/cisco/room_os_spec.cr @@ -0,0 +1,608 @@ +require "placeos-driver/spec" +require "./collaboration_endpoint/xapi" + +DriverSpecs.mock_driver "Cisco::RoomOS" do + # Test command generation helpers + action = Cisco::CollaborationEndpoint::XAPI.xcommand( + "Camera PositionSet", + camera_id: 1, + lens: "Wide", + optional: nil + ) + action.should eq(%(xCommand Camera PositionSet CameraId: 1 Lens: "Wide")) + + action = Cisco::CollaborationEndpoint::XAPI.xcommand( + "Audio Volume Decrease" + ) + action.should eq(%(xCommand Audio Volume Decrease)) + + # Test the response processing helpers + response = JSON.parse(%({ + "Configuration":{ + "Audio":{ + "DefaultVolume":{ + "valueSpaceRef":"/Valuespace/INT_0_100", + "Value":"50" + }, + "Input":{ + "Line":[ + { + "id":"1", + "VideoAssociation":{ + "MuteOnInactiveVideo":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "VideoInputSource":{ + "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2", + "Value":"2" + } + } + } + ], + "Microphone":[ + { + "id":"AAA", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + { + "id":"2", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + } + ] + }, + "Microphones":{ + "Mute":{ + "Enabled":{ + "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled", + "Value":"True" + } + } + } + } + } + })).as_h.flatten_xapi_json + response.should eq({ + "Configuration/Audio/DefaultVolume" => 50, + "Configuration/Audio/Input/Line/1" => { + "VideoAssociation/MuteOnInactiveVideo" => true, + "VideoAssociation/VideoInputSource" => 2, + }, + "Configuration/Audio/Input/Microphone/AAA" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "Configuration/Audio/Input/Microphone/2" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "Configuration/Audio/Microphones/Mute/Enabled" => true, + }) + + transmit "welcome\n*r Login successful\r\n" + + # ==== + # Connection setup + puts "\nCONNECTION SETUP:\n==============" + should_send "xPreferences OutputMode JSON\n" + should_send "xPreferences OutputMode JSON\n" + + # ==== + # System registration + puts "\nSYSTEM REGISTRATION:\n==============" + + data = String.new expect_send + data.starts_with?(%(xCommand Peripherals Connect ID: "uuid" Name: "PlaceOS" Type: ControlSystem | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "PeripheralsConnectResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + + # ==== + # Config push + puts "\nCONFIG PUSH:\n==============" + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Microphones Mute Enabled: "False" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation MuteOnInactiveVideo: "On" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Audio Input Line 1 VideoAssociation VideoInputSource: 2 | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + # MAPS Status ==== + data = String.new expect_send + data.starts_with?(%(xFeedback Register /Configuration | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + should_send "xConfiguration *\n" + responds %({ + "Configuration":{ + "Audio":{ + "DefaultVolume":{ + "valueSpaceRef":"/Valuespace/INT_0_100", + "Value":"50" + }, + "Input":{ + "Line":[ + { + "id":"1", + "VideoAssociation":{ + "MuteOnInactiveVideo":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "VideoInputSource":{ + "valueSpaceRef":"/Valuespace/TTPAR_PresentationSources_2", + "Value":"2" + } + } + } + ], + "Microphone":[ + { + "id":"1", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + { + "id":"2", + "EchoControl":{ + "Dereverberation":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"Off" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + }, + "NoiseReduction":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + }, + "Level":{ + "valueSpaceRef":"/Valuespace/INT_0_24", + "Value":"14" + }, + "Mode":{ + "valueSpaceRef":"/Valuespace/TTPAR_OnOff", + "Value":"On" + } + } + ] + }, + "Microphones":{ + "Mute":{ + "Enabled":{ + "valueSpaceRef":"/Valuespace/TTPAR_MuteEnabled", + "Value":"True" + } + } + } + } + } + }) + + status[:configuration].should eq({ + "/Audio/DefaultVolume" => 50, + "/Audio/Input/Line/1" => { + "VideoAssociation/MuteOnInactiveVideo" => true, + "VideoAssociation/VideoInputSource" => 2, + }, + "/Audio/Input/Microphone/1" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "/Audio/Input/Microphone/2" => { + "EchoControl/Dereverberation" => false, + "EchoControl/Mode" => true, + "EchoControl/NoiseReduction" => true, + "Level" => 14, + "Mode" => true, + }, + "/Audio/Microphones/Mute/Enabled" => true, + }) + + data = String.new expect_send + puts "GOT: #{data}" + data.starts_with?(%(xFeedback Register /Status/Audio/Volume | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + puts "GOT: #{data}" + data.starts_with?(%(xStatus Audio Volume | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "Audio":{ + "Volume":{ + "Value":"50" + } + } + }, + "ResultId": "#{id}" + }) + + # Finish mapping status + status[:volume].should eq(50) + + # ==== + # Audio Status + resp = exec(:xstatus, "Audio") + data = String.new expect_send + data.starts_with?(%(xStatus Audio | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "Status":{ + "Audio":{ + "Input":{ + "Connectors":{ + "Microphone":[ + { + "id":"1", + "ConnectionStatus":{ + "Value":"Connected" + } + }, + { + "id":"2", + "ConnectionStatus":{ + "Value":"NotConnected" + } + } + ] + } + }, + "Microphones":{ + "Mute":{ + "Value":"On" + } + }, + "Output":{ + "Connectors":{ + "Line":[ + { + "id":"1", + "DelayMs":{ + "Value":"0" + } + } + ] + } + }, + "Volume":{ + "Value":"50" + } + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq({ + "Status/Audio/Input/Connectors/Microphone/1" => { + "ConnectionStatus" => "Connected", + }, + "Status/Audio/Input/Connectors/Microphone/2" => { + "ConnectionStatus" => "NotConnected", + }, + "Status/Audio/Microphones/Mute" => true, + "Status/Audio/Output/Connectors/Line/1" => { + "DelayMs" => 0, + }, + "Status/Audio/Volume" => 50, + }) + + # ==== + # Time Status + resp = exec(:xstatus, "Time") + data = String.new expect_send + data.starts_with?(%(xStatus Time | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "Time":{ + "SystemTime":{ + "Value":"2017-11-27T15:14:25+1000" + } + } + }, + "ResultId": "#{id}" + }) + + resp.get.should eq({ + "Status/Time/SystemTime" => "2017-11-27T15:14:25+1000", + }) + + # ==== + # Time Status fail + resp = exec(:xstatus, "Wrong") + data = String.new expect_send + data.starts_with?(%(xStatus Wrong | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "Status":{ + "status":"Error", + "Reason":{ + "Value":"No match on address expression." + }, + "XPath":{ + "Value":"Status/Wrong" + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Basic command + resp = exec(:xcommand, "Standby Deactivate") + data = String.new expect_send + data.starts_with?(%(xCommand Standby Deactivate | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "StandbyDeactivateResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq "OK" + + # Command with arguments + resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, Layout: :PIP}) + data = String.new expect_send + data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 Layout: "PIP" | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + resp.get.should eq "OK" + + # Return device argument errors + resp = exec(:xcommand, command: "Video Input SetMainVideoSource", hash_args: {ConnectorId: 1, SourceId: 1}) + data = String.new expect_send + data.starts_with?(%(xCommand Video Input SetMainVideoSource ConnectorId: 1 SourceId: 1 | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "InputSetMainVideoSourceResult":{ + "status":"Error", + "Reason":{ + "Value":"Must supply either SourceId or ConnectorId (but not both.)" + } + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Return error from invalid / inaccessable xCommands + resp = exec(:xcommand, "Not A Real Command") + data = String.new expect_send + data.starts_with?(%(xCommand Not A Real Command | resultId=")).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "Result":{ + "status":"Error", + "Reason":{ + "Value":"Unknown command" + } + }, + "XPath":{ + "Value":"/Not/A/Real/Command" + } + }, + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Multiline commands + resp = exec(:xcommand, "SystemUnit SignInBanner Set", "Hello\nWorld!") + data = String.new expect_send + data.starts_with?(%(xCommand SystemUnit SignInBanner Set | resultId=")).should be_true + data.ends_with?(%(Hello\nWorld!\n.\n)).should be_true + id = data.split('"')[-2] + + responds %({ + "CommandResponse":{ + "SignInBannerSetResult":{ + "status":"OK" + } + }, + "ResultId": "#{id}" + }) + + resp.get.should eq "OK" + + # Multuple settings return a unit :success when all ok + resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Name: "Borris", Quality: :Motion}) + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Name: "Borris" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + resp.get.should eq true + + # Multiple settings with failure with return a command failure + resp = exec(:xconfiguration, "Video Input Connector 1", {InputSourceType: :Camera, Foo: "Bar", Quality: :Motion}) + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 InputSourceType: "Camera" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Foo: "Bar" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "CommandResponse":{ + "Configuration":{ + "status":"Error", + "Reason":{ + "Value":"No match on address expression." + }, + "XPath":{ + "Value":"Configuration/Video/Input/Connector[1]/Foo" + } + } + }, + "ResultId": "#{id}" + }) + + data = String.new expect_send + data.starts_with?(%(xConfiguration Video Input Connector 1 Quality: "Motion" | resultId=")).should be_true + id = data.split('"')[-2] + responds %({ + "ResultId": "#{id}" + }) + + expect_raises(PlaceOS::Driver::RemoteException) { resp.get } + + # Out of order send + responds %({ + "Status":{ + "Audio":{ + "Volume":{ + "Value":"52" + } + } + } + }) + + # Finish mapping status + status[:volume].should eq(52) +end diff --git a/drivers/cisco/switch/snooping_catalyst.cr b/drivers/cisco/switch/snooping_catalyst.cr new file mode 100644 index 00000000000..9680d0b5ece --- /dev/null +++ b/drivers/cisco/switch/snooping_catalyst.cr @@ -0,0 +1,264 @@ +require "placeos-driver" +require "set" + +class Cisco::Switch::SnoopingCatalyst < PlaceOS::Driver + # Discovery Information + descriptive_name "Cisco Catalyst Switch IP Snooping" + generic_name :Snooping + tcp_port 22 + + # Communication settings + # tokenize delimiter: /\n|-- / + + default_settings({ + ssh: { + username: :cisco, + password: :cisco, + }, + building: "building_code", + ignore_macs: { + "Cisco Phone Dock" => "7001b5", + }, + }) + + # Interfaces that indicate they have a device connected + @check_interface = ::Set(String).new + + # MAC, IP, Interface + @snooping = [] of Tuple(String, String, String) + + # interface to MAC address mappings + @interface_macs = {} of String => String + @devices = {} of String => NamedTuple(mac: String, ip: String) + + @hostname : String? = nil + @switch_name : String? = nil + @ignore_macs = ::Set(String).new + + def on_load + # "--More--" is sent without a newline + transport.tokenizer = Tokenizer.new("\n", "--More--") + + on_update + end + + def on_update + @ignore_macs = ::Set.new((setting?(Hash(String, String), :ignore_macs) || {} of String => String).values) + + self[:name] = @switch_name = setting?(String, :switch_name) + self[:ip_address] = config.ip.not_nil!.downcase + self[:building] = setting?(String, :building) + self[:level] = setting?(String, :level) + self[:last_successful_query] ||= 0 + end + + def connected + schedule.in(1.second) { query_connected_devices } + schedule.every(1.minute) { query_connected_devices } + end + + def disconnected + schedule.clear + queue.clear + end + + # Don't want the every day user using this method + @[Security(Level::Administrator)] + def run(command : String) + do_send command + end + + def query_interface_status + do_send "show interfaces status" + end + + def query_mac_addresses + @interface_macs.clear + do_send "show mac address-table" + end + + def query_snooping_bindings + @snooping.clear + do_send "show ip dhcp snooping binding" + end + + @querying_devices : Bool = false + + def query_connected_devices + return if @querying_devices + @querying_devices = true + + logger.debug { "Querying for connected devices" } + + query_interface_status.get + sleep 3.seconds + + query_mac_addresses.get + sleep 3.seconds + + query_snooping_bindings.get + sleep 2.seconds + + nil + ensure + @querying_devices = false + end + + def received(data, task) + data = String.new(data) + logger.debug { "Switch sent: #{data}" } + + # determine the hostname + if @hostname.nil? + parts = data.split(">") + if parts.size == 2 + self[:hostname] = @hostname = parts[0] + + # Exit early as this line is not a response + return task.try &.success + end + end + + case data + when /More/ + # Detect more data available + # ==> --More-- + send(" ", priority: 99, retries: 0) + return task.try &.success + when /STATIC|DYNAMIC/ + # Interface MAC Address detection + # 33 e4b9.7aa5.aa7f STATIC Gi3/0/8 + # 10 f4db.e618.10a4 DYNAMIC Te2/0/40 + parts = data.split(/\s+/).reject(&.empty?) + mac = format(parts[1]) + interface = normalise(parts[-1]) + + @interface_macs[interface] = mac if mac && interface + + return :success + when /%LINK/ + # Interface change detection + # 07-Aug-2014 17:28:26 %LINK-I-Up: gi2 + # 07-Aug-2014 17:28:31 %STP-W-PORTSTATUS: gi2: STP status Forwarding + # 07-Aug-2014 17:44:43 %LINK-I-Up: gi2, aggregated (1) + # 07-Aug-2014 17:44:47 %STP-W-PORTSTATUS: gi2: STP status Forwarding, aggregated (1) + # 07-Aug-2014 17:45:24 %LINK-W-Down: gi2, aggregated (2) + interface = normalise(data.split(",")[0].split(/\s/)[-1]) + + if data =~ /Up:/ + logger.debug { "Notify Up: #{interface}" } + @check_interface << interface + + # Delay here is to give the PC some time to negotiate an IP address + # schedule.in(3000) { query_snooping_bindings } + elsif data =~ /Down:/ + logger.debug { "Notify Down: #{interface}" } + # We are no longer interested in this interface + @check_interface.delete(interface) + end + + self[:interfaces] = @check_interface + + return task.try &.success + when .starts_with?("Total number") + logger.debug { "Processing #{@snooping.size} bindings" } + checked = Set(String).new + devices = {} of String => NamedTuple(mac: String, ip: String) + state_changed = false + + @snooping.each do |mac, ip, interface| + next unless @check_interface.includes?(interface) + next unless @interface_macs[interface]? == mac + next if checked.includes?(interface) + + checked << interface + iface = @devices[interface]? || {mac: "", ip: ""} + + if iface[:ip] != ip || iface[:mac] != mac + logger.debug { "New connection on #{interface} with #{ip}: #{mac}" } + devices[interface] = {mac: mac, ip: ip} + state_changed = true + else + devices[interface] = iface + end + end + + # did an interface change state + if state_changed + @devices = devices + self[:devices] = devices + end + + # As a link up or down might have modified this list + if @check_interface != checked + @check_interface = checked + self[:interfaces] = checked + end + + self[:last_successful_query] = Time.utc.to_unix + + return task.try &.success + end + + # Grab the parts of the response + entries = data.split(/\s+/).reject(&.empty?) + + # show interfaces status + # Port Name Status Vlan Duplex Speed Type + # Gi1/1 notconnect 1 auto auto No Gbic + # Fa6/1 connected 1 a-full a-100 10/100BaseTX + case entries + when .includes?("connected") + interface = entries[0].downcase + unless @check_interface.includes? interface + logger.debug { "Interface Up: #{interface}" } + @check_interface << interface + end + when .includes?("notconnect") + interface = entries[0].downcase + if @check_interface.includes? interface + # Delete the lookup records + logger.debug { "Interface Down: #{interface}" } + @check_interface.delete(interface) + end + else + if entries.size > 2 + # We are looking for MAC to IP address mappings + # ============================================= + # MacAddress IpAddress Lease(sec) Type VLAN Interface + # ------------------ --------------- ---------- ------------- ---- -------------------- + # 00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/43 + # Total number of bindings: 3 + interface = normalise(entries[-1]) + + # We only want entries that are currently active + if @check_interface.includes? interface + # Ensure the data is valid + mac = entries[0] + if mac =~ /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/ + mac = format(mac) + ip = entries[1] + + @snooping << {mac, ip, interface} unless @ignore_macs.includes?(mac[0..5]) + end + end + end + end + + task.try &.success + end + + protected def do_send(cmd, **options) + logger.debug { "requesting: #{cmd}" } + send("#{cmd}\n", **options) + end + + protected def format(mac) + mac.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + protected def normalise(interface) + # Port-channel == po + interface.downcase.gsub("tengigabitethernet", "te").gsub("twogigabitethernet", "tw").gsub("gigabitethernet", "gi").gsub("fastethernet", "fa") + end +end diff --git a/drivers/cisco/switch/snooping_catalyst_spec.cr b/drivers/cisco/switch/snooping_catalyst_spec.cr new file mode 100644 index 00000000000..b3c8cdee782 --- /dev/null +++ b/drivers/cisco/switch/snooping_catalyst_spec.cr @@ -0,0 +1,63 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Cisco::Switch::SnoopingCatalyst" do + transmit "SG-MARWFA61301>" + sleep 1.5.seconds + + should_send "show interfaces status\n" + transmit "show interfaces status\n" + status[:hostname].should eq("SG-MARWFA61301") + + transmit %(Port Name Status Vlan Duplex Speed Type +Gi1/0/1 notconnect 113 auto auto 10/100/1000BaseTX +Gi1/0/2 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/11 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/12 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/13 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/14 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/15 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/16 notconnect 113 auto auto 10/100/1000BaseTX +Gi2/0/17 notconnect 113 auto auto 10/100/1000BaseTX +Gi3/0/8 connected 33 auto auto 10/100/1000BaseTX + --More--) + + should_send " " + transmit %( +Gi4/0/48 notconnect 113 auto auto 10/100/1000BaseTX +Gi4/1/1 notconnect 1 auto auto unknown +Gi4/1/2 notconnect 1 auto auto unknown +Te4/1/4 connected trunk full 10G SFP-10GBase-SR +Po1 connected trunk a-full a-10G +) + + sleep 3.1.seconds + + should_send "show mac address-table\n" + transmit "show mac address-table\n" + + transmit %(Vlan MAC Type Port +33 e4b9.7aa5.aa7f STATIC Gi3/0/8 +10 f4db.e618.10a4 DYNAMIC Te2/0/40 +) + + sleep 3.1.seconds + + should_send "show ip dhcp snooping binding\n" + transmit %(MacAddress IpAddress Lease(sec) Type VLAN Interface +------------------ --------------- ---------- ------------- ---- -------------------- +38:C9:86:17:A2:07 192.168.1.15 19868 dhcp-snooping 113 tenGigabitEthernet4/1/4 +E4:B9:7A:A5:AA:7F 10.151.128.150 16532 dhcp-snooping 33 GigabitEthernet3/0/8 +00:21:CC:D5:33:F4 10.151.130.1 16283 dhcp-snooping 113 GigabitEthernet3/0/34 +Total number of bindings: 3 + +) + + status["devices"].should eq({ + "gi3/0/8" => { + "mac" => "e4b97aa5aa7f", + "ip" => "10.151.128.150", + }, + }) + + status["interfaces"].should eq(["gi3/0/8"]) +end diff --git a/drivers/cisco/ui_extender.cr b/drivers/cisco/ui_extender.cr new file mode 100644 index 00000000000..7c8d9e3e354 --- /dev/null +++ b/drivers/cisco/ui_extender.cr @@ -0,0 +1,428 @@ +require "promise" +require "placeos-driver" +require "./collaboration_endpoint/response" + +class Cisco::UIExtender < PlaceOS::Driver + descriptive_name "Cisco UI Extender" + generic_name :CiscoUI + description "Cisco Touch 10 UI extensions" + + default_settings({ + codec: "VidConf_1", + cisco_ui_layout: "XML Config", + cisco_ui_bindings: { + "id" => "VidConf_1.binding", + }, + }) + + @event_handlers : Hash(Tuple(String, String), Proc(JSON::Any, Nil)) = {} of Tuple(String, String) => Proc(JSON::Any, Nil) + + # ------------------------------ + # Module callbacks + + def on_load + on_update(true) + end + + def on_unload + clear_extensions + unbind + end + + alias Binding = String | Hash(String, String | Hash(String, String | Hash(String, Array(String)))) + + # id => binding + alias Bindings = Hash(String, Binding) + + def on_update(loading = false) + # we don't want a failure here to prevent loading new settings + unless loading + begin + clear_events + rescue + end + end + + codec_mod = setting?(String, :codec) || "VidConf_1" + unless system.exists? codec_mod + logger.warn { "could not find codec #{codec_mod}" } + return + end + + ui_layout = setting?(String, :cisco_ui_layout) + bindings = setting?(Bindings, :cisco_ui_bindings) || {} of String => Binding + + bind(codec_mod) do + deploy_extensions "PlaceOS", ui_layout if ui_layout + bindings.each { |id, config| link_widget id, config } + end + end + + # ------------------------------ + # Deployment + + # Push a UI definition build with the in-room control editor to the device. + def deploy_extensions(id : String, xml_def : String) + codec.xcommand "UserInterface Extensions Set", xml_def, {"config_id" => id} + end + + # Retrieve the extensions currently loaded. + def list_extensions + codec.xcommand "UserInterface Extensions List" + end + + # Clear any deployed UI extensions. + def clear_extensions + codec.xcommand "UserInterface Extensions Clear" + end + + # ------------------------------ + # Panel interaction + + def close_panel + codec.xcommand "UserInterface Extensions Panel Close" + end + + protected def on_extensions_panel_clicked(event) : Nil + id = event["/Event/UserInterface/Extensions/Panel/Clicked/PanelId"]?.try &.as_s + return unless id + logger.debug { "#{id} opened" } + self[:__active_panel] = id + end + + # ------------------------------ + # Element interaction + + protected def set_actual(id : String, value : String) + logger.debug { "setting #{id} to #{value}" } + update = codec.xcommand "UserInterface Extensions Widget SetValue", + hash_args: {WidgetId: id, Value: value} + + # The device does not raise an event when a widget state is changed via + # the API. In these cases, ensure locally tracked state remains valid. + Promise.defer(same_thread: true) do + update.get + self[id] = Cisco::CollaborationEndpoint::XAPI.value_convert(value) + value.as(String | Nil) + end + end + + protected def set_actual(id : String, value : Nil) + unset id + end + + protected def set_actual(id : String, value : Bool) + switch(id, value).catch { highlight(id, value).get } + end + + # Set the value of a widget. + def set(id : String, value : String | Bool | Nil) + set_actual(id, value) + end + + # Clear the value associated with a widget. + def unset(id : String) + logger.debug { "clearing #{id}" } + + update = codec.xcommand "UserInterface Extensions Widget UnsetValue", + hash_args: {WidgetId: id} + + Promise.defer(same_thread: true) do + update.get + self[id] = nil + nil.as(String | Nil) + end + end + + # Set the state of a switch widget. + def switch(id : String, state : Bool? = nil) + state = !status?(Bool, id) if state.nil? + value = state ? "on" : "off" + set id, value + end + + # Set the highlight state for a button widget. + def highlight(id : String, state : Bool = true, momentary : Bool = false, time : Int32 = 500) + value = state ? "active" : "inactive" + schedule.in(time.milliseconds) { highlight(id, !state); nil } if momentary + set id, value + end + + # Set the text label used on text or spinner widget. + def label(id : String, value : String | Bool | Nil) + set_actual(id, value) + end + + # Callback for changes to widget state. + @action_merged : Hash(String, JSON::Any) = {} of String => JSON::Any + + def on_extensions_widget_action(event : Hash(String, JSON::Any)) + logger.debug { "received widget action update #{event}" } + current_key = event.keys.first + case current_key + when "/Event/UserInterface/Extensions/Widget/Action/WidgetId" + @action_merged["WidgetId"] = event[current_key] + when "/Event/UserInterface/Extensions/Widget/Action", "/Event/UserInterface/Extensions/Widget/Action/Value" + @action_merged["Value"] = event[current_key] + when "/Event/UserInterface/Extensions/Widget/Action/Type" + @action_merged["Type"] = event[current_key] + else + logger.debug { "ignoring key #{current_key} processing widget_action event" } + end + logger.debug { "current action state: #{@action_merged}" } + return unless @action_merged.size == 3 + id, value, type = @action_merged.values_at "WidgetId", "Value", "Type" + @action_merged = {} of String => JSON::Any + + logger.debug { "#{id} #{type} = #{value}" } + + id = id.as_s + type = type.as_s + + # Track values of stateful widgets + self[id] = value unless ["", "increment", "decrement"].includes?(value.raw) + + # Trigger any bindings defined for the widget action + begin + handler = @event_handlers.fetch [id, type], nil + handler.try &.call(value) + rescue e + logger.error(exception: e) { "error in binding for #{id}.#{type}" } + end + + # Provide an event stream for other modules to subscribe to + self[:__event_stream] = {id: id, type: type, value: value} + end + + # ------------------------------ + # Popup messages + + def alert(text : String, title : String = "", duration : Int32 = 0) + codec.xcommand( + "UserInterface Message Alert Display", + hash_args: { + Text: text, + Title: title, + Duration: duration, + } + ) + end + + def clear_alert + codec.xcommand "UserInterface Message Alert Clear" + end + + # ------------------------------ + # Internals + + @codec_mod : String = "" + @subscriptions : Array(PlaceOS::Driver::Subscriptions::Subscription) = [] of PlaceOS::Driver::Subscriptions::Subscription + + protected def clear_subscriptions + logger.debug { "clearing subscriptions!" } + @subscriptions.each { |sub| subscriptions.unsubscribe(sub) } + @subscriptions.clear + end + + # Bind to a Cisco CE device module. + protected def bind(mod : String, &bind_cb : Proc(Nil)) + logger.debug { "binding to #{mod}" } + + @codec_mod = mod + subscriptions.clear + @subscriptions.clear + system.subscribe(@codec_mod, :ready) do |_sub, value| + logger.debug { "codec ready: #{value}" } + next unless value == "true" + clear_subscriptions + subscribe_events + bind_cb.call + sync_widget_state + end + @codec_mod + end + + # Unbind from the device module. + protected def unbind + logger.debug { "unbinding" } + clear_events async: true + @codec_mod = "" + end + + protected def bound? + !@codec_mod.empty? + end + + protected def codec + raise "not currently bound to a codec module" unless bound? + system[@codec_mod] + end + + # Push the current module state to the device. + def sync_widget_state + @__status__.each do |key, value| + next if key == "connected" + + # Non-widget related status prefixed with `__` + next if key =~ /^__.*/ + case value + when .starts_with?("\"") + set key, String.from_json(value) + when "true", "false" + set key, value == "true" + end + end + end + + # Build a list of device XPath -> callback mappings. + protected def event_mappings + ui_callbacks.map do |(function_name, callback)| + path = "/Event/UserInterface/#{function_name[3..-1].split("_").map(&.capitalize).join("/")}" + {path, function_name, callback} + end + end + + protected def each_mapping(async : Bool) + device_mod = codec + event_mappings.each { |(path, function, callback)| yield path, function, callback, device_mod } + end + + # Perform an action for each event -> callback mapping. + protected def each_mapping + device_mod = codec + interactions = event_mappings.map do |(path, function, callback)| + future = yield path, function, callback, device_mod + Promise.defer { future.get } + end + Promise.all(interactions).get + end + + protected def subscribe_events(**opts) + mod_id = module_id + each_mapping(**opts) do |path, function, callback, codec| + logger.debug { "monitoring #{mod_id}/#{function}" } + @subscriptions << monitor("#{mod_id}/#{function}") do |_sub, event_json| + logger.debug { "#{function} received #{event_json}" } + spawn do + begin + callback.call(Hash(String, JSON::Any).from_json(event_json)) + rescue error + logger.error(exception: error) { "processing panel event" } + end + end + end + codec.on_event path, mod_id, function + end + end + + protected def clear_events(**opts) + clear_subscriptions + each_mapping(**opts) do |path, _function, _callback, _codec| + future = codec.clear_event(path) + future.get + future + end + end + + # Wire up a widget based on a binding target. + def link_widget(id : String, bindings : Binding) + logger.debug { "setting up bindings for #{id}" } + + binding = case bindings + in String + %w(clicked changed status).product([bindings]).to_h + in Hash(String, Hash(String, Hash(String, Array(String)) | String) | String) + bindings + end + + binding.each do |type, target| + # Status / feedback state binding + if type == "status" + # String | Hash(String, String | Hash(String, String)) + case target + in String + # "mod.status" + mod, state = target.split "." + link_feedback id, mod, state + in Hash(String, String | Hash(String, Array(String))) + # mod => status (provided for compatability with event bindings) + mod, state = target.first + link_feedback id, mod, state.as(String) + end + + # Event binding + else + handler = build_handler target + if handler + @event_handlers[{id, type}] = handler + else + logger.warn { "invalid #{type} binding for #{id}" } + end + end + end + end + + # Bind a widget to another modules status var for feedback. + protected def link_feedback(id : String, mod : String, state : String) + logger.debug { "linking #{id} state to #{mod}.#{state}" } + + system[mod].subscribe(state) do |_sub, value| + spawn do + begin + logger.debug { "#{mod}.#{state} changed to #{value}, updating #{id}" } + payload = value.presence ? JSON.parse(value).raw.as(String | Bool | Nil) : nil + set id, payload + rescue error + logger.error(exception: error) { "module status update" } + end + end + end + end + + # Given the action for a binding, construct the executable event handler. + protected def build_handler(action) + case action + # Implicit arguments + in String + # "mod.method" + raise "action expected to be in format Module_1.binding not: #{action.inspect}" unless action.includes?(".") + mod, method = action.split "." + ->(value : JSON::Any) { + logger.debug { "proxying event to #{mod}.#{method}" } + proxy = system[mod] + args = proxy.__metadata__.arity(method).zero? ? nil : {value} + proxy.__send__ method, args + nil + } + + # Explicit / static arguments + # mod => { method => [params] } + in Hash(String, String | Hash(String, Array(String))) + mod, command = action.first + method, args = command.as(Hash(String, Array(String))).first + ->(_value : JSON::Any) { + logger.debug { "proxying event to #{mod}.#{method}" } + system[mod].__send__ method, args + nil + } + end + end + + # Build a list of all callback methods that have been defined. + # + # Callback methods are denoted being single arity and beginning with `on_`. + IGNORE_METHODS = %w(on_load on_unload on_update) + + {% begin %} + protected def ui_callbacks + [ + {% for method in @type.methods %} + {% method_name = method.name.stringify %} + {% if method.args.size == 1 && !IGNORE_METHODS.includes?(method_name) && method_name[0..2] == "on_" %} + { {{method_name}}, ->(event : Hash(String, JSON::Any)) { {{method_name.id}}(event); nil } }, + {% end %} + {% end %} + ] + end + {% end %} +end diff --git a/drivers/cisco/ui_extender_spec.cr b/drivers/cisco/ui_extender_spec.cr new file mode 100644 index 00000000000..788425efed2 --- /dev/null +++ b/drivers/cisco/ui_extender_spec.cr @@ -0,0 +1,53 @@ +require "placeos-driver/spec" +# require "./collaboration_endpoint" + +DriverSpecs.mock_driver "Cisco::UIExtender" do + system({ + VidConf: {VidConfMock}, + }) + sleep 1 + + resp = exec(:set, "something", true).get + puts resp.inspect + sleep 1 + status[:something].should eq(true) + + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action/WidgetId" => "something", + }.to_json) + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action" => false, + }.to_json) + PlaceOS::Driver::RedisStorage.with_redis &.publish("placeos/spec_runner/on_extensions_widget_action", { + "/Event/UserInterface/Extensions/Widget/Action/Type" => "changed", + }.to_json) + sleep 1 + status[:something].should eq(false) + sleep 1 +end + +# :nodoc: +class VidConfMock < DriverSpecs::MockDriver + def on_load + spawn(same_thread: true) { + sleep 0.5 + self[:ready] = self[:connected] = true + } + end + + def xcommand( + command : String, + multiline_body : String? = nil, + hash_args : Hash(String, JSON::Any::Type) = {} of String => JSON::Any::Type + ) + puts "Running command: #{command} #{hash_args} + body #{multiline_body.try(&.size) || 0}" + end + + def on_event(path : String, mod_id : String, channel : String) + puts "Registering callback for #{path} to #{mod_id}/#{channel}" + end + + def clear_event(path : String) + puts "Clearing event subscription for #{path}" + end +end diff --git a/drivers/cisco/webex/booking.cr b/drivers/cisco/webex/booking.cr new file mode 100644 index 00000000000..0754eea1c20 --- /dev/null +++ b/drivers/cisco/webex/booking.cr @@ -0,0 +1,69 @@ +require "placeos-driver" +require "placeos-driver/interface/chat_bot" +require "placeos-driver/interface/locatable" +require "place_calendar" + +module Cisco + module Webex + class Booking < PlaceOS::Driver + default_settings({keyword: "book", organization_id: ""}) + + def on_load + on_update + end + + def on_update + organization_id = setting(String, :organization_id) + monitor("chat/webex/#{organization_id}/message") { |_subscription, payload| on_message(payload) } + end + + def on_message(message : String) + message = Interface::ChatBot::Message.from_json(message) + + keyword = message.text.split.first.downcase + + # An example message text would look something like this: + # {% keyword %} a room for 30 minutes + text = message + .text + .sub(keyword, "") + .sub("a room", "") + .strip + + # Ignore the message if the keyword doesn't match the booking keyword specified in the settings + if keyword != setting(String, :keyword) + send_message(message.id, "Specified keyword is not recognized as a valid acommand for the PlaceOS Bot, #{keyword}.") + send_message(message.id, "An example booking command would look something like this: #{setting(String, :keyword)} a room for 30 minutes") + + return + end + + # Notify the user to await for a free room + send_message(message.id, "Looking for an available room to book, please wait!") + + # Split the remaining text into chunks to process them + conjunction, period, measurement = text.split + + case measurement + when "hours" + period_in_seconds = (period.to_i * 3600).to_i64 + event = PlaceCalendar::Event.from_json(system.implementing(Interface::Locatable).book_now(period_in_seconds).get.first.to_json) + send_message(message.id, "Successfully booked an event #{event.title}, from #{event.event_start}, to #{event.event_end}, in #{event.timezone}, on #{event.host}.") + when "minutes" + period_in_seconds = (period.to_i * 60).to_i64 + event = PlaceCalendar::Event.from_json(system.implementing(Interface::Locatable).book_now(period_in_seconds).get.first.to_json) + send_message(message.id, "Successfully booked an event #{event.title}, from #{event.event_start}, to #{event.event_end}, in #{event.timezone}, on #{event.host}.") + when "seconds" + event = PlaceCalendar::Event.from_json(system.implementing(Interface::Locatable).book_now(period.to_i64).get.first.to_json) + send_message(message.id, "Successfully booked an event #{event.title}, from #{event.event_start}, to #{event.event_end}, in #{event.timezone}, on #{event.host}.") + else + send_message(message.id, "Specified measurement is not recognized as a valid measurement, please use: minutes, seconds or hours.") + end + end + + private def send_message(id : Interface::ChatBot::Id, response : String) + system.implementing(Interface::ChatBot).reply(Interface::ChatBot::Message.new(id, response).to_json) + end + end + end +end diff --git a/drivers/cisco/webex/communication.cr b/drivers/cisco/webex/communication.cr new file mode 100644 index 00000000000..8f47af61bdd --- /dev/null +++ b/drivers/cisco/webex/communication.cr @@ -0,0 +1,61 @@ +require "placeos-driver" +require "placeos-driver/interface/chat_bot" +require "http" + +require "./models/**" + +module Cisco + module Webex + class Communication < PlaceOS::Driver + include Interface::ChatBot + + descriptive_name "Cisco Webex Bot Communication" + generic_name :Communication + uri_base "wss://webex.placeos.com/ws/messages" + + default_settings({ + organization_id: "", + api_key: "", + }) + + protected getter! socket : HTTP::WebSocket + + def on_load + on_update + end + + def on_update + headers = HTTP::Headers.new + + organization_id = setting(String, :organization_id) + + headers.merge!({"Organization-ID" => organization_id}) + headers.merge!({"X-API-Key" => setting(String, :api_key)}) + + @socket = HTTP::WebSocket.new(URI.parse(config.uri.not_nil!.to_s), headers) + + spawn do + socket.try(&.on_message do |message| + event = Models::Event.from_json(JSON.parse(message).as_h.["event"].to_json) + event_message = Models::Message.from_json(JSON.parse(message).as_h.["message"].to_json) + + id = Interface::ChatBot::Id.new(event_message.id.to_s, event_message.room_id.to_s, event.data.activity.actor.id, event.data.activity.actor.organization_id) + bot_message = Interface::ChatBot::Message.new(id, event_message.text.to_s) + + publish("chat/webex/#{organization_id}/message", bot_message.to_json) + end) + + socket.try(&.run) + end + end + + def notify_typing(id : Interface::ChatBot::Id) + end + + def reply(id : Interface::ChatBot::Id, response : String, url : String? = nil, attachment : Interface::ChatBot::Attachment? = nil) + files = [url.to_s] if url + socket.try(&.send({"roomId" => id.room_id.to_s, "text" => response, "files" => files || [] of String}.to_json)) + end + end + end +end diff --git a/drivers/cisco/webex/models/event.cr b/drivers/cisco/webex/models/event.cr new file mode 100644 index 00000000000..0d2493989f4 --- /dev/null +++ b/drivers/cisco/webex/models/event.cr @@ -0,0 +1,29 @@ +require "./events/**" + +module Cisco + module Webex + module Models + class Event + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "data")] + property data : Events::Data + + @[JSON::Field(key: "timestamp")] + property timestamp : Int64 + + @[JSON::Field(key: "trackingId")] + property tracking_id : String + + @[JSON::Field(key: "sequenceNumber")] + property sequence_number : Int64 + + @[JSON::Field(key: "filterMessage")] + property filter_message : Bool + end + end + end +end diff --git a/drivers/cisco/webex/models/events/activity.cr b/drivers/cisco/webex/models/events/activity.cr new file mode 100644 index 00000000000..504b2b87c60 --- /dev/null +++ b/drivers/cisco/webex/models/events/activity.cr @@ -0,0 +1,35 @@ +module Cisco + module Webex + module Models + module Events + class Activity + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "url")] + property url : String + + @[JSON::Field(key: "published")] + property published : String + + @[JSON::Field(key: "verb")] + property verb : String + + @[JSON::Field(key: "actor")] + property actor : Actor + + @[JSON::Field(key: "target")] + property target : Target + + @[JSON::Field(key: "clientTempId")] + property client_temp_id : String? + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/actor.cr b/drivers/cisco/webex/models/events/actor.cr new file mode 100644 index 00000000000..8ea659c923d --- /dev/null +++ b/drivers/cisco/webex/models/events/actor.cr @@ -0,0 +1,32 @@ +module Cisco + module Webex + module Models + module Events + class Actor + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "displayName")] + property display_name : String + + @[JSON::Field(key: "orgId")] + property organization_id : String + + @[JSON::Field(key: "emailAddress")] + property email : String + + @[JSON::Field(key: "entryUUID")] + property entry_uuid : String + + @[JSON::Field(key: "type")] + property type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/data.cr b/drivers/cisco/webex/models/events/data.cr new file mode 100644 index 00000000000..6cb51913264 --- /dev/null +++ b/drivers/cisco/webex/models/events/data.cr @@ -0,0 +1,17 @@ +module Cisco + module Webex + module Models + module Events + class Data + include JSON::Serializable + + @[JSON::Field(key: "activity")] + property activity : Activity + + @[JSON::Field(key: "eventType")] + property event_type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/target.cr b/drivers/cisco/webex/models/events/target.cr new file mode 100644 index 00000000000..4c5b77d196c --- /dev/null +++ b/drivers/cisco/webex/models/events/target.cr @@ -0,0 +1,23 @@ +module Cisco + module Webex + module Models + module Events + class Target + include JSON::Serializable + + @[JSON::Field(key: "id")] + property id : String + + @[JSON::Field(key: "objectType")] + property object_type : String + + @[JSON::Field(key: "url")] + property url : String + + @[JSON::Field(key: "published")] + property published : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/events/type.cr b/drivers/cisco/webex/models/events/type.cr new file mode 100644 index 00000000000..f4a6f84f360 --- /dev/null +++ b/drivers/cisco/webex/models/events/type.cr @@ -0,0 +1,14 @@ +module Cisco + module Webex + module Models + module Events + class Type + include JSON::Serializable + + @[JSON::Field(key: "eventType")] + property event_type : String + end + end + end + end +end diff --git a/drivers/cisco/webex/models/message.cr b/drivers/cisco/webex/models/message.cr new file mode 100644 index 00000000000..bb2f02f291c --- /dev/null +++ b/drivers/cisco/webex/models/message.cr @@ -0,0 +1,77 @@ +module Cisco + module Webex + module Models + class Message + include JSON::Serializable + + # The unique identifier for the message. + @[JSON::Field(key: "id")] + property id : String? + + # The unique identifier for the parent message. + @[JSON::Field(key: "parentId")] + property parent_id : String? + + # The room ID of the message. + @[JSON::Field(key: "roomId")] + property room_id : String? + + # The type of room. + @[JSON::Field(key: "roomType")] + property room_type : String? + + # The person ID of the recipient when sending a 1:1 message. + @[JSON::Field(key: "toPersonId")] + property to_person_id : String? + + # The email address of the recipient when sending a 1:1 message. + @[JSON::Field(key: "toPersonEmail")] + property to_person_email : String? + + # The message, in plain text. + @[JSON::Field(key: "text")] + property text : String? + + # The message, in Markdown format. + @[JSON::Field(key: "markdown")] + property markdown : String? + + # The text content of the message, in HTML format. This read-only property is used by the Webex Teams clients. + @[JSON::Field(key: "html")] + property html : String? + + # Public URLs for files attached to the message. + @[JSON::Field(key: "files")] + property files : Array(String)? + + # The person ID of the message author. + @[JSON::Field(key: "personId")] + property person_id : String? + + # The email address of the message author. + @[JSON::Field(key: "personEmail")] + property person_email : String? + + # People IDs for anyone mentioned in the message. + @[JSON::Field(key: "mentionedPeople")] + property mentioned_people : Array(String)? + + # Group names for the groups mentioned in the message. + @[JSON::Field(key: "mentionedGroups")] + property mentioned_groups : Array(String)? + + # Message content attachments attached to the message. + # @[JSON::Field(key: "attachments")] + # property attachments : Array(Attachment)? + + # The date and time the message was created. + @[JSON::Field(key: "created")] + property created : String? + + # The date and time the message was created. + @[JSON::Field(key: "updated")] + property updated : String? + end + end + end +end diff --git a/drivers/crestron/cres_next.cr b/drivers/crestron/cres_next.cr new file mode 100644 index 00000000000..1eb75ba9a06 --- /dev/null +++ b/drivers/crestron/cres_next.cr @@ -0,0 +1,96 @@ +require "placeos-driver" +require "json" +require "path" +require "uri" +require "./nvx_models" +require "./cres_next_auth" + +# Documentation: https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Prerequisites-Assumptions.htm +# inspecting request - response packets from the device webui is also useful + +# Parent module for Crestron DM NVX devices. +abstract class Crestron::CresNext < PlaceOS::Driver + include Crestron::CresNextAuth + + def websocket_headers + authenticate + + headers = HTTP::Headers.new + transport.cookies.add_request_headers(headers) + headers["CREST-XSRF-TOKEN"] = @xsrf_token unless @xsrf_token.empty? + headers["User-Agent"] = "advanced-rest-client" + + # This is just to maintain our session at HTTP level + schedule.clear + schedule.every(10.minutes) { maintain_session } + + headers + end + + def tokenize(path : String) + path.split('/').reject(&.empty?) + end + + # ============================================ + # websocket for state changes and get requests + # ============================================ + protected def query(path : String, **options, &block : (JSON::Any, ::PlaceOS::Driver::Task) -> Nil) + request_path = Path["/Device"].join(path).to_s + tokens = tokenize(request_path) + parts = tokens.map { |part| %("#{part}":) } + + send(request_path, **options) do |data, task| + raw_json = String.new(data) + + # check if the response path is included + if parts.map(&.in?(raw_json)).includes?(false) + # process as an out of band update (not the response) + received(data, nil) + else + logger.debug { "Crestron sent: #{raw_json}" } + # Just grab the relevant data as the response is deeply nested + json = JSON.parse(raw_json) + tokens.each { |key| json = json[key] } + block.call json, task + task.success json + end + end + end + + def received(data, task) + raw_json = String.new data + logger.debug { "Crestron sent: #{raw_json}" } + end + + # ======================================== + # HTTP for updates and session maintenance + # ======================================== + def maintain_session + response = get("/Device/DeviceInfo") + return logout unless response.success? + + # we can parse this value as if it came in via the websocket + received response.body.to_slice, nil + end + + # payload is expected to be a hash or named tuple + protected def update(path : String, value, **options) + queue(**options) do |task| + request_path = Path["/Device"].join(path).to_s + + # expands into object that we need to post + components = tokenize(request_path).map { |part| %({"#{part}") } + payload = %(#{components.join(':')}:#{value.to_json}#{"}" * components.size}) + + response = post request_path, body: payload, headers: HTTP::Headers{"CREST-XSRF-TOKEN" => @xsrf_token} + logger.debug { "updated requested for #{request_path}, response was #{response.body}" } + + # no real need to parse the responses as the changes will be sent down the websocket + if response.success? + task.success JSON.parse(response.body) + else + task.abort "crestron failed to apply changes to: #{path}\n#{response.body}" + end + end + end +end diff --git a/drivers/crestron/cres_next_auth.cr b/drivers/crestron/cres_next_auth.cr new file mode 100644 index 00000000000..1efc33f0e87 --- /dev/null +++ b/drivers/crestron/cres_next_auth.cr @@ -0,0 +1,60 @@ +require "uri" + +module Crestron::CresNextAuth + protected getter xsrf_token : String = "" + + def authenticate + logger.debug { "Authenticating" } + + # some devices require referer and origin to accept the login + uri = URI.parse config.uri.not_nil! + host = uri.host + + response = post("/userlogin.html", headers: { + "Content-Type" => "application/x-www-form-urlencoded", + "Referer" => "https://#{host}/userlogin.html", + "Origin" => "https://#{host}", + }, body: URI::Params.build { |form| + form.add("login", setting(String, :username)) + form.add("passwd", setting(String, :password)) + }) + + case response.status_code + when 200, 302 + auth_cookies = %w(AuthByPasswd iv tag userid userstr) + if (auth_cookies - response.cookies.to_h.keys).empty? + @xsrf_token = response.headers["CREST-XSRF-TOKEN"]? || "" + logger.debug { "Authenticated" } + else + error = "Device did not return all auth information" + end + when 403 + error = "Invalid credentials" + else + error = "Unexpected response (HTTP #{response.status})" + end + + if error + logger.error { error } + raise error + end + end + + def logout + response = post "/logout" + + case response.status + when 302 + logger.debug { "Logout successful" } + true + else + logger.warn { "Unexpected response (HTTP #{response.status})" } + false + end + ensure + @xsrf_token = "" + transport.cookies.clear + schedule.clear + disconnect + end +end diff --git a/drivers/crestron/fusion.cr b/drivers/crestron/fusion.cr new file mode 100644 index 00000000000..4c12d194dba --- /dev/null +++ b/drivers/crestron/fusion.cr @@ -0,0 +1,187 @@ +require "placeos-driver" +require "xml" +require "json" +require "uri" + +# TODO: add handling of security level 2 +# TODO: parse returend results into models +# +# Documentation: https://sdkcon78221.crestron.com/sdk/Fusion_APIs/Content/Topics/Default.htm +class Crestron::Fusion < PlaceOS::Driver + descriptive_name "Crestron Fusion" + generic_name :CrestronFusion + description <<-DESC + Crestron Fusion + DESC + + uri_base "https://fusion.myorg.com/fusion/apiservice/" + + default_settings({ + # Security level: 0 (No Security), 1 (Clear Text), 2 (Encrypted) + security_level: 1, + + user_id: "FUSION_USER_ID", + + # Should be the same as set in the Fusion configuration client + api_pass_code: "FUSION_API_PASS_CODE", + + # xml or json + content_type: "json", + + # uses old ciphers + https_insecure: true, + }) + + @security_level : Int32 = 1 + @user_id : String = "" + @api_pass_code : String = "" + @content_type : String = "" + + def on_load + on_update + end + + def on_update + @security_level = setting(Int32, :security_level) + @user_id = setting(String, :user_id) + @api_pass_code = setting(String, :api_pass_code) + @content_type = "application/" + setting(String, :content_type) + end + + ########### + # Actions # + ########### + + def get_actions(name : String?, room_id : String? = nil, page : Int32? = nil) + params = URI::Params.new + params["search"] = name if name + params["room"] = room_id if room_id + params["page"] = page.to_s if page + + response = perform_request("GET", "/actions", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_action(action_id : String) + response = perform_request("GET", "/actions/#{action_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def send_action(action_id : String?, room_id : String? = nil, node_id : String? = nil) + params = URI::Params.new + params["room"] = room_id if room_id + params["node"] = node_id if node_id + + path = if (id = action_id) && !id.empty? + "/actions/#{id}" + else + "/actions" + end + + response = perform_request("POST", path, params) + JSON.parse(response.body) + end + + ########## + # Alerts # + ########## + + # Severity should be in the range 1-4 + def get_alerts(node_ids : Array(String)? = nil, room_ids : Array(String)? = nil, start_time : String? = nil, end_time : String? = nil, severity : Int32? = nil, active_alerts : Bool = true) + params = URI::Params.new + params["nodes"] = node_ids.join(',') if node_ids + params["rooms"] = room_ids.join(',') if room_ids + params["start"] = start_time if start_time + params["end"] = end_time if end_time + params["severity"] = severity.to_s if severity + params["activeAlerts"] = active_alerts.to_s if active_alerts + + response = perform_request("GET", "/rooms", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ######### + # Rooms # + ######### + + def post_room(room_xml_or_json : String) + response = perform_request("POST", "/rooms", body: room_xml_or_json) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_rooms(name : String?, node_id : String? = nil, page : Int32? = nil) + params = URI::Params.new + params["search"] = name if name + params["node"] = node_id if node_id + params["page"] = page.to_s if page + + response = perform_request("GET", "/rooms", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_room(room_id : String) + response = perform_request("GET", "/rooms/#{room_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def put_room(room_id : String, room_xml_or_json : String) + response = perform_request("PUT", "/rooms/#{room_id}", body: room_xml_or_json) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def delete_room(room_id : String) + response = perform_request("DELETE", "/rooms/#{room_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ################# + # Signal Values # + ################# + + def get_signal_values(symbol_id : String) + response = perform_request("GET", "/signalvalues/#{symbol_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def get_signal_value(symbol_id : String, attribute_id : String) + response = perform_request("GET", "/signalvalues/#{symbol_id}/#{attribute_id}") + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + def put_signal_value(symbol_id : String, attribute_id : String, value : String) + params = URI::Params.new + params["value"] = value + + response = perform_request("PUT", "/signalvalues/#{symbol_id}/#{attribute_id}", params) + @content_type == "xml" ? XML.parse(response.body) : JSON.parse(response.body) + end + + ########### + # Helpers # + ########### + + private def perform_request(method : String, path : String, params : URI::Params = URI::Params.new, body : String? = nil) + if @security_level == 1 + params["auth"] = "#{@api_pass_code} #{@user_id}" + elsif @security_level == 2 + params["auth"] = encrypted_token + end + + headers = HTTP::Headers.new + headers["Content-Type"] = @content_type + headers["Accept"] = @content_type + + response = http(method, path, body, params, headers) + if response.status_code == 200 + response + else + raise "Fusion API request failed. Status code: #{response.status_code}" + end + end + + private def encrypted_token + # TODO: encrypt this + # "#{Time.utc.to_rfc3339} #{@user_id}" + raise "Fusion API security level 2 not supported" + end +end diff --git a/drivers/crestron/fusion_spec.cr b/drivers/crestron/fusion_spec.cr new file mode 100644 index 00000000000..230f29a90bb --- /dev/null +++ b/drivers/crestron/fusion_spec.cr @@ -0,0 +1,75 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::Fusion" do + settings({ + security_level: 0, + user_id: "spec-user-id", + api_pass_code: "spec-api-pass-code", + service_url: "/RoomViewSE/APIService/", + content_type: "xml", + }) + + resp = exec(:get_rooms, "Meeting Room A") + expect_http_request do |_request, response| + response.status_code = 200 + response << rooms.to_json + end + resp.get + + resp = exec(:get_room, "room-id") + expect_http_request do |_request, response| + response.status_code = 200 + response << room.to_json + end + resp.get + + resp = exec(:get_signal_value, "symbol-id", "attribute-id") + expect_http_request do |_request, response| + response.status_code = 200 + response << signal_value.to_json + end + resp.get + + resp = exec(:put_signal_value, "symbol-id", "attribute-id", "start") + expect_http_request do |_request, response| + response.status_code = 200 + response << put_signal_value_response.to_json + end + resp.get +end + +########### +# Helpers # +########### + +private def rooms + { + "rooms" => [ + room, + ], + } +end + +private def room + { + "RoomName" => "Meeting Room A", + } +end + +private def signal_value + { + "API_Signals" => [{ + "AttributeID" => "attribute-id", + "AttributeName" => "PlaceOS_Enabled", + "RawValue" => "False", + "SymbolID" => "symbol-id", + }], + "Status" => "Success", + } +end + +private def put_signal_value_response + { + "Status" => "Success", + } +end diff --git a/drivers/crestron/nvx_address_manager.cr b/drivers/crestron/nvx_address_manager.cr new file mode 100644 index 00000000000..75606c49e37 --- /dev/null +++ b/drivers/crestron/nvx_address_manager.cr @@ -0,0 +1,76 @@ +require "placeos-driver" +require "./nvx_models" + +class Crestron::NvxAddressManager < PlaceOS::Driver + descriptive_name "Crestron NVX Address Manager" + generic_name :NvxAddressManager + + description <<-DESC + Simplified management of NVX encoder multicast addressing. + + Allows a subnet to be assigned with sequential, blocked address + allocation to all NVX encoders appearing alongside instances of this + module. + + This is intended to be instantiated in systems containing all NVX + encoders that share a multicast subnet. + DESC + + default_settings({ + base_address: "239.8.0.2", + block_size: 8, + }) + + # https://github.com/Sija/ipaddress.cr + MULTICAST_ADDRESSES = ::IPAddress::IPv4.new("224.0.0.0/4") + + @base_address : UInt32 = 0_u32 + @block_size : Int32 = 8 + + def on_load + on_update + end + + def on_update + addr = setting(String, :base_address) + base_addr = ::IPAddress::IPv4.new addr + @base_address = base_addr.to_u32 + logger.warn { "#{addr} is not a valid multicast address" } unless MULTICAST_ADDRESSES.includes? base_addr + @block_size = setting(Int32, :block_size) + end + + def readdress_streams + logger.debug { "readdressing devices" } + + address_pairs = encoders.zip(addresses) + + interactions = address_pairs.map_with_index(1) do |(mod, ip_u32), idx| + ip = ::IPAddress::IPv4.parse_u32(ip_u32) + logger.debug { "setting encoder #{idx} to #{ip}" } + mod.multicast_address ip.to_s + end + + failed = 0 + interactions.each do |request| + begin + request.get + rescue error + failed += 1 + logger.warn(exception: error) { "addressing NVX devices" } + end + end + + raise "#{failed} failed to set stream address" unless failed == 0 + interactions.size + end + + protected def encoders + system.implementing(Crestron::Transmitter) + end + + # returns an iterator of IPv4 addresses represented as 32bit numbers + protected def addresses + address_range = (@base_address..MULTICAST_ADDRESSES.last.to_u32) + address_range.step by: @block_size + end +end diff --git a/drivers/crestron/nvx_address_manager_spec.cr b/drivers/crestron/nvx_address_manager_spec.cr new file mode 100644 index 00000000000..bc92e0b00a7 --- /dev/null +++ b/drivers/crestron/nvx_address_manager_spec.cr @@ -0,0 +1,22 @@ +require "placeos-driver/spec" +require "./nvx_models" + +DriverSpecs.mock_driver "Crestron::NvxAddressManager" do + system({ + Encoder: {NvxEncoderMock, NvxEncoderMock}, + }) + + exec(:readdress_streams).get.should eq 2 + + system(:Encoder_1)[:address].should eq "239.8.0.2" + system(:Encoder_2)[:address].should eq "239.8.0.10" +end + +# :nodoc: +class NvxEncoderMock < DriverSpecs::MockDriver + include Crestron::Transmitter + + def multicast_address(address : String) + self[:address] = address + end +end diff --git a/drivers/crestron/nvx_models.cr b/drivers/crestron/nvx_models.cr new file mode 100644 index 00000000000..dc4b4295d49 --- /dev/null +++ b/drivers/crestron/nvx_models.cr @@ -0,0 +1,20 @@ +require "json" + +module Crestron + # Interface for enumerating devices + module Transmitter + end + + module Receiver + end + + enum AspectRatio + MaintainAspectRatio + StretchToFit + end + + enum SourceType + Audio + Video + end +end diff --git a/drivers/crestron/nvx_rx.cr b/drivers/crestron/nvx_rx.cr new file mode 100644 index 00000000000..2d6a6a081c7 --- /dev/null +++ b/drivers/crestron/nvx_rx.cr @@ -0,0 +1,240 @@ +require "./cres_next" +require "placeos-driver/interface/switchable" + +class Crestron::NvxRx < Crestron::CresNext # < PlaceOS::Driver + alias Input = String | Int32? + include PlaceOS::Driver::Interface::InputSelection(Input) + include Crestron::Receiver + + descriptive_name "Crestron NVX Receiver" + generic_name :Decoder + description <<-DESC + Crestron NVX network media decoder. + DESC + + uri_base "wss://192.168.0.5/websockify" + + default_settings({ + username: "admin", + password: "admin", + }) + + @subscriptions : Hash(String, JSON::Any) = {} of String => JSON::Any + + def connected + # NVX hardware can be confiured a either a RX or TX unit - check this + # device is in the correct mode. + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/DeviceSpecific.htm?Highlight=DeviceMode + query("/DeviceSpecific/DeviceMode") do |mode| + # "DeviceMode":"Transmitter|Receiver", + next if mode == "Receiver" + logger.warn { "device configured as a #{mode}" } + self[:WARN] = "device configured as a #{mode}. Expecting Receiver" + end + + # Get the registered subscriptions for index based switching. + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + query("/XioSubscription/Subscriptions") do |subs| + self[:subscriptions] = @subscriptions = subs.as_h + end + + # Background poll for subscription changes. + schedule.every(1.hour) do + query("/XioSubscription/Subscriptions", priority: 5) do |subs| + self[:subscriptions] = @subscriptions = subs.as_h + end + end + + # Background poll to remain in sync with any external routing changes + schedule.every(5.minutes, immediate: true) { update_source_info } + end + + def switch_to(input : Input) + input = input.downcase if input.is_a?(String) + do_switch = case input + when "none", "break", "clear", "blank", "black", nil, 0 + blank + when "input1", "hdmi", "hdmi1" + switch_local "Input1" + when "input2", "hdmi2" + switch_local "Input2" + else + switch_stream input + end + + do_switch.get + update_source_info + end + + def output(state : Bool) + logger.debug { "#{state ? "enabling" : "disabling"} output sync" } + + update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + Hdmi: {IsOutputDisabled: !state}, + }], + }], + name: :output + ) + end + + # aspect ratio defined in nvx_rx_models + def aspect_ratio(mode : AspectRatio) + logger.debug { "setting output aspect ratio mode: #{mode}" } + + update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + AspectRatioMode: mode, + }], + }], + name: :aspect_ratio + ) + end + + protected def query_device_name + query("/Localization/Name", name: "device_name") do |name| + self["device_name"] = name + end + end + + protected def switch_stream(stream_reference : String | Int32) + uuid = uuid_for stream_reference + + logger.debug do + subscription = @subscriptions[uuid].as_h + id, name = subscription.values_at "Position", "SessionName" + "switching to Stream#{id} (#{name})" + end + + payload = { + AvRouting: { + Routes: [{VideoSource: uuid, AudioSource: uuid}], + }, + DeviceSpecific: { + VideoSource: "Stream", + AudioSource: "AudioFollowsVideo", + }, + } + + update "/", payload, name: :switch + end + + protected def switch_local(input) + logger.debug { "switching to #{input}" } + update( + "/DeviceSpecific", + {VideoSource: input, AudioSource: "AudioFollowsVideo"}, + name: :switch + ) + end + + protected def blank + logger.debug { "blanking output" } + + payload = { + AvRouting: { + Routes: [{VideoSource: "", AudioSource: ""}], + }, + DeviceSpecific: { + VideoSource: "None", + AudioSource: "AudioFollowsVideo", + }, + } + + update("/", payload, name: :switch) + end + + # Decoders must first subscribe to encoders they need to receive signals + # from. Switching is then based on device UUID's. + # + # The deivce web UI's (and presumbly XIO director) show these as selectable + # 'inputs' - this mapping allows sources to either be specified as a UUID, + # or their 'input number' as displayed with Crestron tooling. + # + # Alternatively, if a string is provided the list of search props will be + # searched for a match. + protected def uuid_for(reference : String) + if /Stream(\d+)/i =~ reference + # grab the matching data https://crystal-lang.org/api/latest/Regex.html + id = $~[1].to_i + return uuid_for id + end + + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + subscriptions = @subscriptions + + if subscriptions.has_key? reference + uuid = reference + else + {"MulticastAddress", "SessionName"}.each do |prop| + if result = subscriptions.find { |_, x| x.as_h[prop] == reference } + uuid = result[0] + end + break if uuid + end + end + + raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil? + + uuid + end + + protected def uuid_for(reference : Int32) + subscriptions = @subscriptions + + # https://sdkcon78221.crestron.com/sdk/DM_NVX_REST_API/Content/Topics/Objects/XioSubscription.htm?Highlight=XioSubscription + if result = subscriptions.find { |_, x| x.as_h["Position"] == reference } + uuid = result[0] + end + + raise ArgumentError.new("input #{reference} not subscribed") if uuid.nil? + + uuid + end + + enum SourceType + Audio + Video + end + + # Build friendly source names based on a device state. + # + # Maps all streams into `Stream1`...`StreamN` style names based on + # subscriptions. Local inputs (`Input1`, `Input2`, `AnalogueAudio` etc) are + # left untouched. + protected def query_source_name_for(type : SourceType) + type_downcase = type.to_s.downcase + + # "ActiveAudioSource":"Input1|Input2|Analog|PrimaryAudio|SecondaryAudio", + # "ActiveVideoSource":"None|Input1|Input2|Stream", + query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source", priority: 0) do |source_name| + if source_name.as_s.includes? "Stream" + # "Routes": [{ + # "AudioSource": "07147488-9e0b-11e7-abc4-cec278b6b50a", + # "AutomaticStreamRoutingEnabled": false, + # "Name": "PrimaryStream", + # "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c", + # "VideoSource": "07147488-9e0b-11e7-abc4-cec278b6b50a" + # }] + query("/AvRouting/Routes", name: :routes, priority: 1) do |routes| + uuid = routes.dig?(0, "#{type}Source").try &.as_s? + # FIXME: provide 'Stream1..n' rather than uuids + self["#{type_downcase}_source"] = uuid.presence ? "Stream-#{uuid}" : "None" + end + else + self["#{type_downcase}_source"] = source_name + end + end + end + + # Query the device for the current source state and update status vars. + protected def update_source_info + query_source_name_for(:video) + query_source_name_for(:audio) + query_device_name + end +end diff --git a/drivers/crestron/nvx_rx_spec.cr b/drivers/crestron/nvx_rx_spec.cr new file mode 100644 index 00000000000..9337e4f1cd2 --- /dev/null +++ b/drivers/crestron/nvx_rx_spec.cr @@ -0,0 +1,86 @@ +require "placeos-driver/spec" +require "uri" + +DriverSpecs.mock_driver "Crestron::NvxRx" do + # Connected callback makes some queries + should_send "/Device/DeviceSpecific/DeviceMode" + responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Receiver"}}}) + + should_send "/Device/XioSubscription/Subscriptions" + responds %({"Device": {"XioSubscription": {"Subscriptions": { + "00000000-0000-4002-0054-018a0089fd1c": { + "Address": "https://10.254.47.133/onvif/services", + "AudioChannels": 0, + "AudioFormat": "No Audio", + "Bitrate": 750, + "Encryption": true, + "Fps": 0, + "MulticastAddress": "228.228.228.224", + "Position": 2, + "Resolution": "0x0", + "RtspUri": "rtsp://10.254.47.133:554/live.sdp", + "SessionName": "DM-NVX-E30-DEADBEEF1234", + "SnapshotUrl": "", + "Transport": "TS/RTP", + "UniqueId": "00000000-0000-4002-0054-018a0089fd1c", + "VideoFormat": "Pixel", + "IsSyncDetected": false, + "Status": "SUBSCRIBED" + } + }}}}) + + should_send "/Device/Localization/Name" + responds %({"Device": {"Localization": {"Name": "projector"}}}) + + should_send "/Device/DeviceSpecific/ActiveVideoSource" + responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Stream"}}}) + + should_send "/Device/AvRouting/Routes" + responds %({"Device": {"AvRouting": {"Routes": [ + { + "Name": "Routing0", + "AudioSource": "00000000-0000-4002-0054-018a0089fd1c", + "VideoSource": "00000000-0000-4002-0054-018a0089fd1c", + "UsbSource": "00000000-0000-4002-0054-018a0089fd1c", + "AutomaticStreamRoutingEnabled": false, + "UniqueId": "cc063ec3-d135-4413-9ee9-5a9264b5642c" + } + ]}}}) + + should_send "/Device/DeviceSpecific/ActiveAudioSource" + responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}}) + + status[:video_source].should eq("Stream-00000000-0000-4002-0054-018a0089fd1c") + status[:audio_source].should eq("Input1") + status[:device_name].should eq("projector") + + # we call this manually as the driver isn't loaded in websocket mode + exec :authenticate + + # We expect the first thing it to do is authenticate + auth = URI::Params.build { |form| + form.add("login", "admin") + form.add("passwd", "admin") + } + + expect_http_request do |request, response| + io = request.body + if io + request_body = io.gets_to_end + if request_body == auth + response.status_code = 200 + response.headers["CREST-XSRF-TOKEN"] = "1234" + cookies = response.cookies + cookies["AuthByPasswd"] = "true" + cookies["iv"] = "true" + cookies["tag"] = "true" + cookies["userid"] = "admin" + cookies["userstr"] = "admin" + else + response.status_code = 401 + end + else + raise "expected request to include login form #{request.inspect}" + end + end +end diff --git a/drivers/crestron/nvx_scaler_control.cr b/drivers/crestron/nvx_scaler_control.cr new file mode 100644 index 00000000000..df1f0dcf110 --- /dev/null +++ b/drivers/crestron/nvx_scaler_control.cr @@ -0,0 +1,71 @@ +require "placeos-driver" +require "./nvx_models" + +class Crestron::NvxScalerControl < PlaceOS::Driver + descriptive_name "Crestron NVX Scaler Control" + generic_name :NvxAddressManager + + description <<-DESC + Synconisation tool for managing scaling settings on NVX decoders based + on window aspect ratios of a videowall processor. + + To enable flexible / user selectable distribution of both 16:9 and 21:9 + signals, aspect ratio control across both the videowall processor and + NVX decoders is exploited to keep things looking nice. + + In the case a decoder is being displayed on a 16:9 window it is set to + scale-to-fit, enabling ultrawide signals to be letterboxed. When a + signal is being send to an ultrawide window it is instead set to + scale-to-fill (stretch) on the NVX, then a second level of distortion + is applied on the videowall processor to convert this back to it's + original aspect. + + This approach keeps all components of the signal chain at 1080p / 4K and + enables live switching all all sources without EDID re-negotation. + DESC + + default_settings({ + # Mapping of { : } + link_scalers: { + window_1: "Decoder_1", + window_2: "Decoder_2", + }, + }) + + @links : Hash(String, String) = {} of String => String + + # Window of aspect ratio's to detect as 16:9 - allows for +/-5% for + # slightly off-shape windows + SCALE_TO_FIT_BOUNDS = (16 / 9 * 0.95)..(16 / 9 * 1.05) + + def on_load + on_update + end + + def on_update + @links = setting?(Hash(String, String), :link_scalers) || {} of String => String + end + + bind VideoWall_1, :windows, :videowall_windows_changed + + private def videowall_windows_changed(_subscription, new_value) + windows = Hash(String, NamedTuple(canwidth: Float64, canheight: Float64)).from_json new_value + windows.each do |id, props| + next unless @links.has_key? id + + nvx = system.get @links[id] + + aspect_ratio = props[:canwidth] / props[:canheight] + + if aspect_ratio.nan? + logger.debug { "#{id} not positioned on canvas, skipping" } + elsif SCALE_TO_FIT_BOUNDS.includes? aspect_ratio + logger.debug { "detected #{id} as 16:9, maintaining aspect" } + nvx.aspect_ratio AspectRatio::MaintainAspectRatio + else + logger.debug { "detected #{id} as ultrawide, filling window" } + nvx.aspect_ratio AspectRatio::StretchToFit + end + end + end +end diff --git a/drivers/crestron/nvx_scaler_control_spec.cr b/drivers/crestron/nvx_scaler_control_spec.cr new file mode 100644 index 00000000000..06e402ae452 --- /dev/null +++ b/drivers/crestron/nvx_scaler_control_spec.cr @@ -0,0 +1,37 @@ +require "placeos-driver/spec" +require "./nvx_models" + +DriverSpecs.mock_driver "Crestron::NvxScalerControl" do + system({ + Decoder: {NvxDecoderMock, NvxDecoderMock}, + VideoWall: {VideoWallMock}, + }) + + sleep 1 + + system(:Decoder_1)[:aspect_ratio].should eq "MaintainAspectRatio" + system(:Decoder_2)[:aspect_ratio].should eq "StretchToFit" +end + +# :nodoc: +class NvxDecoderMock < DriverSpecs::MockDriver + def aspect_ratio(mode : Crestron::AspectRatio) + self[:aspect_ratio] = mode + end +end + +# :nodoc: +class VideoWallMock < DriverSpecs::MockDriver + def on_load + self[:windows] = { + "window_1" => { + canwidth: 1920, + canheight: 1080, + }, + "window_2" => { + canwidth: 1080, + canheight: 1080, + }, + } + end +end diff --git a/drivers/crestron/nvx_tx.cr b/drivers/crestron/nvx_tx.cr new file mode 100644 index 00000000000..67541e6ad38 --- /dev/null +++ b/drivers/crestron/nvx_tx.cr @@ -0,0 +1,131 @@ +require "./cres_next" +require "placeos-driver/interface/switchable" + +class Crestron::NvxTx < Crestron::CresNext # < PlaceOS::Driver + enum Input + None + Input1 + Input2 + end + include PlaceOS::Driver::Interface::InputSelection(Input) + include Crestron::Transmitter + + descriptive_name "Crestron NVX Transmitter" + generic_name :Encoder + description <<-DESC + Crestron NVX network media encoder. + DESC + + uri_base "wss://192.168.0.5/websockify" + + def connected + # NVX hardware can be confiured a either a RX or TX unit - check this + # device is in the correct mode. + query("/DeviceSpecific/DeviceMode") do |mode| + # "DeviceMode":"Transmitter|Receiver", + next if mode == "Transmitter" + logger.warn { "device configured as a #{mode}" } + self[:WARN] = "device configured as a #{mode}. Expecting Transmitter" + end + + # Background poll to remain in sync with any external routing changes + schedule.every(5.minutes, immediate: true) { update_source_info } + end + + def switch_to(input : Input) + logger.debug { "switching to #{input}" } + update( + "/DeviceSpecific", + {VideoSource: input, AudioSource: "AudioFollowsVideo"}, + name: :switch + ).get + update_source_info + end + + def output(state : Bool) + logger.debug { "#{state ? "enabling" : "disabling"} output sync" } + + update( + "/AudioVideoInputOutput/Outputs", + [{ + Ports: [{ + Hdmi: {IsOutputDisabled: !state}, + }], + }], + name: :output + ) + end + + def multicast_address(address : String) + logger.debug { "setting multicast address to #{address}" } + update("/StreamTransmit/Streams", [{MulticastAddress: address}], name: :multicast_address) + end + + def emulate_input_sync(state : Bool = true, idx : Int32 = 1) + self["input_#{idx}_sync"] = state + end + + # Build friendly source names based on a device state. + protected def query_source_name_for(type : SourceType) + type_downcase = type.to_s.downcase + query("/DeviceSpecific/Active#{type}Source", name: "#{type_downcase}_source") do |source_name| + self["#{type_downcase}_source"] = source_name + end + end + + protected def query_multicast_address + query("/StreamTransmit/Streams", name: "streams") do |streams| + self["multicast_address"] = streams.dig(0, "MulticastAddress") + end + end + + protected def query_stream_name + query("/Localization/Name", name: "stream_name") do |name| + self["stream_name"] = name + end + end + + # Query the device for the current source state and update status vars. + protected def update_source_info + query_stream_name + query_multicast_address + query_source_name_for(:video) + query_source_name_for(:audio) + end + + def received(data, task) + raw_json = String.new data + logger.debug { "Crestron sent: #{raw_json}" } + + return unless raw_json.includes? "AudioVideoInputOutput" + payload = JSON.parse(raw_json) + + # we're checking if a device is plugged into a port + # Device/AudioVideoInputOutput/Inputs/0/Ports/0/IsSyncDetected + if av_inputs = payload.dig?("Device", "AudioVideoInputOutput", "Inputs").try &.as_a? + av_inputs.each do |input| + name = input["Name"]?.try(&.as_s) || "" + + # Device returns inputs as "input0", "input1" ... "inputN" within + # long poll responses, but appears to reference these same inputs + # as "input-1", "input-2" ... "input-N" within direct state queries. + idx = case name + when /input(\d+)/ + # increment by 1 + $~[1].to_i.succ + when /input-(\d+)/ + $~[1].to_i + else + # There also appears to be situations where no name is + # returned. As only the first input is in use across all + # encoders, default to input 1 as a nasty hack around + # this craziness. + 1 + end + + sync = input.dig?("Ports", 0, "IsSyncDetected").try &.as_bool? + self["input_#{idx}_sync"] = sync unless sync.nil? + end + end + end +end diff --git a/drivers/crestron/nvx_tx_spec.cr b/drivers/crestron/nvx_tx_spec.cr new file mode 100644 index 00000000000..88eab595f0a --- /dev/null +++ b/drivers/crestron/nvx_tx_spec.cr @@ -0,0 +1,33 @@ +require "placeos-driver/spec" +require "uri" + +DriverSpecs.mock_driver "Crestron::NvxRx" do + # Connected callback makes some queries + should_send "/Device/DeviceSpecific/DeviceMode" + responds %({"Device": {"DeviceSpecific": {"DeviceMode": "Transmitter"}}}) + + should_send "/Device/Localization/Name" + responds %({"Device": {"Localization": {"Name": "pc-in-rack"}}}) + + should_send "/Device/StreamTransmit/Streams" + responds %({"Device": {"StreamTransmit": {"Streams": [{"MulticastAddress": "192.168.0.2"}]}}}) + + should_send "/Device/DeviceSpecific/ActiveVideoSource" + responds %({"Device": {"DeviceSpecific": {"ActiveVideoSource": "Input1"}}}) + + should_send "/Device/DeviceSpecific/ActiveAudioSource" + responds %({"Device": {"DeviceSpecific": {"ActiveAudioSource": "Input1"}}}) + + status[:stream_name].should eq("pc-in-rack") + status[:multicast_address].should eq("192.168.0.2") + status[:audio_source].should eq("Input1") + status[:audio_source].should eq("Input1") + + transmit %({"Device": {"AudioVideoInputOutput": {"Inputs": [ + {"Name": "input0", "Ports": [{"IsSyncDetected": true}]}, + {"Name": "input-2", "Ports": [{"IsSyncDetected": false}]} + ]}}}) + + status["input_1_sync"].should eq(true) + status["input_2_sync"].should eq(false) +end diff --git a/drivers/crestron/occupancy_sensor.cr b/drivers/crestron/occupancy_sensor.cr new file mode 100644 index 00000000000..9648cb4e0aa --- /dev/null +++ b/drivers/crestron/occupancy_sensor.cr @@ -0,0 +1,139 @@ +require "placeos-driver" +require "placeos-driver/interface/sensor" +require "./cres_next_auth" + +# This device doesn't seem to support a websocket interface +# and relies on long polling + +class Crestron::OccupancySensor < PlaceOS::Driver + include Crestron::CresNextAuth + include Interface::Sensor + + descriptive_name "Crestron Occupancy Sensor" + generic_name :Occupancy + + uri_base "https://192.168.0.5" + + default_settings({ + username: "admin", + password: "admin", + }) + + @mac : String = "" + @name : String? = nil + @occupied : Bool = false + @connected : Bool = false + getter last_update : Int64 = 0_i64 + getter poll_counter : UInt64 = 0_u64 + + @long_polling = false + + def on_load + schedule.every(10.minutes) { authenticate } + schedule.every(1.hour) { poll_device_state } + end + + def connected + @connected = true + + authenticate + poll_device_state + end + + def disconnected + @connected = false + end + + def poll_device_state : Nil + response = get("/Device") + raise "unexpected response code: #{response.status_code}" unless response.success? + payload = JSON.parse(response.body) + + @last_update = Time.utc.to_unix + self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool + self[:presence] = @occupied ? 1.0 : 0.0 + self[:mac] = @mac = format_mac payload.dig("Device", "DeviceInfo", "MacAddress").as_s + self[:name] = @name = payload.dig("Device", "DeviceInfo", "Name").as_s? + + # Start long polling once we have state + @poll_counter += 1 + long_poll unless @long_polling + end + + protected def format_mac(address : String) + address.gsub(/(0x|[^0-9A-Fa-f])*/, "").downcase + end + + # NOTE:: /Device/Longpoll + # 200 == check data + # when nothing new: {"Device":"Response Timeout"} + # when update: {"Device":{"SystemClock":{"CurrentTime":"2022-10-22T20:29:03Z","CurrentTimeWithOffset":"2022-10-22T20:29:03+09:30"}}} + # 301 == authentication required + # could auth every so often to prevent hitting this too + protected def long_poll + @long_polling = true + response = get("/Device/Longpoll") + + authenticate if response.status_code == 301 + raise "unexpected response code: #{response.status_code}" unless response.success? + + raw_json = response.body + logger.debug { "long poll sent: #{raw_json}" } + + return unless raw_json.includes? "IsRoomOccupied" + payload = JSON.parse(raw_json) + + @last_update = Time.utc.to_unix + self[:occupied] = @occupied = payload.dig("Device", "OccupancySensor", "IsRoomOccupied").as_bool + self[:presence] = @occupied ? 1.0 : 0.0 + rescue timeout : IO::TimeoutError + logger.debug { "timeout waiting for long poll to complete" } + rescue error + logger.warn(exception: error) { "during long polling" } + ensure + if @connected + spawn(same_thread: true) { long_poll } + else + @long_polling = false + end + end + + # ====================== + # Sensor interface + # ====================== + + SENSOR_TYPES = {SensorType::Presence} + NO_MATCH = [] of Interface::Sensor::Detail + + def sensors(type : String? = nil, mac : String? = nil, zone_id : String? = nil) : Array(Interface::Sensor::Detail) + logger.debug { "sensors of type: #{type}, mac: #{mac}, zone_id: #{zone_id} requested" } + + return NO_MATCH if mac && mac != @mac + if type + sensor_type = SensorType.parse(type) + return NO_MATCH unless SENSOR_TYPES.includes?(sensor_type) + end + + [get_sensor_details] + end + + def sensor(mac : String, id : String? = nil) : Interface::Sensor::Detail? + logger.debug { "sensor mac: #{mac}, id: #{id} requested" } + return nil unless @mac == mac + get_sensor_details + end + + def get_sensor_details + Detail.new( + type: :presence, + value: @occupied ? 1.0 : 0.0, + last_seen: @connected ? Time.utc.to_unix : @last_update, + mac: @mac, + id: nil, + name: @name, + module_id: module_id, + binding: "presence", + status: @connected ? Status::Normal : Status::Fault, + ) + end +end diff --git a/drivers/crestron/occupancy_sensor_spec.cr b/drivers/crestron/occupancy_sensor_spec.cr new file mode 100644 index 00000000000..b163777e7d0 --- /dev/null +++ b/drivers/crestron/occupancy_sensor_spec.cr @@ -0,0 +1,114 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Crestron::OccupancySensor" do + full_query = %({ + "Device": { + "DeviceInfo": { + "BuildDate": "May 23 2022 (461338)", + "Category": "Linux Device", + "DeviceId": "@E-00107fec2d72", + "DeviceVersion": "3.0000.00002", + "Devicekey": "No SystemKey Server", + "MacAddress": "00.10.7f.ec.2d.72", + "Manufacturer": "Crestron", + "Model": "CEN-ODT-C-POE", + "Name": "Room1-Sensor", + "PufVersion": "3.0000.00002", + "RebootReason": "poweron", + "SerialNumber": "2027NEJ00064", + "Version": "2.1.0" + }, + "OccupancySensor": { + "ForceOccupied": "GET Not Supported, Write Only Property", + "ForceVacant": "GET Not Supported, Write Only Property", + "IsGraceOccupancyDetected": false, + "IsLedFlashEnabled": true, + "IsRoomOccupied": false, + "IsShortTimeoutEnabled": false, + "IsSingleSensorDeterminingOccupancy": true, + "IsSingleSensorDeterminingVacancy": true, + "Pir": { + "IsSensor1Enabled": true, + "OccupiedSensitivity": "Low", + "VacancySensitivity": "Low" + }, + "RawStates": { + "IsRawEnabled": false, + "RawOccupancy": false, + "RawPir": false, + "RawUltrasonic": false + }, + "TimeoutSeconds": 120, + "Ultrasonic": { + "IsSensor1Enabled": true, + "IsSensor2Enabled": true, + "OccupiedSensitivity": "Medium", + "VacancySensitivity": "Medium" + }, + "Version": "1.0.2" + } + } + }) + + # expect authentication + expect_http_request do |request, response| + data = request.body.try(&.gets_to_end) + if data == "login=admin&passwd=admin" + response.status_code = 200 + response.headers.add("Set-Cookie", [ + "userstr=61646d696e00;Path=/;Secure;HttpOnly;", + "userid=483d71e5ce65e6a6689a0e95adb3e2c5ff75ca5582c2f13d669e9213c0eeb9771a6923bf7c1aa1cef460ebf266f3231d;Path=/;Secure;HttpOnly;", + "iv=6023331f67beb11c89bb515f87580a6a;Path=/;Secure;HttpOnly;", + "tag=3877a0be20e70900c0ffb6b620e70900;Path=/;Secure;HttpOnly;", + "AuthByPasswd=crypt:36c319e11b69d1853c6d7070d3da33ec9f3194c840cbbb578f9690a4e9baf7da;Path=/;Secure;HttpOnly;", + "redirectCookie=;expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Secure;HttpOnly;", + ]) + else + response.status_code = 401 + response << "bad password" + end + end + + # expect a complete poll + expect_http_request do |request, response| + if request.path == "/Device" + response.status_code = 200 + response << full_query + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 0.5 + status[:occupied].should be_false + status[:name].should eq "Room1-Sensor" + status[:mac].should eq "00107fec2d72" + + # followed by a series of long polls + expect_http_request do |request, response| + if request.path == "/Device/Longpoll" + response.status_code = 200 + response << %({"Device": {"OccupancySensor": {"IsRoomOccupied": true}}}) + else + response.status_code = 401 + response << "badly formatted" + end + end + + sleep 0.5 + status[:occupied].should be_true + + resp = exec(:get_sensor_details).get.not_nil! + resp.should eq({ + "status" => "normal", + "type" => "presence", + "value" => 1.0, + "last_seen" => resp["last_seen"].as_i64, + "mac" => "00107fec2d72", + "name" => "Room1-Sensor", + "module_id" => "spec_runner", + "binding" => "occupied", + "location" => "sensor", + }) +end diff --git a/drivers/crestron/virtual_switcher.cr b/drivers/crestron/virtual_switcher.cr new file mode 100644 index 00000000000..f82c27996c4 --- /dev/null +++ b/drivers/crestron/virtual_switcher.cr @@ -0,0 +1,105 @@ +require "placeos-driver" +require "placeos-driver/interface/switchable" +require "./nvx_models" + +class Crestron::VirtualSwitcher < PlaceOS::Driver + descriptive_name "Crestron Virtual Switcher" + generic_name :Switcher + description <<-DESC + Enumerates the Creston Transmitters and Receivers in a system and provides + a simple interface for switching between avaiable streams + DESC + + include Interface::Switchable(String, Int32 | String) + + def transmitters + system.implementing(Crestron::Transmitter) + end + + def receivers + system.implementing(Crestron::Receiver) + end + + def switch_to(input : Input) + # todo need to lookup the input stream + receivers.switch_to(input) + end + + def available_inputs + encoder_name_map.keys + end + + def available_outputs + decoder_name_map.keys + end + + protected def decoder_name_map + name_map = {} of String => PlaceOS::Driver::Proxy::Driver + # map-reduce for speed + Promise.all(receivers.map { |rx| + Promise.defer { name_map[rx["device_name"].as_s] = rx rescue nil } + }).get + name_map + end + + protected def encoder_name_map + name_map = {} of String => PlaceOS::Driver::Proxy::Driver + # map-reduce for speed + Promise.all(transmitters.map { |tx| + Promise.defer { name_map[tx["stream_name"].as_s] = tx rescue nil } + }).get + name_map + end + + def switch(map : Hash(Input, Array(Output)), layer : SwitchLayer? = nil) + # TODO:: allow layered switching + layer ||= SwitchLayer::All + return unless layer.all? || layer.video? + + connect(map) do |mod, stream| + mod.switch_to(stream) + end + end + + private def connect(inouts : Hash(Input, Array(Output)), &) + inouts.each do |input, outputs| + if int_input = input.to_i? + if int_input == 0 + stream = 0 # disconnected + else + # Subtract one as Encoder_1 on the system would be encoder[0] here + if tx = transmitters[int_input - 1]? + stream = tx[:stream_name] + else + logger.warn { "could not find Encoder_#{input}" } + next + end + end + else + stream = input + end + + outputs = outputs.is_a?(Array) ? outputs : [outputs] + decoders = receivers + device_names = nil + outputs.each do |output| + case output + in Int32 + # Subtract one as Decoder_1 on the system would be decoder[0] here + if decoder = decoders[output - 1]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder_#{output}" } + end + in String + device_names = decoder_name_map unless device_names + if decoder = device_names[output]? + yield(decoder, stream) + else + logger.warn { "could not find Decoder with name: #{output}" } + end + end + end + end + end +end diff --git a/drivers/denon/amplifier/av_receiver.cr b/drivers/denon/amplifier/av_receiver.cr new file mode 100644 index 00000000000..d2d98469ced --- /dev/null +++ b/drivers/denon/amplifier/av_receiver.cr @@ -0,0 +1,216 @@ +require "digest/md5" +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/switchable" + +# +module Denon; end + +module Denon::Amplifier; end + +# Protocol: https://aca.im/driver_docs/Denon/Denon%20AVR%20PROTOCOL%20V7.5.0.pdf +# +# NOTE:: Denon doesn't respond to commands that request the current state +# (ie if the volume is 100 and you request 100 it will not respond) +# + +class Denon::Amplifier::AvReceiver < PlaceOS::Driver + include PlaceOS::Driver::Interface::Powerable + include PlaceOS::Driver::Utilities::Transcoder + + @channel : Channel(String) = Channel(String).new + @stable_power : Bool = true + + COMMANDS = { + power: :PW, + power_query: :PW?, + mute: :MU, + mute_query: :MU?, + volume: :MV, + volume_query: :MV?, + input: :SI, + input_query: :SI?, + } + COMMANDS.to_h.merge!(COMMANDS.to_h.invert) + + @volume_range = 0..196 + + default_settings({ + max_waits: 10, + timeout: 3000, + }) + # Discovery Information + tcp_port 23 # Telnet + descriptive_name "Denon AVR (Switcher Amplifier)" + generic_name :Switcher + + # Denon requires some breathing room + # delay between_sends: 30 + # delay on_receive: 30 + + def on_load + # transport.tokenizer = Tokenizer.new(Bytes[0x0D]) + transport.tokenizer = Tokenizer.new("\r") + self[:volume_min] = 0 + self[:volume_max] = @volume_range.max # == 98 * 2 - Times by 2 so we can account for the half steps + on_update + end + + def on_update + self[:max_waits] = 10 + self[:timeout] = 3000 + end + + def connected + # + # Get state + # + # power? + # input? + # mute? + + schedule.every(60.seconds) do + logger.info { "-- Polling Denon AVR" } + power? + do_send(:input, priority: 0, name: :input) + end + end + + def disconnected + schedule.clear + end + + def power(state : Bool = false) + # self[:power] is current as we would be informed otherwise + if state && (self[:power] == "OFF" || self[:power] == "STANDBY") # Request to power on if off + do_send(:power, "ON", delay: 3.milliseconds, name: :power) # Manual states delay for 1 second, just to be safe + elsif !state && self[:power] == "ON" # Request to power off if on + do_send(:power, "STANDBY", delay: 3.milliseconds, name: :power) + end + end + + def power? + # def power?(**options) + # options[:emit] = {:power => block} unless block.nil? + do_send(:power_query, priority: 0, name: :power_query) + end + + def mute? + self[:mute] = "OFF" + do_send(:mute_query, priority: 0, name: :mute_query) + end + + def mute(state : Bool = true) + req = state ? "ON" : "OFF" + return if self[:mute] == req + do_send(:mute, req, name: :mute) + end + + def mute_audio(state : Bool = true) + mute state + end + + def unmute + mute false + end + + def unmute_audio + unmute + end + + def volume(level : Float64 | Int32 = 0) + level = level.to_f.clamp(0.0, 100.0) + return if self[:volume] == level + + percentage = level / 100.0 + value = (percentage * @volume_range.end.to_f).round_away.to_i + + # The denon is weird 99 is volume off, + # 99.5 is the minimum volume, + # 0 is the next lowest volume and 985 is the loudest volume + # => So we are treating 99, 995 and 0 as 0 + step = value % 2 + actual = value / 2 + req = actual.to_s.rjust(2, '0') + req += "5" if step != 0 + + do_send(:volume, req, name: :volume) # Name prevents needless queuing of commands + end + + def volume? + do_send(:volume_query, priority: 0, name: :volume_query) + end + + # Just here for documentation (there are many more) + # + # INPUTS = [:cd, :tuner, :dvd, :bd, :tv, :"sat/cbl", :dvr, :game, :game2, :"v.aux", :dock] + def input(input : String = "") + status = input.upcase # .downcase.to_sym + if status != self[:input] + input = input.to_s.upcase + do_send(:input, input, name: :input) + end + end + + def input? + do_send(:input_query, priority: 0, name: :input_query) + end + + def received(data, task) + data = String.new(data) + logger.info { "Denon sent #{data.inspect}" } + + return unless task + + # Process the response + cmd = data[0..1] # first 2 chars are the key / command + val = data[2..-2] # anything following the above and before \r is a response value + + case cmd + when "PW" + self[:power] = val + when "SI" + self[:input] = val + when "MV" + # return :ignore if val.chars.size > 3 # May send 'MVMAX 98' after volume command + # self[:volume] = 0 + # vol = val.to_i32 + # self[:volume] = val unless val.to_i32 > @volume_range.max + vol_percent = ((val.to_f * 2) / @volume_range.end.to_f) * 100.0 + self[:volume] = vol_percent + # return :ignore if param.length > 3 # May send 'MVMAX 98' after volume command + # vol = param[0..1].to_i * 2 + # vol += 1 if param.length == 3 + # vol == 0 if vol > @volume_range.max # this means the volume was 99 or 995 + # self[:volume] = vol + + when "MU" + self[:mute] = val + else + return :ignore + end + + task.try &.success + end + + protected def do_send(command, param = nil, **options) + # prepare the command + cmd = if param.nil? + "#{COMMANDS[command]}" + else + "#{COMMANDS[command]}#{param}" + end + logger.info { "Queing: #{cmd}" } + + # queue the request + queue(**({ + name: command, + }.merge(options))) do + @channel = Channel(String).new + # send the request + logger.info { " Sending: #{cmd}" } + transport.send(cmd) + end + end +end diff --git a/drivers/denon/amplifier/av_receiver_spec.cr b/drivers/denon/amplifier/av_receiver_spec.cr new file mode 100644 index 00000000000..fa81e17bd9e --- /dev/null +++ b/drivers/denon/amplifier/av_receiver_spec.cr @@ -0,0 +1,73 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Denon::Amplifier::AvReceiver" do + #### + # POWER + # + sleep 1.second + # query power + exec(:power?) + should_send("PW?") + responds("PWOFF\r") + status[:power].should eq("OFF") + # turn power on + exec(:power, true) + should_send("PWON") + responds("PWON\r") + status[:power].should eq("ON") + # power off turns amp to STANDBY not actually OFF + exec(:power, false) + should_send("PWSTANDBY") + responds("PWSTANDBY\r") + status[:power].should eq("STANDBY") + + #### + # INPUT + # + sleep 1.second + # query input > DVD + exec(:input?) + should_send("SI?") + responds("SIDVD\r") + status[:input].should eq("DVD") + # chaange input to tuner + exec(:input, "TUNER") + should_send("SITUNER") + responds("SITUNER\r") + status[:input].should eq("TUNER") + + #### + # VOLUME + # + sleep 1.second + # query + exec(:volume?) + should_send("MV?") + responds("MV49\r") + status[:volume].should eq(50.0) + # change volume + exec(:volume, 100) + should_send("MV98.0") + responds("MV98.0\r") + status[:volume].should eq(100.0) + + #### + # MUTE + # + sleep 1.second + # query + exec(:mute?) + should_send("MU?") + responds("MUOFF\r") + status[:mute].should eq("OFF") + # mute on + exec(:mute, true) + should_send("MUON") + responds("MUON\r") + status[:mute].should eq("ON") + # mute off + exec(:mute, false) + should_send("MUOFF") + responds("MUOFF\r") + status[:mute].should eq("OFF") +end diff --git a/drivers/echo360/device_capture.cr b/drivers/echo360/device_capture.cr new file mode 100644 index 00000000000..e96ef9df3e7 --- /dev/null +++ b/drivers/echo360/device_capture.cr @@ -0,0 +1,171 @@ +require "placeos-driver" +require "oq" + +# Documentation: https://aca.im/driver_docs/Echo360/EchoSystemCaptureAPI_v301.pdf + +class Echo360::DeviceCapture < PlaceOS::Driver + # Discovery Information + generic_name :Capture + descriptive_name "Echo365 Device Capture" + uri_base "https://echo.server" + + default_settings({ + basic_auth: { + username: "srvc_acct", + password: "password!", + }, + }) + + def on_load + on_update + end + + def on_update + schedule.clear + schedule.every(15.seconds) do + logger.debug { "-- Polling Capture" } + system_status + capture_status + end + end + + STATUS_CMDS = { + system_status: :system, + capture_status: :captures, + next: :next_capture, + current: :current_capture, + state: :monitoring, + } + + {% begin %} + {% for function, route in STATUS_CMDS %} + {% path = "/status/#{route.id}" %} + def {{function.id}} + response = get({{path}}) + process_status check(response) + end + {% end %} + {% end %} + + @[Security(PlaceOS::Driver::Level::Support)] + def restart_application + post("/diagnostics/restart_all").success? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def reboot + post("/diagnostics/reboot").success? + end + + @[Security(PlaceOS::Driver::Level::Support)] + def captures + response = get("/diagnostics/recovery/saved-content") + self[:captures] = check(response)["captures"]["capture"] + end + + @[Security(PlaceOS::Driver::Level::Support)] + def upload(id : String) + response = post("/diagnostics/recovery/#{id}/upload") + raise "upload request failed with #{response.status_code}\n#{response.body}" unless response.success? + response.body + end + + # This will auto-start a recording + def capture(name : String, duration : Int32, profile : String? = nil) + profile ||= self[:capture_profiles][0].as_s + response = post("/capture/new_capture", body: URI::Params.build { |form| + form.add("description", name) + form.add("duration", duration.to_s) + form.add("capture_profile_name", profile) + }) + check(response)["ok"]["#text"].as_s + end + + def test_capture(name : String, duration : Int32, profile : String? = nil) + profile ||= self[:capture_profiles][0].as_s + response = post("/capture/confidence_monitor", body: URI::Params.build { |form| + form.add("description", name) + form.add("duration", duration.to_s) + form.add("capture_profile_name", profile) + }) + check(response)["ok"]["#text"].as_s + end + + def extend(duration : Int32) + response = post("/capture/confidence_monitor", body: URI::Params.build { |form| + form.add("duration", duration.to_s) + }) + check(response)["ok"]["#text"].as_s + end + + def pause + response = post("/capture/pause") + check(response)["ok"]["#text"].as_s + end + + def start + response = post("/capture/record") + check(response)["ok"]["#text"].as_s + end + + def resume + start + end + + def record + start + end + + def stop + response = post("/capture/stop") + check(response)["ok"]["#text"].as_s + end + + # Converts the response into the appropriate format and indicates success / failure + protected def check(response) + raise "request failed with #{response.status_code}\n#{response.body}" unless response.success? + + # Convert the XML to JSON for simple parsing + # https://www.xml.com/pub/a/2006/05/31/converting-between-xml-and-json.html + input_io = IO::Memory.new response.body + output_io = IO::Memory.new + OQ::Converters::XML.deserialize input_io, output_io + + output_io.rewind + json = JSON.parse(output_io) + logger.debug { "response was\n#{json.pretty_inspect}" } + json + end + + CHECK = {"next", "current"} + + # generic function for processing status and exposing the state + protected def process_status(data) + if results = data["status"]?.try(&.as_h) + results.each do |key, value| + if key.in?(CHECK) && (value.as_s?.try(&.strip.empty?) || value["schedule"]?.try(&.as_s?.try(&.strip.empty?))) + # next / current recordings are not present + self[key] = nil + elsif key[-1] == 's' && (hash = value.as_h?) + # This handles `{"api-versions" => {"api-version" => "3.0"}}` + inner = hash[key[0..-2]]? + if inner + self[key] = inner + else + self[key] = hash + end + elsif str_val = value.as_s?.try(&.strip) + # cleanup whitespace around string values + self[key] = str_val + else + # otherwise we don't manipulate the value and expose it for use + self[key] = value + end + end + results + else + logger.debug { "namespace 'status' not found, ignoring payload" } + data + end + end +end diff --git a/drivers/echo360/device_capture_spec.cr b/drivers/echo360/device_capture_spec.cr new file mode 100644 index 00000000000..aec824dcce2 --- /dev/null +++ b/drivers/echo360/device_capture_spec.cr @@ -0,0 +1,182 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Echo360::DeviceCapture" do + retval = exec(:system_status) + expect_http_request do |request, response| + if request.path == "/status/system" + response.status_code = 200 + response << SYSTEM_STATUS + else + puts "unexpected path #{request.path}" + response.status_code = 404 + end + end + retval.get + + status["api-versions"].should eq "3.0" + + retval = exec(:captures) + expect_http_request do |_request, response| + response.status_code = 200 + response << CAPTURE_STATUS + end + retval.get + status[:captures].as_a.size.should eq(2) +end + +CAPTURE_STATUS = <<-HEREDOC + + + Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014 + 2014-02-12T15:30:00.000Z + 3000 +
Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ + + John Doe + + +
+ + Some other capture + 2014-02-13T15:30:00.000Z + 1500 +
Some other capture
+ + + Steve + + +
+
+ HEREDOC + +SYSTEM_STATUS = <<-HEREDOC + + 2014-02-12T15:02:19.037Z + + 3.0 + + + Audio Only (Podcast). Balanced between file size & quality + Display Only (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + Display/Video (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + Display/Video (Podcast/Vodcast/EchoPlayer). Optimized for quality/full motion video + DualDisplay (Podcast/Vodcast/EchoPlayer). Optimized for file size & bandwidth + Dual Video (Podcast/Vodcast/EchoPlayer) -Balance between file size & quality + Dual Video (Podcast/Vodcast/EchoPlayer) -High Quality + Video Only (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + + + Display/Video (Podcast/Vodcast/EchoPlayer). Balanced between file size & quality + + + media + 2014-02-12T23:00:00.000Z + 3000 + + Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014 +
Underwater Basket Weaving 101 (UWBW-101-100) Spring 2014
+ + John Doe + + + Display/Video (Podcast/Vodcast/EchoPlayer). Optimized for quality/full motion video + archive + + + + balanced + stereo + -6 + 44100 + 0 + false + + + 1 + dvi + 50 + 50 + 50 + 10.0 + 960 + 720 + true + true + + + 2 + composite + 50 + 50 + 50 + 29.97 + 704 + 480 + true + false + ntsc + + + audio + aac + true + + 128000 + lc + + + + graphics1 + h264 + + vbr + 736000 + 1104000 + base + 50 + + + + graphics2 + h264 + + vbr + 1056000 + 1584000 + base + 150 + + + + audio-archive + + file + audio.aac + + + + graphics1-archive + + file + display.h264 + + + + graphics2-archive + + file + video.h264 + + + + + +
+
+ + + +
+ HEREDOC diff --git a/drivers/epson/projector/esc_vp21.cr b/drivers/epson/projector/esc_vp21.cr new file mode 100644 index 00000000000..73ad5e58641 --- /dev/null +++ b/drivers/epson/projector/esc_vp21.cr @@ -0,0 +1,231 @@ +require "placeos-driver" +require "placeos-driver/interface/muteable" +require "placeos-driver/interface/powerable" +require "placeos-driver/interface/switchable" + +class Epson::Projector::EscVp21 < PlaceOS::Driver + include Interface::Powerable + include Interface::Muteable + + enum Input + HDMI = 0x30 + HDBaseT = 0x80 + end + + include Interface::InputSelection(Input) + + # Discovery Information + tcp_port 3629 + descriptive_name "Epson Projector" + generic_name :Display + + @power_target : Bool? = nil + @unmute_volume : Float64 = 60.0 + + def on_load + transport.tokenizer = Tokenizer.new("\r") + self[:type] = :projector + end + + def connected + # Have to init comms + send("ESC/VP.net\x10\x03\x00\x00\x00\x00") + schedule.every(52.seconds, true) { do_poll } + end + + def disconnected + schedule.clear + end + + def power(state : Bool) + if state + @power_target = true + logger.debug { "-- epson Proj, requested to power on" } + do_send(:power, "ON", delay: 40.seconds, name: "power") + else + @power_target = false + logger.debug { "-- epson Proj, requested to power off" } + do_send(:power, "OFF", delay: 10.seconds, name: "power") + end + self[:power] = state + power? + end + + def power?(**options) : Bool + do_send(:power, **options).get + !!self[:power]?.try(&.as_bool) + end + + def switch_to(input : Input) + logger.debug { "-- epson Proj, requested to switch to: #{input}" } + do_send(:input, input.value.to_s(16), name: :input) + + # for a responsive UI + self[:input] = input # for a responsive UI + self[:video_mute] = false + input? + end + + def input? + do_send(:input, priority: 0).get + self[:input] + end + + # Volume commands are sent using the inpt command + def volume(vol : Float64 | Int32, **options) + vol = vol.to_f.clamp(0.0, 100.0) + percentage = vol / 100.0 + vol_actual = (percentage * 255.0).round_away.to_i + + @unmute_volume = self[:volume].as_f if (mute = vol == 0.0) && self[:volume]? + do_send(:volume, vol_actual, **options, name: :volume) + + # for a responsive UI + self[:volume] = vol + self[:audio_mute] = mute + volume? + end + + def volume? + do_send(:volume, priority: 0).get + self[:volume]?.try(&.as_f) + end + + def mute( + state : Bool = true, + index : Int32 | String = 0, + layer : MuteLayer = MuteLayer::AudioVideo + ) + case layer + when .audio_video? + do_send(:av_mute, state ? "ON" : "OFF", name: :mute) + do_send(:av_mute, name: :mute?, priority: 0) + when .video? + do_send(:video_mute, state ? "ON" : "OFF", name: :video_mute) + video_mute? + when .audio? + val = state ? 0.0 : @unmute_volume + volume(val) + end + end + + def video_mute? + do_send(:video_mute, priority: 0).get + !!self[:video_mute]?.try(&.as_bool) + end + + ERRORS = [ + "00: no error", + "01: fan error", + "03: lamp failure at power on", + "04: high internal temperature", + "06: lamp error", + "07: lamp cover door open", + "08: cinema filter error", + "09: capacitor is disconnected", + "0A: auto iris error", + "0B: subsystem error", + "0C: low air flow error", + "0D: air flow sensor error", + "0E: ballast power supply error", + "0F: shutter error", + "10: peltiert cooling error", + "11: pump cooling error", + "12: static iris error", + "13: power supply unit error", + "14: exhaust shutter error", + "15: obstacle detection error", + "16: IF board discernment error", + ] + + def inspect_error + do_send(:error, priority: 0) + end + + COMMAND = { + power: "PWR", + input: "SOURCE", + volume: "VOL", + av_mute: "MUTE", + video_mute: "MSEL", + error: "ERR", + lamp: "LAMP", + } + RESPONSE = COMMAND.to_h.invert + + def received(data, task) + return task.try(&.success) if data.size <= 2 + data = String.new(data[1..-2]) + logger.debug { "epson Proj sent: #{data}" } + + data = data.split('=') + case RESPONSE[data[0]] + when :error + if data[1]? + code = data[1].to_i(16) + self[:last_error] = ERRORS[code]? || "#{data[1]}: unknown error code #{code}" + return task.try(&.success("Epson PJ error was #{self[:last_error]}")) + else # Lookup error! + return task.try(&.abort("Epson PJ sent error response for #{task.not_nil!.name || "unknown"}")) + end + when :power + state = data[1].to_i + self[:power] = state < 3 + self[:warming] = state == 2 + self[:cooling] = state == 3 + + if self[:warming].as_bool || self[:cooling].as_bool + schedule.in(5.seconds) { power?(priority: 0) } + end + + if (power_target = @power_target) && self[:power] == power_target + @power_target = nil + self[:video_mute] = false unless self[:power].as_bool + end + when :av_mute + self[:video_mute] = self[:audio_mute] = data[1] == "ON" + self[:volume] = 0.0 + when :video_mute + self[:video_mute] = data[1] == "ON" + when :volume + # convert to a percentage + vol = data[1].to_i + vol_percent = (vol.to_f / 255.0) * 100.0 + self[:volume] = vol_percent + + mute = vol == 0 + self[:audio_mute] = mute if mute + @unmute_volume ||= vol_percent unless mute + when :lamp + self[:lamp_usage] = data[1].to_i + when :input + self[:input] = Input.from_value(data[1].to_i(16)) || "unknown" + end + + task.try(&.success) + end + + def do_poll + if power?(priority: 0) + if power_target = @power_target + if self[:power]? != power_target + power(power_target) + else + @power_target = nil + end + else + input? + video_mute? + volume? + end + end + do_send(:lamp, priority: 0) + end + + private def do_send(command, param = nil, **options) + command = COMMAND[command] + cmd = param ? "#{command} #{param}\r" : "#{command}?\r" + logger.debug { "Epson proj sending #{command}: #{cmd}" } + send(cmd, **options) + end +end diff --git a/drivers/epson/projector/esc_vp21_spec.cr b/drivers/epson/projector/esc_vp21_spec.cr new file mode 100644 index 00000000000..726d7b36df1 --- /dev/null +++ b/drivers/epson/projector/esc_vp21_spec.cr @@ -0,0 +1,61 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Epson::Projector::EscVp21" do + # connected + should_send("ESC/VP.net\x10\x03\x00\x00\x00\x00") + responds(":\r") + # do_poll + # power? + should_send("PWR?\r") + responds(":PWR=01\r") + status[:power].should eq(true) + # input? + should_send("SOURCE?\r") + responds(":SOURCE=30\r") + status[:input].should eq("HDMI") + # video_mute? + should_send("MSEL?\r") + responds(":MSEL=0\r") + status[:video_mute].should eq(false) + # volume? + should_send("VOL?\r") + responds(":VOL=10\r") + status[:volume].should eq(10) + # lamp + should_send("LAMP?\r") + responds(":LAMP=20\r") + status[:lamp_usage].should eq(20) + + exec(:mute) + should_send("MUTE ON\r") + responds(":\r") + should_send("MUTE?\r") + responds(":MUTE=ON\r") + status[:video_mute].should eq(true) + status[:audio_mute].should eq(true) + status[:volume].should eq(0) + + exec(:switch_to, "HDBaseT") + should_send("SOURCE 80\r") + responds(":\r") + should_send("SOURCE?\r") + responds(":SOURCE=80\r") + status[:input].should eq("HDBaseT") + status[:video_mute].should eq(false) + + exec(:mute_audio, false) + should_send("VOL 10\r") + responds(":\r") + should_send("VOL?\r") + responds(":VOL=10\r") + status[:volume].should eq(10) + status[:audio_mute].should eq(false) + + exec(:volume, 50) + should_send("VOL 50\r") + responds(":\r") + should_send("VOL?\r") + responds(":VOL=50\r") + status[:volume].should eq(50) + status[:audio_mute].should eq(false) +end diff --git a/drivers/exterity/avedia_player/m93xx.cr b/drivers/exterity/avedia_player/m93xx.cr new file mode 100644 index 00000000000..102ea5747d0 --- /dev/null +++ b/drivers/exterity/avedia_player/m93xx.cr @@ -0,0 +1,181 @@ +require "telnet" +require "placeos-driver" + +class Exterity::AvediaPlayer::R93xx < PlaceOS::Driver + descriptive_name "Exterity Avedia Player (M93xx)" + generic_name :IPTV + tcp_port 22 + + default_settings({ + ssh: { + username: :ctrl, + password: :labrador, + }, + max_waits: 100, + channel_details: [ + { + name: "Al Jazeera", + icon: "https://url-to-svg-or-png", + channel: "udp://239.192.10.170:5000?hwchan=0", + }, + ], + }) + + class ChannelDetail + include JSON::Serializable + + getter name : String + getter icon : String? + getter channel : String + end + + @ready : Bool = false + @channel_lookup : Hash(String, ChannelDetail) = {} of String => ChannelDetail + + def on_load + on_update + end + + def on_update + channel_lookup = {} of String => ChannelDetail + if channel_details = setting?(Array(ChannelDetail), :channel_details) + self[:channel_details] = channel_details + channel_details.each { |lookup| channel_lookup[lookup.channel] = lookup } + else + self[:channel_details] = nil + end + @channel_lookup = channel_lookup + end + + def connected + self[:ready] = @ready = false + + schedule.every(59.seconds) do + logger.debug { "-- Polling Exterity Player" } + current_channel + current_channel_name + end + + schedule.every(1.hour) do + logger.debug { "-- Polling Exterity Player" } + dump + end + end + + def disconnected + self[:ready] = @ready = false + transport.tokenizer = nil + schedule.clear + end + + def channel(number : Int32 | String) + if number.is_a? Number + set :playChannelNumber, number, name: :channel + else + stream number + end + end + + def channel_name(name : String) + set(:currentChannel_name, name, name: :name).get + current_channel_name + end + + def stream(uri : String) + set(:playChannelUri, uri, name: :channel).get + name = @channel_lookup[uri]?.try &.name + + schedule.in(2.second) do + current_channel.get + if name && uri == self[:current_channel] + channel_name name + end + end + + name + end + + def current_channel + get :currentChannel + end + + def current_channel_name + get :currentChannel_name + end + + def dump + do_send "^dump!\r", name: :dump + end + + def help + do_send "^help!\r", name: :help + end + + def reboot + remote :reboot + end + + def tv_info + get :tv_info + end + + def version + get :SoftwareVersion + end + + @[Security(Level::Support)] + def manual(cmd : String) + do_send cmd + end + + def received(data, task) + data = String.new(data).strip + + logger.debug { "Exterity sent #{data}" } + + if !@ready && data =~ /Terminal Control Interface/i + logger.info { "-- got the control interface message, we're READY now" } + transport.tokenizer = Tokenizer.new("!") + self[:ready] = @ready = true + dump + return + end + + # Extract response between the ^ and ! + resp = data.split("^")[1][0..-2] + process_resp resp, task + end + + protected def process_resp(data, task) + logger.debug { "Resp details #{data}" } + + parts = data.split ':', 2 + + case parts[0] + when "error" + message = task ? "Error when requesting: #{task.try &.name}" : "Error response received" + logger.warn { message } + task.try &.abort(message) + else + self[parts[0].underscore] = parts[1] + task.try &.success(parts[1]) + end + end + + protected def do_send(command, **options) + logger.debug { "requesting #{command}" } + send command, **options + end + + protected def set(command, data, **options) + do_send "^set:#{command}:#{data}!\r", **options.merge({wait: false}) + end + + protected def remote(cmd, **options) + do_send "^send:#{cmd}!\r", **options + end + + protected def get(status, **options) + do_send "^get:#{status}!\r", **options + end +end diff --git a/drivers/exterity/avedia_player/m93xx_spec.cr b/drivers/exterity/avedia_player/m93xx_spec.cr new file mode 100644 index 00000000000..c55987759bb --- /dev/null +++ b/drivers/exterity/avedia_player/m93xx_spec.cr @@ -0,0 +1,29 @@ +require "placeos-driver/spec" + +DriverSpecs.mock_driver "Exterity::AvediaPlayer::R92xx" do + # this lets the driver know it's successfully connected + sleep 1 + status[:ready].should eq(false) + responds("Terminal Control Interface\r") + status[:ready].should eq(true) + + should_send("^dump!\r").responds %(^currentChannel:udp://239.193.3.169:5000?hwchan=4! +^currentChannel_name:SBS ONE HD! +^currentChannel_number:30! +^currentAVChannel:udp://239.193.3.169:5000?hwchan=4! +^new_channel:NO VALUE! +^cur_channel:udp://239.193.3.169:5000?hwchan=4!) + + status[:cur_channel].should eq "udp://239.193.3.169:5000?hwchan=4" + status[:current_channel_name].should eq "SBS ONE HD" + + resp = exec(:version) + responds("^SoftwareVersion:123!\r") + resp.get + status[:software_version].should eq("123") + + resp = exec(:tv_info) + responds("^tv_info:a,b,c,d,e,f,g!\r") + resp.get + status[:tv_info].should eq("a,b,c,d,e,f,g") +end diff --git a/drivers/exterity/avedia_player/r92xx.cr b/drivers/exterity/avedia_player/r92xx.cr new file mode 100644 index 00000000000..78bee328f70 --- /dev/null +++ b/drivers/exterity/avedia_player/r92xx.cr @@ -0,0 +1,167 @@ +require "telnet" +require "placeos-driver" + +class Exterity::AvediaPlayer::R92xx < PlaceOS::Driver + descriptive_name "Exterity Avedia Player (R92xx)" + generic_name :IPTV + tcp_port 23 + + default_settings({ + max_waits: 100, + username: "admin", + password: "labrador", + }) + + @ready : Bool = false + @telnet : Telnet? = nil + + def on_load + new_telnet_client + transport.pre_processor { |bytes| @telnet.try &.buffer(bytes) } + end + + def connected + @ready = false + self[:ready] = false + + schedule.every(60.seconds) do + logger.info { "-- Polling Exterity Player" } + tv_info + end + end + + def disconnected + # ensures the buffer is cleared + new_telnet_client + + schedule.clear + end + + def channel(number : Int32 | String) + if number.is_a? Number + set :playChannelNumber, number + else + stream number + end + end + + def stream(uri : String) + set :playChannelUri, uri + end + + def dump + do_send "^dump!", name: :dump + end + + def help + do_send "^help!", name: :help + end + + def reboot + remote :reboot + end + + def tv_info + get :tv_info + end + + def version + get :SoftwareVersion + end + + def manual(cmd : String) + do_send cmd + end + + def received(data, task) + data = String.new(data).strip + + logger.info { "Exterity sent #{data}" } + + if @ready + # Detect if logged out of serialCommandInterface + if data =~ /sh: .* not found/i + # Launch command processor + do_send "/usr/bin/serialCommandInterface", wait: false, delay: 2.seconds, priority: 95 + return :failure + end + + # Extract response + data.split("!").map(&.strip("^")).each do |resp| + process_resp(resp, task) + end + elsif data =~ /Exterity Control Interface| Exit/i + logger.info { "-- got the control interface message, we're READY now" } + @ready = true + self[:ready] = true + version + elsif data =~ /login:/i + logger.info { "-- got the login: prompt" } + transport.tokenizer = Tokenizer.new("\r") + + # login + do_send setting(String, :username), wait: false, delay: 200.milliseconds, priority: 98 + do_send setting(String, :password), wait: false, delay: 200.milliseconds, priority: 97 + + # select open shell option + do_send "6", wait: false, delay: 2.seconds, priority: 96 + + # launch command processor + do_send "/usr/bin/serialCommandInterface", wait: false, delay: 200.milliseconds, priority: 95 + + # we need to disconnect if we don't see the serialCommandInterface after a certain amount of time + schedule.in(20.seconds) do + if !@ready + logger.error { "Exterity connection failed to be ready after 5 seconds. Check username and password." } + disconnect + end + end + elsif logger.info { "Somehow we got here #{data}" } + end + + task.try &.success + end + + protected def process_resp(data, task) + logger.info { "Resp details #{data}" } + + parts = data.split ':' + + case parts[0].to_s + when "error" + if task != nil + logger.warn { "Error when requesting: #{task.try &.name}" } + else + logger.warn { "Error response received" } + end + when "tv_info" + self[:tv_info] = parts[1] + when "SoftwareVersion" + self[:version] = parts[1] + end + end + + protected def new_telnet_client + @telnet = Telnet.new do |data| + transport.send(data) + end + end + + protected def do_send(command, **options) + logger.info { "requesting #{command}" } + send @telnet.not_nil!.prepare(command), **options + end + + protected def set(command, data, **options) + # options[:name] = :"set_#{command}" unless options[:name] + do_send "^set:#{command}:#{data}!", **options + end + + protected def remote(cmd, **options) + do_send "^send:#{cmd}!", **options + end + + protected def get(status, **options) + do_send "^get:#{status}!", **options + end +end diff --git a/drivers/exterity/avedia_player/r92xx_protocol.md b/drivers/exterity/avedia_player/r92xx_protocol.md new file mode 100644 index 00000000000..fdad269cab1 --- /dev/null +++ b/drivers/exterity/avedia_player/r92xx_protocol.md @@ -0,0 +1,415 @@ + +# Exterity AvediaPlayer r9200 Control Protocol. + +NOTE:: All information in this document was obtained via exploration of the R9200 device. +No information here was provided by Exterity during this process + + +## Connecting + +* Telnet Protocol (port 23) +* `telnet 192.168.1.13` +* Default username: `admin` +* Default password: `labrador` +* Select option `6` to run a shell + + +## Shell Navigation + +Once in the shell you can use following tools to read files: + +* `less` for scanning through files +* `cat` for dumping files +* `ps aux` for viewing processes +* `ls` for listing files + +The file system is readonly so moving files to `/usr/local/www` for downloading was not possible. + + +## Applications + +* Application are installed at: `/usr/bin` + * `serialCommandInterface` allows programmatic control of the device + * `irsend` for sending IR commands +* Configuration is at: `/etc` + * `lircd.conf` contains the human readable names of all the IR commands + +``` +begin remote + + name exterity_remote_2 + + bits 16 + flags SPACE_ENC + eps 20 + aeps 200 + + header 8800 4400 + one 550 1650 + zero 550 550 + ptrail 550 + repeat 8800 2200 + pre_data_bits 16 + pre_data 0xB5B7 + gap 38500 + toggle_bit 0 + frequency 38000 + +#! exterity_bit_period 560 +#! exterity_aeps 500 +#! exterity_rmpower_len 66 +#! exterity_rmpower_pattern 16 8 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 1 1 3 1 3 1 3 1 3 1 1 1 3 1 1 1 3 1 3 1 1 1 3 1 1 1 3 1 1 1 3 1 1 1 1 1 3 1 1 + + begin codes + rm_1 0x45ba + rm_2 0x35ca + rm_3 0x6d92 + rm_4 0xc53a + rm_5 0xb54a + rm_6 0xed12 + rm_7 0x25da + rm_8 0x758a + rm_9 0x1de2 + rm_cancel 0x03fc + rm_0 0xf50a + rm_menu 0xa55a + rm_power 0xad52 + rm_chup 0x0df2 + rm_chdown 0x8d72 + rm_volup 0x5da2 + rm_voldown 0xdd22 + rm_up 0x4db2 + rm_left 0x956a + rm_enter 0xcd32 + rm_right 0xbd42 + rm_down 0x2dd2 + rm_mute 0xa35c + rm_red 0x837c + rm_green 0x43bc + rm_yellow 0xc33c + rm_blue 0x23dc + rm_rewind 0x15ea + rm_play 0x55aa + rm_pause 0xe51a + rm_ff 0x3dc2 + rm_skipback 0x639c + rm_skipfwd 0xe31c + rm_stop 0x7d82 + rm_record 0x659a + rm_exterity 0x13ec + rm_fn_tv 0x936c + rm_fn_home 0x53ac + rm_guide 0xd32c + rm_subtitle 0x857A + rm_info 0x33CC + rm_help 0xB34C + rm_audio 0x9D62 + rm_teletext 0xD52A + rm_av 0xFD02 + end codes + +end remote + +``` + + +## Serial Command Interface + +* All lines start with `^` +* All lines end with `!` + +Dump of the help text: + + +``` +^help! +To display a value: ^get: