├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github ├── dependabot.yml ├── release-please-config.json └── workflows │ ├── docker-publish.yml2 │ ├── node.yaml │ └── release-please.yml ├── .gitignore ├── .husky └── pre-commit ├── .node-version ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .release-please-manifest.json ├── .vscode └── settings.json ├── .yarnrc.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docs ├── 1_gettingstarted │ ├── before_you_open.md │ ├── configure_satellite.md │ ├── images │ │ ├── configure-connected.png │ │ ├── configure-page.png │ │ ├── connected-surfaces.png │ │ ├── contextmenu.png │ │ ├── disconnected.jpg │ │ └── surface-plugins.png │ ├── scan_for_surfaces.md │ └── start_the_program.md ├── 1_welcome.md ├── 2_raspberrypi │ ├── revert_to_dhcp.md │ └── static_ip.md ├── 2_rpi.md ├── disconnected-screen.png └── structure.json ├── eslint.config.mjs ├── openapi.yaml ├── package.json ├── pi-image ├── .gitignore ├── fixup-config.sh ├── install.sh ├── motd ├── satellite-config ├── satellite-edit-config ├── satellite-help ├── satellite-license ├── satellite-update ├── satellite.service ├── satellitepi.pkr.hcl ├── update-prompt │ ├── .gitignore │ ├── main.js │ ├── package.json │ └── yarn.lock └── update.sh ├── samples └── xencelabs-quick-keys-page.companionconfig ├── satellite ├── assets │ ├── icon.png │ ├── linux │ │ ├── 50-satellite.rules │ │ └── README │ ├── tray-offline.ico │ ├── tray-offline.png │ ├── tray.ico │ ├── tray.png │ ├── trayOfflineTemplate.png │ ├── trayOfflineTemplate@2x.png │ ├── trayTemplate.png │ └── trayTemplate@2x.png ├── entitlements.mac.plist ├── package.json ├── src │ ├── aboutPreload.cts │ ├── apiTypes.ts │ ├── client.ts │ ├── clientImplementations.ts │ ├── config.ts │ ├── device-types │ │ ├── api.ts │ │ ├── blackmagic-panel.ts │ │ ├── contour-shuttle.ts │ │ ├── infinitton.ts │ │ ├── lib.ts │ │ ├── loupedeck-live-s.ts │ │ ├── loupedeck-live.ts │ │ ├── loupedeck-plugin.ts │ │ ├── pincode.ts │ │ ├── razer-stream-controller-x.ts │ │ ├── streamdeck.ts │ │ └── xencelabs-quick-keys.ts │ ├── electron.ts │ ├── electronPreload.cts │ ├── electronUpdater.ts │ ├── fixup-pi-config.ts │ ├── generated │ │ └── openapi.ts │ ├── graphics │ │ ├── cards.ts │ │ ├── drawingState.ts │ │ ├── lib.ts │ │ ├── locking.ts │ │ └── writeQueue.ts │ ├── lib.ts │ ├── main.ts │ ├── mdnsAnnouncer.ts │ ├── rest.ts │ ├── surface-manager.ts │ └── surfaceProxy.ts ├── tsconfig.build.json └── tsconfig.json ├── tools ├── build_electron.mts └── tsconfig.json ├── tsconfig.json ├── webui ├── .gitignore ├── README.md ├── about.html ├── components.json ├── electron.html ├── index.html ├── package.json ├── postcss.config.js ├── public │ └── icon.png ├── src │ ├── Api │ │ ├── Context.tsx │ │ ├── rest.ts │ │ └── types.ts │ ├── App.css │ ├── Util │ │ └── ErrorBoundary.tsx │ ├── about.ts │ ├── app │ │ ├── App.tsx │ │ ├── ConnectedSurfacesTab.tsx │ │ ├── ConnectionConfig.tsx │ │ ├── ConnectionStatus.tsx │ │ ├── ConnectionTab.tsx │ │ ├── Content.tsx │ │ ├── SurfacePluginsTab.tsx │ │ ├── SurfacesRescan.tsx │ │ └── constants.ts │ ├── components │ │ └── ui │ │ │ ├── alert.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── select.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ └── tabs.tsx │ ├── electron.tsx │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | docs 3 | dist 4 | node_modules 5 | 6 | Dockerfile 7 | .editorconfig 8 | .eslintrc.js 9 | .gitattributes 10 | .gitignore 11 | .prettierignore 12 | 13 | .yarn/cache 14 | .yarn/install-state.gz 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | 4 | [*.{cs,js,ts,json}] 5 | indent_size = 4 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | 13 | - package-ecosystem: 'npm' 14 | directory: '/' 15 | schedule: 16 | interval: 'daily' 17 | # Disable version updates for npm dependencies (we only want security updates) 18 | open-pull-requests-limit: 0 19 | 20 | - package-ecosystem: 'npm' 21 | directory: '/pi-image/update-prompt' 22 | schedule: 23 | interval: 'daily' 24 | # Disable version updates for npm dependencies (we only want security updates) 25 | open-pull-requests-limit: 0 26 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | 4 | "bootstrap-sha": "ed43061b96cc7a02f442533a40c4c731d9125bd7", 5 | 6 | "include-component-in-tag": false, 7 | 8 | "packages": { 9 | ".": { 10 | "release-type": "node", 11 | "extra-files": [ 12 | { 13 | "type": "json", 14 | "path": "satellite/package.json", 15 | "jsonpath": "$.version" 16 | }, 17 | { 18 | "type": "json", 19 | "path": "webui/package.json", 20 | "jsonpath": "$.version" 21 | } 22 | ] 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml2: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - v* 9 | 10 | jobs: 11 | push: 12 | runs-on: ubuntu-latest 13 | if: github.event_name == 'push' 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v1 20 | - name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v1 22 | 23 | - name: Login to registry 24 | uses: docker/login-action@v1 25 | with: 26 | registry: ghcr.io 27 | username: ${{ github.actor }} 28 | password: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Determine images to publish 31 | id: image-tags 32 | run: | 33 | IMAGE_ID=ghcr.io/${{ github.repository }} 34 | 35 | # Change all uppercase to lowercase 36 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 37 | 38 | # Strip git ref prefix from version 39 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 40 | 41 | # Strip "v" prefix from tag name 42 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 43 | 44 | # Use Docker `latest` tag convention 45 | [ "$VERSION" == "main" ] && VERSION=latest 46 | 47 | IMAGES= 48 | IMAGES="$IMAGE_ID:$VERSION"$'\n'$IMAGES 49 | 50 | # debug output 51 | echo images $IMAGES 52 | echo ::set-output name=images::"$IMAGES" 53 | 54 | - name: Build and push 55 | uses: docker/build-push-action@v2 56 | if: ${{ steps.image-tags.outputs.images }} 57 | with: 58 | context: . 59 | platforms: linux/amd64,linux/arm/v7,linux/arm64 60 | push: true 61 | tags: ${{ steps.image-tags.outputs.images }} 62 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | 12 | jobs: 13 | release-please: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: google-github-actions/release-please-action@v4 17 | with: 18 | # use a token so that git pushes trigger subsequent workflow runs 19 | token: ${{ secrets.RELEASE_PLEASE_TOKEN }} 20 | config-file: .github/release-please-config.json 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | dist 4 | config.json 5 | electron-output 6 | .DS_STORE 7 | .cache 8 | 9 | # Yarn 3 files 10 | .pnp.* 11 | .yarn/* 12 | !.yarn/patches 13 | !.yarn/plugins 14 | !.yarn/releases 15 | !.yarn/sdks 16 | !.yarn/versions 17 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.18.1 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | .node-version -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | CHANGELOG.md -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "printWidth": 120, 5 | "semi": false, 6 | "singleQuote": true, 7 | "useTabs": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "2.2.1" 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | "satellite", 4 | "webui", 5 | ], 6 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | supportedArchitectures: 4 | cpu: 5 | - x64 6 | - arm64 7 | - ia32 8 | os: 9 | - current 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-bullseye 2 | 3 | WORKDIR /app 4 | COPY . /app/ 5 | 6 | RUN apt-get update && apt-get install -y \ 7 | libusb-1.0-0-dev \ 8 | libudev-dev \ 9 | unzip \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | RUN yarn config set network-timeout 100000 -g 13 | RUN yarn --frozen-lockfile 14 | RUN yarn build 15 | RUN yarn --prod --frozen-lockfile 16 | 17 | # rebuild node-usb to not use udev, as udev doesn't work in docker 18 | RUN sed -i "s/'use_udev%': 1/'use_udev%': 0/" node_modules/usb/libusb.gypi 19 | RUN cd node_modules/usb && rm -R prebuilds && yarn node-gyp-build 20 | 21 | # patch node-hid to use the same version of libusb as node-usb, otherwise freshly plugged devices never appear 22 | # 'stealing' some help from node-usb, as they have a decent build system for libusb 23 | ADD https://github.com/libusb/libusb/archive/e782eeb2514266f6738e242cdcb18e3ae1ed06fa.zip node_modules/node-hid/libusb.zip 24 | ADD https://raw.githubusercontent.com/node-usb/node-usb/52b879c91df3fc594832d37081c9c3bf4b02d064/libusb.gypi node_modules/node-hid/libusb.gypi 25 | RUN cd node_modules/node-hid && unzip libusb.zip && mv libusb-e782eeb2514266f6738e242cdcb18e3ae1ed06fa libusb 26 | RUN sed -i "s/'use_udev%': 1/'use_udev%': 0/" node_modules/node-hid/libusb.gypi 27 | RUN mkdir node_modules/node-hid/libusb_config && touch node_modules/node-hid/libusb_config/config.h 28 | # TODO: this is very brittle working by line number, this needs a better matcher 29 | RUN sed -i "36s/.*/'dependencies': [ 'libusb.gypi\:libusb', ]/g" node_modules/node-hid/binding.gyp 30 | RUN cd node_modules/node-hid && rm -R build && yarn gypconfigure && yarn gypbuild 31 | 32 | FROM node:16-bullseye-slim 33 | 34 | WORKDIR /app 35 | COPY --from=0 /app/ /app/ 36 | 37 | USER node 38 | ENTRYPOINT ["node", "/app/satellite/dist/main.js"] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Julian Waller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Companion Satellite 2 | 3 | [![License](https://img.shields.io/github/license/bitfocus/companion-satellite)](https://github.com/bitfocus/companion-satellite/blob/main/LICENSE) 4 | [![Version](https://img.shields.io/github/v/release/bitfocus/companion-satellite)](https://github.com/bitfocus/companion-satellite/releases) 5 | 6 | A small application to allow for connecting a streamdeck to [Bitfocus Companion](https://github.com/bitfocus/companion) over a network. 7 | 8 | Companion 3.4.0 and newer are supported 9 | 10 | Each surface will appear in companion as its own 'satellite' surface, and can be configured as if they are local. 11 | 12 | Note: This connects over the satellite surface api which uses port TCP 16622. 13 | 14 | [![Satellite Getting Started](http://img.youtube.com/vi/eNnUxRl4yP4/0.jpg)](http://www.youtube.com/watch?v=eNnUxRl4yP4 'Remote Stream Deck control with Companion Satellite') 15 | 16 | ## Getting started 17 | 18 | You can find installers on the [Bitfocus website](https://user.bitfocus.io/download) 19 | 20 | ### Raspberry Pi 21 | 22 | A prebuilt image is provided for recent releases. Check the releases tab for the latest image. 23 | 24 | After writing the image to an sd card, edit the satellite-config file in the boot partition to point to your companion instance. 25 | 26 | ### Desktop 27 | 28 | This application can be built with electron to provide a minimal ui and to allow for minimising to the system tray. 29 | You can right click the tray icon to: 30 | 31 | - Set the ip address of the companion instance to connect to 32 | - Force a manual scan for surfaces. This is done automatically when a new surface is detected, but it can sometimes miss some 33 | 34 | To manually build the latest version for your machine: 35 | 36 | - `yarn install` 37 | - `yarn dist` 38 | - Locate the output under `electron-output/` 39 | 40 | ### Manual Headless / Raspberry pi 41 | 42 | If using a Raspberry Pi, we recommend using the 64bit 'Raspberry Pi OS Lite' images, the non-64bit version should work too but it less tested. 43 | If using a different brand SBC, we recommend running [Armbian](https://www.armbian.com/) specifically the minimal debian images, as this provides a minimal and consistent debian environment and are typically more up to date then the manufacturer images. 44 | 45 | It can be built and run as a systemd service on a pi or other linux machine 46 | 47 | No images are provided for this, but the process has been written to be a single script. 48 | 49 | As root, run the following: 50 | 51 | ``` 52 | curl https://raw.githubusercontent.com/bitfocus/companion-satellite/main/pi-image/install.sh | bash 53 | ``` 54 | 55 | After this, you can use `sudo satellite-update` to change the version it has installed. Note: this is currently not fully implemented. 56 | 57 | Note: This script will create a new user called `satellite`, which Satellite will be run as and will own the configuration. 58 | 59 | ### REST API 60 | 61 | The default rest port is 9999 62 | a GET request to `http://Satellite-IP:9999/api/host` will return the current target ip in plain text 63 | a GET request to `http://Satellite-IP:9999/api/port` will return the current target port in plain text 64 | a GET request to `http://Satellite-IP:9999/api/config` will return the current target port and ip as json 65 | 66 | a POST request to `http://Satellite-IP:9999/api/host` with json body `{"host": "newhostip"}` or plain text `newhostip` will connect the satellite to that ip or hostname 67 | a POST request to `http://Satellite-IP:9999/api/port` with `{"port": 16622}` or plain text `16622` will connect the satellite to that port 68 | a POST request to `http://Satellite-IP:9999/api/config` with `{"host": "newhostip", "port": 16622}` will connect the satellite to that ip/hostname and port 69 | 70 | ## Development 71 | 72 | NodeJS 20 is required 73 | 74 | ### Headless 75 | 76 | 1. Install the dependencies `yarn install` 77 | 1. Run it `yarn dev` substituting in your companion instance ip address 78 | 1. In another terminal run `yarn dev:webui` to serve the web interface 79 | 1. Access the web interface at http://127.0.0.1:5173 80 | 81 | ### Electron 82 | 83 | 1. Install the dependencies `yarn install` 84 | 1. In one terminal run `yarn dev:webui` to serve the web interface 85 | 1. Run it `yarn dev-electron` 86 | 87 | You can package for electron with `yarn dist`. 88 | Building for another platform has not been tested. 89 | -------------------------------------------------------------------------------- /docs/1_gettingstarted/before_you_open.md: -------------------------------------------------------------------------------- 1 | - Connect the Stream Deck or other surfaces that you want to use. 2 | - In the Elgato Stream Deck app, make sure to firmware upgrade the Stream Deck to the latest version available. 3 | - Close the Elgato Stream Deck app. Companion Satellite will not detect your device if the Elgato Stream Deck app is open. 4 | - Close the application for any other surface types you wish to use. 5 | -------------------------------------------------------------------------------- /docs/1_gettingstarted/configure_satellite.md: -------------------------------------------------------------------------------- 1 | To connect the Satellite surface to your Companion setup, you need to configure the connection. 2 | 3 | 1. Right-click on the Companion Satellite icon in the system tray. 4 | ![Context Menu](images/contextmenu.png?raw=true 'Context Menu') 5 | 6 | If you are running Companion Satellite on a headless machine, such as a Raspberry Pi, you can instead navigate to `http://192.168.100.100:9999` (substitute the correct ip address) and access the same configuration 7 | 8 | 1. Choose "Configure" in the context menu. 9 | This will open a new window 10 | ![Configure window](images/configure-page.png?raw=true 'Configure window') 11 | 12 | 1. Type in the IP Address or hostname of the remote server into the Address field, and click Save at the bottom. 13 | In most cases you **should not** change the port number from the default. 14 | 15 | 1. Optionally, you can configure other settings in here. 16 | 17 | 1. Shortly after you have clicked Save, the top section should update to confirm that it has connected 18 | ![Connected Status](images/configure-connected.png?raw=true 'Connected Status') 19 | 20 | 1. Next, switch to the 'Surface Plugins' tab, and make sure that the surface types you wish to use are all enabled 21 | ![Surface Plugins](images/surface-plugins.png?raw=true 'Surface Plugins') 22 | 23 | 1. Finally, you can confirm in the 'Connected Surfaces' tab that Satellite has correctly detected each surface 24 | ![Connected Surfaces](images/connected-surfaces.png?raw=true 'Connected Surfaces') 25 | 26 | 1. You will also be able to see your surfaces in Companion, where they will appear just like other surfaces, but with the ip address of the Satellite machine under the 'Location' column 27 | 28 | While Satellite is in the disconnected state, your surfaces will show a placeholder card to indicate this, looking like: 29 | ![Disconnected](images/disconnected.jpg?raw=true 'Disconnected') 30 | -------------------------------------------------------------------------------- /docs/1_gettingstarted/images/configure-connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/docs/1_gettingstarted/images/configure-connected.png -------------------------------------------------------------------------------- /docs/1_gettingstarted/images/configure-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/docs/1_gettingstarted/images/configure-page.png -------------------------------------------------------------------------------- /docs/1_gettingstarted/images/connected-surfaces.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/docs/1_gettingstarted/images/connected-surfaces.png -------------------------------------------------------------------------------- /docs/1_gettingstarted/images/contextmenu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/docs/1_gettingstarted/images/contextmenu.png -------------------------------------------------------------------------------- /docs/1_gettingstarted/images/disconnected.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/docs/1_gettingstarted/images/disconnected.jpg -------------------------------------------------------------------------------- /docs/1_gettingstarted/images/surface-plugins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/docs/1_gettingstarted/images/surface-plugins.png -------------------------------------------------------------------------------- /docs/1_gettingstarted/scan_for_surfaces.md: -------------------------------------------------------------------------------- 1 | If you disconnect, reconnect, or add new Satellite surfaces on the local machine, Satellite should automatically detect the surfaces as they are attached. 2 | 3 | If for some reason it does not detect it by itself, you can instead trigger a rescan from either the tray icon: 4 | ![Scan](images/scan.png?raw=true 'Scan') 5 | 6 | or from the configuration page: 7 | ![Scan](images/scan.png?raw=true 'Scan') 8 | 9 | They will then be detected as normal and appear in the 'Connected Surfaces' tab and in Companion. 10 | -------------------------------------------------------------------------------- /docs/1_gettingstarted/start_the_program.md: -------------------------------------------------------------------------------- 1 | Open the program. You will see it launch into your system tray area. For MacOS users, this is generally at the top right of your screen. For Windows users, bottom right. -------------------------------------------------------------------------------- /docs/1_welcome.md: -------------------------------------------------------------------------------- 1 | This program is allows you to have your physical Stream Deck and other control surfaces connected via USB to your local computer but act as if they are connected to a remote Companion server. The computers do not have to be on the same subnet/vlan, but should be able to reach Companion via a TCP connection, whether this is on the same LAN or via a VPN connection. 2 | -------------------------------------------------------------------------------- /docs/2_raspberrypi/revert_to_dhcp.md: -------------------------------------------------------------------------------- 1 | To revert back to DHCP, use the following command: 2 | 3 | ```sudo nmcli con modify "Wired connection 1" ipv4.method auto``` 4 | 5 | Restart network or reboot. 6 | -------------------------------------------------------------------------------- /docs/2_raspberrypi/static_ip.md: -------------------------------------------------------------------------------- 1 | Sometimes you may wish to set a static IP address on your Satellite Pi. 2 | 3 | To do this, use the `nmcli` command: 4 | 5 | You can view your Raspberry Pi's connections with the following command: 6 | 7 | `sudo nmcli -p connection show` 8 | 9 | To set the IP address (in this instance on "Wired Connection 1"), run the following commands to set the Satellite IP and Subnet, Default Gateway and DNS Server. 10 | 11 | ``` 12 | sudo nmcli con mod "Wired connection 1" ipv4.addresses 10.1.1.123/24 ipv4.method manual 13 | sudo nmcli con mod "Wired connection 1" ipv4.gateway 10.1.1.1 14 | sudo nmcli con mod "Wired connection 1" ipv4.dns "10.1.1.1" 15 | ``` 16 | If you are on the console directly, you can restart the network as follows, otherwise reboot. 17 | 18 | You can now restart the network with the following command: 19 | ``` 20 | sudo nmcli con down "Wired connection 1" && \sudo nmcli con up "Wired connection 1" 21 | ``` 22 | Alternatively, you may reboot the device. 23 | -------------------------------------------------------------------------------- /docs/2_rpi.md: -------------------------------------------------------------------------------- 1 | This section contains details which are specific to the Raspberry Pi edition of companion satellite. -------------------------------------------------------------------------------- /docs/disconnected-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/docs/disconnected-screen.png -------------------------------------------------------------------------------- /docs/structure.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "label": "Getting started", 4 | "file": "1_welcome.md", 5 | "children": [ 6 | { 7 | "label": "Before you open Companion Satellite", 8 | "file": "1_gettingstarted/before_you_open.md" 9 | }, 10 | { 11 | "label": "Start the program", 12 | "file": "1_gettingstarted/start_the_program.md" 13 | }, 14 | { 15 | "label": "Configure Satellite", 16 | "file": "1_gettingstarted/configure_satellite.md" 17 | }, 18 | { 19 | "label": "Scan for Surfaces", 20 | "file": "1_gettingstarted/scan_for_surfaces.md" 21 | } 22 | ] 23 | }, 24 | { 25 | "label": "Raspberry Pi Setup", 26 | "file:": "2_rpi.md", 27 | "children": [ 28 | { 29 | "label": "Static IP Configuration", 30 | "file": "2_raspberrypi/static_ip.md" 31 | }, 32 | { 33 | "label": "Reverting to DHCP", 34 | "file": "2_raspberrypi/revert_to_dhcp.md" 35 | } 36 | ] 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-extraneous-import */ 2 | // @ts-check 3 | 4 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' 5 | import eslint from '@eslint/js' 6 | import neslint from 'eslint-plugin-n' 7 | import tseslint from 'typescript-eslint' 8 | import reacteslint from 'eslint-plugin-react' 9 | import hookseslint from 'eslint-plugin-react-hooks' 10 | import reactRefreshEslint from 'eslint-plugin-react-refresh' 11 | 12 | export default [ 13 | // setup the parser first 14 | { 15 | languageOptions: { 16 | parser: tseslint.parser, 17 | parserOptions: { 18 | project: true, 19 | }, 20 | }, 21 | }, 22 | 23 | { 24 | ...neslint.configs['flat/recommended-script'], 25 | ignores: [...(neslint.configs['flat/recommended-script'].ignores ?? []), 'webui/**/*'], 26 | }, 27 | { 28 | // extends: commonExtends, 29 | plugins: { 30 | '@typescript-eslint': tseslint.plugin, 31 | }, 32 | rules: { 33 | // Default rules to be applied everywhere 34 | 'prettier/prettier': 'error', 35 | 36 | ...eslint.configs.recommended.rules, 37 | 38 | 'no-console': 'off', 39 | 40 | '@typescript-eslint/no-unused-vars': [ 41 | 'error', 42 | { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_', varsIgnorePattern: '^_(.+)' }, 43 | ], 44 | 'no-extra-semi': 'off', 45 | // 'n/no-unsupported-features/es-syntax': ['error', { ignores: ['modules'] }], 46 | 'no-use-before-define': 'off', 47 | 'no-warning-comments': ['error', { terms: ['nocommit', '@nocommit', '@no-commit'] }], 48 | // 'jest/no-mocks-import': 'off', 49 | }, 50 | files: ['**/*.ts', '**/*.cts', '**/*.mts'], 51 | }, 52 | ...tseslint.configs.recommendedTypeChecked, 53 | { 54 | // disable type-aware linting on JS files 55 | files: ['**/*.js', '**/*.cjs', '**/*.mjs'], 56 | ...tseslint.configs.disableTypeChecked, 57 | }, 58 | { 59 | files: ['*.mjs'], 60 | languageOptions: { 61 | sourceType: 'module', 62 | }, 63 | }, 64 | { 65 | files: ['**/*.tsx', '**/*.ts', '**/*.cts', '**/*.mts'], 66 | rules: { 67 | '@typescript-eslint/no-explicit-any': 'off', 68 | '@typescript-eslint/interface-name-prefix': 'off', 69 | '@typescript-eslint/no-floating-promises': 'error', 70 | '@typescript-eslint/explicit-module-boundary-types': ['error'], 71 | '@typescript-eslint/promise-function-async': 'error', 72 | '@typescript-eslint/require-await': 'off', // conflicts with 'promise-function-async' 73 | 74 | /** Disable some annoyingly strict rules from the 'recommended-requiring-type-checking' pack */ 75 | '@typescript-eslint/no-unsafe-assignment': 0, 76 | '@typescript-eslint/no-unsafe-member-access': 0, 77 | '@typescript-eslint/no-unsafe-argument': 0, 78 | '@typescript-eslint/no-unsafe-return': 0, 79 | '@typescript-eslint/no-unsafe-call': 0, 80 | '@typescript-eslint/restrict-template-expressions': 0, 81 | '@typescript-eslint/restrict-plus-operands': 0, 82 | '@typescript-eslint/no-redundant-type-constituents': 0, 83 | /** End 'recommended-requiring-type-checking' overrides */ 84 | }, 85 | }, 86 | { 87 | files: ['**/__tests__/**/*', 'test/**/*'], 88 | rules: { 89 | '@typescript-eslint/ban-ts-ignore': 'off', 90 | '@typescript-eslint/ban-ts-comment': 'off', 91 | }, 92 | }, 93 | 94 | // Add prettier at the end to give it final say on formatting 95 | eslintPluginPrettierRecommended, 96 | { 97 | // But lastly, ensure that we ignore certain paths 98 | ignores: [ 99 | '**/dist/*', 100 | '**/build/*', 101 | '/dist', 102 | '**/pkg/*', 103 | '**/docs/*', 104 | '**/generated/*', 105 | '**/node_modules/*', 106 | '**/electron-output/*', 107 | 'webui/vite.config.ts', 108 | 'pi-image/**/*', 109 | ], 110 | }, 111 | { 112 | files: ['eslint.config.*'], 113 | rules: { 114 | 'n/no-unpublished-import': 'off', 115 | }, 116 | }, 117 | 118 | // The above is mostly copied from https://github.com/bitfocus/companion-module-tools/blob/main/eslint/config.mjs with very little modifications. The below is extra rules that have been added 119 | { 120 | files: ['webui/**/*.tsx', 'webui/**/*.jsx', 'webui/**/*.ts', 'webui/**/*.js'], 121 | plugins: { 122 | 'react-hooks': hookseslint, 123 | 'react-refresh': reactRefreshEslint, 124 | react: reacteslint, 125 | }, 126 | rules: { 127 | ...hookseslint.configs.recommended.rules, 128 | 'react-refresh/only-export-components': 'warn', 129 | }, 130 | }, 131 | { 132 | rules: { 133 | '@typescript-eslint/no-namespace': 'off', 134 | '@typescript-eslint/no-redundant-type-constituents': 'off', 135 | '@typescript-eslint/no-duplicate-type-constituents': 'off', 136 | }, 137 | }, 138 | ] 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "companion-satellite", 3 | "version": "2.2.1", 4 | "description": "Satellite Streamdeck connector for Bitfocus Companion", 5 | "author": { 6 | "name": "Julian Waller", 7 | "email": "git@julusian.co.uk", 8 | "url": "https://github.com/julusian" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/bitfocus/companion-satellite.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/bitfocus/companion-satellite/issues" 16 | }, 17 | "homepage": "https://github.com/bitfocus/companion-satellite#readme", 18 | "license": "MIT", 19 | "private": true, 20 | "workspaces": [ 21 | "satellite", 22 | "webui" 23 | ], 24 | "scripts": { 25 | "postinstall": "husky", 26 | "dev": "yarn workspace satellite dev", 27 | "dev:electron": "yarn workspace satellite dev:electron", 28 | "dev:webui": "yarn workspace webui dev", 29 | "build:openapi": "openapi-typescript ./openapi.yaml -o ./satellite/src/generated/openapi.ts", 30 | "build": "yarn workspaces foreach --all run build", 31 | "lint:raw": "eslint", 32 | "lint": "eslint .", 33 | "license-validate": "sofie-licensecheck", 34 | "dist": "run build && tsx tools/build_electron.mts" 35 | }, 36 | "devDependencies": { 37 | "@sofie-automation/eslint-plugin": "^0.1.1", 38 | "@tsconfig/node20": "^20.1.5", 39 | "eslint": "^9.25.1", 40 | "eslint-config-prettier": "^10.1.2", 41 | "eslint-plugin-n": "^17.17.0", 42 | "eslint-plugin-prettier": "^5.2.6", 43 | "eslint-plugin-react": "^7.37.5", 44 | "eslint-plugin-react-hooks": "^5.2.0", 45 | "eslint-plugin-react-refresh": "^0.4.20", 46 | "husky": "^9.1.7", 47 | "lint-staged": "^15.5.1", 48 | "openapi-typescript": "^7.6.1", 49 | "prettier": "^3.5.3", 50 | "tsx": "^4.19.4", 51 | "typescript": "~5.7.3", 52 | "typescript-eslint": "^8.31.1", 53 | "zx": "^8.5.3" 54 | }, 55 | "engines": { 56 | "node": "^20.14" 57 | }, 58 | "packageManager": "yarn@4.9.1" 59 | } 60 | -------------------------------------------------------------------------------- /pi-image/.gitignore: -------------------------------------------------------------------------------- 1 | output-* 2 | */.yarn/ -------------------------------------------------------------------------------- /pi-image/fixup-config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | SATELLITE_CONFIG_PATH=/boot/satellite-config 4 | 5 | # path could be a symlink 6 | SATELLITE_CONFIG_PATH=$(realpath $SATELLITE_CONFIG_PATH) 7 | 8 | # config file doesn't exist, try and find it 9 | if ! [ -f "$SATELLITE_CONFIG_PATH" ]; then 10 | if [ -f "/boot/firmware/satellite-config" ]; then 11 | ln -s /boot/firmware/satellite-config $SATELLITE_CONFIG_PATH 12 | else 13 | echo "Warning: Failed to find config" 14 | fi 15 | fi 16 | -------------------------------------------------------------------------------- /pi-image/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | if [ ! "$BASH_VERSION" ] ; then 5 | echo "You must use bash to run this script. If running this script from curl, make sure the final word is 'bash'" 1>&2 6 | exit 1 7 | fi 8 | 9 | CURRENT_ARCH=$(dpkg --print-architecture) 10 | if [[ "$CURRENT_ARCH" != "x64" && "$CURRENT_ARCH" != "amd64" && "$CURRENT_ARCH" != "arm64" ]]; then 11 | echo "$CURRENT_ARCH is not a supported cpu architecture for running Companion Satellite." 12 | echo "If you are running on an arm device (such as a Raspberry Pi), make sure to use an arm64 image." 13 | exit 1 14 | fi 15 | 16 | echo "This will attempt to install Companion Satellite as a system service on this device." 17 | echo "It is designed to be run on headless servers, but can be used on desktop machines if you are happy to not have the tray icon." 18 | echo "A user called 'satellite' will be created to run the service, and various scripts will be installed to manage the service" 19 | 20 | if [ $(/usr/bin/id -u) -ne 0 ]; then 21 | echo "Must be run as root" 22 | exit 1 23 | fi 24 | 25 | # Install a specific stable build. It is advised to not use this, as attempting to install a build that doesn't 26 | # exist can leave your system in a broken state that needs fixing manually 27 | SATELLITE_BUILD="${SATELLITE_BUILD:-beta}" 28 | # Development only: Allow building using a testing branch of this updater 29 | SATELLITE_BRANCH="${SATELLITE_BRANCH:-main}" 30 | 31 | # install some dependencies 32 | export DEBIAN_FRONTEND=noninteractive 33 | apt-get update 34 | apt-get install -yq git zip unzip curl libusb-1.0-0-dev libudev-dev cmake libfontconfig1 nano adduser wget 35 | apt-get clean 36 | 37 | # add a system user 38 | id -u satellite &>/dev/null || adduser --disabled-password satellite --gecos "" 39 | 40 | # install fnm to manage node version 41 | # we do this to /opt/fnm, so that the satellite user can use the same installation 42 | export FNM_DIR=/opt/fnm 43 | echo "export FNM_DIR=/opt/fnm" >> /root/.bashrc 44 | curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir /opt/fnm &>/dev/null 45 | export PATH=/opt/fnm:$PATH 46 | eval "`fnm env --shell bash`" 47 | 48 | BUILD_BRANCH=beta 49 | if [ "$SATELLITE_BRANCH" == "stable" ]; then 50 | BUILD_BRANCH=stable 51 | SATELLITE_BRANCH=main 52 | fi 53 | 54 | # clone the repository 55 | rm -R /usr/local/src/companion-satellite &>/dev/null || true 56 | git clone https://github.com/bitfocus/companion-satellite.git -b $SATELLITE_BRANCH /usr/local/src/companion-satellite 57 | cd /usr/local/src/companion-satellite 58 | 59 | # configure git for future updates 60 | git config --global pull.rebase false 61 | 62 | # run the update script 63 | ./pi-image/update.sh "$BUILD_BRANCH" "$SATELLITE_BUILD" 64 | 65 | # enable start on boot 66 | systemctl enable satellite 67 | 68 | # copy config file into place 69 | cp ./pi-image/satellite-config /boot/satellite-config 70 | 71 | # add the fnm node to this users path 72 | # TODO - verify permissions 73 | echo "export PATH=/opt/fnm/aliases/default/bin:\$PATH" >> /home/satellite/.bashrc 74 | 75 | # check that a build of satellite was installed 76 | if [ ! -d "/opt/companion-satellite" ] 77 | then 78 | echo "No Companion Satellite build was installed!\nIt should be possible to recover from this with \"sudo satellite-update\"" 79 | exit 9999 # die with error code 9999 80 | fi 81 | 82 | echo "Companion Satellite is installed!" 83 | echo "You can edit a subset of the configuration at \"/boot/satellite-config\" then can start it with \"sudo systemctl start satellite\" or \"sudo satellite-update\"" 84 | echo "A http server will be started on port 9999 which gives access to the full configuration" 85 | 86 | -------------------------------------------------------------------------------- /pi-image/motd: -------------------------------------------------------------------------------- 1 | The source code repository for this project can be found here: 2 | https://github.com/bitfocus/companion-satellite 3 | 4 | Full licensing information for Bitfocus Commpanion can be found by running 'satellite-license' 5 | in the terminal or visiting https://github.com/bitfocus/companion-satellite/blob/main/LICENSE in a web browser 6 | 7 | Any bugs, issues, or feature requests for the Companion Satellite software should be reported on the project's GitHub: 8 | https://github.com/bitfocus/companion-satellite/issues 9 | 10 | Companion Satellite should auto-start on this computer as soon as a viable network connection is detected. 11 | 12 | For more information on Companion Satellite specific commands you can run try running `satellite-help` 13 | -------------------------------------------------------------------------------- /pi-image/satellite-config: -------------------------------------------------------------------------------- 1 | # 2 | # Since v1.7.0 of satellite this is no longer the main config file. 3 | # It is read at startup to import configuration to the main config file. 4 | # After it has been read, it gets reset back to defaults. 5 | # 6 | # More options are available in the web interface. 7 | # 8 | 9 | # Set this to the ip address or hostname of your companion installation 10 | # examples: 11 | # - COMPANION_IP=192.168.100.1 12 | # - COMPANION_IP=companion.example.org 13 | # COMPANION_IP=127.0.0.1 14 | 15 | # If you are connecting through a router or firewall which has remapped the port, you will need to change that here to match 16 | # COMPANION_PORT=16622 17 | 18 | # Port for the REST server (0 to disable) 19 | # REST_PORT=9999 20 | -------------------------------------------------------------------------------- /pi-image/satellite-edit-config: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [[ $(/usr/bin/id -u) -ne 0 ]]; then 4 | echo "Must be run as root. Try sudo satellite-edit-config" 5 | exit 1 6 | fi 7 | 8 | # stop satellite 9 | systemctl stop satellite 10 | 11 | # check default path 12 | SATELLITE_CONFIG_PATH=/boot/satellite-config 13 | SATELLITE_CONFIG_PATH=$(realpath $SATELLITE_CONFIG_PATH) 14 | 15 | # may not exist, try alternate path 16 | if ! [ -f "$SATELLITE_CONFIG_PATH" ]; then 17 | SATELLITE_CONFIG_PATH=/boot/firmware/satellite-config 18 | fi 19 | 20 | # open config editor 21 | if [ -f "$SATELLITE_CONFIG_PATH" ]; then 22 | nano "$SATELLITE_CONFIG_PATH" 23 | else 24 | echo "Failed to find config file to edit. Something looks wrong with your installation" 25 | fi 26 | 27 | # restart satellite 28 | systemctl start satellite 29 | -------------------------------------------------------------------------------- /pi-image/satellite-help: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "To view the Companion Satellite License information run:" 4 | echo " $ satellite-license" 5 | echo "" 6 | 7 | echo "To stop/start/restart or check the status of Companion Satellite run:" 8 | echo " $ sudo systemctl [start/stop/restart/status] satellite" 9 | echo "" 10 | 11 | echo "To update Companion Satellite run:" 12 | echo " $ sudo satellite-update" 13 | echo "and follow the prompts" 14 | echo "" 15 | 16 | echo "To edit the Companion Satellite configuration run:" 17 | echo " $ sudo satellite-edit-config" 18 | echo "and follow the prompts" 19 | echo "" 20 | -------------------------------------------------------------------------------- /pi-image/satellite-license: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | less /usr/local/src/companion-satellite/LICENSE -------------------------------------------------------------------------------- /pi-image/satellite-update: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [[ $(/usr/bin/id -u) -ne 0 ]]; then 4 | echo "Must be run as root. Try sudo satellite-update" 5 | exit 1 6 | fi 7 | 8 | # stop satellite 9 | systemctl stop satellite 10 | 11 | # fetch new code 12 | cd /usr/local/src/companion-satellite 13 | git pull -q 14 | 15 | # TODO - prompt for which branch to use 16 | 17 | # do the update 18 | ./pi-image/update.sh 19 | 20 | # restart satellite 21 | # reboot 22 | systemctl start satellite 23 | 24 | echo "Update is complete" 25 | -------------------------------------------------------------------------------- /pi-image/satellite.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Bitfocus Companion Satellite 3 | After=network-online.target 4 | Wants=network-online.target 5 | 6 | [Service] 7 | Type=simple 8 | User=satellite 9 | WorkingDirectory=/opt/companion-satellite/satellite 10 | ExecStartPre=+/opt/fnm/aliases/default/bin/node /opt/companion-satellite/satellite/dist/fixup-pi-config.js /home/satellite/satellite-config.json 11 | ExecStart=/opt/fnm/aliases/default/bin/node /opt/companion-satellite/satellite/dist/main.js /home/satellite/satellite-config.json 12 | Restart=on-failure 13 | KillSignal=SIGINT 14 | TimeoutStopSec=60 15 | 16 | [Install] 17 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /pi-image/satellitepi.pkr.hcl: -------------------------------------------------------------------------------- 1 | packer { 2 | required_plugins { 3 | arm-image = { 4 | version = "0.2.7" 5 | source = "github.com/solo-io/arm-image" 6 | } 7 | } 8 | } 9 | 10 | variable "branch" { 11 | type = string 12 | default = "main" 13 | } 14 | 15 | variable "build" { 16 | type = string 17 | default = "beta" 18 | } 19 | 20 | source "arm-image" "satellitepi" { 21 | iso_checksum = "sha256:6ac3a10a1f144c7e9d1f8e568d75ca809288280a593eb6ca053e49b539f465a4" 22 | iso_url = "https://downloads.raspberrypi.com/raspios_lite_arm64/images/raspios_lite_arm64-2024-11-19/2024-11-19-raspios-bookworm-arm64-lite.img.xz" 23 | last_partition_extra_size = 2147483648 24 | qemu_binary = "qemu-aarch64-static" 25 | } 26 | 27 | build { 28 | sources = ["source.arm-image.satellitepi"] 29 | 30 | provisioner "file" { 31 | source = "install.sh" 32 | destination = "/tmp/install.sh" 33 | } 34 | 35 | provisioner "shell" { 36 | #system setup 37 | inline = [ 38 | # # enable ssh 39 | # "touch /boot/ssh", 40 | 41 | # change the hostname 42 | "CURRENT_HOSTNAME=`cat /etc/hostname | tr -d \" \t\n\r\"`", 43 | "echo satellitepi > /etc/hostname", 44 | "sed -i \"s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tsatellitepi/g\" /etc/hosts", 45 | ] 46 | } 47 | 48 | provisioner "shell" { 49 | # run as root 50 | execute_command = "chmod +x {{ .Path }}; {{ .Vars }} su root -c {{ .Path }}" 51 | inline_shebang = "/bin/bash -e" 52 | inline = [ 53 | 54 | # run the script 55 | "export SATELLITE_BRANCH=${var.branch}", 56 | "export SATELLITE_BUILD=${var.build}", 57 | "chmod +x /tmp/install.sh", 58 | "/tmp/install.sh" 59 | ] 60 | } 61 | 62 | } -------------------------------------------------------------------------------- /pi-image/update-prompt/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /pi-image/update-prompt/main.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import semver from 'semver' 3 | import inquirer from 'inquirer' 4 | import fs from 'fs' 5 | import { setGlobalDispatcher, EnvHttpProxyAgent } from 'undici' 6 | 7 | // Setup support for HTTP_PROXY before anything might use it 8 | if (process.env.NODE_USE_ENV_PROXY) { 9 | // HACK: This is temporary and should be removed once https://github.com/nodejs/node/pull/57165 has been backported to node 22 10 | const envHttpProxyAgent = new EnvHttpProxyAgent() 11 | setGlobalDispatcher(envHttpProxyAgent) 12 | } 13 | 14 | const ALLOWED_VERSIONS = '^1.5.0 || ^2.0.0' 15 | 16 | let currentVersion 17 | try { 18 | currentVersion = fs.readFileSync('/opt/companion-satellite/BUILD').toString().trim() 19 | } catch (_e) { 20 | // Assume none installed 21 | } 22 | 23 | async function getLatestBuildsForBranch(branch, targetCount) { 24 | // This is a bit fragile, but is good enough 25 | let target = `${process.platform}-${process.arch}-tgz` 26 | if (target === 'linux-x64-tgz') target = 'linux-tgz' 27 | 28 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 29 | const data = await fetch( 30 | `https://api.bitfocus.io/v1/product/companion-satellite/packages?branch=${branch}&limit=${targetCount}&target=${target}`, 31 | ) 32 | const jsonData = await data.json() 33 | 34 | // console.log('searching for', target, 'in', data.data.packages) 35 | 36 | // assume the builds are sorted by date already 37 | const result = [] 38 | for (const pkg of jsonData.packages) { 39 | if (pkg.target === target) { 40 | try { 41 | if (semver.satisfies(pkg.version, ALLOWED_VERSIONS, { includePrerelease: true })) { 42 | result.push({ 43 | name: pkg.version, 44 | uri: pkg.uri, 45 | published: new Date(pkg.published), 46 | }) 47 | } 48 | } catch (_e) { 49 | // Not a semver tag, so ignore 50 | } 51 | } 52 | } 53 | 54 | return result 55 | } 56 | 57 | async function selectBuildOfType(type, targetBuild) { 58 | const candidates = await getLatestBuildsForBranch(type, 1) 59 | const selectedBuild = targetBuild ? candidates.find((c) => c.name == targetBuild) : candidates[0] 60 | if (selectedBuild) { 61 | if (selectedBuild.name === currentVersion) { 62 | console.log(`The latest build of ${type} (${selectedBuild.name}) is already installed`) 63 | } else { 64 | console.log(`Selected ${type}: ${selectedBuild.name}`) 65 | fs.writeFileSync('/tmp/satellite-version-selection', selectedBuild.uri) 66 | fs.writeFileSync('/tmp/satellite-version-selection-name', selectedBuild.name) 67 | } 68 | } else { 69 | console.error(`No matching ${type} build was found!`) 70 | } 71 | } 72 | async function chooseOfType(type) { 73 | const candidates = await getLatestBuildsForBranch(type, 10) 74 | 75 | if (candidates.length === 0) { 76 | console.error(`No ${type} build was found!`) 77 | } else { 78 | const selectedBuild = await inquirer.prompt([ 79 | { 80 | type: 'list', 81 | name: 'ref', 82 | message: 'Which version do you want? ', 83 | choices: [...candidates.map((c) => c.name), 'cancel'], 84 | }, 85 | ]) 86 | 87 | if (selectedBuild.ref && selectedBuild.ref !== 'cancel') { 88 | if (selectedBuild.ref === currentVersion) { 89 | const confirm = await inquirer.prompt([ 90 | { 91 | type: 'confirm', 92 | name: 'confirm', 93 | message: `Build "${currentVersion}" is already installed. Do you wish to reinstall it?`, 94 | }, 95 | ]) 96 | if (!confirm.confirm) { 97 | return 98 | } 99 | } 100 | 101 | const build = candidates.find((c) => c.name === selectedBuild.ref) 102 | if (build) { 103 | console.log(`Selected ${type}: ${build.name}`) 104 | fs.writeFileSync('/tmp/satellite-version-selection', build.uri) 105 | fs.writeFileSync('/tmp/satellite-version-selection-name', build.name) 106 | } else { 107 | console.error('Invalid selection!') 108 | } 109 | } else { 110 | console.error('No version was selected!') 111 | } 112 | } 113 | } 114 | 115 | async function runPrompt() { 116 | console.log('Warning: Downgrading to an older version can cause issues with the database not being compatible') 117 | 118 | let isOnBeta = true 119 | 120 | console.log(`You are currently on "${currentVersion || 'Unknown'}"`) 121 | 122 | // TODO - restore this 123 | // if (currentBranch) { 124 | // console.log(`You are currently on branch: ${currentBranch}`) 125 | // } else if (currentTag) { 126 | // console.log(`You are currently on release: ${currentTag}`) 127 | // } else { 128 | // console.log('Unable to determine your current version') 129 | // } 130 | 131 | const answer = await inquirer.prompt([ 132 | { 133 | type: 'list', 134 | name: 'ref', 135 | message: 'What version do you want? ', 136 | choices: ['latest stable', 'latest beta', 'specific stable', 'specific beta', 'custom-url', 'cancel'], 137 | default: isOnBeta ? 'latest beta' : 'latest stable', 138 | }, 139 | ]) 140 | 141 | if (answer.ref === 'custom-url') { 142 | console.log( 143 | 'Warning: This must be an linux build of Companion for the correct architecture, or companion will not be able to launch afterwards', 144 | ) 145 | const answer = await inquirer.prompt([ 146 | { 147 | type: 'input', 148 | name: 'url', 149 | message: 'What build url?', 150 | }, 151 | ]) 152 | 153 | const confirm = await inquirer.prompt([ 154 | { 155 | type: 'confirm', 156 | name: 'confirm', 157 | message: `Are you sure you to download the build "${answer.url}"?\nMake sure you trust the source.\nIf you don't know what you are doing you could break your SatellitePi installation`, 158 | }, 159 | ]) 160 | if (!confirm.confirm) { 161 | return runPrompt() 162 | } else { 163 | fs.writeFileSync('/tmp/satellite-version-selection', answer.url) 164 | fs.writeFileSync('/tmp/satellite-version-selection-name', '') 165 | } 166 | } else if (!answer.ref || answer.ref === 'cancel') { 167 | console.error('No version was selected!') 168 | } else if (answer.ref === 'latest beta') { 169 | selectBuildOfType('beta') 170 | } else if (answer.ref === 'latest stable') { 171 | selectBuildOfType('stable') 172 | } else if (answer.ref === 'specific beta') { 173 | chooseOfType('beta') 174 | } else if (answer.ref === 'specific stable') { 175 | chooseOfType('stable') 176 | } 177 | } 178 | 179 | if (process.argv[2]) { 180 | selectBuildOfType(process.argv[2], process.argv[3]) 181 | } else { 182 | runPrompt() 183 | } 184 | -------------------------------------------------------------------------------- /pi-image/update-prompt/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "update-prompt", 3 | "version": "1.0.0", 4 | "main": "main.js", 5 | "type": "module", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@types/node": "^22.15.21", 9 | "inquirer": "^12.6.2", 10 | "semver": "^7.7.2", 11 | "undici": "^6.21.3" 12 | }, 13 | "packageManager": "yarn@4.6.0", 14 | "engines": { 15 | "node": ">=20.18" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pi-image/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # this is the bulk of the update script 4 | # It is a separate file, so that the freshly cloned copy is invoked, not the old copy 5 | 6 | # fail if this happens, to avoid breaking existing arm installations 7 | CURRENT_ARCH=$(dpkg --print-architecture) 8 | if [[ "$CURRENT_ARCH" != "x64" && "$CURRENT_ARCH" != "amd64" && "$CURRENT_ARCH" != "arm64" ]]; then 9 | echo "$CURRENT_ARCH is not a supported cpu architecture for running Companion Satellite." 10 | echo "If you are running on an arm device (such as a Raspberry Pi), make sure to use an arm64 image." 11 | echo "YOUR INSTALLATION HAS NOT BEEN CHANGED. You must reinstall a new satellite image to update." 12 | exit 0 13 | fi 14 | 15 | # don't prompt before downloading yarn 16 | export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 17 | 18 | # imitiate the fnm setup done in .bashrc 19 | export FNM_DIR=/opt/fnm 20 | export PATH=/opt/fnm:$PATH 21 | eval "`fnm env`" 22 | 23 | cd /usr/local/src/companion-satellite 24 | 25 | # update the node version 26 | fnm use --install-if-missing 27 | fnm default $(fnm current) 28 | corepack enable 29 | 30 | if [ $(getent group dialout) ]; then 31 | adduser -q satellite dialout # for serial based surfaces 32 | fi 33 | 34 | # ensure some dependencies are installed 35 | ensure_installed() { 36 | if ! dpkg --verify "$1" 2>/dev/null; then 37 | # Future: batch the installs, if there are multiple 38 | apt-get install -qq -y $1 39 | fi 40 | } 41 | ensure_installed "wget unattended-upgrades" 42 | 43 | # Run interactive version picker 44 | yarn --cwd "pi-image/update-prompt" install >/dev/null 45 | node "pi-image/update-prompt/main.js" $1 $2 46 | 47 | # Get result 48 | if [ -f /tmp/satellite-version-selection ]; then 49 | SELECTED_URL=$(cat /tmp/satellite-version-selection) 50 | SELECTED_NAME=$(cat /tmp/satellite-version-selection-name) 51 | rm -f /tmp/satellite-version-selection 52 | rm -f /tmp/satellite-version-selection-name 53 | fi 54 | 55 | if [ -n "$SELECTED_URL" ]; then 56 | echo "Installing from $SELECTED_URL" 57 | 58 | # download it 59 | wget "$SELECTED_URL" -O /tmp/satellite-update.tar.gz -q --show-progress 60 | 61 | # extract download 62 | echo "Extracting..." 63 | rm -R -f /tmp/satellite-update 64 | mkdir /tmp/satellite-update 65 | tar -xzf /tmp/satellite-update.tar.gz --strip-components=1 -C /tmp/satellite-update 66 | rm /tmp/satellite-update.tar.gz 67 | 68 | # copy across the useful files 69 | rm -R -f /opt/companion-satellite 70 | npx --yes @electron/asar e /tmp/satellite-update/resources/app.asar /tmp/satellite-update/resources/app 71 | mkdir /opt/companion-satellite 72 | mv /tmp/satellite-update/resources/app /opt/companion-satellite/satellite 73 | mkdir /opt/companion-satellite/webui 74 | mv /tmp/satellite-update/resources/webui /opt/companion-satellite/webui/dist 75 | # mv /tmp/satellite-update/*.rules /opt/companion-satellite/ 76 | rm -R /tmp/satellite-update 77 | 78 | echo "$SELECTED_NAME" > /opt/companion-satellite/BUILD 79 | 80 | # remove the old dependencies 81 | rm -R -f node_modules || true 82 | rm -R -f webui/node_modules || true 83 | 84 | echo "Finishing" 85 | else 86 | echo "Skipping update" 87 | fi 88 | 89 | # update some tooling 90 | if [ -d "/etc/udev/rules.d/" ]; then 91 | cp satellite/assets/linux/50-satellite.rules /etc/udev/rules.d/ 92 | udevadm control --reload-rules || true 93 | fi 94 | 95 | # update startup script 96 | cp pi-image/satellite.service /etc/systemd/system 97 | 98 | # ADD REST_PORT to old config files 99 | if [ -f /boot/satellite-config ]; then 100 | if grep -q REST_PORT /boot/satellite-config; then 101 | echo "config ok" 102 | else 103 | echo " 104 | # Port for the REST server (0 to disable) 105 | REST_PORT=9999" >> /boot/satellite-config 106 | fi 107 | chmod 666 /boot/satellite-config 108 | fi 109 | 110 | systemctl daemon-reload 111 | 112 | # install some scripts 113 | ln -s -f /usr/local/src/companion-satellite/pi-image/satellite-license /usr/local/bin/satellite-license 114 | ln -s -f /usr/local/src/companion-satellite/pi-image/satellite-help /usr/local/bin/satellite-help 115 | ln -s -f /usr/local/src/companion-satellite/pi-image/satellite-update /usr/local/sbin/satellite-update 116 | ln -s -f /usr/local/src/companion-satellite/pi-image/satellite-edit-config /usr/local/sbin/satellite-edit-config 117 | 118 | # install the motd 119 | ln -s -f /usr/local/src/companion-satellite/pi-image/motd /etc/motd 120 | -------------------------------------------------------------------------------- /samples/xencelabs-quick-keys-page.companionconfig: -------------------------------------------------------------------------------- 1 | {"version":2,"type":"page","config":{"1":{"style":"png","text":"Menu","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"2":{"style":"png","text":"1","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"3":{"style":"png","text":"2","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"4":{"style":"png","text":"3","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"5":{"style":"png","text":"4","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"6":{"style":"png","text":"wheel click","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"7":{},"8":{},"9":{},"10":{"style":"png","text":"5","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"11":{"style":"png","text":"6","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"12":{"style":"png","text":"7","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"13":{"style":"png","text":"8","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"14":{"style":"png","text":"wheel\\n$(internal:custom_counter)","size":"auto","alignment":"center:center","pngalignment":"center:center","color":16777215,"bgcolor":0,"latch":false,"relative_delay":false},"15":{},"16":{},"17":{},"18":{},"19":{},"20":{},"21":{},"22":{},"23":{},"24":{},"25":{},"26":{},"27":{},"28":{},"29":{},"30":{},"31":{},"32":{}},"instances":{"bitfocus-companion":{"instance_type":"bitfocus-companion","label":"internal","id":"bitfocus-companion","_configIdx":2}},"actions":{"1":[],"2":[],"3":[],"4":[],"5":[],"6":[{"id":"Dm1gI9NeN","label":"bitfocus-companion:custom_variable_set_value","instance":"bitfocus-companion","action":"custom_variable_set_value","options":{"name":"counter","value":"0"},"delay":0}],"7":[],"8":[],"9":[],"10":[],"11":[],"12":[],"13":[],"14":[{"id":"-PTHSkRr7","label":"bitfocus-companion:custom_variable_set_expression","instance":"bitfocus-companion","action":"custom_variable_set_expression","options":{"name":"counter","expression":"(($(internal:custom_counter) + 11) % 20) - 10"},"delay":0}],"15":[],"16":[],"17":[],"18":[],"19":[],"20":[],"21":[],"22":[],"23":[],"24":[],"25":[],"26":[],"27":[],"28":[],"29":[],"30":[],"31":[],"32":[]},"release_actions":{"1":[],"2":[],"3":[],"4":[],"5":[],"6":[],"7":[],"8":[],"9":[],"10":[],"11":[],"12":[],"13":[],"14":[{"id":"ZtFexRhto","label":"bitfocus-companion:custom_variable_set_expression","instance":"bitfocus-companion","action":"custom_variable_set_expression","options":{"name":"counter","expression":"(($(internal:custom_counter) -11) % 20) + 10"},"delay":0}],"15":[],"16":[],"17":[],"18":[],"19":[],"20":[],"21":[],"22":[],"23":[],"24":[],"25":[],"26":[],"27":[],"28":[],"29":[],"30":[],"31":[],"32":[]},"feedbacks":{"1":[],"2":[{"id":"1omtiPet9","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*1*"}}],"3":[{"id":"PRZEbFCPD","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*2*"}}],"4":[{"id":"f5JMdWKwmF","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*3*"}}],"5":[{"id":"a5KwuQTrj","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*4*"}}],"6":[],"10":[{"id":"l2HVJkDOH","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*5*"}}],"11":[{"id":"nU7Yz4Ig49","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*6*"}}],"12":[{"id":"P_pfmX_m2","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*7*"}}],"13":[{"id":"LitdnTOrky","type":"bank_pushed","instance_id":"bitfocus-companion","options":{"page":"0","bank":0},"style":{"text":"*8*"}}],"14":[{"id":"Ocx8w3gcW","type":"variable_value","instance_id":"bitfocus-companion","options":{"variable":"internal:custom_counter","op":"lt","value":"0"},"style":{"bgcolor":16711680}},{"id":"P_xYtK4oB","type":"variable_value","instance_id":"bitfocus-companion","options":{"variable":"internal:custom_counter","op":"eq","value":"0"},"style":{"bgcolor":65433}},{"id":"bcR7zujCe","type":"variable_value","instance_id":"bitfocus-companion","options":{"variable":"internal:custom_counter","op":"gt","value":"0"},"style":{"bgcolor":3368703}}]},"page":{"name":"QuickKeys"}} -------------------------------------------------------------------------------- /satellite/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/icon.png -------------------------------------------------------------------------------- /satellite/assets/linux/50-satellite.rules: -------------------------------------------------------------------------------- 1 | SUBSYSTEM=="input", GROUP="input", MODE="0666" 2 | 3 | # infinitton 4 | SUBSYSTEM=="usb", ATTRS{idVendor}=="ffff", ATTRS{idProduct}=="1f40", MODE:="666", GROUP="satellite" 5 | KERNEL=="hidraw*", ATTRS{idVendor}=="ffff", ATTRS{idProduct}=="1f40", MODE:="666", GROUP="satellite" 6 | SUBSYSTEM=="usb", ATTRS{idVendor}=="ffff", ATTRS{idProduct}=="1f41", MODE:="666", GROUP="satellite" 7 | KERNEL=="hidraw*", ATTRS{idVendor}=="ffff", ATTRS{idProduct}=="1f41", MODE:="666", GROUP="satellite" 8 | 9 | # streamdeck 10 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="666", GROUP="satellite" 11 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="666", GROUP="satellite" 12 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="666", GROUP="satellite" 13 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE:="666", GROUP="satellite" 14 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE:="666", GROUP="satellite" 15 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE:="666", GROUP="satellite" 16 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666", GROUP="satellite" 17 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="008f", MODE:="666", GROUP="satellite" 18 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", GROUP="satellite" 19 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00a5", MODE:="660", GROUP="satellite" 20 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00aa", MODE:="660", GROUP="satellite" 21 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00b8", MODE:="660", GROUP="satellite" 22 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00b9", MODE:="660", GROUP="satellite" 23 | SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00ba", MODE:="660", GROUP="satellite" 24 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="666", GROUP="satellite" 25 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="666", GROUP="satellite" 26 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="666", GROUP="satellite" 27 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006d", MODE:="666", GROUP="satellite" 28 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0080", MODE:="666", GROUP="satellite" 29 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0084", MODE:="666", GROUP="satellite" 30 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666", GROUP="satellite" 31 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="008f", MODE:="666", GROUP="satellite" 32 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", GROUP="satellite" 33 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009a", MODE:="666", GROUP="satellite" 34 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00a5", MODE:="660", GROUP="satellite" 35 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00aa", MODE:="660", GROUP="satellite" 36 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00b8", MODE:="660", GROUP="satellite" 37 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00b9", MODE:="660", GROUP="satellite" 38 | KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00ba", MODE:="660", GROUP="satellite" 39 | 40 | # blackmagic panels 41 | KERNEL=="hidraw*", ATTRS{idVendor}=="1edb", ATTRS{idProduct}=="bef0", MODE:="660", GROUP="satellite" 42 | KERNEL=="hidraw*", ATTRS{idVendor}=="1edb", ATTRS{idProduct}=="da11", MODE:="660", GROUP="satellite" 43 | 44 | #xencelabs 45 | KERNEL=="hidraw*", ATTRS{busnum}=="1", ATTRS{idVendor}=="28bd", ATTRS{idProduct}=="5202", MODE:="666", GROUP="satellite" 46 | KERNEL=="hidraw*", ATTRS{busnum}=="1", ATTRS{idVendor}=="28bd", ATTRS{idProduct}=="5203", MODE:="666", GROUP="satellite" 47 | 48 | #contour-shuttle 49 | KERNEL=="hidraw*", ATTRS{idVendor}=="0b33", MODE:="666", GROUP="satellite" 50 | -------------------------------------------------------------------------------- /satellite/assets/linux/README: -------------------------------------------------------------------------------- 1 | # Quickstart guide 2 | 3 | 1. Headless Companion Satellite 4 | 5 | If you want to run satellite on a headless machine, consider using the install script 6 | 7 | This can be run with: 8 | $ curl https://raw.githubusercontent.com/bitfocus/companion-satellite/main/pi-image/install.sh | bash 9 | 10 | The benefit of this approach is that it will setup and manage any required dependencies, udev rules for you, and provide you with an easy script to run to perform an update. 11 | 12 | Alternatively, you are able to set it up yourself and define a systemd unit to launch it or do whatever you wish. For examples on how to do this, check the scripts in the repository https://github.com/bitfocus/companion-satellite 13 | 14 | 2. Required Dependencies 15 | 16 | Satellite requires some dependencies that aren't included by default on some Debian and Ubuntu installations. 17 | 18 | $ apt-get update 19 | $ apt-get install -y libusb-1.0-0-dev libudev-dev libfontconfig1 20 | 21 | 3. Udev rules 22 | 23 | For Satellite to be able to access your Streamdecks, Loupedecks, xkeys or other supported USB devices, some udev rules must be setup. 24 | You can do this by running as root: 25 | $ cp 50-satellite.rules /etc/udev/rules.d/50-satellite.rules 26 | $ udevadm control --reload-rules 27 | And replugging any usb devices that Satellite should be able to use. 28 | 29 | Sometimes this file can change, when adding support for new devices, so it can be a good idea to update it when updating Satellite. 30 | 31 | 4. Launching 32 | 33 | To run Satellite, either run companion-satellite to get the desktop build, or run `node dist/main.js` to start it headless. 34 | -------------------------------------------------------------------------------- /satellite/assets/tray-offline.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/tray-offline.ico -------------------------------------------------------------------------------- /satellite/assets/tray-offline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/tray-offline.png -------------------------------------------------------------------------------- /satellite/assets/tray.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/tray.ico -------------------------------------------------------------------------------- /satellite/assets/tray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/tray.png -------------------------------------------------------------------------------- /satellite/assets/trayOfflineTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/trayOfflineTemplate.png -------------------------------------------------------------------------------- /satellite/assets/trayOfflineTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/trayOfflineTemplate@2x.png -------------------------------------------------------------------------------- /satellite/assets/trayTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/trayTemplate.png -------------------------------------------------------------------------------- /satellite/assets/trayTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/satellite/assets/trayTemplate@2x.png -------------------------------------------------------------------------------- /satellite/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-dyld-environment-variables 6 | 7 | com.apple.security.cs.disable-library-validation 8 | 9 | com.apple.security.cs.allow-jit 10 | 11 | com.apple.security.cs.allow-unsigned-executable-memory 12 | 13 | com.apple.security.cs.debugger 14 | 15 | com.apple.security.network.client 16 | 17 | com.apple.security.network.server 18 | 19 | com.apple.security.files.user-selected.read-only 20 | 21 | com.apple.security.inherit 22 | 23 | com.apple.security.automation.apple-events 24 | 25 | 26 | -------------------------------------------------------------------------------- /satellite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "satellite", 3 | "version": "2.2.1", 4 | "description": "Satellite Streamdeck connector for Bitfocus Companion", 5 | "author": { 6 | "name": "Julian Waller", 7 | "email": "git@julusian.co.uk", 8 | "url": "https://github.com/julusian" 9 | }, 10 | "type": "module", 11 | "main": "dist/electron.js", 12 | "license": "MIT", 13 | "private": true, 14 | "scripts": { 15 | "dev": "tsx watch src/main.ts config.json", 16 | "dev:electron": "run build:main && electron dist/electron.js", 17 | "build": "rimraf dist && run build:main", 18 | "build:main": "run -T tsc -p tsconfig.build.json", 19 | "check-types": "run build:main --noEmit", 20 | "watch-types": "run build:main --noEmit --watch" 21 | }, 22 | "devDependencies": { 23 | "@types/eslint": "^9.6.1", 24 | "@types/koa": "^2.15.0", 25 | "@types/koa-router": "^7.4.8", 26 | "@types/koa-static": "^4.0.4", 27 | "@types/node": "^20.17.32", 28 | "@types/semver": "^7.7.0", 29 | "@types/ws": "^8.18.1", 30 | "cross-env": "^7.0.3", 31 | "electron": "34.5.3", 32 | "electron-builder": "^26.0.12", 33 | "rimraf": "^6.0.1", 34 | "tsx": "^4.19.4" 35 | }, 36 | "engines": { 37 | "node": "^20.14" 38 | }, 39 | "dependencies": { 40 | "@blackmagic-controller/node": "^0.2.0", 41 | "@elgato-stream-deck/node": "^7.2.0", 42 | "@julusian/bonjour-service": "^1.3.0-2", 43 | "@julusian/image-rs": "^1.1.1", 44 | "@julusian/jpeg-turbo": "^2.2.0", 45 | "@julusian/segfault-raub": "^2.3.1", 46 | "@loupedeck/node": "^1.2.0", 47 | "@napi-rs/canvas": "^0.1.65", 48 | "@xencelabs-quick-keys/node": "^1.0.0", 49 | "conf": "^13.1.0", 50 | "debounce-fn": "^6.0.0", 51 | "electron-store": "^10.0.1", 52 | "electron-updater": "^6.6.2", 53 | "exit-hook": "^4.0.0", 54 | "infinitton-idisplay": "^1.2.0", 55 | "koa": "^2.16.1", 56 | "koa-body": "^6.0.1", 57 | "koa-router": "^13.0.1", 58 | "koa-static": "^5.0.0", 59 | "nanoid": "^5.1.5", 60 | "node-hid": "^3.1.2", 61 | "semver": "^7.7.1", 62 | "shuttle-node": "^0.1.1", 63 | "tslib": "^2.8.1", 64 | "usb": "^2.15.0", 65 | "ws": "^8.18.1" 66 | }, 67 | "lint-staged": { 68 | "*.{css,json,md,scss}": [ 69 | "prettier --write" 70 | ], 71 | "*.{ts,tsx,js,jsx}": [ 72 | "run -T lint:raw --fix" 73 | ] 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /satellite/src/aboutPreload.cts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | const { contextBridge, ipcRenderer } = require('electron') 3 | 4 | const aboutApi = { 5 | getVersion: async (): Promise => ipcRenderer.invoke('getVersion'), 6 | openShell: async (url: string): Promise => ipcRenderer.invoke('openShell', url), 7 | } 8 | 9 | contextBridge.exposeInMainWorld('aboutApi', aboutApi) 10 | 11 | export type { aboutApi } 12 | -------------------------------------------------------------------------------- /satellite/src/apiTypes.ts: -------------------------------------------------------------------------------- 1 | import Conf from 'conf' 2 | import { CompanionSatelliteClient } from './client.js' 3 | import { SatelliteConfig, SatelliteConfigInstance } from './config.js' 4 | import type { components as openapiComponents } from './generated/openapi.js' 5 | 6 | export type ApiStatusResponse = openapiComponents['schemas']['StatusResponse'] 7 | export type ApiConfigData = openapiComponents['schemas']['ConfigData'] 8 | export type ApiConfigDataUpdate = openapiComponents['schemas']['ConfigDataUpdate'] 9 | export type ApiSurfaceInfo = openapiComponents['schemas']['SurfaceInfo'] 10 | export type ApiSurfacePluginInfo = openapiComponents['schemas']['SurfacePluginInfo'] 11 | export type ApiSurfacePluginsEnabled = Record 12 | 13 | export type ApiConfigDataUpdateElectron = ApiConfigDataUpdate & Pick, 'httpEnabled' | 'httpPort'> 14 | 15 | export interface SatelliteUiApi { 16 | includeApiEnable: boolean 17 | getConfig: () => Promise 18 | saveConfig: (newConfig: ApiConfigDataUpdate) => Promise 19 | getStatus: () => Promise 20 | rescanSurfaces: () => Promise 21 | connectedSurfaces: () => Promise 22 | surfacePlugins: () => Promise 23 | surfacePluginsEnabled: () => Promise 24 | surfacePluginsEnabledUpdate: (newConfig: ApiSurfacePluginsEnabled) => Promise 25 | } 26 | 27 | export function compileStatus(client: CompanionSatelliteClient): ApiStatusResponse { 28 | return { 29 | connected: client.connected, 30 | companionVersion: client.companionVersion, 31 | companionApiVersion: client.companionApiVersion, 32 | companionUnsupportedApi: client.companionUnsupported, 33 | } 34 | } 35 | 36 | export function compileConfig(appConfig: Conf): ApiConfigData { 37 | return { 38 | protocol: appConfig.get('remoteProtocol'), 39 | host: appConfig.get('remoteIp'), 40 | port: appConfig.get('remotePort'), 41 | wsAddress: appConfig.get('remoteWsAddress'), 42 | 43 | installationName: appConfig.get('installationName'), 44 | 45 | httpEnabled: appConfig.get('restEnabled'), 46 | httpPort: appConfig.get('restPort'), 47 | 48 | mdnsEnabled: appConfig.get('mdnsEnabled'), 49 | } 50 | } 51 | 52 | export function updateConfig(appConfig: SatelliteConfigInstance, newConfig: ApiConfigDataUpdateElectron): void { 53 | if (newConfig.protocol !== undefined) appConfig.set('remoteProtocol', newConfig.protocol) 54 | if (newConfig.host !== undefined) appConfig.set('remoteIp', newConfig.host) 55 | if (newConfig.port !== undefined) appConfig.set('remotePort', newConfig.port) 56 | if (newConfig.wsAddress !== undefined) appConfig.set('remoteWsAddress', newConfig.wsAddress) 57 | 58 | if (newConfig.httpEnabled !== undefined) appConfig.set('restEnabled', newConfig.httpEnabled) 59 | if (newConfig.httpPort !== undefined) appConfig.set('restPort', newConfig.httpPort) 60 | 61 | if (newConfig.mdnsEnabled !== undefined) appConfig.set('mdnsEnabled', newConfig.mdnsEnabled) 62 | if (newConfig.installationName !== undefined) appConfig.set('installationName', newConfig.installationName) 63 | } 64 | 65 | export function updateSurfacePluginsEnabledConfig( 66 | appConfig: SatelliteConfigInstance, 67 | newConfig: ApiSurfacePluginsEnabled, 68 | ): void { 69 | for (const [pluginId, enabled] of Object.entries(newConfig)) { 70 | appConfig.set(`surfacePluginsEnabled.${pluginId}`, !!enabled) 71 | } 72 | 73 | console.log('Updated surfacePluginsEnabled:', appConfig.get('surfacePluginsEnabled')) 74 | } 75 | -------------------------------------------------------------------------------- /satellite/src/clientImplementations.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'net' 2 | import { WebSocket } from 'ws' 3 | 4 | export interface ICompanionSatelliteClientEvents { 5 | error: [Error] 6 | close: [] 7 | data: [Buffer] 8 | connect: [] 9 | } 10 | 11 | export interface ICompanionSatelliteClientOptions { 12 | onError: (error: Error) => void 13 | onClose: () => void 14 | onData: (data: string) => void 15 | onConnect: () => void 16 | } 17 | 18 | export interface ICompanionSatelliteClient { 19 | write(data: string): void 20 | end(): void 21 | destroy(): void 22 | } 23 | 24 | export interface TcpConnectionDetails { 25 | mode: 'tcp' 26 | host: string 27 | port: number 28 | } 29 | export interface WsConnectionDetails { 30 | mode: 'ws' 31 | url: string 32 | } 33 | export type SomeConnectionDetails = TcpConnectionDetails | WsConnectionDetails 34 | 35 | export class CompanionSatelliteTcpClient implements ICompanionSatelliteClient { 36 | #socket: Socket 37 | 38 | constructor(options: ICompanionSatelliteClientOptions, details: TcpConnectionDetails) { 39 | this.#socket = new Socket() 40 | 41 | this.#socket.on('error', (err) => options.onError(err)) 42 | this.#socket.on('close', () => options.onClose()) 43 | this.#socket.on('data', (data) => options.onData(data.toString())) 44 | this.#socket.on('connect', () => options.onConnect()) 45 | 46 | this.#socket.connect(details.port, details.host) 47 | } 48 | 49 | write(data: string): void { 50 | this.#socket.write(data) 51 | } 52 | end(): void { 53 | this.#socket.end() 54 | } 55 | destroy(): void { 56 | this.#socket.destroy() 57 | } 58 | } 59 | 60 | export class CompanionSatelliteWsClient implements ICompanionSatelliteClient { 61 | #socket: WebSocket 62 | 63 | constructor(options: ICompanionSatelliteClientOptions, details: WsConnectionDetails) { 64 | this.#socket = new WebSocket(details.url, { 65 | timeout: 5000, 66 | }) 67 | 68 | this.#socket.on('error', (err) => options.onError(err)) 69 | this.#socket.on('close', () => options.onClose()) 70 | // eslint-disable-next-line @typescript-eslint/no-base-to-string 71 | this.#socket.on('message', (data) => options.onData(data.toString())) 72 | this.#socket.on('open', () => options.onConnect()) 73 | } 74 | 75 | write(data: string): void { 76 | this.#socket.send(data) 77 | } 78 | end(): void { 79 | this.#socket.terminate() 80 | } 81 | destroy(): void { 82 | this.#socket.close() 83 | } 84 | } 85 | 86 | export function formatConnectionUrl(details: SomeConnectionDetails): string { 87 | if (details.mode === 'tcp') { 88 | return `tcp://${details.host}:${details.port}` 89 | } else { 90 | return details.url 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /satellite/src/config.ts: -------------------------------------------------------------------------------- 1 | import Conf, { Schema } from 'conf' 2 | import path from 'path' 3 | import os from 'os' 4 | import { customAlphabet } from 'nanoid' 5 | import { SomeConnectionDetails } from './clientImplementations.js' 6 | import { assertNever, DEFAULT_TCP_PORT, DEFAULT_WS_PORT } from './lib.js' 7 | import debounceFn from 'debounce-fn' 8 | import { setMaxListeners } from 'events' 9 | 10 | const nanoidHex = customAlphabet('0123456789abcdef') 11 | 12 | export type SatelliteConfigInstance = Conf 13 | 14 | export interface SatelliteConfig { 15 | remoteProtocol: 'tcp' | 'ws' 16 | remoteIp: string 17 | remotePort: number 18 | remoteWsAddress: string 19 | 20 | installationName: string 21 | 22 | restEnabled: boolean 23 | restPort: number 24 | 25 | mdnsEnabled: boolean 26 | 27 | surfacePluginsEnabled: Record 28 | } 29 | 30 | export const satelliteConfigSchema: Schema = { 31 | remoteProtocol: { 32 | type: 'string', 33 | enum: ['tcp', 'ws'], 34 | description: 'Protocol to use for connecting to Companion installation', 35 | default: 'tcp', 36 | }, 37 | remoteIp: { 38 | type: 'string', 39 | description: 'Address of Companion installation', 40 | default: '127.0.0.1', 41 | }, 42 | remotePort: { 43 | type: 'integer', 44 | description: 'Port number of Companion installation', 45 | minimum: 1, 46 | maximum: 65535, 47 | default: DEFAULT_TCP_PORT, 48 | }, 49 | remoteWsAddress: { 50 | type: 'string', 51 | description: 'Websocket address of Companion installation', 52 | default: `ws://127.0.0.1:${DEFAULT_WS_PORT}`, 53 | }, 54 | 55 | installationName: { 56 | type: 'string', 57 | description: 'Name for this Satellite installation', 58 | default: `Satellite ${os.hostname()} (${nanoidHex(8)})`, 59 | }, 60 | 61 | restEnabled: { 62 | type: 'boolean', 63 | description: 'Enable HTTP api', 64 | default: true, 65 | }, 66 | restPort: { 67 | type: 'integer', 68 | description: 'Port number to run HTTP server on', 69 | minimum: 1, 70 | maximum: 65535, 71 | default: 9999, 72 | }, 73 | mdnsEnabled: { 74 | type: 'boolean', 75 | description: 'Enable mDNS announcement', 76 | default: true, 77 | }, 78 | 79 | surfacePluginsEnabled: { 80 | type: 'object', 81 | patternProperties: { 82 | '': { 83 | type: 'boolean', 84 | }, 85 | }, 86 | description: 'Enabled Surface Plugins', 87 | default: { 88 | 'elgato-streamdeck': true, 89 | loupedeck: true, 90 | infinitton: true, 91 | }, 92 | }, 93 | } 94 | 95 | export function ensureFieldsPopulated(store: Conf): void { 96 | // Note: This doesn't appear to do anything, as Conf is populated with defaults 97 | for (const [key, schema] of Object.entries(satelliteConfigSchema)) { 98 | if (store.get(key) === undefined && schema.default !== undefined) { 99 | // Ensure values are written to disk 100 | store.set(key, schema.default) 101 | } 102 | } 103 | 104 | // Ensure that the store with the filled in defaults is written to disk 105 | // eslint-disable-next-line no-self-assign 106 | store.store = store.store 107 | } 108 | 109 | export function openHeadlessConfig(rawConfigPath: string): Conf { 110 | const absoluteConfigPath = path.isAbsolute(rawConfigPath) ? rawConfigPath : path.join(process.cwd(), rawConfigPath) 111 | 112 | const appConfig = new Conf({ 113 | schema: satelliteConfigSchema, 114 | configName: path.parse(absoluteConfigPath).name, 115 | projectName: 'companion-satellite', 116 | cwd: path.dirname(absoluteConfigPath), 117 | }) 118 | setMaxListeners(0, appConfig.events) 119 | 120 | ensureFieldsPopulated(appConfig) 121 | return appConfig 122 | } 123 | 124 | export function getConnectionDetailsFromConfig(config: SatelliteConfigInstance): SomeConnectionDetails { 125 | const protocol = config.get('remoteProtocol') 126 | switch (protocol) { 127 | case 'tcp': 128 | return { 129 | mode: 'tcp', 130 | host: config.get('remoteIp') || '127.0.0.1', 131 | port: config.get('remotePort') || DEFAULT_TCP_PORT, 132 | } 133 | case 'ws': 134 | console.log('get', config.get('remoteWsAddress')) 135 | return { 136 | mode: 'ws', 137 | url: config.get('remoteWsAddress') || `ws://127.0.0.1:${DEFAULT_WS_PORT}`, 138 | } 139 | default: 140 | assertNever(protocol) 141 | return { 142 | mode: 'tcp', 143 | host: config.get('remoteIp'), 144 | port: config.get('remotePort'), 145 | } 146 | } 147 | } 148 | 149 | export function listenToConnectionConfigChanges(config: SatelliteConfigInstance, tryConnect: () => void): void { 150 | const debounceConnect = debounceFn(tryConnect, { wait: 50, after: true, before: false }) 151 | 152 | config.onDidChange('remoteProtocol', debounceConnect) 153 | config.onDidChange('remoteIp', debounceConnect) 154 | config.onDidChange('remotePort', debounceConnect) 155 | config.onDidChange('remoteWsAddress', debounceConnect) 156 | } 157 | -------------------------------------------------------------------------------- /satellite/src/device-types/api.ts: -------------------------------------------------------------------------------- 1 | import type HID from 'node-hid' 2 | import type { CardGenerator } from '../graphics/cards.js' 3 | import EventEmitter from 'events' 4 | import type { PixelFormat } from '@julusian/image-rs' 5 | 6 | export type HIDDevice = HID.Device 7 | 8 | export type SurfaceId = string 9 | 10 | export type DeviceDrawImageFn = (width: number, height: number, format: PixelFormat) => Promise 11 | 12 | export interface DeviceDrawProps { 13 | deviceId: string 14 | keyIndex: number 15 | image?: DeviceDrawImageFn 16 | color?: string // hex 17 | text?: string 18 | } 19 | export interface DeviceRegisterProps { 20 | brightness: boolean 21 | rowCount: number 22 | columnCount: number 23 | bitmapSize: number | null 24 | colours: boolean 25 | text: boolean 26 | transferVariables?: Array 27 | pincodeMap: SurfacePincodeMap | null 28 | } 29 | 30 | export interface DeviceRegisterInputVariable { 31 | id: string 32 | type: 'input' 33 | name: string 34 | description?: string 35 | } 36 | export interface DeviceRegisterOutputVariable { 37 | id: string 38 | type: 'output' 39 | name: string 40 | description?: string 41 | } 42 | 43 | export interface DiscoveredSurfaceInfo { 44 | surfaceId: string 45 | description: string 46 | pluginInfo: T 47 | } 48 | 49 | export interface SurfacePluginDetectionEvents { 50 | deviceAdded: [device: DiscoveredSurfaceInfo] 51 | deviceRemoved: [deviceId: SurfaceId] 52 | } 53 | 54 | /** 55 | * For some plugins which only support using a builtin detection mechanism, this can be used to provide the detection info 56 | */ 57 | export interface SurfacePluginDetection extends EventEmitter> { 58 | /** 59 | * Trigger this plugin to perform a scan for any connected surfaces. 60 | * This is used when the user triggers a scan, so should refresh any caches when possible 61 | */ 62 | triggerScan(): Promise 63 | } 64 | 65 | /** 66 | * The base SurfacePlugin interface, for all surface plugins 67 | */ 68 | export interface SurfacePlugin { 69 | readonly pluginId: string 70 | readonly pluginName: string 71 | readonly pluginComment?: string[] 72 | 73 | /** 74 | * Some plugins are forced to use a builtin detection mechanism by their surfaces or inner library 75 | * In this case, this property should be set to an instance of SurfacePluginDetection 76 | * 77 | * It is preferred that plugins to NOT use this, and to instead use the abtractions we provide to reduce the cost of scanning and detection 78 | */ 79 | readonly detection?: SurfacePluginDetection 80 | 81 | /** 82 | * Initialize the plugin 83 | */ 84 | init(): Promise 85 | 86 | /** 87 | * Uninitialise the plugin 88 | */ 89 | destroy(): Promise 90 | 91 | /** 92 | * Check if a HID device is supported by this plugin 93 | * Note: This must not open the device, just perform checks based on the provided info to see if it is supported 94 | * @param device HID device to check 95 | * @returns Info about the device if it is supported, otherwise null 96 | */ 97 | checkSupportsHidDevice?: (device: HIDDevice) => DiscoveredSurfaceInfo | null 98 | 99 | /** 100 | * Perform a scan for devices, but not open them 101 | * Note: This should only be used if the plugin uses a protocol where we don't have other handling for 102 | */ 103 | scanForSurfaces?: () => Promise[]> 104 | 105 | /** 106 | * Open a discovered/known surface 107 | * @param surfaceId Id of the surface 108 | * @param pluginInfo Plugin specific info about the surface 109 | * @param context Context for the surface 110 | * @returns Instance of the surface 111 | */ 112 | openSurface: (surfaceId: string, pluginInfo: TInfo, context: SurfaceContext) => Promise 113 | } 114 | 115 | export interface OpenSurfaceResult { 116 | surface: SurfaceInstance 117 | registerProps: DeviceRegisterProps 118 | } 119 | 120 | export type SurfacePincodeMap = SurfacePincodeMapPageSingle | SurfacePincodeMapPageMultiple | SurfacePincodeMapCustom 121 | export interface SurfacePincodeMapCustom { 122 | type: 'custom' 123 | } 124 | export interface SurfacePincodeMapPageSingle extends SurfacePincodeMapPageEntry { 125 | type: 'single-page' 126 | pincode: [number, number] | null 127 | } 128 | export interface SurfacePincodeMapPageMultiple { 129 | type: 'multiple-page' 130 | pincode: [number, number] 131 | nextPage: [number, number] 132 | pages: Partial[] 133 | } 134 | export interface SurfacePincodeMapPageEntry { 135 | 0: [number, number] 136 | 1: [number, number] 137 | 2: [number, number] 138 | 3: [number, number] 139 | 4: [number, number] 140 | 5: [number, number] 141 | 6: [number, number] 142 | 7: [number, number] 143 | 8: [number, number] 144 | 9: [number, number] 145 | } 146 | 147 | export interface SurfaceInstance { 148 | readonly pluginId: string 149 | 150 | readonly surfaceId: SurfaceId 151 | readonly productName: string 152 | 153 | close(): Promise 154 | 155 | initDevice(): Promise 156 | 157 | updateCapabilities(capabilities: ClientCapabilities): void 158 | 159 | deviceAdded(): Promise 160 | 161 | setBrightness(percent: number): Promise 162 | 163 | blankDevice(): Promise 164 | 165 | draw(signal: AbortSignal, data: DeviceDrawProps): Promise 166 | 167 | onVariableValue?(name: string, value: string): void 168 | 169 | onLockedStatus?(locked: boolean, characterCount: number): void 170 | 171 | showStatus(signal: AbortSignal, cardGenerator: CardGenerator, hostname: string, status: string): Promise 172 | } 173 | 174 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 175 | export interface ClientCapabilities { 176 | // For future use to support new functionality 177 | } 178 | 179 | export interface CompanionClient { 180 | get displayHost(): string 181 | 182 | keyDownXY(deviceId: string, x: number, y: number): void 183 | keyUpXY(deviceId: string, x: number, y: number): void 184 | rotateLeftXY(deviceId: string, x: number, y: number): void 185 | rotateRightXY(deviceId: string, x: number, y: number): void 186 | pincodeKey(deviceId: string, keyCode: number): void 187 | 188 | sendVariableValue(deviceId: string, variable: string, value: any): void 189 | } 190 | 191 | export interface SurfaceContext { 192 | get isLocked(): boolean 193 | // get displayHost(): string 194 | 195 | disconnect(error: Error): void 196 | 197 | keyDown(keyIndex: number): void 198 | keyUp(keyIndex: number): void 199 | keyDownUp(keyIndex: number): void 200 | rotateLeft(keyIndex: number): void 201 | rotateRight(keyIndex: number): void 202 | 203 | keyDownXY(x: number, y: number): void 204 | keyUpXY(x: number, y: number): void 205 | keyDownUpXY(x: number, y: number): void 206 | rotateLeftXY(x: number, y: number): void 207 | rotateRightXY(x: number, y: number): void 208 | 209 | sendVariableValue(variable: string, value: any): void 210 | } 211 | -------------------------------------------------------------------------------- /satellite/src/device-types/infinitton.ts: -------------------------------------------------------------------------------- 1 | import type { CardGenerator } from '../graphics/cards.js' 2 | import type { 3 | ClientCapabilities, 4 | DeviceDrawProps, 5 | SurfacePlugin, 6 | DiscoveredSurfaceInfo, 7 | SurfaceInstance, 8 | HIDDevice, 9 | OpenSurfaceResult, 10 | SurfaceContext, 11 | } from './api.js' 12 | import * as imageRs from '@julusian/image-rs' 13 | import Infinitton from 'infinitton-idisplay' 14 | import { Pincode5x3 } from './pincode.js' 15 | 16 | export interface InfinittonDeviceInfo { 17 | path: string 18 | } 19 | 20 | const PLUGIN_ID = 'infinitton' 21 | 22 | export class InfinittonPlugin implements SurfacePlugin { 23 | readonly pluginId = PLUGIN_ID 24 | readonly pluginName = 'Infinitton' 25 | 26 | async init(): Promise { 27 | // Nothing to do 28 | } 29 | async destroy(): Promise { 30 | // Nothing to do 31 | } 32 | 33 | checkSupportsHidDevice = (device: HIDDevice): DiscoveredSurfaceInfo | null => { 34 | if ( 35 | device.path && 36 | device.serialNumber && 37 | device.vendorId === Infinitton.VENDOR_ID && 38 | Infinitton.PRODUCT_IDS.includes(device.productId) 39 | ) { 40 | return { 41 | surfaceId: `infinitton:${device.serialNumber}`, 42 | description: `Infinitton`, 43 | pluginInfo: { path: device.path }, 44 | } 45 | } else { 46 | return null 47 | } 48 | } 49 | 50 | openSurface = async ( 51 | surfaceId: string, 52 | pluginInfo: InfinittonDeviceInfo, 53 | context: SurfaceContext, 54 | ): Promise => { 55 | const infinitton = new Infinitton(pluginInfo.path) 56 | return { 57 | surface: new InfinittonWrapper(surfaceId, infinitton, context), 58 | registerProps: { 59 | brightness: true, 60 | rowCount: 3, 61 | columnCount: 5, 62 | bitmapSize: 72, 63 | colours: false, 64 | text: false, 65 | pincodeMap: Pincode5x3(), 66 | }, 67 | } 68 | } 69 | } 70 | 71 | export class InfinittonWrapper implements SurfaceInstance { 72 | readonly pluginId = PLUGIN_ID 73 | 74 | readonly #panel: Infinitton 75 | readonly #surfaceId: string 76 | 77 | public get surfaceId(): string { 78 | return this.#surfaceId 79 | } 80 | public get productName(): string { 81 | return `Infinitton` 82 | } 83 | 84 | public constructor(surfaceId: string, panel: Infinitton, context: SurfaceContext) { 85 | this.#panel = panel 86 | this.#surfaceId = surfaceId 87 | 88 | this.#panel.on('error', (e) => context.disconnect(e)) 89 | 90 | this.#panel.on('down', (key: number) => context.keyDown(key)) 91 | this.#panel.on('up', (key: number) => context.keyUp(key)) 92 | } 93 | 94 | async close(): Promise { 95 | this.#panel.close() 96 | } 97 | async initDevice(): Promise { 98 | // Start with blanking it 99 | await this.blankDevice() 100 | } 101 | 102 | updateCapabilities(_capabilities: ClientCapabilities): void { 103 | // Nothing to do 104 | } 105 | 106 | async deviceAdded(): Promise {} 107 | async setBrightness(percent: number): Promise { 108 | this.#panel.setBrightness(percent) 109 | } 110 | async blankDevice(): Promise { 111 | this.#panel.clearAllKeys() 112 | } 113 | async draw(_signal: AbortSignal, d: DeviceDrawProps): Promise { 114 | if (d.image) { 115 | const buffer = await d.image(72, 72, imageRs.PixelFormat.Rgb) 116 | this.#panel.fillImage(d.keyIndex, buffer) 117 | } else { 118 | throw new Error(`Cannot draw for Streamdeck without image`) 119 | } 120 | } 121 | async showStatus( 122 | signal: AbortSignal, 123 | cardGenerator: CardGenerator, 124 | hostname: string, 125 | status: string, 126 | ): Promise { 127 | const width = Infinitton.ICON_SIZE * Infinitton.NUM_KEYS_PER_ROW 128 | const height = Infinitton.ICON_SIZE * Math.floor(Infinitton.NUM_KEYS / Infinitton.NUM_KEYS_PER_ROW) 129 | const buffer = await cardGenerator.generateBasicCard(width, height, imageRs.PixelFormat.Rgb, hostname, status) 130 | 131 | if (signal.aborted) return 132 | 133 | // still valid 134 | this.#panel.fillPanelImage(buffer) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /satellite/src/device-types/lib.ts: -------------------------------------------------------------------------------- 1 | import * as imageRs from '@julusian/image-rs' 2 | 3 | export function parseColor(color: string | undefined): { r: number; g: number; b: number } { 4 | const r = color ? parseInt(color.substr(1, 2), 16) : 0 5 | const g = color ? parseInt(color.substr(3, 2), 16) : 0 6 | const b = color ? parseInt(color.substr(5, 2), 16) : 0 7 | 8 | return { r, g, b } 9 | } 10 | 11 | export interface TransformButtonImage { 12 | buffer: Buffer 13 | width: number 14 | height: number 15 | pixelFormat: imageRs.PixelFormat 16 | } 17 | 18 | /** 19 | * Transform a button image render to the format needed for a surface integration 20 | */ 21 | export async function transformButtonImage( 22 | rawImage: TransformButtonImage | undefined, 23 | targetWidth: number, 24 | targetHeight: number, 25 | targetFormat: imageRs.PixelFormat, 26 | ): Promise { 27 | if (!rawImage) throw new Error('No input image provided') 28 | 29 | if (rawImage.width === targetWidth && rawImage.height === targetHeight && targetFormat === rawImage.pixelFormat) 30 | return rawImage.buffer 31 | 32 | let image = imageRs.ImageTransformer.fromBuffer( 33 | rawImage.buffer, 34 | rawImage.width, 35 | rawImage.height, 36 | rawImage.pixelFormat, 37 | ) 38 | 39 | image = image.scale(targetWidth, targetHeight, imageRs.ResizeMode.Fit) 40 | 41 | // pad, in case a button is non-square 42 | const dimensions = image.getCurrentDimensions() 43 | const xOffset = (targetWidth - dimensions.width) / 2 44 | const yOffset = (targetHeight - dimensions.height) / 2 45 | 46 | image = image.pad(Math.floor(xOffset), Math.ceil(xOffset), Math.floor(yOffset), Math.ceil(yOffset), { 47 | red: 0, 48 | green: 0, 49 | blue: 0, 50 | alpha: 255, 51 | }) 52 | 53 | const computedImage = await image.toBuffer(targetFormat) 54 | return computedImage.buffer 55 | } 56 | -------------------------------------------------------------------------------- /satellite/src/device-types/loupedeck-live-s.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoupedeckDevice, 3 | LoupedeckDisplayId, 4 | LoupedeckBufferFormat, 5 | LoupedeckModelId, 6 | LoupedeckControlType, 7 | } from '@loupedeck/node' 8 | import * as imageRs from '@julusian/image-rs' 9 | import { CardGenerator } from '../graphics/cards.js' 10 | import type { 11 | ClientCapabilities, 12 | SurfaceContext, 13 | DeviceDrawProps, 14 | DeviceRegisterProps, 15 | SurfaceInstance, 16 | } from './api.js' 17 | import { parseColor } from './lib.js' 18 | import { LOUPEDECK_PLUGIN_ID } from './loupedeck-plugin.js' 19 | import { Pincode5x3 } from './pincode.js' 20 | 21 | export function compileLoupedeckLiveSProps(device: LoupedeckDevice): DeviceRegisterProps { 22 | return { 23 | brightness: true, 24 | rowCount: 3, 25 | columnCount: 7, 26 | bitmapSize: device.lcdKeySize, 27 | colours: true, 28 | text: false, 29 | pincodeMap: Pincode5x3(1), 30 | } 31 | } 32 | 33 | export class LoupedeckLiveSWrapper implements SurfaceInstance { 34 | readonly pluginId = LOUPEDECK_PLUGIN_ID 35 | 36 | readonly #deck: LoupedeckDevice 37 | readonly #surfaceId: string 38 | 39 | public get surfaceId(): string { 40 | return this.#surfaceId 41 | } 42 | public get productName(): string { 43 | return this.#deck.modelName 44 | } 45 | 46 | public constructor(surfaceId: string, device: LoupedeckDevice, context: SurfaceContext) { 47 | this.#deck = device 48 | this.#surfaceId = surfaceId 49 | 50 | this.#deck.on('error', (e) => context.disconnect(e)) 51 | 52 | if (device.modelId !== LoupedeckModelId.LoupedeckLiveS) throw new Error('Incorrect model passed to wrapper!') 53 | 54 | const convertButtonId = (type: 'button' | 'rotary', id: number): number => { 55 | if (type === 'button') { 56 | // return 24 + id 57 | switch (id) { 58 | case 0: 59 | return 14 60 | case 1: 61 | return 6 62 | case 2: 63 | return 13 64 | case 3: 65 | return 20 66 | } 67 | } else if (type === 'rotary') { 68 | switch (id) { 69 | case 0: 70 | return 0 71 | case 1: 72 | return 7 73 | } 74 | } 75 | 76 | // Discard 77 | return 99 78 | } 79 | 80 | this.#deck.on('down', (info) => context.keyDown(convertButtonId(info.type, info.index))) 81 | this.#deck.on('up', (info) => context.keyUp(convertButtonId(info.type, info.index))) 82 | this.#deck.on('rotate', (info, delta) => { 83 | if (info.type !== LoupedeckControlType.Rotary) return 84 | 85 | const id2 = convertButtonId(info.type, info.index) 86 | if (id2 < 90) { 87 | if (delta < 0) { 88 | context.rotateLeft(id2) 89 | } else if (delta > 0) { 90 | context.rotateRight(id2) 91 | } 92 | } 93 | }) 94 | const translateKeyIndex = (key: number): number => { 95 | const x = key % 5 96 | const y = Math.floor(key / 5) 97 | return y * 7 + x + 1 98 | } 99 | this.#deck.on('touchstart', (data) => { 100 | for (const touch of data.changedTouches) { 101 | if (touch.target.key !== undefined) { 102 | context.keyDown(translateKeyIndex(touch.target.key)) 103 | } 104 | } 105 | }) 106 | this.#deck.on('touchend', (data) => { 107 | for (const touch of data.changedTouches) { 108 | if (touch.target.key !== undefined) { 109 | context.keyUp(translateKeyIndex(touch.target.key)) 110 | } 111 | } 112 | }) 113 | } 114 | 115 | async close(): Promise { 116 | await this.#deck.blankDevice(true, true).catch(() => null) 117 | 118 | await this.#deck.close() 119 | } 120 | async initDevice(): Promise { 121 | // Start with blanking it 122 | await this.blankDevice() 123 | } 124 | 125 | updateCapabilities(_capabilities: ClientCapabilities): void { 126 | // Not used 127 | } 128 | 129 | async deviceAdded(): Promise {} 130 | async setBrightness(percent: number): Promise { 131 | await this.#deck.setBrightness(percent / 100) 132 | } 133 | async blankDevice(skipButtons?: boolean): Promise { 134 | await this.#deck.blankDevice(true, !skipButtons) 135 | } 136 | async draw(signal: AbortSignal, d: DeviceDrawProps): Promise { 137 | let buttonIndex: number | undefined 138 | switch (d.keyIndex) { 139 | case 14: 140 | buttonIndex = 0 141 | break 142 | case 6: 143 | buttonIndex = 1 144 | break 145 | case 13: 146 | buttonIndex = 2 147 | break 148 | case 20: 149 | buttonIndex = 3 150 | break 151 | } 152 | if (buttonIndex !== undefined) { 153 | const color = parseColor(d.color) 154 | 155 | await this.#deck.setButtonColor({ 156 | id: buttonIndex, 157 | red: color.r, 158 | green: color.g, 159 | blue: color.b, 160 | }) 161 | 162 | return 163 | } 164 | 165 | const x = (d.keyIndex % 7) - 1 166 | const y = Math.floor(d.keyIndex / 7) 167 | 168 | if (x >= 0 && x < 5) { 169 | const keyIndex = x + y * 5 170 | if (d.image) { 171 | const buffer = await d.image(this.#deck.lcdKeySize, this.#deck.lcdKeySize, imageRs.PixelFormat.Rgb) 172 | await this.#deck.drawKeyBuffer(keyIndex, buffer, LoupedeckBufferFormat.RGB) 173 | } else { 174 | throw new Error(`Cannot draw for Loupedeck without image`) 175 | } 176 | } 177 | } 178 | 179 | async showStatus( 180 | signal: AbortSignal, 181 | cardGenerator: CardGenerator, 182 | hostname: string, 183 | status: string, 184 | ): Promise { 185 | const width = this.#deck.displayMain.width 186 | const height = this.#deck.displayMain.height 187 | 188 | const buffer = await cardGenerator.generateBasicCard(width, height, imageRs.PixelFormat.Rgb, hostname, status) 189 | 190 | if (signal.aborted) return 191 | 192 | await this.#deck.drawBuffer(LoupedeckDisplayId.Center, buffer, LoupedeckBufferFormat.RGB, width, height, 0, 0) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /satellite/src/device-types/loupedeck-live.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LoupedeckDevice, 3 | LoupedeckDisplayId, 4 | LoupedeckBufferFormat, 5 | LoupedeckModelId, 6 | LoupedeckControlType, 7 | } from '@loupedeck/node' 8 | import * as imageRs from '@julusian/image-rs' 9 | import type { CardGenerator } from '../graphics/cards.js' 10 | import type { 11 | ClientCapabilities, 12 | SurfaceContext, 13 | DeviceDrawProps, 14 | DeviceRegisterProps, 15 | SurfaceInstance, 16 | } from './api.js' 17 | import { parseColor } from './lib.js' 18 | import { LOUPEDECK_PLUGIN_ID } from './loupedeck-plugin.js' 19 | import { Pincode4x3 } from './pincode.js' 20 | 21 | export function compileLoupedeckLiveProps(device: LoupedeckDevice): DeviceRegisterProps { 22 | return { 23 | brightness: true, 24 | rowCount: 4, 25 | columnCount: 8, 26 | bitmapSize: device.lcdKeySize, 27 | colours: true, 28 | text: false, 29 | pincodeMap: Pincode4x3(2), 30 | } 31 | } 32 | export class LoupedeckLiveWrapper implements SurfaceInstance { 33 | readonly pluginId = LOUPEDECK_PLUGIN_ID 34 | 35 | readonly #deck: LoupedeckDevice 36 | readonly #surfaceId: string 37 | 38 | public get surfaceId(): string { 39 | return this.#surfaceId 40 | } 41 | public get productName(): string { 42 | return this.#deck.modelName 43 | } 44 | 45 | public constructor(surfaceId: string, device: LoupedeckDevice, context: SurfaceContext) { 46 | this.#deck = device 47 | this.#surfaceId = surfaceId 48 | 49 | this.#deck.on('error', (e) => context.disconnect(e)) 50 | 51 | if ( 52 | device.modelId !== LoupedeckModelId.LoupedeckLive && 53 | device.modelId !== LoupedeckModelId.RazerStreamController 54 | ) 55 | throw new Error('Incorrect model passed to wrapper!') 56 | 57 | const convertButtonId = (type: 'button' | 'rotary', id: number): number => { 58 | if (type === 'button' && id >= 0 && id < 8) { 59 | return 24 + id 60 | } else if (type === 'rotary') { 61 | switch (id) { 62 | case 0: 63 | return 0 64 | case 1: 65 | return 8 66 | case 2: 67 | return 16 68 | case 3: 69 | return 7 70 | case 4: 71 | return 15 72 | case 5: 73 | return 23 74 | } 75 | } 76 | 77 | // Discard 78 | return 99 79 | } 80 | this.#deck.on('down', (info) => context.keyDown(convertButtonId(info.type, info.index))) 81 | this.#deck.on('up', (info) => context.keyUp(convertButtonId(info.type, info.index))) 82 | this.#deck.on('rotate', (info, delta) => { 83 | if (info.type !== LoupedeckControlType.Rotary) return 84 | 85 | const id2 = convertButtonId(info.type, info.index) 86 | if (id2 < 90) { 87 | if (delta < 0) { 88 | context.rotateLeft(id2) 89 | } else if (delta > 0) { 90 | context.rotateRight(id2) 91 | } 92 | } 93 | }) 94 | const translateKeyIndex = (key: number): number => { 95 | const x = key % 4 96 | const y = Math.floor(key / 4) 97 | return y * 8 + x + 2 98 | } 99 | this.#deck.on('touchstart', (data) => { 100 | for (const touch of data.changedTouches) { 101 | if (touch.target.key !== undefined) { 102 | context.keyDown(translateKeyIndex(touch.target.key)) 103 | } 104 | } 105 | }) 106 | this.#deck.on('touchend', (data) => { 107 | for (const touch of data.changedTouches) { 108 | if (touch.target.key !== undefined) { 109 | context.keyUp(translateKeyIndex(touch.target.key)) 110 | } 111 | } 112 | }) 113 | } 114 | 115 | async close(): Promise { 116 | await this.#deck.blankDevice(true, true).catch(() => null) 117 | 118 | await this.#deck.close() 119 | } 120 | async initDevice(): Promise { 121 | // Start with blanking it 122 | await this.blankDevice() 123 | } 124 | 125 | updateCapabilities(_capabilities: ClientCapabilities): void { 126 | // Not used 127 | } 128 | 129 | async deviceAdded(): Promise {} 130 | async setBrightness(percent: number): Promise { 131 | await this.#deck.setBrightness(percent / 100) 132 | } 133 | async blankDevice(skipButtons?: boolean): Promise { 134 | await this.#deck.blankDevice(true, !skipButtons) 135 | } 136 | async draw(signal: AbortSignal, d: DeviceDrawProps): Promise { 137 | if (d.keyIndex >= 24 && d.keyIndex < 32) { 138 | const index = d.keyIndex - 24 139 | 140 | const color = parseColor(d.color) 141 | 142 | await this.#deck.setButtonColor({ 143 | id: index, 144 | red: color.r, 145 | green: color.g, 146 | blue: color.b, 147 | }) 148 | 149 | return 150 | } 151 | const x = (d.keyIndex % 8) - 2 152 | const y = Math.floor(d.keyIndex / 8) 153 | 154 | if (x >= 0 && x < 4) { 155 | const keyIndex = x + y * 4 156 | if (d.image) { 157 | const buffer = await d.image(this.#deck.lcdKeySize, this.#deck.lcdKeySize, imageRs.PixelFormat.Rgb) 158 | await this.#deck.drawKeyBuffer(keyIndex, buffer, LoupedeckBufferFormat.RGB) 159 | } else { 160 | throw new Error(`Cannot draw for Loupedeck without image`) 161 | } 162 | } 163 | } 164 | 165 | async showStatus( 166 | signal: AbortSignal, 167 | cardGenerator: CardGenerator, 168 | hostname: string, 169 | status: string, 170 | ): Promise { 171 | const width = this.#deck.displayMain.width 172 | const height = this.#deck.displayMain.height 173 | 174 | const buffer = await cardGenerator.generateBasicCard(width, height, imageRs.PixelFormat.Rgb, hostname, status) 175 | 176 | if (signal.aborted) return 177 | 178 | await this.#deck.drawBuffer(LoupedeckDisplayId.Center, buffer, LoupedeckBufferFormat.RGB, width, height, 0, 0) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /satellite/src/device-types/loupedeck-plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | listLoupedecks, 3 | LoupedeckDevice, 4 | LoupedeckModelId, 5 | openLoupedeck, 6 | type LoupedeckDeviceInfo, 7 | } from '@loupedeck/node' 8 | import type { 9 | SurfacePlugin, 10 | DiscoveredSurfaceInfo, 11 | SurfaceInstance, 12 | DeviceRegisterProps, 13 | OpenSurfaceResult, 14 | SurfaceContext, 15 | } from './api.js' 16 | import { compileLoupedeckLiveProps, LoupedeckLiveWrapper } from './loupedeck-live.js' 17 | import { assertNever } from '../lib.js' 18 | import { compileLoupedeckLiveSProps, LoupedeckLiveSWrapper } from './loupedeck-live-s.js' 19 | import { compileRazerStreamControllerXProps, RazerStreamControllerXWrapper } from './razer-stream-controller-x.js' 20 | 21 | export const LOUPEDECK_PLUGIN_ID = 'loupedeck' 22 | 23 | export class LoupedeckPlugin implements SurfacePlugin { 24 | readonly pluginId = LOUPEDECK_PLUGIN_ID 25 | readonly pluginName = 'Loupedeck' 26 | 27 | async init(): Promise { 28 | // Nothing to do 29 | } 30 | async destroy(): Promise { 31 | // Nothing to do 32 | } 33 | 34 | scanForSurfaces = async (): Promise[]> => { 35 | const surfaceInfos = await listLoupedecks() 36 | 37 | const result: DiscoveredSurfaceInfo[] = [] 38 | for (const surfaceInfo of surfaceInfos) { 39 | if (!surfaceInfo.serialNumber) continue 40 | 41 | result.push({ 42 | surfaceId: `loupedeck:${surfaceInfo.serialNumber}`, 43 | description: surfaceInfo.model, // TODO: Better description 44 | pluginInfo: surfaceInfo, 45 | }) 46 | } 47 | 48 | return result 49 | } 50 | 51 | openSurface = async ( 52 | surfaceId: string, 53 | pluginInfo: LoupedeckDeviceInfo, 54 | context: SurfaceContext, 55 | ): Promise => { 56 | let factory: new (deviceId: string, device: LoupedeckDevice, context: SurfaceContext) => SurfaceInstance 57 | let propsFactory: (device: LoupedeckDevice) => DeviceRegisterProps 58 | 59 | switch (pluginInfo.model) { 60 | case LoupedeckModelId.LoupedeckLive: 61 | case LoupedeckModelId.RazerStreamController: 62 | factory = LoupedeckLiveWrapper 63 | propsFactory = compileLoupedeckLiveProps 64 | break 65 | case LoupedeckModelId.LoupedeckLiveS: 66 | factory = LoupedeckLiveSWrapper 67 | propsFactory = compileLoupedeckLiveSProps 68 | break 69 | case LoupedeckModelId.RazerStreamControllerX: 70 | factory = RazerStreamControllerXWrapper 71 | propsFactory = compileRazerStreamControllerXProps 72 | break 73 | case LoupedeckModelId.LoupedeckCt: 74 | case LoupedeckModelId.LoupedeckCtV1: 75 | throw new Error('Unsupported model') 76 | default: 77 | assertNever(pluginInfo.model) 78 | throw new Error('Unsupported model') 79 | } 80 | 81 | const loupedeck = await openLoupedeck(pluginInfo.path) 82 | return { 83 | surface: new factory(surfaceId, loupedeck, context), 84 | registerProps: propsFactory(loupedeck), 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /satellite/src/device-types/pincode.ts: -------------------------------------------------------------------------------- 1 | import { SurfacePincodeMap } from './api.js' 2 | 3 | /** 4 | * This file contains some default pincode layouts, and utils for generating simple layouts. 5 | * These should be used by plugins when appropriate, so that the layouts are consistent across devices. 6 | * But it is ok to use custom layouts if needed. 7 | */ 8 | 9 | export function Pincode5x3(x = 0, y = 0): SurfacePincodeMap { 10 | return { 11 | type: 'single-page', 12 | pincode: [x, y + 1], 13 | 0: [x + 4, y + 1], 14 | 1: [x + 1, y + 2], 15 | 2: [x + 2, y + 2], 16 | 3: [x + 3, y + 2], 17 | 4: [x + 1, y + 1], 18 | 5: [x + 2, y + 1], 19 | 6: [x + 3, y + 1], 20 | 7: [x + 1, y], 21 | 8: [x + 2, y], 22 | 9: [x + 3, y], 23 | } 24 | } 25 | 26 | export function Pincode4x3(x = 0, y = 0): SurfacePincodeMap { 27 | return { 28 | type: 'single-page', 29 | pincode: [x, y], 30 | 0: [x, y + 2], 31 | 1: [x + 1, y + 2], 32 | 2: [x + 2, y + 2], 33 | 3: [x + 3, y + 2], 34 | 4: [x + 1, y + 1], 35 | 5: [x + 2, y + 1], 36 | 6: [x + 3, y + 1], 37 | 7: [x + 1, y], 38 | 8: [x + 2, y], 39 | 9: [x + 3, y], 40 | } 41 | } 42 | 43 | export function Pincode4x4(x = 0, y = 0): SurfacePincodeMap { 44 | return { 45 | type: 'single-page', 46 | pincode: [x, y + 1], 47 | 0: [x + 2, y + 3], 48 | 1: [x + 1, y + 2], 49 | 2: [x + 2, y + 2], 50 | 3: [x + 3, y + 2], 51 | 4: [x + 1, y + 1], 52 | 5: [x + 2, y + 1], 53 | 6: [x + 3, y + 1], 54 | 7: [x + 1, y], 55 | 8: [x + 2, y], 56 | 9: [x + 3, y], 57 | } 58 | } 59 | 60 | export function Pincode6x2(x = 0, y = 0): SurfacePincodeMap { 61 | return { 62 | type: 'single-page', 63 | pincode: [x, y], 64 | 0: [x + 1, y + 1], 65 | 1: [x + 2, y + 1], 66 | 2: [x + 3, y + 1], 67 | 3: [x + 4, y + 1], 68 | 4: [x + 5, y + 1], 69 | 5: [x + 1, y], 70 | 6: [x + 2, y], 71 | 7: [x + 3, y], 72 | 8: [x + 4, y], 73 | 9: [x + 5, y], 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /satellite/src/device-types/razer-stream-controller-x.ts: -------------------------------------------------------------------------------- 1 | import { LoupedeckDevice, LoupedeckDisplayId, LoupedeckBufferFormat, LoupedeckModelId } from '@loupedeck/node' 2 | import * as imageRs from '@julusian/image-rs' 3 | import type { CardGenerator } from '../graphics/cards.js' 4 | import type { 5 | ClientCapabilities, 6 | SurfaceContext, 7 | DeviceDrawProps, 8 | SurfaceInstance, 9 | DeviceRegisterProps, 10 | } from './api.js' 11 | import { LOUPEDECK_PLUGIN_ID } from './loupedeck-plugin.js' 12 | import { Pincode5x3 } from './pincode.js' 13 | 14 | export function compileRazerStreamControllerXProps(device: LoupedeckDevice): DeviceRegisterProps { 15 | return { 16 | brightness: true, 17 | rowCount: 3, 18 | columnCount: 5, 19 | bitmapSize: device.lcdKeySize, 20 | colours: true, 21 | text: false, 22 | pincodeMap: Pincode5x3(), 23 | } 24 | } 25 | 26 | export class RazerStreamControllerXWrapper implements SurfaceInstance { 27 | readonly pluginId = LOUPEDECK_PLUGIN_ID 28 | 29 | readonly #deck: LoupedeckDevice 30 | readonly #surfaceId: string 31 | 32 | public get surfaceId(): string { 33 | return this.#surfaceId 34 | } 35 | public get productName(): string { 36 | return this.#deck.modelName 37 | } 38 | 39 | public constructor(surfaceId: string, device: LoupedeckDevice, context: SurfaceContext) { 40 | this.#deck = device 41 | this.#surfaceId = surfaceId 42 | 43 | this.#deck.on('error', (e) => context.disconnect(e)) 44 | 45 | if (device.modelId !== LoupedeckModelId.RazerStreamControllerX) 46 | throw new Error('Incorrect model passed to wrapper!') 47 | 48 | const convertButtonId = (type: 'button' | 'rotary', id: number): number => { 49 | if (type === 'button') { 50 | return id 51 | } 52 | 53 | // Discard 54 | return 99 55 | } 56 | this.#deck.on('down', (info) => context.keyDown(convertButtonId(info.type, info.index))) 57 | this.#deck.on('up', (info) => context.keyUp(convertButtonId(info.type, info.index))) 58 | } 59 | 60 | async close(): Promise { 61 | await this.#deck.blankDevice(true, true).catch(() => null) 62 | 63 | await this.#deck.close() 64 | } 65 | async initDevice(): Promise { 66 | // Start with blanking it 67 | await this.blankDevice() 68 | } 69 | 70 | updateCapabilities(_capabilities: ClientCapabilities): void { 71 | // Not used 72 | } 73 | 74 | async deviceAdded(): Promise {} 75 | async setBrightness(percent: number): Promise { 76 | await this.#deck.setBrightness(percent / 100) 77 | } 78 | async blankDevice(skipButtons?: boolean): Promise { 79 | await this.#deck.blankDevice(true, !skipButtons) 80 | } 81 | async draw(_signal: AbortSignal, d: DeviceDrawProps): Promise { 82 | if (d.image) { 83 | const buffer = await d.image(this.#deck.lcdKeySize, this.#deck.lcdKeySize, imageRs.PixelFormat.Rgb) 84 | await this.#deck.drawKeyBuffer(d.keyIndex, buffer, LoupedeckBufferFormat.RGB) 85 | } else { 86 | throw new Error(`Cannot draw for Loupedeck without image`) 87 | } 88 | } 89 | 90 | async showStatus( 91 | signal: AbortSignal, 92 | cardGenerator: CardGenerator, 93 | hostname: string, 94 | status: string, 95 | ): Promise { 96 | const width = this.#deck.displayMain.width 97 | const height = this.#deck.displayMain.height 98 | 99 | const buffer = await cardGenerator.generateBasicCard(width, height, imageRs.PixelFormat.Rgb, hostname, status) 100 | 101 | if (signal.aborted) return 102 | 103 | await this.#deck.drawBuffer(LoupedeckDisplayId.Center, buffer, LoupedeckBufferFormat.RGB, width, height, 0, 0) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /satellite/src/device-types/xencelabs-quick-keys.ts: -------------------------------------------------------------------------------- 1 | import { 2 | XencelabsQuickKeys, 3 | XencelabsQuickKeysDisplayBrightness, 4 | XencelabsQuickKeysWheelSpeed, 5 | XencelabsQuickKeysDisplayOrientation, 6 | WheelEvent, 7 | XencelabsQuickKeysManagerInstance, 8 | } from '@xencelabs-quick-keys/node' 9 | import type { 10 | SurfaceInstance, 11 | DeviceDrawProps, 12 | ClientCapabilities, 13 | SurfacePlugin, 14 | SurfacePluginDetectionEvents, 15 | SurfacePluginDetection, 16 | OpenSurfaceResult, 17 | SurfaceContext, 18 | } from './api.js' 19 | import { parseColor } from './lib.js' 20 | import { EventEmitter } from 'events' 21 | import type { CardGenerator } from '../graphics/cards.js' 22 | 23 | class QuickKeysPluginDetection 24 | extends EventEmitter> 25 | implements SurfacePluginDetection 26 | { 27 | initialised = false 28 | 29 | async triggerScan(): Promise { 30 | if (!this.initialised) return 31 | // TODO - or should this go the other route and use openDevicesFromArray? 32 | await XencelabsQuickKeysManagerInstance.scanDevices() 33 | } 34 | } 35 | 36 | const PLUGIN_ID = 'xencelabs-quick-keys' 37 | 38 | export class QuickKeysPlugin implements SurfacePlugin { 39 | readonly pluginId = PLUGIN_ID 40 | readonly pluginName = 'Xencelabs Quick Keys' 41 | 42 | readonly detection = new QuickKeysPluginDetection() 43 | 44 | async init(): Promise { 45 | if (this.detection.initialised) return 46 | 47 | this.detection.initialised = true 48 | 49 | XencelabsQuickKeysManagerInstance.on('connect', this.#connectListener) 50 | XencelabsQuickKeysManagerInstance.on('disconnect', this.#disconnectListener) 51 | } 52 | async destroy(): Promise { 53 | this.detection.initialised = false 54 | 55 | XencelabsQuickKeysManagerInstance.off('connect', this.#connectListener) 56 | XencelabsQuickKeysManagerInstance.off('disconnect', this.#disconnectListener) 57 | 58 | // Ensure all devices are closed 59 | await XencelabsQuickKeysManagerInstance.closeAll() 60 | } 61 | 62 | #connectListener = (surface: XencelabsQuickKeys) => { 63 | if (surface.deviceId) { 64 | this.detection.emit('deviceAdded', { 65 | surfaceId: `quickkeys:${surface.deviceId}`, 66 | description: `Quick Keys`, 67 | pluginInfo: surface, 68 | }) 69 | } else { 70 | console.warn('Ignoring wired XencelabsQuickKeys device without serial number') 71 | 72 | surface.on('error', (e) => { 73 | // Ensure errors don't cause a crash 74 | console.error('Error from device:', e) 75 | }) 76 | } 77 | } 78 | #disconnectListener = (surface: XencelabsQuickKeys) => { 79 | if (surface.deviceId) { 80 | this.detection.emit('deviceRemoved', surface.deviceId) 81 | } 82 | } 83 | 84 | openSurface = async ( 85 | surfaceId: string, 86 | quickkeys: XencelabsQuickKeys, 87 | context: SurfaceContext, 88 | ): Promise => { 89 | return { 90 | surface: new QuickKeysWrapper(surfaceId, quickkeys, context), 91 | registerProps: { 92 | brightness: true, 93 | rowCount: 2, 94 | columnCount: 6, 95 | bitmapSize: null, 96 | colours: true, 97 | text: true, 98 | pincodeMap: null, // TODO - implement? 99 | }, 100 | } 101 | } 102 | } 103 | 104 | function keyToCompanion(k: number): number | null { 105 | if (k >= 0 && k < 4) return k + 1 106 | if (k >= 4 && k < 8) return k + 3 107 | if (k === 8) return 0 108 | if (k === 9) return 5 109 | return null 110 | } 111 | 112 | export class QuickKeysWrapper implements SurfaceInstance { 113 | readonly pluginId = PLUGIN_ID 114 | 115 | readonly #surface: XencelabsQuickKeys 116 | readonly #surfaceId: string 117 | 118 | #statusTimer: NodeJS.Timeout | undefined 119 | #unsub: (() => void) | undefined 120 | 121 | public get surfaceId(): string { 122 | return this.#surfaceId 123 | } 124 | public get productName(): string { 125 | return 'Xencelabs Quick Keys' 126 | } 127 | 128 | public constructor(surfaceId: string, surface: XencelabsQuickKeys, context: SurfaceContext) { 129 | this.#surface = surface 130 | this.#surfaceId = surfaceId 131 | 132 | this.#surface.on('error', (e) => context.disconnect(e as any)) 133 | 134 | const handleDown = (key: number) => { 135 | const k = keyToCompanion(key) 136 | if (k !== null) { 137 | context.keyDown(k) 138 | } 139 | } 140 | const handleUp = (key: number) => { 141 | const k = keyToCompanion(key) 142 | if (k !== null) { 143 | context.keyUp(k) 144 | } 145 | } 146 | const handleWheel = (ev: WheelEvent) => { 147 | switch (ev) { 148 | case WheelEvent.Left: 149 | context.rotateLeft(5) 150 | break 151 | case WheelEvent.Right: 152 | context.rotateRight(5) 153 | break 154 | } 155 | } 156 | 157 | this.#surface.on('down', handleDown) 158 | this.#surface.on('up', handleUp) 159 | this.#surface.on('wheel', handleWheel) 160 | this.#unsub = () => { 161 | this.#surface.off('down', handleDown) 162 | this.#surface.off('up', handleUp) 163 | this.#surface.off('wheel', handleWheel) 164 | } 165 | } 166 | 167 | async close(): Promise { 168 | this.#unsub?.() 169 | 170 | this.stopStatusInterval() 171 | 172 | await this.#surface.stopData() 173 | } 174 | async initDevice(): Promise { 175 | await this.#surface.startData() 176 | 177 | await this.#surface.setWheelSpeed(XencelabsQuickKeysWheelSpeed.Normal) // TODO dynamic 178 | await this.#surface.setDisplayOrientation(XencelabsQuickKeysDisplayOrientation.Rotate0) // TODO dynamic 179 | await this.#surface.setSleepTimeout(0) // TODO dynamic 180 | 181 | // Start with blanking it 182 | await this.blankDevice() 183 | } 184 | 185 | updateCapabilities(_capabilities: ClientCapabilities): void { 186 | // Not used 187 | } 188 | 189 | async deviceAdded(): Promise { 190 | await this.clearStatus() 191 | } 192 | async setBrightness(percent: number): Promise { 193 | const opts = Object.values( 194 | XencelabsQuickKeysDisplayBrightness, 195 | ).filter((k): k is XencelabsQuickKeysDisplayBrightness => typeof k === 'number') 196 | 197 | const perStep = 100 / (opts.length - 1) 198 | const step = Math.round(percent / perStep) 199 | 200 | await this.#surface.setDisplayBrightness(opts[step]) 201 | } 202 | async blankDevice(): Promise { 203 | await this.clearStatus() 204 | 205 | // Do some initial setup too 206 | 207 | await this.#surface.setWheelColor(0, 0, 0) 208 | 209 | for (let i = 0; i < 8; i++) { 210 | await this.#surface.setKeyText(i, '') 211 | } 212 | } 213 | async draw(signal: AbortSignal, data: DeviceDrawProps): Promise { 214 | await this.clearStatus() 215 | 216 | if (signal.aborted) return 217 | 218 | if (typeof data.text === 'string') { 219 | let keyIndex: number | null = null 220 | if (data.keyIndex >= 1 && data.keyIndex < 5) keyIndex = data.keyIndex - 1 221 | if (data.keyIndex >= 7 && data.keyIndex < 11) keyIndex = data.keyIndex - 3 222 | 223 | if (keyIndex !== null) { 224 | await this.#surface.setKeyText(keyIndex, data.text.substr(0, 8)) 225 | } 226 | } 227 | 228 | if (signal.aborted) return 229 | 230 | const wheelIndex = 5 231 | if (data.color && data.keyIndex === wheelIndex) { 232 | const { r, g, b } = parseColor(data.color) 233 | 234 | await this.#surface.setWheelColor(r, g, b) 235 | } 236 | } 237 | async showStatus( 238 | _signal: AbortSignal, 239 | _cardGenerator: CardGenerator, 240 | _hostname: string, 241 | status: string, 242 | ): Promise { 243 | this.stopStatusInterval() 244 | 245 | const newMessage = status 246 | this.#statusTimer = setInterval(() => { 247 | // Update on an interval, as we cant set it unlimited 248 | this.#surface.showOverlayText(5, newMessage).catch((e) => { 249 | console.error(`Overlay failed: ${e}`) 250 | }) 251 | }, 3000) 252 | 253 | await this.#surface.showOverlayText(5, newMessage) 254 | } 255 | 256 | private stopStatusInterval(): boolean { 257 | if (this.#statusTimer) { 258 | clearInterval(this.#statusTimer) 259 | this.#statusTimer = undefined 260 | 261 | return true 262 | } 263 | 264 | return false 265 | } 266 | private async clearStatus(msg?: string): Promise { 267 | if (this.stopStatusInterval()) { 268 | await this.#surface.showOverlayText(1, msg ?? '') 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /satellite/src/electronPreload.cts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | const { contextBridge, ipcRenderer } = require('electron') 3 | import type { 4 | ApiConfigData, 5 | ApiConfigDataUpdateElectron, 6 | ApiStatusResponse, 7 | ApiSurfaceInfo, 8 | ApiSurfacePluginInfo, 9 | ApiSurfacePluginsEnabled, 10 | SatelliteUiApi, 11 | // @ts-expect-error weird interop between cjs and mjs 12 | } from './apiTypes.js' 13 | 14 | const electronApi: SatelliteUiApi = { 15 | includeApiEnable: true, 16 | rescanSurfaces: async (): Promise => ipcRenderer.send('rescan'), 17 | getStatus: async (): Promise => ipcRenderer.invoke('getStatus'), 18 | getConfig: async (): Promise => ipcRenderer.invoke('getConfig'), 19 | saveConfig: async (newConfig: ApiConfigDataUpdateElectron): Promise => 20 | ipcRenderer.invoke('saveConfig', newConfig), 21 | connectedSurfaces: async (): Promise => ipcRenderer.invoke('connectedSurfaces'), 22 | surfacePlugins: async (): Promise => ipcRenderer.invoke('surfacePlugins'), 23 | surfacePluginsEnabled: async (): Promise => ipcRenderer.invoke('surfacePluginsEnabled'), 24 | surfacePluginsEnabledUpdate: async (newConfig: ApiSurfacePluginsEnabled): Promise => 25 | ipcRenderer.invoke('surfacePluginsEnabledUpdate', newConfig), 26 | } 27 | 28 | contextBridge.exposeInMainWorld('electronApi', electronApi) 29 | 30 | export type { electronApi } 31 | -------------------------------------------------------------------------------- /satellite/src/electronUpdater.ts: -------------------------------------------------------------------------------- 1 | import { MenuItem, app, dialog, Notification } from 'electron' 2 | import electronUpdater from 'electron-updater' 3 | import { createRequire } from 'module' 4 | 5 | const { autoUpdater } = electronUpdater 6 | 7 | // For development testing 8 | // autoUpdater.forceDevUpdateConfig = true 9 | // autoUpdater.setFeedURL({ 10 | // provider: 'generic', 11 | // publishAutoUpdate: false, 12 | // url: 'https://api-staging.bitfocus.io/v1/product/electron-updater/companion-satellite', 13 | // }) 14 | 15 | const require = createRequire(import.meta.url) 16 | const pkgJson = require('../package.json') 17 | const updateChannel: string | undefined = app.isPackaged ? pkgJson.updateChannel : 'beta' 18 | 19 | // Configure the updater 20 | autoUpdater.autoDownload = false 21 | autoUpdater.autoInstallOnAppQuit = true 22 | autoUpdater.autoRunAppAfterInstall = true 23 | autoUpdater.requestHeaders = { 'User-Agent': `Companion Satellite v${autoUpdater.currentVersion}` } 24 | autoUpdater.channel = updateChannel ?? '' // TODO - this will likely want to vary for each macos arch.. 25 | 26 | export function isUpdateSupported(): boolean { 27 | return ( 28 | !!updateChannel && 29 | process.platform === 'win32' /*|| process.platform === 'darwin')*/ && // HACK: disable for macos for now 30 | autoUpdater.isUpdaterActive() 31 | ) 32 | } 33 | 34 | export class ElectronUpdater { 35 | readonly menuItem: MenuItem 36 | #updateNotification: Notification | undefined 37 | 38 | constructor() { 39 | this.menuItem = new MenuItem({ 40 | label: 'Check for updates', 41 | visible: isUpdateSupported(), 42 | click: () => this.check(true), 43 | }) 44 | } 45 | 46 | private installPending(): void { 47 | autoUpdater 48 | .downloadUpdate() 49 | .then(() => { 50 | autoUpdater.quitAndInstall() 51 | }) 52 | .catch((e) => { 53 | dialog.showErrorBox( 54 | 'Install update failed', 55 | 'Failed to download update.\nTry again later, or try installing the update manually.', 56 | ) 57 | console.log('failed to download', e) 58 | }) 59 | } 60 | 61 | check(notifyWithDialog = false): void { 62 | if (!isUpdateSupported()) return 63 | 64 | autoUpdater 65 | .checkForUpdates() 66 | .then((info) => { 67 | if (!info) return 68 | 69 | if (notifyWithDialog) { 70 | if (info.isUpdateAvailable) { 71 | dialog 72 | .showMessageBox({ 73 | title: 'Companion Satellite', 74 | message: `Version ${info.updateInfo.version} is available`, 75 | buttons: ['Install', 'Cancel'], 76 | }) 77 | .then((v) => { 78 | if (v.response === 0) { 79 | this.installPending() 80 | } 81 | }) 82 | .catch((e) => { 83 | console.error('dialog error', e) 84 | }) 85 | } else { 86 | dialog 87 | .showMessageBox({ 88 | title: 'Companion Satellite', 89 | message: 'No update is available', 90 | buttons: ['Close'], 91 | }) 92 | .catch((e) => { 93 | console.error('dialog error', e) 94 | }) 95 | } 96 | } else { 97 | // Show a system notification instead 98 | if (info.isUpdateAvailable) { 99 | if (!this.#updateNotification) { 100 | this.#updateNotification = new Notification({ 101 | title: 'An update is available', 102 | body: ``, 103 | }) 104 | } 105 | this.#updateNotification.body = `Version ${info.updateInfo.version} is available to be installed` 106 | this.#updateNotification.show() 107 | } 108 | } 109 | }) 110 | .catch((e) => { 111 | console.error('Failed to check for updates', e) 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /satellite/src/fixup-pi-config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is a small pre-launch step that gets run on SatellitePi. 3 | * It's purpose is to import user defined overrides from the 'boot' partition 4 | * Note: This gets run as root! 5 | */ 6 | 7 | import { stat, readFile, copyFile, chown } from 'fs/promises' 8 | import { openHeadlessConfig } from './config.js' 9 | import { fileURLToPath } from 'url' 10 | 11 | const configFilePath = process.argv[2] 12 | if (!configFilePath) throw new Error(`Missing config file path parameter`) 13 | 14 | const appConfig = openHeadlessConfig(configFilePath) 15 | 16 | // Ensure the satellite user owns the file. This is a bit dodgey guessing the ids like this.. 17 | chown(appConfig.path, 1000, 1000).catch(() => null) 18 | 19 | const templatePathName = fileURLToPath( 20 | new URL('/usr/local/src/companion-satellite/pi-image/satellite-config', import.meta.url), 21 | ) 22 | 23 | const importFromPaths = [ 24 | // Paths to search for a config file to 'import' from 25 | '/boot/satellite-config', 26 | '/boot/firmware/satellite-config', 27 | '/satellite-config', 28 | // templatePathName, // For testing 29 | ] 30 | 31 | Promise.resolve() 32 | .then(async () => { 33 | for (const importPath of importFromPaths) { 34 | try { 35 | const fileStat = await stat(importPath) 36 | if (!fileStat.isFile()) throw new Error('Not a file') 37 | 38 | const fileContentStr = await readFile(importPath) 39 | const lines = fileContentStr.toString().split('\n') 40 | 41 | console.log(`Importing config from ${importPath}`) 42 | 43 | for (let line of lines) { 44 | line = line.trim() 45 | 46 | // Ignore any comments 47 | if (line.startsWith('#')) continue 48 | 49 | const splitIndex = line.indexOf('=') 50 | if (splitIndex == -1) continue 51 | 52 | const key = line.slice(0, splitIndex).trim() 53 | const value = line.slice(splitIndex + 1).trim() 54 | 55 | switch (key.toUpperCase()) { 56 | case 'COMPANION_IP': 57 | appConfig.set('remoteIp', value) 58 | break 59 | case 'COMPANION_PORT': { 60 | const port = Number(value) 61 | if (isNaN(port)) { 62 | console.log('COMPANION_PORT is not a number!') 63 | break 64 | } 65 | appConfig.set('remotePort', port) 66 | break 67 | } 68 | case 'REST_PORT': { 69 | const port = Number(value) 70 | if (isNaN(port)) { 71 | console.log('REST_PORT is not a number!') 72 | break 73 | } 74 | if (port > 0) { 75 | appConfig.set('restPort', port) 76 | appConfig.set('restEnabled', true) 77 | } else { 78 | appConfig.set('restEnabled', false) 79 | } 80 | break 81 | } 82 | default: 83 | console.log(`Unknown value: ${key}=${value}`) 84 | break 85 | } 86 | } 87 | 88 | if (templatePathName !== importPath) { 89 | await copyFile(templatePathName, importPath) 90 | } 91 | } catch (e: any) { 92 | if (e.code === 'ENOENT') continue 93 | // Failed, try next file 94 | console.log(`Unable to import from file "${importPath}"`, e) 95 | } 96 | } 97 | }) 98 | .catch(() => { 99 | // Ignore 100 | }) 101 | -------------------------------------------------------------------------------- /satellite/src/graphics/cards.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises' 2 | import { Canvas, Image, loadImage } from '@napi-rs/canvas' 3 | import * as imageRs from '@julusian/image-rs' 4 | import { networkInterfaces } from 'os' 5 | 6 | export class CardGenerator { 7 | private iconImage: Image | undefined 8 | 9 | async loadIcon(): Promise { 10 | if (!this.iconImage) { 11 | const rawData = await readFile(new URL('../assets/icon.png', import.meta.url)) 12 | 13 | this.iconImage = await loadImage(rawData) 14 | } 15 | 16 | return this.iconImage 17 | } 18 | 19 | async generateBasicCard( 20 | width: number, 21 | height: number, 22 | pixelFormat: imageRs.PixelFormat, 23 | remoteIp: string, 24 | status: string, 25 | ): Promise { 26 | const iconImage = await this.loadIcon() 27 | 28 | const overSampling = 2 // Must be 1 or greater 29 | 30 | const canvasWidth = width * overSampling 31 | const canvasHeight = height * overSampling 32 | const canvas = new Canvas(canvasWidth, canvasHeight) 33 | const context2d = canvas.getContext('2d') 34 | context2d.scale(overSampling, overSampling) 35 | 36 | // draw icon 37 | const iconTargetSize = Math.round(Math.min(width, height) * 0.6) 38 | const iconTargetX = (width - iconTargetSize) / 2 39 | const iconTargetY = (height - iconTargetSize) / 2 40 | context2d.drawImage( 41 | iconImage, 42 | 0, 43 | 0, 44 | iconImage.width, 45 | iconImage.height, 46 | iconTargetX, 47 | iconTargetY, 48 | iconTargetSize, 49 | iconTargetSize, 50 | ) 51 | 52 | // draw text 53 | context2d.font = `normal normal normal ${12}px sans-serif` 54 | context2d.textAlign = 'left' 55 | context2d.fillStyle = '#ffffff' 56 | 57 | context2d.fillText(`Remote: ${remoteIp}`, 10, height - 10) 58 | context2d.fillText(`Local: ${getIPAddress()}`, 10, height - 30) 59 | context2d.fillText(`Status: ${status}`, 10, height - 50) 60 | 61 | // return result 62 | const rawImage = Buffer.from(context2d.getImageData(0, 0, canvasWidth, canvasHeight).data) 63 | 64 | const computedImage = await imageRs.ImageTransformer.fromBuffer( 65 | rawImage, 66 | canvasWidth, 67 | canvasHeight, 68 | imageRs.PixelFormat.Rgba, 69 | ) 70 | .scale(width, height, imageRs.ResizeMode.Exact) 71 | .toBuffer(pixelFormat) 72 | 73 | return computedImage.buffer 74 | } 75 | 76 | async generateLcdStripCard( 77 | width: number, 78 | height: number, 79 | pixelFormat: imageRs.PixelFormat, 80 | remoteIp: string, 81 | status: string, 82 | ): Promise { 83 | const iconImage = await this.loadIcon() 84 | 85 | const overSampling = 2 // Must be 1 or greater 86 | 87 | const canvasWidth = width * overSampling 88 | const canvasHeight = height * overSampling 89 | const canvas = new Canvas(canvasWidth, canvasHeight) 90 | const context2d = canvas.getContext('2d') 91 | context2d.scale(overSampling, overSampling) 92 | 93 | // draw icon 94 | const iconBoundingSize = Math.min(width, height) 95 | const iconTargetSize = Math.round(iconBoundingSize * 0.8) 96 | const iconTargetX = width - iconBoundingSize 97 | const iconTargetY = (height - iconTargetSize) / 2 98 | context2d.drawImage( 99 | iconImage, 100 | 0, 101 | 0, 102 | iconImage.width, 103 | iconImage.height, 104 | iconTargetX, 105 | iconTargetY, 106 | iconTargetSize, 107 | iconTargetSize, 108 | ) 109 | 110 | // draw text 111 | context2d.font = `normal normal normal ${12}px sans-serif` 112 | context2d.textAlign = 'left' 113 | context2d.fillStyle = '#ffffff' 114 | 115 | context2d.fillText(`Remote: ${remoteIp}`, 10, height - 10) 116 | context2d.fillText(`Local: ${getIPAddress()}`, 10, height - 30) 117 | context2d.fillText(`Status: ${status}`, 10, height - 50) 118 | 119 | // return result 120 | const rawImage = Buffer.from(context2d.getImageData(0, 0, canvasWidth, canvasHeight).data) 121 | 122 | const computedImage = await imageRs.ImageTransformer.fromBuffer( 123 | rawImage, 124 | canvasWidth, 125 | canvasHeight, 126 | imageRs.PixelFormat.Rgba, 127 | ) 128 | .scale(width, height, imageRs.ResizeMode.Exact) 129 | .toBuffer(pixelFormat) 130 | 131 | return computedImage.buffer 132 | } 133 | 134 | async generateLogoCard(width: number, height: number): Promise { 135 | const iconImage = await this.loadIcon() 136 | 137 | const canvasWidth = width 138 | const canvasHeight = height 139 | const canvas = new Canvas(canvasWidth, canvasHeight) 140 | const context2d = canvas.getContext('2d') 141 | 142 | // draw icon 143 | const iconTargetSize = Math.round(Math.min(width, height) * 0.8) 144 | const iconTargetX = (width - iconTargetSize) / 2 145 | const iconTargetY = (height - iconTargetSize) / 2 146 | context2d.drawImage( 147 | iconImage, 148 | 0, 149 | 0, 150 | iconImage.width, 151 | iconImage.height, 152 | iconTargetX, 153 | iconTargetY, 154 | iconTargetSize, 155 | iconTargetSize, 156 | ) 157 | 158 | // return result 159 | const rawImage = Buffer.from(context2d.getImageData(0, 0, canvasWidth, canvasHeight).data) 160 | 161 | return rawImage 162 | } 163 | } 164 | 165 | function getIPAddress() { 166 | for (const devName in networkInterfaces()) { 167 | const iface = networkInterfaces()[devName] 168 | if (iface) { 169 | for (let i = 0; i < iface.length; i++) { 170 | const alias = iface[i] 171 | if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) return alias.address 172 | } 173 | } 174 | } 175 | return '0.0.0.0' 176 | } 177 | -------------------------------------------------------------------------------- /satellite/src/graphics/drawingState.ts: -------------------------------------------------------------------------------- 1 | import { ImageWriteQueue } from './writeQueue.js' 2 | 3 | export class DrawingState { 4 | #queue: ImageWriteQueue 5 | #state: string 6 | 7 | #isAborting = false 8 | #execBeforeRunQueue: (() => Promise) | null = null 9 | 10 | get state(): string { 11 | return this.#state 12 | } 13 | 14 | constructor(state: string) { 15 | this.#state = state 16 | this.#queue = new ImageWriteQueue() 17 | } 18 | 19 | queueJob(key: TKey, fn: (key: TKey, signal: AbortSignal) => Promise): void { 20 | this.#queue.queue(key, fn) 21 | } 22 | 23 | abortQueued(newState: string, fnBeforeRunQueue?: () => Promise): void { 24 | let abortQueue: ImageWriteQueue | null = null 25 | if (!this.#isAborting) { 26 | this.#isAborting = true 27 | abortQueue = this.#queue 28 | } 29 | 30 | console.log(`Aborting queue: ${this.#state} -> ${newState}`, !!abortQueue) 31 | 32 | this.#state = newState 33 | this.#queue = new ImageWriteQueue(false) 34 | this.#execBeforeRunQueue = fnBeforeRunQueue ?? null 35 | 36 | if (abortQueue) { 37 | abortQueue 38 | .abort() 39 | .catch((e) => { 40 | console.error(`Failed to abort queue: ${e}`) 41 | }) 42 | .then(async () => { 43 | if (this.#execBeforeRunQueue) { 44 | await this.#execBeforeRunQueue().catch((e) => { 45 | console.error(`Failed to run before queue: ${e}`) 46 | }) 47 | this.#execBeforeRunQueue = null 48 | } 49 | }) 50 | .finally(() => { 51 | this.#isAborting = false 52 | 53 | console.log('aborted') 54 | 55 | // Start execution 56 | this.#queue.setRunning() 57 | }) 58 | .catch((e) => { 59 | console.error(`Failed to abort queue: ${e}`) 60 | }) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /satellite/src/graphics/lib.ts: -------------------------------------------------------------------------------- 1 | import { CardGenerator } from './cards.js' 2 | import { LockingGraphicsGenerator } from './locking.js' 3 | 4 | export { CardGenerator, LockingGraphicsGenerator } 5 | 6 | export interface SurfaceGraphicsContext { 7 | readonly locking: LockingGraphicsGenerator 8 | readonly cards: CardGenerator 9 | } 10 | -------------------------------------------------------------------------------- /satellite/src/graphics/locking.ts: -------------------------------------------------------------------------------- 1 | import { Canvas } from '@napi-rs/canvas' 2 | 3 | export class LockingGraphicsGenerator { 4 | generatePincodeChar(width: number, height: number, keyCode: number | string): Uint8ClampedArray { 5 | const canvasWidth = width 6 | const canvasHeight = height 7 | 8 | const canvas = new Canvas(canvasWidth, canvasHeight) 9 | const context2d = canvas.getContext('2d') 10 | 11 | // Ensure background is black 12 | context2d.fillStyle = '#000000' 13 | context2d.fillRect(0, 0, width, height) 14 | 15 | // Draw centered text 16 | context2d.font = `${Math.floor(height * 0.7)}px` 17 | context2d.textAlign = 'center' 18 | context2d.textBaseline = 'middle' 19 | context2d.fillStyle = '#ffffff' 20 | context2d.fillText(keyCode + '', width / 2, height / 2) 21 | 22 | return context2d.getImageData(0, 0, canvasWidth, canvasHeight).data 23 | } 24 | 25 | generatePincodeValue(width: number, height: number, charCount: number): Uint8ClampedArray { 26 | const canvasWidth = width 27 | const canvasHeight = height 28 | 29 | const canvas = new Canvas(canvasWidth, canvasHeight) 30 | const context2d = canvas.getContext('2d') 31 | 32 | // Ensure background is black 33 | context2d.fillStyle = '#000000' 34 | context2d.fillRect(0, 0, width, height) 35 | 36 | if (width > 2 * height) { 37 | // Note: this is tuned for the SD Neo, which is 248x58px 38 | // This should be made more generic or configurable as needed 39 | 40 | // Custom render when width is much larger than height 41 | context2d.textAlign = 'center' 42 | context2d.textBaseline = 'middle' 43 | 44 | // Draw heading 45 | context2d.font = `${Math.floor(height * 0.4)}px` 46 | context2d.fillStyle = '#ffc600' 47 | const textWidth = context2d.measureText('Lockout').width 48 | if (textWidth > width * 0.5) { 49 | context2d.font = `${Math.floor(height * 0.25)}px` 50 | } 51 | 52 | context2d.fillText('Lockout', width * 0.25, height * 0.5) 53 | 54 | // Draw progress 55 | context2d.fillStyle = '#ffffff' 56 | context2d.font = `${Math.floor(height * 0.2)}px` 57 | context2d.fillText('*'.repeat(charCount), width * 0.75, height * 0.5) 58 | } else { 59 | context2d.textAlign = 'center' 60 | context2d.textBaseline = 'middle' 61 | 62 | // Draw heading 63 | context2d.font = `${Math.floor(height * 0.2)}px` 64 | context2d.fillStyle = '#ffc600' 65 | context2d.fillText('Lockout', width / 2, height * 0.2) 66 | 67 | // Draw progress 68 | context2d.fillStyle = '#ffffff' 69 | context2d.font = `${Math.floor(height * 0.2)}px` 70 | context2d.fillText('*'.repeat(charCount), width / 2, height * 0.65) 71 | } 72 | 73 | return context2d.getImageData(0, 0, canvasWidth, canvasHeight).data 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /satellite/src/graphics/writeQueue.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of the Companion project 3 | * Copyright (c) 2018 Bitfocus AS 4 | * Authors: William Viker , Håkon Nessjøen 5 | * 6 | * This program is free software. 7 | * You should have received a copy of the MIT licence as well as the Bitfocus 8 | * Individual Contributor License Agreement for companion along with 9 | * this program. 10 | */ 11 | 12 | interface DrainPromise { 13 | promise: Promise 14 | resolve: () => void 15 | } 16 | 17 | export class ImageWriteQueue { 18 | private readonly maxConcurrent = 3 19 | private readonly pendingImages: Array<{ key: TKey; fn: (key: TKey, signal: AbortSignal) => Promise }> = [] 20 | private readonly drainAbort = new AbortController() 21 | private readonly inProgress = new Set() 22 | private drainPromise: DrainPromise | null = null 23 | 24 | #running: boolean 25 | 26 | get running(): boolean { 27 | return this.#running 28 | } 29 | 30 | constructor(autostart = true) { 31 | this.#running = autostart 32 | } 33 | 34 | public setRunning(): void { 35 | if (this.drainAbort.signal.aborted) throw new Error('queue is aborted') 36 | 37 | this.#running = true 38 | 39 | this.tryDequeue() 40 | } 41 | 42 | public async abort(): Promise { 43 | this.pendingImages.splice(0, this.pendingImages.length) 44 | this.drainAbort.abort() 45 | 46 | if (!this.drainPromise && this.inProgress.size > 0) { 47 | let resolve = () => {} 48 | const promise = new Promise((resolve_) => { 49 | resolve = resolve_ 50 | }) 51 | this.drainPromise = { 52 | resolve, 53 | promise, 54 | } 55 | } 56 | 57 | if (this.drainPromise) await this.drainPromise.promise 58 | } 59 | 60 | public queue(key: TKey, fn: (key: TKey, signal: AbortSignal) => Promise): void { 61 | if (this.drainAbort.signal.aborted) throw new Error('queue is aborted') 62 | 63 | let updated = false 64 | // Try and replace an existing queued image first 65 | for (const img of this.pendingImages) { 66 | if (img.key === key) { 67 | img.fn = fn 68 | updated = true 69 | break 70 | } 71 | } 72 | 73 | // If key isnt queued, then append 74 | if (!updated) { 75 | this.pendingImages.push({ key: key, fn: fn }) 76 | } 77 | 78 | this.tryDequeue() 79 | } 80 | 81 | private tryDequeue() { 82 | if (!this.#running) return 83 | 84 | // Start another if not too many in progress 85 | if (this.inProgress.size < this.maxConcurrent && this.pendingImages.length > 0) { 86 | // Find first image where key is not being worked on 87 | const nextImageIndex = this.pendingImages.findIndex((img) => !this.inProgress.has(img.key)) 88 | if (nextImageIndex === -1) { 89 | return 90 | } 91 | 92 | const nextImage = this.pendingImages[nextImageIndex] 93 | this.pendingImages.splice(nextImageIndex, 1) 94 | if (!nextImage) { 95 | return 96 | } 97 | 98 | // Track which key is being processed 99 | this.inProgress.add(nextImage.key) 100 | 101 | nextImage 102 | .fn(nextImage.key, this.drainAbort.signal) 103 | .catch((e) => { 104 | // Ensure it doesnt error out 105 | console.error('fillImage error:', e) 106 | }) 107 | .finally(() => { 108 | // Stop tracking key 109 | this.inProgress.delete(nextImage.key) 110 | 111 | if (this.inProgress.size === 0 && this.drainPromise) { 112 | try { 113 | const resolve = this.drainPromise.resolve 114 | this.drainPromise = null 115 | resolve() 116 | } catch (e) { 117 | console.error('drain promise error:', e) 118 | } 119 | } 120 | 121 | if (this.drainAbort.signal.aborted) return 122 | 123 | // Run again 124 | setImmediate(() => this.tryDequeue()) 125 | }) 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /satellite/src/lib.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_TCP_PORT = 16622 2 | export const DEFAULT_WS_PORT = 16623 3 | 4 | export function assertNever(_v: never): void { 5 | // Nothing to do 6 | } 7 | 8 | export function wrapAsync( 9 | fn: (...args: TArgs) => Promise, 10 | catcher: (e: any) => void, 11 | ): (...args: TArgs) => void { 12 | return (...args) => { 13 | fn(...args).catch(catcher) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /satellite/src/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-process-exit */ 2 | import '@julusian/segfault-raub' 3 | 4 | import exitHook from 'exit-hook' 5 | import { CompanionSatelliteClient } from './client.js' 6 | import { SurfaceManager } from './surface-manager.js' 7 | import { RestServer } from './rest.js' 8 | import { getConnectionDetailsFromConfig, listenToConnectionConfigChanges, openHeadlessConfig } from './config.js' 9 | import { fileURLToPath } from 'url' 10 | import { MdnsAnnouncer } from './mdnsAnnouncer.js' 11 | import debounceFn from 'debounce-fn' 12 | 13 | const rawConfigPath = process.argv[2] 14 | if (!rawConfigPath) { 15 | console.log(` 16 | Usage 17 | $ companion-satellite 18 | 19 | Examples 20 | $ companion-satellite config.json 21 | $ companion-satellite /home/satellite/.config/companion-satellite.json 22 | `) 23 | 24 | process.exit(1) 25 | } 26 | 27 | const appConfig = openHeadlessConfig(rawConfigPath) 28 | 29 | console.log('Starting', appConfig.path) 30 | 31 | const webRoot = fileURLToPath(new URL('../../webui/dist', import.meta.url)) 32 | 33 | const client = new CompanionSatelliteClient({ debug: true }) 34 | const surfaceManager = await SurfaceManager.create(client, appConfig.get('surfacePluginsEnabled')) 35 | const server = new RestServer(webRoot, appConfig, client, surfaceManager) 36 | const mdnsAnnouncer = new MdnsAnnouncer(appConfig) 37 | 38 | client.on('log', (l) => console.log(l)) 39 | client.on('error', (e) => console.error(e)) 40 | 41 | exitHook(() => { 42 | console.log('Exiting') 43 | client.disconnect() 44 | surfaceManager.close().catch(() => null) 45 | server.close() 46 | }) 47 | 48 | const tryConnect = () => { 49 | client.connect(getConnectionDetailsFromConfig(appConfig)).catch((e) => { 50 | console.log(`Failed to connect`, e) 51 | }) 52 | } 53 | 54 | listenToConnectionConfigChanges(appConfig, tryConnect) 55 | appConfig.onDidChange( 56 | 'surfacePluginsEnabled', 57 | debounceFn(() => surfaceManager.updatePluginsEnabled(appConfig.get('surfacePluginsEnabled')), { 58 | wait: 50, 59 | after: true, 60 | before: false, 61 | }), 62 | ) 63 | 64 | tryConnect() 65 | server.open() 66 | mdnsAnnouncer.start() 67 | -------------------------------------------------------------------------------- /satellite/src/mdnsAnnouncer.ts: -------------------------------------------------------------------------------- 1 | import type Conf from 'conf' 2 | import type { SatelliteConfig } from './config.js' 3 | import { Bonjour, Service } from '@julusian/bonjour-service' 4 | import os from 'os' 5 | import debounceFn from 'debounce-fn' 6 | 7 | export class MdnsAnnouncer { 8 | readonly #appConfig: Conf 9 | readonly #bonjour = new Bonjour() 10 | 11 | #bonjourService: Service | null = null 12 | 13 | constructor(appConfig: Conf) { 14 | this.#appConfig = appConfig 15 | 16 | this.#appConfig.onDidChange('mdnsEnabled', () => this.#restart()) 17 | this.#appConfig.onDidChange('installationName', () => this.#restart()) 18 | this.#appConfig.onDidChange('restPort', () => this.#restart()) 19 | this.#appConfig.onDidChange('restEnabled', () => this.#restart()) 20 | } 21 | 22 | readonly #restart = debounceFn( 23 | () => { 24 | this.stop() 25 | this.start() 26 | }, 27 | { 28 | wait: 50, 29 | before: false, 30 | after: true, 31 | }, 32 | ) 33 | 34 | start(): void { 35 | if (!this.#appConfig.get('mdnsEnabled')) return 36 | if (this.#bonjourService) return 37 | 38 | try { 39 | const restEnabled = this.#appConfig.get('restEnabled') 40 | const restPort = this.#appConfig.get('restPort') 41 | const installationName = this.#appConfig.get('installationName') || os.hostname() || 'Unnamed Satellite' 42 | 43 | this.#bonjourService = this.#bonjour.publish( 44 | { 45 | name: installationName, 46 | type: 'companion-satellite', 47 | protocol: 'tcp', 48 | port: restPort || 9999, 49 | txt: { 50 | restEnabled: restEnabled, 51 | }, 52 | ttl: 150, 53 | }, 54 | { 55 | announceOnInterval: 60 * 1000, 56 | }, 57 | ) 58 | } catch (e) { 59 | console.error('Failed to setup mdns publisher', e) 60 | } 61 | } 62 | 63 | stop(): void { 64 | if (this.#bonjourService) { 65 | this.#bonjourService.stop?.() 66 | this.#bonjourService = null 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /satellite/src/rest.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import Router from 'koa-router' 3 | import { koaBody } from 'koa-body' 4 | import serve from 'koa-static' 5 | import http from 'http' 6 | import type Conf from 'conf' 7 | import type { CompanionSatelliteClient } from './client.js' 8 | import type { SurfaceManager } from './surface-manager.js' 9 | import type { SatelliteConfig } from './config.js' 10 | import { 11 | ApiConfigData, 12 | ApiConfigDataUpdate, 13 | ApiConfigDataUpdateElectron, 14 | ApiSurfaceInfo, 15 | ApiSurfacePluginInfo, 16 | ApiSurfacePluginsEnabled, 17 | compileConfig, 18 | compileStatus, 19 | updateConfig, 20 | updateSurfacePluginsEnabledConfig, 21 | } from './apiTypes.js' 22 | 23 | export class RestServer { 24 | private readonly appConfig: Conf 25 | private readonly client: CompanionSatelliteClient 26 | private readonly surfaceManager: SurfaceManager 27 | private readonly app: Koa 28 | private readonly router: Router 29 | private server: http.Server | undefined 30 | 31 | constructor( 32 | webRoot: string, 33 | appConfig: Conf, 34 | client: CompanionSatelliteClient, 35 | surfaceManager: SurfaceManager, 36 | ) { 37 | this.appConfig = appConfig 38 | this.client = client 39 | this.surfaceManager = surfaceManager 40 | 41 | // Monitor for config changes 42 | this.appConfig.onDidChange('restEnabled', this.open.bind(this)) 43 | this.appConfig.onDidChange('restPort', this.open.bind(this)) 44 | 45 | this.app = new Koa() 46 | this.app.use(serve(webRoot)) 47 | 48 | this.router = new Router() 49 | 50 | //GET 51 | this.router.get('/api/host', async (ctx) => { 52 | ctx.body = this.appConfig.get('remoteIp') 53 | }) 54 | this.router.get('/api/port', (ctx) => { 55 | ctx.body = this.appConfig.get('remotePort') 56 | }) 57 | this.router.get('/api/connected', (ctx) => { 58 | ctx.body = this.client.connected 59 | }) 60 | this.router.get('/api/config', (ctx) => { 61 | ctx.body = compileConfig(this.appConfig) 62 | }) 63 | this.router.get('/api/status', (ctx) => { 64 | ctx.body = compileStatus(this.client) 65 | }) 66 | 67 | //POST 68 | this.router.post('/api/host', koaBody(), async (ctx) => { 69 | let host = '' 70 | if (ctx.request.type == 'application/json') { 71 | host = ctx.request.body['host'] 72 | } else if (ctx.request.type == 'text/plain') { 73 | host = ctx.request.body 74 | } 75 | 76 | if (host) { 77 | this.appConfig.set('remoteIp', host) 78 | 79 | ctx.body = 'OK' 80 | } else { 81 | ctx.status = 400 82 | ctx.body = 'Invalid host' 83 | } 84 | }) 85 | this.router.post('/api/port', koaBody(), async (ctx) => { 86 | let newPort = NaN 87 | if (ctx.request.type == 'application/json') { 88 | newPort = Number(ctx.request.body['port']) 89 | } else if (ctx.request.type == 'text/plain') { 90 | newPort = Number(ctx.request.body) 91 | } 92 | 93 | if (!isNaN(newPort) && newPort > 0 && newPort <= 65535) { 94 | this.appConfig.set('remotePOrt', newPort) 95 | 96 | ctx.body = 'OK' 97 | } else { 98 | ctx.status = 400 99 | ctx.body = 'Invalid port' 100 | } 101 | }) 102 | this.router.post('/api/config', koaBody(), async (ctx) => { 103 | if (ctx.request.type == 'application/json') { 104 | const body = ctx.request.body as Partial 105 | 106 | const partialConfig: ApiConfigDataUpdate = {} 107 | 108 | const protocol = body.protocol 109 | if (protocol !== undefined) { 110 | if (typeof protocol === 'string') { 111 | partialConfig.protocol = protocol 112 | } else { 113 | ctx.status = 400 114 | ctx.body = 'Invalid protocol' 115 | } 116 | } 117 | 118 | const host = body.host 119 | if (host !== undefined) { 120 | if (typeof host === 'string') { 121 | partialConfig.host = host 122 | } else { 123 | ctx.status = 400 124 | ctx.body = 'Invalid host' 125 | } 126 | } 127 | 128 | const port = Number(body.port) 129 | if (isNaN(port) || port <= 0 || port > 65535) { 130 | ctx.status = 400 131 | ctx.body = 'Invalid port' 132 | } else { 133 | partialConfig.port = port 134 | } 135 | 136 | const wsAddress = body.wsAddress 137 | if (wsAddress !== undefined) { 138 | if (typeof wsAddress === 'string') { 139 | partialConfig.wsAddress = wsAddress 140 | } else { 141 | ctx.status = 400 142 | ctx.body = 'Invalid wsAddress' 143 | } 144 | } 145 | 146 | const installationName = body.installationName 147 | if (installationName !== undefined) { 148 | if (typeof installationName === 'string') { 149 | partialConfig.installationName = installationName 150 | } else { 151 | ctx.status = 400 152 | ctx.body = 'Invalid installationName' 153 | } 154 | } 155 | 156 | const mdnsEnabled = body.mdnsEnabled 157 | if (mdnsEnabled !== undefined) { 158 | if (typeof mdnsEnabled === 'boolean') { 159 | partialConfig.mdnsEnabled = mdnsEnabled 160 | } else { 161 | ctx.status = 400 162 | ctx.body = 'Invalid mdnsEnabled' 163 | } 164 | } 165 | 166 | // Ensure some fields cannot be changed 167 | const tmpPartialConfig: ApiConfigDataUpdateElectron = partialConfig 168 | delete tmpPartialConfig.httpEnabled 169 | delete tmpPartialConfig.httpPort 170 | 171 | updateConfig(this.appConfig, partialConfig) 172 | ctx.body = compileConfig(this.appConfig) 173 | } 174 | }) 175 | 176 | this.router.post('/api/surfaces/rescan', async (ctx) => { 177 | this.surfaceManager.scanForSurfaces() 178 | 179 | ctx.body = 'OK' 180 | }) 181 | 182 | this.router.get('/api/surfaces', async (ctx) => { 183 | ctx.body = this.surfaceManager.getOpenSurfacesInfo() satisfies ApiSurfaceInfo[] 184 | }) 185 | 186 | this.router.get('/api/surfaces/plugins/installed', async (ctx) => { 187 | ctx.body = this.surfaceManager.getAvailablePluginsInfo() satisfies ApiSurfacePluginInfo[] 188 | }) 189 | 190 | this.router.get('/api/surfaces/plugins/enabled', async (ctx) => { 191 | ctx.body = this.appConfig.get('surfacePluginsEnabled') satisfies ApiSurfacePluginsEnabled 192 | }) 193 | this.router.post('/api/surfaces/plugins/enabled', koaBody(), async (ctx) => { 194 | if (ctx.request.type != 'application/json') { 195 | ctx.status = 400 196 | ctx.body = 'Invalid request' 197 | return 198 | } 199 | 200 | if (typeof ctx.request.body !== 'object') { 201 | ctx.status = 400 202 | ctx.body = 'Invalid request' 203 | return 204 | } 205 | 206 | const newConfig = ctx.request.body as ApiSurfacePluginsEnabled 207 | updateSurfacePluginsEnabledConfig(this.appConfig, newConfig) 208 | 209 | ctx.body = 'OK' 210 | }) 211 | 212 | this.app.use(this.router.routes()).use(this.router.allowedMethods()) 213 | } 214 | 215 | public open(): void { 216 | this.close() 217 | 218 | const enabled = this.appConfig.get('restEnabled') 219 | const port = this.appConfig.get('restPort') 220 | 221 | if (enabled && port) { 222 | try { 223 | this.server = this.app.listen(port) 224 | console.log(`REST server starting: port: ${port}`) 225 | } catch (error) { 226 | console.error('Error starting REST server:', error) 227 | } 228 | } else { 229 | console.log('REST server not starting: port 0') 230 | } 231 | } 232 | 233 | public close(): void { 234 | if (this.server && this.server.listening) { 235 | this.server.close() 236 | this.server.closeAllConnections() 237 | delete this.server 238 | console.log('The rest server is closed') 239 | } 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /satellite/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": ["src/**/*.ts", "src/**/*.cts", "src/**/*.mts"], 4 | "exclude": ["node_modules/**", "src/**/*spec.ts", "src/**/__tests__/*", "src/**/__mocks__/*"], 5 | "compilerOptions": { 6 | "outDir": "./dist", 7 | "baseUrl": "./", 8 | "paths": { 9 | "*": ["./node_modules/*"], 10 | "{{PACKAGE-NAME}}": ["./src/index.ts"] 11 | }, 12 | "types": ["node"], 13 | "lib": ["dom"], 14 | "skipLibCheck": true, 15 | "resolveJsonModule": true, 16 | "declaration": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /satellite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "exclude": ["node_modules/**"], 4 | "compilerOptions": { 5 | "types": ["node"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tools/build_electron.mts: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-process-exit */ 2 | import { fs, usePowerShell, argv } from 'zx' 3 | // eslint-disable-next-line n/no-extraneous-import 4 | import electronBuilder from 'electron-builder' 5 | 6 | if (process.platform === 'win32') { 7 | usePowerShell() // to enable powershell 8 | } 9 | 10 | const platform = argv._[0] || `${process.platform}-${process.arch}` 11 | 12 | let platformInfo: { platform: string; arch: electronBuilder.Arch } 13 | // let nodePreGypArgs: string[] = [] 14 | 15 | console.log(`Building for platform: ${platform}`) 16 | 17 | if (platform === 'mac-x64' || platform === 'darwin-x64') { 18 | platformInfo = { platform: 'mac', arch: electronBuilder.Arch.x64 } 19 | // nodePreGypArgs = ['--target_platform=darwin', '--target_arch=x64', '--target_libc=unknown'] 20 | } else if (platform === 'mac-arm64' || platform === 'darwin-arm64') { 21 | platformInfo = { platform: 'mac', arch: electronBuilder.Arch.arm64 } 22 | // nodePreGypArgs = ['--target_platform=darwin', '--target_arch=arm64', '--target_libc=unknown'] 23 | } else if (platform === 'win-x64' || platform === 'win32-x64') { 24 | platformInfo = { platform: 'win', arch: electronBuilder.Arch.x64 } 25 | // nodePreGypArgs = ['--target_platform=win32', '--target_arch=x64', '--target_libc=unknown'] 26 | } else if (platform === 'linux-x64') { 27 | platformInfo = { platform: 'linux', arch: electronBuilder.Arch.x64 } 28 | // nodePreGypArgs = ['--target_platform=linux', '--target_arch=x64', '--target_libc=glibc'] 29 | } else if (platform === 'linux-arm7') { 30 | platformInfo = { platform: 'linux', arch: electronBuilder.Arch.armv7l } 31 | // nodePreGypArgs = ['--target_platform=linux', '--target_arch=arm', '--target_libc=glibc'] 32 | } else if (platform === 'linux-arm64') { 33 | platformInfo = { platform: 'linux', arch: electronBuilder.Arch.arm64 } 34 | // nodePreGypArgs = ['--target_platform=linux', '--target_arch=arm64', '--target_libc=glibc'] 35 | } else { 36 | console.error('Unknown platform') 37 | process.exit(1) 38 | } 39 | 40 | // HACK: skip this as it is trying to rebuild everything from source and failing 41 | // if (!platform) { 42 | // // If for our own platform, make sure the correct deps are installed 43 | // await $`electron-builder install-app-deps` 44 | // } 45 | // console.log('pregyp args:', nodePreGypArgs) 46 | 47 | // perform the electron build 48 | await fs.remove('./electron-output') 49 | 50 | const options: electronBuilder.Configuration = { 51 | publish: [ 52 | { 53 | provider: 'generic', 54 | publishAutoUpdate: false, 55 | url: 'https://api.bitfocus.io/v1/product/electron-updater/companion-satellite', 56 | }, 57 | ], 58 | productName: 'Companion Satellite', 59 | appId: 'remote.companion.bitfocus.no', 60 | npmRebuild: false, 61 | directories: { 62 | buildResources: 'assets/', 63 | output: '../electron-output/', 64 | }, 65 | mac: { 66 | category: 'no.bitfocus.companion.remote', 67 | target: 'dmg', 68 | extendInfo: { 69 | LSBackgroundOnly: 1, 70 | LSUIElement: 1, 71 | }, 72 | hardenedRuntime: true, 73 | gatekeeperAssess: false, 74 | entitlements: 'satellite/entitlements.mac.plist', 75 | entitlementsInherit: 'satellite/entitlements.mac.plist', 76 | }, 77 | dmg: { 78 | artifactName: 'companion-satellite-${arch}.dmg', 79 | sign: !!process.env.CSC_LINK, // Only sign in ci 80 | }, 81 | win: { 82 | target: 'nsis', 83 | verifyUpdateCodeSignature: false, // Enabling this would need publishedName to be set, not sure if that is possible 84 | signtoolOptions: { 85 | signingHashAlgorithms: ['sha256'], 86 | 87 | sign: async function sign(config, packager) { 88 | // Do not sign if no certificate is provided. 89 | if (!config.cscInfo) { 90 | return 91 | } 92 | 93 | if (!packager) throw new Error('Packager is required') 94 | 95 | const targetPath = config.path 96 | // Do not sign elevate file, because that prompts virus warning? 97 | if (targetPath.endsWith('elevate.exe')) { 98 | return 99 | } 100 | 101 | if (!process.env.BF_CODECERT_KEY) throw new Error('BF_CODECERT_KEY variable is not set') 102 | 103 | const vm = await packager.vm.value 104 | await vm.exec( 105 | 'powershell.exe', 106 | ['c:\\actions-runner-bitfocus\\sign.ps1', targetPath, `-Description`, 'Bitfocus Companion Satellite'], 107 | { 108 | timeout: 10 * 60 * 1000, 109 | env: process.env, 110 | }, 111 | ) 112 | }, 113 | }, 114 | }, 115 | nsis: { 116 | createStartMenuShortcut: true, 117 | perMachine: true, 118 | oneClick: false, 119 | allowElevation: true, 120 | artifactName: 'companion-satellite-x64.exe', 121 | }, 122 | linux: { 123 | target: 'tar.gz', 124 | artifactName: 'companion-satellite-${arch}.tar.gz', 125 | extraFiles: [ 126 | { 127 | from: 'assets/linux', 128 | to: '.', 129 | }, 130 | ], 131 | }, 132 | files: ['**/*', 'assets/*', '!.nvmrc', '!.node_version', '!docs', '!samples', '!src', '!tools', '!pi-image'], 133 | extraResources: [ 134 | { 135 | from: '../webui/dist', 136 | to: 'webui', 137 | }, 138 | ], 139 | } 140 | 141 | const satellitePkgJsonPath = new URL('../satellite/package.json', import.meta.url) 142 | const satellitePkgJsonStr = await fs.readFile(satellitePkgJsonPath) 143 | 144 | const satellitePkgJson = JSON.parse(satellitePkgJsonStr.toString()) 145 | satellitePkgJson.updateChannel = process.env.EB_UPDATE_CHANNEL 146 | console.log('Injecting update channel: ' + satellitePkgJson.updateChannel) 147 | 148 | if (process.env.BUILD_VERSION) satellitePkgJson.version = process.env.BUILD_VERSION 149 | 150 | await fs.writeFile(satellitePkgJsonPath, JSON.stringify(satellitePkgJson)) 151 | 152 | try { 153 | // perform the electron build 154 | await electronBuilder.build({ 155 | targets: electronBuilder.Platform.fromString(platformInfo.platform).createTarget(null, platformInfo.arch), 156 | config: options, 157 | projectDir: 'satellite', 158 | }) 159 | } finally { 160 | // undo the changes made 161 | await fs.writeFile(satellitePkgJsonPath, satellitePkgJsonStr) 162 | } 163 | -------------------------------------------------------------------------------- /tools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": ["**/*.ts", "**/*.cts", "**/*.mts"], 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "baseUrl": "./", 7 | "paths": { 8 | "*": ["./node_modules/*"], 9 | }, 10 | "types": ["node"], 11 | "lib": ["dom"], 12 | "skipLibCheck": true, 13 | "resolveJsonModule": true, 14 | "declaration": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | // 5 | { "path": "satellite" }, 6 | { "path": "webui" }, 7 | { "path": "tools" } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /webui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .yarn/install-state.gz -------------------------------------------------------------------------------- /webui/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | parserOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | project: ['./tsconfig.json', './tsconfig.node.json'], 21 | tsconfigRootDir: __dirname, 22 | }, 23 | ``` 24 | 25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 28 | -------------------------------------------------------------------------------- /webui/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | About Companion Satellite 8 | 9 | 10 | 93 | 94 | 95 | 96 | 99 |

Companion Satellite

100 |

101 | Satellite Streamdeck connector for Bitfocus Companion 102 |
103 | Supports 3.4.0 and newer 104 |

105 | 110 |
111 |
112 |
113 | 114 |
115 | 116 | 117 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /webui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/App.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /webui/electron.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Companion Satellite 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /webui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Companion Satellite 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /webui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webui", 3 | "private": true, 4 | "version": "2.2.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-label": "^2.1.4", 13 | "@radix-ui/react-select": "^2.2.2", 14 | "@radix-ui/react-slot": "^1.2.0", 15 | "@radix-ui/react-switch": "^1.2.2", 16 | "@radix-ui/react-tabs": "^1.1.9", 17 | "@tanstack/react-form": "^0.41.4", 18 | "@tanstack/react-query": "^5.74.11", 19 | "class-variance-authority": "^0.7.1", 20 | "clsx": "^2.1.1", 21 | "lucide-react": "^0.503.0", 22 | "openapi-fetch": "^0.13.5", 23 | "react": "^18.3.1", 24 | "react-dom": "^18.3.1", 25 | "react-error-boundary": "^5.0.0", 26 | "react-spinners": "^0.17.0", 27 | "tailwind-merge": "^3.2.0", 28 | "tailwindcss-animate": "^1.0.7" 29 | }, 30 | "devDependencies": { 31 | "@types/react": "^18.3.20", 32 | "@types/react-dom": "^18.3.7", 33 | "@vitejs/plugin-react": "^4.4.1", 34 | "autoprefixer": "^10.4.21", 35 | "postcss": "^8.5.3", 36 | "sass": "^1.87.0", 37 | "tailwindcss": "^3.4.17", 38 | "vite": "^6.3.4" 39 | }, 40 | "lint-staged": { 41 | "*.{css,json,md,scss}": [ 42 | "prettier --write" 43 | ], 44 | "*.{ts,tsx,js,jsx}": [ 45 | "run -T lint:raw --fix" 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /webui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /webui/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfocus/companion-satellite/315e92ccfc8b7a915153872a8af638cbe1efc796/webui/public/icon.png -------------------------------------------------------------------------------- /webui/src/Api/Context.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react' 2 | import type { SatelliteUiApi } from './types' 3 | 4 | const ApiContext = createContext(null) 5 | 6 | // eslint-disable-next-line react-refresh/only-export-components 7 | export function useSatelliteApi(): SatelliteUiApi { 8 | const api = useContext(ApiContext) 9 | if (!api) throw new Error('useApi must be used within an ApiProvider') 10 | 11 | return api 12 | } 13 | 14 | export function SatelliteApiProvider({ 15 | children, 16 | api, 17 | }: { 18 | children: React.ReactNode 19 | api: SatelliteUiApi 20 | }): JSX.Element { 21 | return {children} 22 | } 23 | -------------------------------------------------------------------------------- /webui/src/Api/rest.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SatelliteUiApi, 3 | ApiConfigData, 4 | ApiStatusResponse, 5 | ApiConfigDataUpdate, 6 | ApiSurfaceInfo, 7 | ApiSurfacePluginInfo, 8 | ApiSurfacePluginsEnabled, 9 | } from './types' 10 | import type { paths } from '../../../satellite/src/generated/openapi' 11 | import createClient from 'openapi-fetch' 12 | 13 | const client = createClient({ baseUrl: '/api/' }) 14 | 15 | export const SatelliteRestApi: SatelliteUiApi = { 16 | includeApiEnable: false, 17 | getStatus: async function (): Promise { 18 | const { data, error } = await client.GET('/status', {}) 19 | if (error) throw new Error(error.error) 20 | return data 21 | }, 22 | getConfig: async function (): Promise { 23 | const { data, error } = await client.GET('/config', {}) 24 | if (error) throw new Error(error.error) 25 | return data 26 | }, 27 | saveConfig: async function (newConfig: ApiConfigDataUpdate): Promise { 28 | const { data, error } = await client.POST('/config', { 29 | body: newConfig, 30 | }) 31 | if (error) throw new Error(error.error) 32 | return data 33 | }, 34 | rescanSurfaces: async function (): Promise { 35 | const { data, error } = await client.POST('/surfaces/rescan', {}) 36 | if (error) throw new Error(error.error) 37 | return data 38 | }, 39 | connectedSurfaces: async function (): Promise { 40 | const { data, error } = await client.GET('/surfaces', {}) 41 | if (error) throw new Error(error.error) 42 | return data 43 | }, 44 | surfacePlugins: async function (): Promise { 45 | const { data, error } = await client.GET('/surfaces/plugins/installed', {}) 46 | if (error) throw new Error(error.error) 47 | return data 48 | }, 49 | surfacePluginsEnabled: async function (): Promise { 50 | const { data, error } = await client.GET('/surfaces/plugins/enabled', {}) 51 | if (error) throw new Error(error.error) 52 | return data 53 | }, 54 | surfacePluginsEnabledUpdate: async function ( 55 | newConfig: ApiSurfacePluginsEnabled, 56 | ): Promise { 57 | const { data, error } = await client.POST('/surfaces/plugins/enabled', { 58 | body: newConfig, 59 | }) 60 | if (error) throw new Error(error.error) 61 | return data 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /webui/src/Api/types.ts: -------------------------------------------------------------------------------- 1 | export type * from '../../../satellite/src/apiTypes.js' 2 | -------------------------------------------------------------------------------- /webui/src/App.css: -------------------------------------------------------------------------------- 1 | /* Future styling goes here */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | @layer base { 7 | :root { 8 | --background: 0 0% 100%; 9 | --foreground: 0 0% 3.9%; 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | --popover: 0 0% 100%; 13 | --popover-foreground: 0 0% 3.9%; 14 | --primary: 0 0% 9%; 15 | --primary-foreground: 0 0% 98%; 16 | --secondary: 0 0% 96.1%; 17 | --secondary-foreground: 0 0% 9%; 18 | --muted: 0 0% 96.1%; 19 | --muted-foreground: 0 0% 45.1%; 20 | --accent: 0 0% 96.1%; 21 | --accent-foreground: 0 0% 9%; 22 | --destructive: 0 84.2% 60.2%; 23 | --destructive-foreground: 0 0% 98%; 24 | --border: 0 0% 89.8%; 25 | --input: 0 0% 89.8%; 26 | --ring: 0 0% 3.9%; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | --radius: 0.5rem; 33 | } 34 | .dark { 35 | --background: 0 0% 3.9%; 36 | --foreground: 0 0% 98%; 37 | --card: 0 0% 3.9%; 38 | --card-foreground: 0 0% 98%; 39 | --popover: 0 0% 3.9%; 40 | --popover-foreground: 0 0% 98%; 41 | --primary: 0 0% 98%; 42 | --primary-foreground: 0 0% 9%; 43 | --secondary: 0 0% 14.9%; 44 | --secondary-foreground: 0 0% 98%; 45 | --muted: 0 0% 14.9%; 46 | --muted-foreground: 0 0% 63.9%; 47 | --accent: 0 0% 14.9%; 48 | --accent-foreground: 0 0% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 0 0% 98%; 51 | --border: 0 0% 14.9%; 52 | --input: 0 0% 14.9%; 53 | --ring: 0 0% 83.1%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | @layer base { 62 | * { 63 | @apply border-border; 64 | } 65 | body { 66 | @apply bg-background text-foreground; 67 | } 68 | 69 | /* Hide arrows on number input fields */ 70 | input[type='number']::-webkit-outer-spin-button, 71 | input[type='number']::-webkit-inner-spin-button, 72 | input[type='number'] { 73 | -webkit-appearance: none; 74 | margin: 0; 75 | -moz-appearance: textfield !important; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /webui/src/Util/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Alert } from '@/components/ui/alert' 2 | import { Button } from '@/components/ui/button' 3 | import { PropsWithChildren } from 'react' 4 | import { ErrorBoundary, FallbackProps } from 'react-error-boundary' 5 | 6 | function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { 7 | return ( 8 | 9 |

Something went wrong:

10 |
{error?.message ?? ''}
11 | 14 |
15 | ) 16 | } 17 | 18 | export function MyErrorBoundary({ children }: PropsWithChildren): JSX.Element { 19 | return {children} 20 | } 21 | -------------------------------------------------------------------------------- /webui/src/about.ts: -------------------------------------------------------------------------------- 1 | declare const aboutApi: typeof import('../../satellite/dist/aboutPreload.cjs').aboutApi 2 | 3 | const bug_report = document.querySelector('.bug-report-link') as HTMLDivElement 4 | bug_report.addEventListener('click', (e) => { 5 | e.preventDefault() 6 | aboutApi.openShell('https://github.com/bitfocus/companion-satellite/issues').catch((e) => { 7 | console.error('failed to open bug report url', e) 8 | }) 9 | }) 10 | 11 | const open_home = () => { 12 | aboutApi.openShell('https://github.com/bitfocus/companion-satellite').catch((e) => { 13 | console.error('failed to open homepage url', e) 14 | }) 15 | } 16 | 17 | const title_elem = document.querySelector('.title') as HTMLHeadingElement 18 | // title_elem.innerText += ` ${version}` 19 | 20 | title_elem.addEventListener('click', open_home) 21 | title_elem.classList.add('clickable') 22 | const logo_elem = document.querySelector('.logo') as HTMLHeadingElement 23 | logo_elem.addEventListener('click', open_home) 24 | logo_elem.classList.add('clickable') 25 | 26 | const yearElm = document.querySelector('#year') as HTMLSpanElement 27 | yearElm.innerText = new Date().getFullYear().toString() 28 | 29 | aboutApi 30 | .getVersion() 31 | .then((version) => { 32 | console.log('eaa', version) 33 | title_elem.innerText += ` ${version}` 34 | }) 35 | .catch((e) => { 36 | console.error('failed to get version', e) 37 | }) 38 | -------------------------------------------------------------------------------- /webui/src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from '@/components/ui/card' 2 | import { AppContent } from './Content' 3 | 4 | export function App(): JSX.Element { 5 | return ( 6 |
7 |
8 | 9 | Companion Satellite 10 | Companion Satellite 11 | 12 | 13 | 14 | 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /webui/src/app/ConnectedSurfacesTab.tsx: -------------------------------------------------------------------------------- 1 | import { SurfacesRescan } from './SurfacesRescan' 2 | import { useQuery } from '@tanstack/react-query' 3 | import { useSatelliteApi } from '@/Api/Context' 4 | import { ApiSurfaceInfo } from '@/Api/types' 5 | import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, TableCaption } from '@/components/ui/table' 6 | import { BarLoader } from 'react-spinners' 7 | import { CONNECTED_SURFACES_QUERY_KEY } from './constants' 8 | 9 | export function ConnectedSurfacesTab(): JSX.Element { 10 | const api = useSatelliteApi() 11 | const connectedSurfaces = useQuery({ 12 | queryKey: [CONNECTED_SURFACES_QUERY_KEY], 13 | queryFn: async () => api.connectedSurfaces(), 14 | refetchInterval: 5000, 15 | }) 16 | 17 | return ( 18 |
19 |

20 | Connected Surfaces 21 | 22 |

23 | 24 | {connectedSurfaces.isLoading ? : null} 25 | {connectedSurfaces.error ?

Error: {connectedSurfaces.error.message.toString()}

: null} 26 | {connectedSurfaces.data && } 27 |
28 | ) 29 | } 30 | 31 | function ConnectedSurfacesList({ surfaces }: { surfaces: ApiSurfaceInfo[] }): JSX.Element { 32 | return ( 33 | 34 | 35 | 36 | Name 37 | ID 38 | 39 | 40 | 41 | {surfaces.map((surface) => ( 42 | 43 | 44 | {surface.productName} 45 |
46 | {surface.pluginName} 47 |
48 | {surface.surfaceId} 49 |
50 | ))} 51 |
52 | 53 | {surfaces.length === 0 &&

No surfaces are connected

} 54 | You can enable and disable support for different surface types in the Surface Plugins tab. 55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /webui/src/app/ConnectionStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useSatelliteApi } from '@/Api/Context' 2 | import type { ApiStatusResponse } from '../Api/types.js' 3 | import { useQuery } from '@tanstack/react-query' 4 | import { BarLoader } from 'react-spinners' 5 | import { CONNECTION_STATUS_QUERY_KEY } from './constants' 6 | 7 | export function ConnectionStatus(): JSX.Element { 8 | const api = useSatelliteApi() 9 | const status = useQuery({ queryKey: [CONNECTION_STATUS_QUERY_KEY], queryFn: api.getStatus, refetchInterval: 2000 }) 10 | 11 | return ( 12 | <> 13 |

Status

14 | 15 | {status.isLoading ? : null} 16 | {status.error ?

Error: {status.error.toString()}

: null} 17 | {status.data ? : null} 18 | 19 | ) 20 | } 21 | 22 | interface ConnectionStatusDataProps { 23 | status: ApiStatusResponse 24 | } 25 | function ConnectionStatusData({ status }: ConnectionStatusDataProps) { 26 | if (status.companionUnsupportedApi) { 27 | return

Companion {status.companionVersion ?? ''} is not supported

28 | } else if (status.connected) { 29 | return

Connected to Companion {status.companionVersion}

30 | } else { 31 | return

Connecting...

32 | } 33 | } 34 | -------------------------------------------------------------------------------- /webui/src/app/ConnectionTab.tsx: -------------------------------------------------------------------------------- 1 | import { MyErrorBoundary } from '@/Util/ErrorBoundary' 2 | import { ConnectionStatus } from './ConnectionStatus' 3 | import { ConnectionConfig } from './ConnectionConfig' 4 | 5 | export function ConnectionTab(): JSX.Element { 6 | return ( 7 |
8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /webui/src/app/Content.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 2 | import { CardContent, CardHeader } from '@/components/ui/card' 3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' 4 | import { ConnectionTab } from './ConnectionTab' 5 | import { ConnectedSurfacesTab } from './ConnectedSurfacesTab' 6 | import { SurfacePluginsTab } from './SurfacePluginsTab' 7 | 8 | const queryClient = new QueryClient() 9 | 10 | export function AppContent(): JSX.Element { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | Connection 18 | 19 | 20 | Connected Surfaces 21 | 22 | 23 | Surface Plugins 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /webui/src/app/SurfacePluginsTab.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from '@tanstack/react-query' 2 | import { useSatelliteApi } from '@/Api/Context' 3 | import { ApiSurfacePluginInfo, ApiSurfacePluginsEnabled } from '@/Api/types' 4 | import { BarLoader } from 'react-spinners' 5 | import { useForm } from '@tanstack/react-form' 6 | import { Label } from '@/components/ui/label' 7 | import { Switch } from '@/components/ui/switch' 8 | import { Button } from '@/components/ui/button' 9 | import { MyErrorBoundary } from '@/Util/ErrorBoundary' 10 | import { CONNECTED_SURFACES_QUERY_KEY, SURFACE_PLUGINS_ENABLED_QUERY_KEY } from './constants' 11 | 12 | export function SurfacePluginsTab(): JSX.Element { 13 | const api = useSatelliteApi() 14 | const surfacePlugins = useQuery({ 15 | queryKey: ['surfacePlugins'], 16 | queryFn: async () => api.surfacePlugins(), 17 | }) 18 | 19 | const surfacePluginsEnabled = useQuery({ 20 | queryKey: [SURFACE_PLUGINS_ENABLED_QUERY_KEY], 21 | queryFn: async () => api.surfacePluginsEnabled(), 22 | }) 23 | 24 | const loadingError = surfacePlugins.error || surfacePluginsEnabled.error 25 | 26 | return ( 27 |
28 |

Surface Plugins

29 | 30 |

31 | Here you can enable or disable support for the different surface types. 32 |
33 | In the future, we expect to make these be installable plugins. 34 |

35 | 36 | {surfacePlugins.isLoading || surfacePluginsEnabled.isLoading ? ( 37 | 38 | ) : null} 39 | {loadingError ?

Error: {loadingError.message.toString()}

: null} 40 | {surfacePlugins.data && surfacePluginsEnabled.data && ( 41 | 42 | 43 | 44 | )} 45 |
46 | ) 47 | } 48 | 49 | function SurfacePluginsConfig({ 50 | plugins, 51 | config, 52 | }: { 53 | plugins: ApiSurfacePluginInfo[] 54 | config: ApiSurfacePluginsEnabled 55 | }): JSX.Element { 56 | const api = useSatelliteApi() 57 | const queryClient = useQueryClient() 58 | 59 | const form = useForm({ 60 | defaultValues: config, 61 | onSubmit: async ({ value }) => { 62 | // Do something with form data 63 | console.log('saving', value) 64 | 65 | const savedData = await api.surfacePluginsEnabledUpdate(value) 66 | 67 | console.log('new', savedData) 68 | // TODO - this doesn't work 69 | // form.reset(savedData) 70 | await queryClient.invalidateQueries({ queryKey: [CONNECTED_SURFACES_QUERY_KEY] }) 71 | }, 72 | }) 73 | 74 | return ( 75 |
{ 77 | e.preventDefault() 78 | e.stopPropagation() 79 | form.handleSubmit().catch((e) => console.error(e)) 80 | }} 81 | > 82 |
83 | {plugins.map((plugin) => ( 84 | ( 87 | <> 88 | 91 |
92 | field.handleChange(checked)} 98 | /> 99 |
100 | {plugin.pluginComment && ( 101 |
102 |

103 | {plugin.pluginComment.map((line) => ( 104 | <> 105 | {line} 106 |
107 | 108 | ))} 109 |

110 |
111 | )} 112 | 113 | )} 114 | /> 115 | ))} 116 | 117 | [state.canSubmit, state.isSubmitting, state.isDirty]} 119 | children={([canSubmit, isSubmitting, isDirty]) => ( 120 |
121 | 124 |
125 | )} 126 | /> 127 |
128 |
129 | ) 130 | } 131 | -------------------------------------------------------------------------------- /webui/src/app/SurfacesRescan.tsx: -------------------------------------------------------------------------------- 1 | import { useSatelliteApi } from '@/Api/Context' 2 | import { BeatLoader } from 'react-spinners' 3 | import { useState } from 'react' 4 | import { Button } from '@/components/ui/button' 5 | import { useQueryClient } from '@tanstack/react-query' 6 | import { CONNECTED_SURFACES_QUERY_KEY } from './constants' 7 | 8 | export function SurfacesRescan({ className }: { className?: string }): JSX.Element { 9 | const api = useSatelliteApi() 10 | const queryClient = useQueryClient() 11 | 12 | const [running, setRunning] = useState(false) 13 | 14 | const doRescan = () => { 15 | setRunning(true) 16 | 17 | api 18 | .rescanSurfaces() 19 | .then(async () => { 20 | // Sleep to give the rescan time to complete 21 | await new Promise((resolve) => setTimeout(resolve, 1000)) 22 | 23 | await queryClient.invalidateQueries({ queryKey: [CONNECTED_SURFACES_QUERY_KEY] }) 24 | }) 25 | .finally(() => { 26 | setRunning(false) 27 | }) 28 | .catch((e) => { 29 | console.error('rescan failed', e) 30 | }) 31 | } 32 | 33 | return ( 34 | <> 35 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /webui/src/app/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONNECTION_STATUS_QUERY_KEY = 'connection_status' 2 | 3 | export const CONNECTION_CONFIG_QUERY_KEY = 'connection_config' 4 | 5 | export const CONNECTED_SURFACES_QUERY_KEY = 'connectedSurfaces' 6 | 7 | export const SURFACE_PLUGINS_ENABLED_QUERY_KEY = 'surfacePluginsEnabled' 8 | -------------------------------------------------------------------------------- /webui/src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | 4 | import { cn } from '@/lib/utils' 5 | 6 | const alertVariants = cva( 7 | 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'bg-background text-foreground', 12 | destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', 13 | }, 14 | }, 15 | defaultVariants: { 16 | variant: 'default', 17 | }, 18 | }, 19 | ) 20 | 21 | const Alert = React.forwardRef< 22 | HTMLDivElement, 23 | React.HTMLAttributes & VariantProps 24 | >(({ className, variant, ...props }, ref) => ( 25 |
26 | )) 27 | Alert.displayName = 'Alert' 28 | 29 | const AlertTitle = React.forwardRef>( 30 | ({ className, ...props }, ref) => ( 31 |
32 | ), 33 | ) 34 | AlertTitle.displayName = 'AlertTitle' 35 | 36 | const AlertDescription = React.forwardRef>( 37 | ({ className, ...props }, ref) => ( 38 |
39 | ), 40 | ) 41 | AlertDescription.displayName = 'AlertDescription' 42 | 43 | export { Alert, AlertTitle, AlertDescription } 44 | -------------------------------------------------------------------------------- /webui/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | 5 | import { cn } from '@/lib/utils' 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 13 | destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 14 | outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 15 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 16 | ghost: 'hover:bg-accent hover:text-accent-foreground', 17 | link: 'text-primary underline-offset-4 hover:underline', 18 | }, 19 | size: { 20 | default: 'h-9 px-4 py-2', 21 | sm: 'h-8 rounded-md px-3 text-xs', 22 | lg: 'h-10 rounded-md px-8', 23 | icon: 'h-9 w-9', 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: 'default', 28 | size: 'default', 29 | }, 30 | }, 31 | ) 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps { 36 | asChild?: boolean 37 | } 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, asChild = false, ...props }, ref) => { 41 | const Comp = asChild ? Slot : 'button' 42 | return 43 | }, 44 | ) 45 | Button.displayName = 'Button' 46 | 47 | // eslint-disable-next-line react-refresh/only-export-components 48 | export { Button, buttonVariants } 49 | -------------------------------------------------------------------------------- /webui/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Card = React.forwardRef>(({ className, ...props }, ref) => ( 6 |
7 | )) 8 | Card.displayName = 'Card' 9 | 10 | const CardHeader = React.forwardRef>( 11 | ({ className, ...props }, ref) => ( 12 |
13 | ), 14 | ) 15 | CardHeader.displayName = 'CardHeader' 16 | 17 | const CardTitle = React.forwardRef>( 18 | ({ className, ...props }, ref) => ( 19 |
20 | ), 21 | ) 22 | CardTitle.displayName = 'CardTitle' 23 | 24 | const CardDescription = React.forwardRef>( 25 | ({ className, ...props }, ref) => ( 26 |
27 | ), 28 | ) 29 | CardDescription.displayName = 'CardDescription' 30 | 31 | const CardContent = React.forwardRef>( 32 | ({ className, ...props }, ref) =>
, 33 | ) 34 | CardContent.displayName = 'CardContent' 35 | 36 | const CardFooter = React.forwardRef>( 37 | ({ className, ...props }, ref) => ( 38 |
39 | ), 40 | ) 41 | CardFooter.displayName = 'CardFooter' 42 | 43 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 44 | -------------------------------------------------------------------------------- /webui/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | }, 19 | ) 20 | Input.displayName = 'Input' 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /webui/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as LabelPrimitive from '@radix-ui/react-label' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | 5 | import { cn } from '@/lib/utils' 6 | 7 | const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70') 8 | 9 | const Label = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef & VariantProps 12 | >(({ className, ...props }, ref) => ( 13 | 14 | )) 15 | Label.displayName = LabelPrimitive.Root.displayName 16 | 17 | export { Label } 18 | -------------------------------------------------------------------------------- /webui/src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as SelectPrimitive from '@radix-ui/react-select' 3 | import { Check, ChevronDown, ChevronUp } from 'lucide-react' 4 | 5 | import { cn } from '@/lib/utils' 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | span]:line-clamp-1', 21 | className, 22 | )} 23 | {...props} 24 | > 25 | {children} 26 | 27 | 28 | 29 | 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectScrollUpButton = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 42 | 43 | 44 | )) 45 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 46 | 47 | const SelectScrollDownButton = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, ...props }, ref) => ( 51 | 56 | 57 | 58 | )) 59 | SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName 60 | 61 | const SelectContent = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, children, position = 'popper', ...props }, ref) => ( 65 | 66 | 77 | 78 | 85 | {children} 86 | 87 | 88 | 89 | 90 | )) 91 | SelectContent.displayName = SelectPrimitive.Content.displayName 92 | 93 | const SelectLabel = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, ...props }, ref) => ( 97 | 98 | )) 99 | SelectLabel.displayName = SelectPrimitive.Label.displayName 100 | 101 | const SelectItem = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, children, ...props }, ref) => ( 105 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | )) 121 | SelectItem.displayName = SelectPrimitive.Item.displayName 122 | 123 | const SelectSeparator = React.forwardRef< 124 | React.ElementRef, 125 | React.ComponentPropsWithoutRef 126 | >(({ className, ...props }, ref) => ( 127 | 128 | )) 129 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 130 | 131 | export { 132 | Select, 133 | SelectGroup, 134 | SelectValue, 135 | SelectTrigger, 136 | SelectContent, 137 | SelectLabel, 138 | SelectItem, 139 | SelectSeparator, 140 | SelectScrollUpButton, 141 | SelectScrollDownButton, 142 | } 143 | -------------------------------------------------------------------------------- /webui/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as SwitchPrimitives from '@radix-ui/react-switch' 3 | 4 | import { cn } from '@/lib/utils' 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )) 25 | Switch.displayName = SwitchPrimitives.Root.displayName 26 | 27 | export { Switch } 28 | -------------------------------------------------------------------------------- /webui/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Table = React.forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 |
8 | 9 | 10 | ), 11 | ) 12 | Table.displayName = 'Table' 13 | 14 | const TableHeader = React.forwardRef>( 15 | ({ className, ...props }, ref) => , 16 | ) 17 | TableHeader.displayName = 'TableHeader' 18 | 19 | const TableBody = React.forwardRef>( 20 | ({ className, ...props }, ref) => ( 21 | 22 | ), 23 | ) 24 | TableBody.displayName = 'TableBody' 25 | 26 | const TableFooter = React.forwardRef>( 27 | ({ className, ...props }, ref) => ( 28 | tr]:last:border-b-0', className)} {...props} /> 29 | ), 30 | ) 31 | TableFooter.displayName = 'TableFooter' 32 | 33 | const TableRow = React.forwardRef>( 34 | ({ className, ...props }, ref) => ( 35 | 40 | ), 41 | ) 42 | TableRow.displayName = 'TableRow' 43 | 44 | const TableHead = React.forwardRef>( 45 | ({ className, ...props }, ref) => ( 46 |
[role=checkbox]]:translate-y-[2px]', 50 | className, 51 | )} 52 | {...props} 53 | /> 54 | ), 55 | ) 56 | TableHead.displayName = 'TableHead' 57 | 58 | const TableCell = React.forwardRef>( 59 | ({ className, ...props }, ref) => ( 60 | [role=checkbox]]:translate-y-[2px]', className)} 63 | {...props} 64 | /> 65 | ), 66 | ) 67 | TableCell.displayName = 'TableCell' 68 | 69 | const TableCaption = React.forwardRef>( 70 | ({ className, ...props }, ref) => ( 71 |
72 | ), 73 | ) 74 | TableCaption.displayName = 'TableCaption' 75 | 76 | export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } 77 | -------------------------------------------------------------------------------- /webui/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as TabsPrimitive from '@radix-ui/react-tabs' 3 | 4 | import { cn } from '@/lib/utils' 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /webui/src/electron.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { SatelliteApiProvider } from './Api/Context.tsx' 4 | import { AppContent } from './app/Content.tsx' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | 11 | , 12 | ) 13 | -------------------------------------------------------------------------------- /webui/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]): string { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /webui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { App } from './app/App.tsx' 4 | import { SatelliteApiProvider } from './Api/Context.tsx' 5 | import { SatelliteRestApi } from './Api/rest.ts' 6 | 7 | ReactDOM.createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | 11 | 12 | , 13 | ) 14 | -------------------------------------------------------------------------------- /webui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const electronApi: typeof import('../../satellite/dist/electronPreload.cjs').electronApi 4 | -------------------------------------------------------------------------------- /webui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | /** @type {import('tailwindcss').Config} */ 3 | export default { 4 | darkMode: ['class'], 5 | content: ['./index.html', './electron.html', './src/**/*.{ts,tsx,js,jsx}'], 6 | theme: { 7 | extend: { 8 | borderRadius: { 9 | lg: 'var(--radius)', 10 | md: 'calc(var(--radius) - 2px)', 11 | sm: 'calc(var(--radius) - 4px)', 12 | }, 13 | colors: { 14 | background: 'hsl(var(--background))', 15 | foreground: 'hsl(var(--foreground))', 16 | card: { 17 | DEFAULT: 'hsl(var(--card))', 18 | foreground: 'hsl(var(--card-foreground))', 19 | }, 20 | popover: { 21 | DEFAULT: 'hsl(var(--popover))', 22 | foreground: 'hsl(var(--popover-foreground))', 23 | }, 24 | primary: { 25 | DEFAULT: 'hsl(var(--primary))', 26 | foreground: 'hsl(var(--primary-foreground))', 27 | }, 28 | secondary: { 29 | DEFAULT: 'hsl(var(--secondary))', 30 | foreground: 'hsl(var(--secondary-foreground))', 31 | }, 32 | muted: { 33 | DEFAULT: 'hsl(var(--muted))', 34 | foreground: 'hsl(var(--muted-foreground))', 35 | }, 36 | accent: { 37 | DEFAULT: 'hsl(var(--accent))', 38 | foreground: 'hsl(var(--accent-foreground))', 39 | }, 40 | destructive: { 41 | DEFAULT: 'hsl(var(--destructive))', 42 | foreground: 'hsl(var(--destructive-foreground))', 43 | }, 44 | border: 'hsl(var(--border))', 45 | input: 'hsl(var(--input))', 46 | ring: 'hsl(var(--ring))', 47 | chart: { 48 | 1: 'hsl(var(--chart-1))', 49 | 2: 'hsl(var(--chart-2))', 50 | 3: 'hsl(var(--chart-3))', 51 | 4: 'hsl(var(--chart-4))', 52 | 5: 'hsl(var(--chart-5))', 53 | }, 54 | }, 55 | }, 56 | }, 57 | plugins: [require('tailwindcss-animate')], 58 | } 59 | -------------------------------------------------------------------------------- /webui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | 14 | /* Bundler mode */ 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | 22 | /* Linting */ 23 | "strict": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noFallthroughCasesInSwitch": true 27 | }, 28 | "include": ["src"], 29 | "references": [{ "path": "./tsconfig.node.json" }] 30 | } 31 | -------------------------------------------------------------------------------- /webui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | }, 14 | "include": ["vite.config.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /webui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import { resolve } from 'path' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | base: '', // Fix electron file paths 8 | 9 | plugins: [react()], 10 | server: { 11 | proxy: { 12 | '/api': 'http://localhost:9999', 13 | }, 14 | }, 15 | build: { 16 | rollupOptions: { 17 | input: { 18 | main: resolve(__dirname, 'index.html'), 19 | electron: resolve(__dirname, 'electron.html'), 20 | about: resolve(__dirname, 'about.html'), 21 | // preload: resolve(__dirname, 'preload.ts'), 22 | }, 23 | }, 24 | }, 25 | css: { 26 | preprocessorOptions: { 27 | scss: { 28 | api: 'modern-compiler', 29 | quietDeps: true, 30 | }, 31 | }, 32 | }, 33 | resolve: { 34 | alias: { 35 | '@': resolve(__dirname, './src'), 36 | }, 37 | }, 38 | }) 39 | --------------------------------------------------------------------------------