├── .gitignore ├── common-types ├── .gitignore ├── package.json ├── package-lock.json ├── tsconfig.json ├── device.ts └── events.ts ├── firmware └── teensy2 │ ├── build │ ├── obj │ │ └── .gitkeep │ └── makefile │ ├── Reset.h │ ├── ADC.h │ ├── .gitignore │ ├── .editorconfig │ ├── Config │ ├── DancePadConfig.h │ └── LUFAConfig.h │ ├── ConfigStore.h │ ├── Communication.c │ ├── Reset.c │ ├── Pad.h │ ├── Communication.h │ ├── ADC.c │ ├── ConfigStore.c │ ├── AnalogDancePad.h │ ├── Pad.c │ ├── Descriptors.h │ ├── AnalogDancePad.c │ └── Descriptors.c ├── server ├── .gitignore ├── .editorconfig ├── .prettierrc ├── src │ ├── util │ │ ├── delay.ts │ │ └── ExtendableStrictEmitter.ts │ ├── types │ │ └── usb-detection.d.ts │ ├── driver │ │ ├── Driver.ts │ │ ├── Device.ts │ │ └── teensy2 │ │ │ ├── util │ │ │ └── Teensy2Reset.ts │ │ │ ├── Teensy2Reports.ts │ │ │ └── Teensy2DeviceDriver.ts │ ├── index.ts │ └── server.ts ├── .eslintrc ├── tsconfig.json └── package.json ├── client ├── src │ ├── react-app-env.d.ts │ ├── views │ │ ├── DeviceView │ │ │ ├── index.tsx │ │ │ ├── deviceConfiguration │ │ │ │ ├── DeviceConfigurationMenu.tsx │ │ │ │ ├── SensorLabel.tsx │ │ │ │ └── ConfigurationForm.tsx │ │ │ ├── calibration │ │ │ │ ├── CalibrationButton.tsx │ │ │ │ ├── CalibrationSlider.tsx │ │ │ │ └── Calibration.tsx │ │ │ ├── DeviceView.tsx │ │ │ ├── deviceButtons │ │ │ │ ├── DeviceButtons.tsx │ │ │ │ ├── Button.tsx │ │ │ │ └── Sensor.tsx │ │ │ └── Device.tsx │ │ └── LandingView.tsx │ ├── utils │ │ ├── scale.ts │ │ ├── toPercentage.ts │ │ ├── useFreeze.ts │ │ ├── usePreventiOSDrag.tsx │ │ ├── colors.ts │ │ ├── SubscriptionManager.tsx │ │ ├── useSensorValueSpring.tsx │ │ └── ServerConnection.tsx │ ├── components │ │ ├── topBar │ │ │ ├── TopBarSubtitle.tsx │ │ │ ├── TopBarTitle.tsx │ │ │ ├── TopBarButton.tsx │ │ │ └── TopBar.tsx │ │ ├── menu │ │ │ ├── MenuHeader.tsx │ │ │ └── Menu.tsx │ │ ├── Typography.tsx │ │ ├── IconButton.tsx │ │ ├── IconAndTextPage.tsx │ │ ├── mainMenu │ │ │ ├── MainMenu.tsx │ │ │ └── MenuServer.tsx │ │ └── Range.tsx │ ├── App.test.tsx │ ├── stores │ │ ├── useMainMenuStore.ts │ │ └── useServerStore.ts │ ├── index.tsx │ ├── domain │ │ └── Button.ts │ ├── config.ts │ ├── App.tsx │ └── serviceWorker.ts ├── public │ ├── robots.txt │ ├── favicon.png │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── .editorconfig ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── README.md └── package.json ├── .gitmodules ├── .vscode └── settings.json ├── .travis.yml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /common-types/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /firmware/teensy2/build/obj/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.tsbuildinfo -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/views/DeviceView/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './DeviceView' 2 | -------------------------------------------------------------------------------- /server/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | indent_size = 2 -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "printWidth": 100 5 | } -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /client/src/utils/scale.ts: -------------------------------------------------------------------------------- 1 | const scale = (n: number) => `${n * 8}px` 2 | 3 | export default scale 4 | -------------------------------------------------------------------------------- /client/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kauhsa/analog-dance-pad/HEAD/client/public/favicon.png -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kauhsa/analog-dance-pad/HEAD/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kauhsa/analog-dance-pad/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /firmware/teensy2/Reset.h: -------------------------------------------------------------------------------- 1 | #ifndef _RESET_H_ 2 | #define _RESET_H_ 3 | void Reset_JumpToBootloader(void); 4 | #endif -------------------------------------------------------------------------------- /client/src/utils/toPercentage.ts: -------------------------------------------------------------------------------- 1 | const toPercentage = (n: number) => n * 100 + '%' 2 | 3 | export default toPercentage 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "firmware/teensy2/lufa"] 2 | path = firmware/teensy2/lufa 3 | url = https://github.com/abcminiuser/lufa 4 | -------------------------------------------------------------------------------- /server/src/util/delay.ts: -------------------------------------------------------------------------------- 1 | const delay = (ms: number): Promise => new Promise(resolve => setTimeout(() => resolve(), ms)) 2 | 3 | export default delay 4 | -------------------------------------------------------------------------------- /firmware/teensy2/ADC.h: -------------------------------------------------------------------------------- 1 | #ifndef _ADC_H_ 2 | #define _ADC_H_ 3 | #include 4 | 5 | void ADC_Init(void); 6 | uint16_t ADC_Read(uint8_t channel); 7 | #endif 8 | -------------------------------------------------------------------------------- /firmware/teensy2/.gitignore: -------------------------------------------------------------------------------- 1 | *.o 2 | *.d 3 | *.elf 4 | *.hex 5 | *.eep 6 | *.sym 7 | *.bin 8 | *.lss 9 | *.map 10 | *.bak 11 | *.class 12 | build/**/!.gitkeep 13 | build/!makefile -------------------------------------------------------------------------------- /server/src/types/usb-detection.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'usb-detection' { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | const usbDetection: any 4 | export = usbDetection 5 | } 6 | -------------------------------------------------------------------------------- /firmware/teensy2/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | 5 | [Makefile] 6 | indent_style = tab 7 | 8 | [*.{c,h}] 9 | indent_style = space 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /client/src/components/topBar/TopBarSubtitle.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const TopBarSubtitle = styled.span` 4 | display: block; 5 | font-size: 10px; 6 | opacity: 0.7; 7 | ` 8 | 9 | export default TopBarSubtitle 10 | -------------------------------------------------------------------------------- /client/src/components/topBar/TopBarTitle.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { largeText } from '../Typography' 3 | 4 | const TopBarTitle = styled.h1` 5 | ${largeText}; 6 | flex-grow: 1; 7 | line-height: 1.3; 8 | ` 9 | 10 | export default TopBarTitle 11 | -------------------------------------------------------------------------------- /common-types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common-types", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "tsc -b" 7 | }, 8 | "author": "", 9 | "license": "ISC", 10 | "dependencies": { 11 | "typescript": "^3.6.4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/src/util/ExtendableStrictEmitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import StrictEventEmitter from 'strict-event-emitter-types/types/src' 3 | 4 | export const ExtendableEmitter = () => 5 | EventEmitter as { 6 | new (): StrictEventEmitter 7 | } 8 | -------------------------------------------------------------------------------- /client/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | ReactDOM.unmountComponentAtNode(div) 9 | }) 10 | -------------------------------------------------------------------------------- /client/src/components/menu/MenuHeader.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | import { largeText } from '../Typography' 4 | import scale from '../../utils/scale' 5 | 6 | const MenuHeader = styled.h1` 7 | ${largeText}; 8 | margin: ${scale(2)} ${scale(2)} 0 ${scale(2)}; 9 | ` 10 | 11 | export default MenuHeader 12 | -------------------------------------------------------------------------------- /client/src/utils/useFreeze.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const useFreeze = (node: React.ReactNode, frozen: boolean) => { 4 | const renderedNode = React.useRef(node) 5 | 6 | if (!frozen) { 7 | renderedNode.current = node 8 | } 9 | 10 | return renderedNode.current 11 | } 12 | 13 | export default useFreeze 14 | -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": [ 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "prettier/@typescript-eslint", 7 | "plugin:prettier/recommended", 8 | ], 9 | "parserOptions": { 10 | "ecmaVersion": 2018, 11 | "sourceType": "module", 12 | } 13 | } -------------------------------------------------------------------------------- /client/src/components/topBar/TopBarButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import IconButton from '../IconButton' 3 | import scale from '../../utils/scale' 4 | 5 | const TopBarButton = styled(IconButton).attrs({ size: scale(2.75) })` 6 | flex-shrink: 1; 7 | cursor: pointer; 8 | padding: ${scale(2)}; 9 | ` 10 | 11 | export default TopBarButton 12 | -------------------------------------------------------------------------------- /client/src/views/LandingView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconAndTextPage from '../components/IconAndTextPage' 3 | import { faPooStorm } from '@fortawesome/free-solid-svg-icons' 4 | 5 | const LandingView = () => ( 6 | 7 | Select a device from menu! 8 | 9 | ) 10 | 11 | export default LandingView 12 | -------------------------------------------------------------------------------- /server/src/driver/Driver.ts: -------------------------------------------------------------------------------- 1 | import StrictEventEmitter from 'strict-event-emitter-types' 2 | import { EventEmitter } from 'events' 3 | import { Device } from './Device' 4 | 5 | export interface DeviceDriverEvents { 6 | newDevice: Device 7 | } 8 | 9 | export interface DeviceDriver extends StrictEventEmitter { 10 | start: () => void 11 | close: () => void 12 | } 13 | -------------------------------------------------------------------------------- /client/src/stores/useMainMenuStore.ts: -------------------------------------------------------------------------------- 1 | import create from 'zustand' 2 | 3 | interface State { 4 | isMenuOpen: boolean 5 | openMenu: () => void 6 | closeMenu: () => void 7 | } 8 | 9 | const [useMainMenuStore] = create(set => ({ 10 | isMenuOpen: false, 11 | openMenu: () => set({ isMenuOpen: true }), 12 | closeMenu: () => set({ isMenuOpen: false }) 13 | })) 14 | 15 | export default useMainMenuStore 16 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /common-types/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "common-types", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "typescript": { 8 | "version": "3.6.4", 9 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz", 10 | "integrity": "sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /common-types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDeclarationOnly": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "lib": ["es2018"], 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "outDir": "./dist", 12 | "strict": true, 13 | "target": "es2018", 14 | }, 15 | "include": [ 16 | "*.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Pads", 3 | "name": "Pad Configuration", 4 | "icons": [ 5 | { 6 | "src": "logo192.png", 7 | "type": "image/png", 8 | "sizes": "192x192" 9 | }, 10 | { 11 | "src": "logo512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#041623", 19 | "background_color": "#041623" 20 | } -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2018", 5 | "module": "commonjs", 6 | "lib": [ 7 | "es2018" 8 | ], 9 | "outDir": "./dist", 10 | "strict": true, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true, 13 | "skipLibCheck": true 14 | }, 15 | "include": [ 16 | "src/" 17 | ], 18 | "references": [ 19 | { 20 | "path": "../common-types" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /client/src/components/Typography.tsx: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components' 2 | import { colors } from '../utils/colors' 3 | 4 | export const smallText = css` 5 | font-size: 13px; 6 | font-weight: 400; 7 | color: ${colors.text}; 8 | ` 9 | 10 | export const basicText = css` 11 | font-size: 14px; 12 | font-weight: 400; 13 | color: ${colors.text}; 14 | ` 15 | 16 | export const largeText = css` 17 | font-size: 16px; 18 | font-weight: 600; 19 | color: ${colors.text}; 20 | ` 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "directory": "./server", 5 | "changeProcessCWD": true 6 | }, 7 | { 8 | "directory": "./client", 9 | "changeProcessCWD": true 10 | } 11 | ], 12 | "eslint.validate": [ 13 | { 14 | "language": "typescript", 15 | "autoFix": true 16 | }, 17 | { 18 | "language": "typescriptreact", 19 | "autoFix": true 20 | } 21 | ], 22 | "C_Cpp.default.cStandard": "c11", 23 | "files.associations": { 24 | "typeinfo": "c" 25 | } 26 | } -------------------------------------------------------------------------------- /firmware/teensy2/Config/DancePadConfig.h: -------------------------------------------------------------------------------- 1 | #ifndef _DANCE_PAD_CONFIG_H_ 2 | #define _DANCE_PAD_CONFIG_H_ 3 | // this value doesn't mean we're actively using all these buttons. 4 | // it's just what we report and is technically possible to use. 5 | // for now, should be divisible by 8. 6 | #define BUTTON_COUNT 16 7 | 8 | // this value doesn't mean we're reading all these sensors. 9 | // teensy 2.0 has 12 analog sensors, so that's what we use. 10 | #define SENSOR_COUNT 12 11 | 12 | // don't actually use ACD values that are read. 13 | #define ADC_TEST_MODE 0 14 | #endif 15 | -------------------------------------------------------------------------------- /firmware/teensy2/ConfigStore.h: -------------------------------------------------------------------------------- 1 | #ifndef _CONFIGSTORE_H_ 2 | #define _CONFIGSTORE_H_ 3 | #include "Pad.h" 4 | 5 | #define MAX_NAME_SIZE 50 6 | 7 | typedef struct { 8 | uint8_t size; 9 | char name[MAX_NAME_SIZE]; 10 | } __attribute__((packed)) NameAndSize; 11 | 12 | typedef struct { 13 | PadConfiguration padConfiguration; 14 | NameAndSize nameAndSize; 15 | } __attribute__((packed)) Configuration; 16 | 17 | void ConfigStore_LoadConfiguration(Configuration* conf); 18 | void ConfigStore_StoreConfiguration(const Configuration* conf); 19 | #endif 20 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | import * as serviceWorker from './serviceWorker' 6 | import config from './config' 7 | 8 | // Why on earth? See README for explanation. 9 | if (config.forceNonSecure && document.location.protocol === 'https:') { 10 | document.location.protocol = 'http:' 11 | } else { 12 | ReactDOM.render(, document.getElementById('root')) 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister() 18 | } 19 | -------------------------------------------------------------------------------- /firmware/teensy2/Communication.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "Config/DancePadConfig.h" 4 | #include "Communication.h" 5 | #include "Pad.h" 6 | 7 | void Communication_WriteInputHIDReport(InputHIDReport* report) { 8 | // first, update pad state 9 | Pad_UpdateState(); 10 | 11 | // write buttons to the report 12 | for (int i = 0; i < BUTTON_COUNT; i++) { 13 | // trol https://stackoverflow.com/a/47990 14 | report->buttons[i / 8] ^= (-PAD_STATE.buttonsPressed[i] ^ report->buttons[i / 8]) & (1UL << i % 8); 15 | } 16 | 17 | // write sensor values to the report 18 | for (int i = 0; i < SENSOR_COUNT; i++) { 19 | report->sensorValues[i] = PAD_STATE.sensorValues[i]; 20 | } 21 | } -------------------------------------------------------------------------------- /server/src/driver/Device.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeviceConfiguration, 3 | DeviceProperties, 4 | DeviceInputData 5 | } from '../../../common-types/device' 6 | 7 | import StrictEventEmitter from 'strict-event-emitter-types' 8 | import { EventEmitter } from 'events' 9 | 10 | export interface DeviceEvents { 11 | inputData: DeviceInputData 12 | eventRate: number 13 | disconnect: void 14 | } 15 | 16 | export interface Device extends StrictEventEmitter { 17 | id: string 18 | properties: DeviceProperties 19 | configuration: DeviceConfiguration 20 | updateConfiguration: (conf: Partial) => Promise 21 | saveConfiguration: () => Promise 22 | close: () => void 23 | } 24 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "downlevelIteration": true, 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react" 22 | }, 23 | "include": [ 24 | "src" 25 | ], 26 | "references": [ 27 | { 28 | "path": "../common-types" 29 | } 30 | ] 31 | } -------------------------------------------------------------------------------- /common-types/device.ts: -------------------------------------------------------------------------------- 1 | // this is information that user is excepted to reconfigure 2 | export interface DeviceConfiguration { 3 | name: string 4 | sensorThresholds: number[] 5 | releaseThreshold: number 6 | sensorToButtonMapping: number[] 7 | } 8 | 9 | // this is information from device that cannot be changed 10 | export interface DeviceProperties { 11 | buttonCount: number 12 | sensorCount: number 13 | } 14 | 15 | export interface DeviceDescription { 16 | id: string 17 | configuration: DeviceConfiguration 18 | properties: DeviceProperties 19 | } 20 | 21 | export interface DeviceInputData { 22 | sensors: number[] 23 | buttons: boolean[] 24 | } 25 | 26 | export type DeviceDescriptionMap = { [deviceId: string]: DeviceDescription } -------------------------------------------------------------------------------- /firmware/teensy2/Reset.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include "Reset.h" 5 | 6 | void Reset_JumpToBootloader(void) { 7 | // see https://www.pjrc.com/teensy/jump_to_bootloader.html 8 | 9 | cli(); 10 | // disable watchdog, if enabled 11 | // disable all peripherals 12 | UDCON = 1; 13 | USBCON = (1< 4 | #include 5 | #include "Config/DancePadConfig.h" 6 | 7 | typedef struct { 8 | uint16_t sensorThresholds[SENSOR_COUNT]; 9 | float releaseMultiplier; 10 | int8_t sensorToButtonMapping[SENSOR_COUNT]; 11 | } __attribute__((packed)) PadConfiguration; 12 | 13 | typedef struct { 14 | uint16_t sensorValues[SENSOR_COUNT]; 15 | bool buttonsPressed[BUTTON_COUNT]; 16 | } PadState; 17 | 18 | void Pad_Initialize(const PadConfiguration* padConfiguration); 19 | void Pad_UpdateState(void); 20 | void Pad_UpdateConfiguration(const PadConfiguration* padConfiguration); 21 | 22 | extern PadConfiguration PAD_CONF; 23 | extern PadState PAD_STATE; 24 | #endif 25 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:react/recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier/@typescript-eslint', 7 | 'plugin:prettier/recommended' 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 2018, 11 | sourceType: 'module', 12 | ecmaFeatures: { 13 | jsx: true 14 | } 15 | }, 16 | plugins: ['react', 'prettier', 'react-hooks'], 17 | rules: { 18 | '@typescript-eslint/explicit-function-return-type': 0, 19 | '@typescript-eslint/no-use-before-define': 0, 20 | 'react/prop-types': 0, 21 | 'react/display-name': 0, 22 | 'react-hooks/rules-of-hooks': 1, 23 | 'react-hooks/exhaustive-deps': 1 24 | }, 25 | settings: { 26 | react: { 27 | version: 'detect' 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/utils/usePreventiOSDrag.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, RefObject } from 'react' 2 | 3 | const preventDefault = (e: Event) => e.preventDefault() 4 | 5 | // Mobile safari overscroll is pretty persistent. ugly solution, but I 6 | // couldn't find better one. We don't want to apply this globally because 7 | // it prevents useful scroll effects as well, but rather surgically to where 8 | // it's needed! 9 | export const usePreventMobileSafariDrag = (ref: RefObject) => { 10 | useLayoutEffect(() => { 11 | if (!ref.current) { 12 | return 13 | } 14 | 15 | const element = ref.current 16 | 17 | element.addEventListener('touchmove', preventDefault, { 18 | passive: false 19 | }) 20 | 21 | return () => { 22 | element.removeEventListener('touchmove', preventDefault) 23 | } 24 | }, [ref]) 25 | } 26 | -------------------------------------------------------------------------------- /server/src/driver/teensy2/util/Teensy2Reset.ts: -------------------------------------------------------------------------------- 1 | import * as HID from 'node-hid' 2 | import { PRODUCT_ID, VENDOR_ID } from '../Teensy2DeviceDriver' 3 | import { ReportID } from '../Teensy2Reports' 4 | 5 | console.log('Setting Teensy devices to program mode...') 6 | 7 | HID.devices().forEach(device => { 8 | // only known devices 9 | if (device.productId !== PRODUCT_ID || device.vendorId !== VENDOR_ID) { 10 | return 11 | } 12 | 13 | if (device.path) { 14 | console.log('Trying to reset', device.path) 15 | const hidDevice = new HID.HID(device.path) 16 | try { 17 | hidDevice.write([ReportID.RESET, 0x00]) 18 | } catch (e) { 19 | // error is expected because the device will boot upon write above, and 20 | // it probably happens too fast to node-hid's liking. 21 | } 22 | } 23 | }) 24 | 25 | console.log('Done!') 26 | -------------------------------------------------------------------------------- /client/src/components/topBar/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import scale from '../../utils/scale' 4 | import { faBars } from '@fortawesome/free-solid-svg-icons' 5 | import { colors } from '../../utils/colors' 6 | import TopBarButton from './TopBarButton' 7 | import useMainMenuStore from '../../stores/useMainMenuStore' 8 | 9 | interface Props { 10 | children?: React.ReactNode 11 | } 12 | 13 | const Container = styled.div` 14 | height: ${scale(7)}; 15 | display: flex; 16 | align-items: center; 17 | color: ${colors.text}; 18 | ` 19 | 20 | const TopBar = React.memo(({ children }) => { 21 | const openMenu = useMainMenuStore(store => store.openMenu) 22 | 23 | return ( 24 | 25 | 26 | {children} 27 | 28 | ) 29 | }) 30 | 31 | export default TopBar 32 | -------------------------------------------------------------------------------- /client/src/views/DeviceView/deviceConfiguration/DeviceConfigurationMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Menu from '../../../components/menu/Menu' 3 | import MenuHeader from '../../../components/menu/MenuHeader' 4 | import ConfigurationForm from './ConfigurationForm' 5 | import { 6 | DeviceDescription, 7 | DeviceConfiguration 8 | } from '../../../../../common-types/device' 9 | 10 | interface Props { 11 | serverAddress: string 12 | device: DeviceDescription 13 | isOpen: boolean 14 | onSave: (configuration: Partial) => void 15 | onClose: () => void 16 | } 17 | 18 | const DeviceConfigurationMenu = React.memo( 19 | ({ device, onClose, onSave, isOpen, serverAddress }) => ( 20 | 21 | Configuration 22 | 27 | 28 | ) 29 | ) 30 | 31 | export default DeviceConfigurationMenu 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | matrix: 2 | include: 3 | - name: Client 4 | language: node_js 5 | node_js: 12 6 | before_script: 7 | - (cd common-types && npm i && npm run build) 8 | - cd client 9 | - npm i 10 | script: 11 | - npm run lint 12 | - npm run build 13 | 14 | - name: Server 15 | language: node_js 16 | node_js: 12 17 | before_script: 18 | - (cd common-types && npm i && npm run build) 19 | - cd server 20 | - npm i 21 | script: 22 | - npm run lint 23 | - npm run build 24 | addons: 25 | apt: 26 | packages: 27 | - libudev-dev # building usb-detection needs this. 28 | 29 | - name: Teensy 2 firmware 30 | language: c 31 | dist: bionic # to not have ancient avr-gcc. 32 | before_script: 33 | - cd firmware/teensy2/build 34 | script: 35 | - make 36 | addons: 37 | apt: 38 | packages: 39 | - gcc-avr 40 | - binutils-avr 41 | - avr-libc -------------------------------------------------------------------------------- /client/src/utils/colors.ts: -------------------------------------------------------------------------------- 1 | import chroma from 'chroma-js' 2 | 3 | export const colorValues = { 4 | darkBlue: '#041623', 5 | blue: '#2364AA', 6 | lightBlue: '#1791EA', 7 | lighterBlue: '#46C7FE', 8 | yellow: '#FEE501', 9 | white: '#FFFFFF' 10 | } 11 | 12 | // TODO: nuke this and just use colorValues. 13 | export const colors = { 14 | background: colorValues.darkBlue, 15 | menuBackground: chroma(colorValues.darkBlue) 16 | .brighten(0.3) 17 | .css(), 18 | menuBackdrop: 'rgba(0, 0, 0, 0.5)', 19 | menuItem: chroma(colorValues.darkBlue) 20 | .brighten(0.5) 21 | .css(), 22 | buttonBottomColor: colorValues.blue, 23 | buttonTopColor: colorValues.lighterBlue, 24 | pressedButtonBottomColor: chroma(colorValues.blue) 25 | .brighten(0.5) 26 | .css(), 27 | pressedBottomTopColor: chroma(colorValues.lighterBlue) 28 | .brighten(0.5) 29 | .css(), 30 | sensorBarColor: colorValues.yellow, 31 | thresholdBar: 'rgba(0, 0, 0, 0.15)', 32 | overThresholdBar: chroma(colorValues.yellow) 33 | .brighten(0.5) 34 | .css(), 35 | text: 'white' 36 | } 37 | -------------------------------------------------------------------------------- /client/src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { IconLookup } from '@fortawesome/fontawesome-svg-core' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import styled from 'styled-components' 5 | 6 | interface Props { 7 | icon: IconLookup 8 | size?: string 9 | color?: string 10 | onClick?: () => void 11 | className?: string 12 | } 13 | 14 | const Button = styled.button<{ size: string; color: string }>` 15 | border: none; 16 | outline: none; 17 | color: ${props => props.color}; 18 | font-size: ${props => props.size}; 19 | line-height: 1; 20 | padding: 0; 21 | transform: scale(1); 22 | transition: 100ms transform; 23 | 24 | &:active { 25 | transform: scale(0.8); 26 | } 27 | ` 28 | 29 | const IconButton = React.memo( 30 | ({ icon, size, color, onClick, className }) => ( 31 | 39 | ) 40 | ) 41 | 42 | export default IconButton 43 | -------------------------------------------------------------------------------- /firmware/teensy2/Communication.h: -------------------------------------------------------------------------------- 1 | #ifndef _COMMUNICATION_H_ 2 | #define _COMMUNICATION_H_ 3 | 4 | #include 5 | #include "Config/DancePadConfig.h" 6 | #include "Pad.h" 7 | #include "Communication.h" 8 | #include "ConfigStore.h" 9 | 10 | // small helper macro to do x / y, but rounded up instead of floored. 11 | #define CEILING(x,y) (((x) + (y) - 1) / (y)) 12 | 13 | // 14 | // INPUT REPORTS 15 | // ie. from microcontroller to computer 16 | // 17 | 18 | typedef struct { 19 | uint8_t buttons[CEILING(BUTTON_COUNT, 8)]; 20 | uint16_t sensorValues[SENSOR_COUNT]; 21 | } __attribute__((packed)) InputHIDReport; 22 | 23 | // 24 | // FEATURE REPORTS 25 | // ie. can be requested by computer and written by computer 26 | // 27 | 28 | typedef struct { 29 | PadConfiguration configuration; 30 | } __attribute__((packed)) PadConfigurationFeatureHIDReport; 31 | 32 | typedef struct { 33 | NameAndSize nameAndSize; 34 | } __attribute__((packed)) NameFeatureHIDReport; 35 | 36 | void Communication_WriteInputHIDReport(InputHIDReport* report); 37 | #endif 38 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Pad Configuration 12 | 13 | 14 | 15 | 16 |
17 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /client/src/components/IconAndTextPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { IconProp } from '@fortawesome/fontawesome-svg-core' 5 | 6 | import scale from '../utils/scale' 7 | import TopBar from './topBar/TopBar' 8 | 9 | interface Props { 10 | icon: IconProp 11 | children?: React.ReactNode 12 | } 13 | 14 | const Container = styled.div` 15 | display: flex; 16 | color: white; 17 | align-items: center; 18 | justify-content: center; 19 | text-align: center; 20 | height: 100%; 21 | width: 100%; 22 | padding: ${scale(10)}; 23 | flex-direction: column; 24 | opacity: 0.9; 25 | 26 | > svg { 27 | font-size: ${scale(10)}; 28 | margin-bottom: ${scale(2)}; 29 | } 30 | 31 | > div { 32 | font-size: ${scale(2)}; 33 | max-width: ${scale(60)}; 34 | } 35 | ` 36 | 37 | const IconAndTextPage = React.memo(({ icon, children }) => ( 38 | <> 39 | 40 | 41 | 42 |
{children}
43 |
44 | 45 | )) 46 | 47 | export default IconAndTextPage 48 | -------------------------------------------------------------------------------- /client/src/views/DeviceView/calibration/CalibrationButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import scale from '../../../utils/scale' 4 | import { basicText } from '../../../components/Typography' 5 | 6 | interface Props { 7 | onCalibrate: (calibrationBuffer: number) => void 8 | name: string 9 | calibrationBuffer: number 10 | } 11 | 12 | const Button = styled.div` 13 | ${basicText}; 14 | font-weight: bold; 15 | display: block; 16 | color: black; 17 | background-color: white; 18 | padding: ${scale(1)}; 19 | text-align: center; 20 | border-radius: 999px; 21 | transition: transform 200ms; 22 | transform: scale(1); 23 | 24 | &:active { 25 | transform: scale(0.9); 26 | } 27 | ` 28 | 29 | const CalibrationButton = React.memo( 30 | ({ onCalibrate, name, calibrationBuffer }) => { 31 | const handleClick = React.useCallback(() => { 32 | onCalibrate(calibrationBuffer) 33 | }, [calibrationBuffer, onCalibrate]) 34 | 35 | return ( 36 | 39 | ) 40 | } 41 | ) 42 | 43 | export default CalibrationButton 44 | -------------------------------------------------------------------------------- /client/src/views/DeviceView/DeviceView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconAndTextPage from '../../components/IconAndTextPage' 3 | import { faPoo, faPlug, faGamepad } from '@fortawesome/free-solid-svg-icons' 4 | import Device from './Device' 5 | import useServerStore, { 6 | ServerConnectionStatus, 7 | serverByAddr 8 | } from '../../stores/useServerStore' 9 | 10 | interface Props { 11 | serverId: string 12 | deviceId: string 13 | } 14 | 15 | const DeviceView: React.FC = ({ serverId, deviceId }) => { 16 | const server = useServerStore(serverByAddr(serverId)) 17 | 18 | if (!server) { 19 | return Unknown server! 20 | } 21 | 22 | if (server.connectionStatus !== ServerConnectionStatus.Connected) { 23 | return ( 24 | Not connected to server! 25 | ) 26 | } 27 | 28 | const device = server.devices[deviceId] 29 | 30 | if (!device) { 31 | return ( 32 | 33 | No such device connected! 34 | 35 | ) 36 | } 37 | 38 | return 39 | } 40 | 41 | export default DeviceView 42 | -------------------------------------------------------------------------------- /common-types/events.ts: -------------------------------------------------------------------------------- 1 | import { DeviceConfiguration, DeviceInputData, DeviceDescriptionMap } from './device' 2 | 3 | // events from server 4 | export namespace ServerEvents { 5 | export type DevicesUpdated = { 6 | devices: DeviceDescriptionMap 7 | } 8 | 9 | export type EventRate = { 10 | deviceId: string 11 | eventRate: number 12 | } 13 | 14 | export type InputEvent = { 15 | deviceId: string 16 | inputData: DeviceInputData 17 | } 18 | } 19 | 20 | // from client 21 | export namespace ClientEvents { 22 | export const enum Names { 23 | } 24 | 25 | export type UpdateConfiguration = { 26 | deviceId: string 27 | configuration: Partial 28 | store: boolean 29 | } 30 | 31 | export type SubscribeToDevice = { 32 | deviceId: string 33 | } 34 | 35 | export type UnsubscribeFromDevice = { 36 | deviceId: string 37 | } 38 | 39 | export type SaveConfiguration = { 40 | deviceId: string 41 | } 42 | 43 | export type UpdateSensorThreshold = { 44 | deviceId: string 45 | sensorIndex: number, 46 | newThreshold: number 47 | store: boolean 48 | } 49 | 50 | export type Calibrate = { 51 | deviceId: string, 52 | calibrationBuffer: number 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2019 Mika Viinamäki (kauhsa [at] kapsi [dot] com) 4 | Copyright 2019 Markus Kokkonen 5 | 6 | Teensy 2 firmware is based on LUFA example code, which is copyrighted to: 7 | 8 | Copyright 2017 Dean Camera (dean [at] fourwalledcubicle [dot] com) 9 | 10 | Permission to use, copy, modify, distribute, and sell this 11 | software and its documentation for any purpose is hereby granted 12 | without fee, provided that the above copyright notice appear in 13 | all copies and that both that the copyright notice and this 14 | permission notice and warranty disclaimer appear in supporting 15 | documentation, and that the name of the author not be used in 16 | advertising or publicity pertaining to distribution of the 17 | software without specific, written prior permission. 18 | 19 | The author disclaims all warranties with regard to this 20 | software, including all implied warranties of merchantability 21 | and fitness. In no event shall the author be liable for any 22 | special, indirect or consequential damages or any damages 23 | whatsoever resulting from loss of use, data or profits, whether 24 | in an action of contract, negligence or other tortious action, 25 | arising out of or in connection with the use or performance of 26 | this software. -------------------------------------------------------------------------------- /client/src/domain/Button.ts: -------------------------------------------------------------------------------- 1 | import { DeviceDescription } from '../../../common-types/device' 2 | 3 | export interface ButtonType { 4 | buttonIndex: number 5 | sensors: SensorType[] 6 | } 7 | 8 | export interface SensorType { 9 | sensorIndex: number 10 | threshold: number // between 0 and 1 11 | } 12 | 13 | export const buttonsFromDeviceDescription = (device: DeviceDescription) => { 14 | const buttons: Record = {} 15 | 16 | for ( 17 | let sensorIndex = 0; 18 | sensorIndex < device.properties.sensorCount; 19 | sensorIndex++ 20 | ) { 21 | const buttonIndex = device.configuration.sensorToButtonMapping[sensorIndex] 22 | 23 | if (buttonIndex < 0 || buttonIndex >= device.properties.buttonCount) { 24 | continue 25 | } 26 | 27 | const sensor = { 28 | sensorIndex, 29 | threshold: device.configuration.sensorThresholds[sensorIndex] 30 | } 31 | 32 | const button = buttons[buttonIndex] 33 | 34 | if (!button) { 35 | buttons[buttonIndex] = { 36 | buttonIndex: buttonIndex, 37 | sensors: [sensor] 38 | } 39 | } else { 40 | buttons[buttonIndex].sensors.push(sensor) 41 | } 42 | } 43 | 44 | // TODO: sort. 45 | return Object.values(buttons) 46 | } 47 | -------------------------------------------------------------------------------- /client/src/utils/SubscriptionManager.tsx: -------------------------------------------------------------------------------- 1 | export default class SubscriptionManager { 2 | private subscriptionsById: { 3 | [id: string]: Set<(event: T) => void> 4 | } = {} 5 | 6 | public emit(id: string, event: T) { 7 | const subscriptions = this.subscriptionsById[id] 8 | 9 | if (!subscriptions) { 10 | return 11 | } 12 | 13 | for (const subscription of subscriptions.values()) { 14 | subscription(event) 15 | } 16 | } 17 | 18 | public hasSubscriptionsFor(id: string) { 19 | if (!this.subscriptionsById[id]) { 20 | return false 21 | } 22 | 23 | return this.subscriptionsById[id].size > 0 24 | } 25 | 26 | public subscribe(id: string, callback: (event: T) => void) { 27 | if (!this.subscriptionsById.hasOwnProperty(id)) { 28 | this.subscriptionsById[id] = new Set([callback]) 29 | } else { 30 | this.subscriptionsById[id].add(callback) 31 | } 32 | } 33 | 34 | public unsubscribe(id: string, callback: (event: T) => void) { 35 | if (!this.subscriptionsById[id]) { 36 | return 37 | } 38 | 39 | this.subscriptionsById[id].delete(callback) 40 | 41 | if (this.subscriptionsById[id].size === 0) { 42 | delete this.subscriptionsById[id] 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/src/components/mainMenu/MainMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { sortBy } from 'lodash-es' 4 | 5 | import scale from '../../utils/scale' 6 | import MenuServer from './MenuServer' 7 | import Menu from '../menu/Menu' 8 | import MenuHeader from '../menu/MenuHeader' 9 | import useServerStore from '../../stores/useServerStore' 10 | import useMainMenuStore from '../../stores/useMainMenuStore' 11 | 12 | const ServersContainer = styled.div` 13 | > * { 14 | margin-bottom: ${scale(5)}; 15 | } 16 | ` 17 | 18 | const MainMenu = () => { 19 | const { isMenuOpen, closeMenu } = useMainMenuStore() 20 | const servers = useServerStore(store => store.servers) 21 | 22 | const sortedServers = React.useMemo( 23 | () => sortBy(Object.values(servers), s => s.address), 24 | [servers] 25 | ) 26 | 27 | return ( 28 | <> 29 | 30 | Devices 31 | 32 | {sortedServers.map(server => ( 33 | 38 | ))} 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default MainMenu 46 | -------------------------------------------------------------------------------- /client/src/utils/useSensorValueSpring.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSpring } from 'react-spring' 3 | import ServerConnection from './ServerConnection' 4 | import { DeviceInputData } from '../../../common-types/device' 5 | 6 | interface Settings { 7 | serverConnection: ServerConnection | undefined 8 | deviceId: string 9 | sensorIndex: number 10 | } 11 | 12 | // TODO: perhaps in the future the backend tells us the rate it sends inputevents 13 | // so we can synchronize. 14 | const BACKEND_EVENT_SENT_EVERY_MS = 1000 / 20 15 | 16 | const useSensorValueSpring = ({ 17 | serverConnection, 18 | deviceId, 19 | sensorIndex 20 | }: Settings) => { 21 | const [{ value: sensorValue }, setSensorValue] = useSpring(() => ({ 22 | value: 0, 23 | config: { duration: BACKEND_EVENT_SENT_EVERY_MS, clamp: true } 24 | })) 25 | 26 | const handleInputEvent = React.useCallback( 27 | (inputData: DeviceInputData) => { 28 | const value = inputData.sensors[sensorIndex] 29 | 30 | setSensorValue({ 31 | value 32 | }) 33 | }, 34 | [sensorIndex, setSensorValue] 35 | ) 36 | 37 | React.useEffect(() => { 38 | if (!serverConnection) { 39 | return 40 | } 41 | 42 | return serverConnection.subscribeToInputEvents(deviceId, handleInputEvent) 43 | }, [deviceId, handleInputEvent, serverConnection]) 44 | 45 | return sensorValue 46 | } 47 | 48 | export default useSensorValueSpring 49 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors' 2 | import express from 'express' 3 | import { Server as HttpServer } from 'http' 4 | import SocketIO from 'socket.io' 5 | 6 | import { Teensy2DeviceDriver } from './driver/teensy2/Teensy2DeviceDriver' 7 | import createServer from './server' 8 | import consola from 'consola' 9 | 10 | function start(port: number, host: string) { 11 | const expressApplication = express() 12 | const httpServer = new HttpServer(expressApplication) 13 | const socketIOServer = SocketIO(httpServer, { 14 | perMessageDeflate: false, 15 | httpCompression: false, 16 | pingInterval: 2000, 17 | pingTimeout: 1000 18 | }) 19 | expressApplication.use(cors()) 20 | expressApplication.use(express.json()) 21 | 22 | const closeServer = createServer({ 23 | expressApplication, 24 | socketIOServer, 25 | deviceDrivers: [new Teensy2DeviceDriver()] 26 | }) 27 | 28 | httpServer.listen(port, host, () => 29 | consola.info(`Application started, listening ${host}:${port}`) 30 | ) 31 | 32 | const stop = () => { 33 | closeServer() 34 | process.exit(0) 35 | } 36 | 37 | // this shouldn't be needed, but usb-detection library we're using is being 38 | // annoying. or something. figuring it out is TODO. 39 | process.on('SIGINT', stop) 40 | process.on('SIGTERM', stop) 41 | } 42 | 43 | const port = (process.env.PORT && parseInt(process.env.PORT, 10)) || 3333 44 | const host = process.env.HOST || '0.0.0.0' 45 | start(port, host) 46 | -------------------------------------------------------------------------------- /firmware/teensy2/build/makefile: -------------------------------------------------------------------------------- 1 | # 2 | # LUFA Library 3 | # Copyright (C) Dean Camera, 2017. 4 | # 5 | # dean [at] fourwalledcubicle [dot] com 6 | # www.lufa-lib.org 7 | # 8 | # -------------------------------------- 9 | # LUFA Project Makefile. 10 | # -------------------------------------- 11 | 12 | # Run "make help" for target help. 13 | 14 | MCU = atmega32u4 15 | ARCH = AVR8 16 | BOARD = TEENSY2 17 | F_CPU = 16000000 18 | F_USB = $(F_CPU) 19 | OPTIMIZATION = 3 20 | TARGET = AnalogDancePad 21 | SRC = ../$(TARGET).c ../Descriptors.c ../ADC.c ../Pad.c ../Communication.c ../ConfigStore.c ../Reset.c $(LUFA_SRC_USB) $(LUFA_SRC_USBCLASS) 22 | LUFA_PATH = ../lufa/LUFA 23 | CC_FLAGS = -DUSE_LUFA_CONFIG_HEADER -I../Config/ -I.. 24 | LD_FLAGS = 25 | 26 | # Default target 27 | all: 28 | 29 | # Include LUFA-specific DMBS extension modules 30 | DMBS_LUFA_PATH ?= $(LUFA_PATH)/Build/LUFA 31 | include $(DMBS_LUFA_PATH)/lufa-sources.mk 32 | include $(DMBS_LUFA_PATH)/lufa-gcc.mk 33 | 34 | # Include common DMBS build system modules 35 | DMBS_PATH ?= $(LUFA_PATH)/Build/DMBS/DMBS 36 | include $(DMBS_PATH)/core.mk 37 | include $(DMBS_PATH)/cppcheck.mk 38 | include $(DMBS_PATH)/doxygen.mk 39 | include $(DMBS_PATH)/dfu.mk 40 | include $(DMBS_PATH)/gcc.mk 41 | include $(DMBS_PATH)/hid.mk 42 | include $(DMBS_PATH)/avrdude.mk 43 | include $(DMBS_PATH)/atprogram.mk 44 | 45 | install: 46 | teensy_loader_cli --mcu=atmega32u4 -w ./AnalogDancePad.hex 47 | -------------------------------------------------------------------------------- /firmware/teensy2/ADC.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "Config/DancePadConfig.h" 5 | 6 | // see page 308 of https://cdn.sparkfun.com/datasheets/Dev/Arduino/Boards/ATMega32U4.pdf for these 7 | static const uint8_t sensorToAnalogPin[12] = { 8 | 0b000000, 9 | 0b000001, 10 | 0b000100, 11 | 0b000101, 12 | 0b000110, 13 | 0b000111, 14 | 0b100000, 15 | 0b100001, 16 | 0b100010, 17 | 0b100011, 18 | 0b100100, 19 | 0b100101 20 | }; 21 | 22 | #if ADC_TEST_MODE 23 | static uint16_t test_mode_value = 0; 24 | #endif 25 | 26 | void ADC_Init(void) { 27 | // different prescalers change conversion speed. tinker! 111 is slowest, and not fast enough for many sensors. 28 | const uint8_t prescaler = (1 << ADPS2) | (1 << ADPS1) | (0 << ADPS0); 29 | 30 | ADCSRA = (1 << ADEN) | prescaler; 31 | ADMUX = (1 << REFS0); // analog reference = 5V VCC 32 | ADCSRB = (1 << ADHSM); // enable high speed mode 33 | } 34 | 35 | uint16_t ADC_Read(uint8_t sensor) { 36 | uint8_t pin = sensorToAnalogPin[sensor]; 37 | 38 | // see: https://www.avrfreaks.net/comment/885267#comment-885267 39 | ADMUX = (ADMUX & 0xE0) | (pin & 0x1F); // select channel (MUX0-4 bits) 40 | ADCSRB = (ADCSRB & 0xDF) | (pin & 0x20); // select channel (MUX5 bit) 41 | 42 | ADCSRA |= (1 << ADSC); // start conversion 43 | while (ADCSRA & (1 << ADSC)) {}; // wait until done 44 | 45 | #if ADC_TEST_MODE 46 | test_mode_value++; 47 | return ((test_mode_value / 50) + (sensor * 50)) % 1024; 48 | #else 49 | return ADC; 50 | #endif 51 | } 52 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "analog-dance-pad-server", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "dependencies": { 6 | "@types/binary-parser": "^1.3.1", 7 | "binary-parser": "^1.5.0", 8 | "consola": "^2.10.1", 9 | "cors": "^2.8.5", 10 | "express": "^4.17.1", 11 | "lodash": "^4.17.15", 12 | "node-hid": "^0.7.9", 13 | "p-queue": "^6.2.1", 14 | "socket.io": "^2.3.0", 15 | "usb-detection": "^4.5.0" 16 | }, 17 | "devDependencies": { 18 | "@types/cors": "^2.8.6", 19 | "@types/express": "^4.17.1", 20 | "@types/lodash": "^4.14.144", 21 | "@types/node": "^12.11.1", 22 | "@types/node-hid": "^0.7.3", 23 | "@types/socket.io": "^2.1.4", 24 | "@typescript-eslint/eslint-plugin": "^2.4.0", 25 | "@typescript-eslint/parser": "^2.4.0", 26 | "eslint": "^6.5.1", 27 | "eslint-config-prettier": "^6.4.0", 28 | "eslint-plugin-prettier": "^3.1.1", 29 | "nodemon": "^1.19.4", 30 | "prettier": "^1.18.2", 31 | "socket.io-client": "^2.3.0", 32 | "strict-event-emitter-types": "^2.0.0", 33 | "ts-node": "^8.4.1", 34 | "typescript": "^3.6.4" 35 | }, 36 | "scripts": { 37 | "test": "echo \"Error: no test specified\" && exit 1", 38 | "lint": "eslint src/**/*.ts --max-warnings=0", 39 | "build": "tsc --build", 40 | "start": "nodemon --transpile-only src/index.ts", 41 | "reset-teensy": "ts-node src/driver/teensy2/util/Teensy2Reset.ts", 42 | "socket-cli": "DEBUG=socket.io-client:socket* node -i -e 'const client = require(\"socket.io-client\")(\"http://localhost:3333\")'" 43 | }, 44 | "license": "MIT" 45 | } -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- 1 | // Server addresses. As of now, app doesn't support adding/removing addresses 2 | // to connect to, so it needs to be defined at build time. Example: 3 | // REACT_APP_SERVER_ADDRESSES="196.168.1.10:3333,196.168.1.11:3333" 4 | 5 | const serverAddresses: string[] = process.env.REACT_APP_SERVER_ADDRESSES 6 | ? process.env.REACT_APP_SERVER_ADDRESSES.split(',') 7 | : ['localhost:3333'] 8 | 9 | // Calibration presets need to, for now, also be defined at build time. Format 10 | // should be as follows. 11 | // REACT_APP_CALIBARTION_PRESETS="Sensitive:2.00,Normal:5.00,Stiff:7.69" 12 | 13 | type CalibrationPreset = { 14 | name: string 15 | calibrationBuffer: number 16 | } 17 | 18 | const parseCalibrationPresets = (env: string) => { 19 | return env.split(',').map(preset => { 20 | const [name, calibrationBuffer] = preset.split(':') 21 | 22 | return { 23 | name, 24 | calibrationBuffer: parseFloat(calibrationBuffer) 25 | } 26 | }) 27 | } 28 | 29 | const calibrationPresetEnv = process.env.REACT_APP_CALIBRATION_PRESETS 30 | 31 | const calibrationPresets: CalibrationPreset[] = calibrationPresetEnv 32 | ? parseCalibrationPresets(calibrationPresetEnv) 33 | : [ 34 | { name: 'Sensitive', calibrationBuffer: 0.05 }, 35 | { name: 'Normal', calibrationBuffer: 0.1 }, 36 | { name: 'Stiff', calibrationBuffer: 0.15 } 37 | ] 38 | 39 | // Set REACT_APP_FORCE_INSECURE=true to automatically redirect to HTTP using 40 | // JavaScript in case you need it. 41 | 42 | const forceNonSecure = process.env.REACT_APP_FORCE_INSECURE === 'true' 43 | 44 | export default { 45 | serverAddresses, 46 | calibrationPresets, 47 | forceNonSecure 48 | } 49 | -------------------------------------------------------------------------------- /client/src/views/DeviceView/deviceConfiguration/SensorLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useSensorValueSpring from '../../../utils/useSensorValueSpring' 3 | import styled from 'styled-components' 4 | import { animated } from 'react-spring' 5 | import useServerStore, { 6 | serverConnectionByAddr 7 | } from '../../../stores/useServerStore' 8 | import { basicText } from '../../../components/Typography' 9 | import scale from '../../../utils/scale' 10 | 11 | interface Props { 12 | serverAddress: string 13 | deviceId: string 14 | sensorIndex: number 15 | } 16 | 17 | const LabelContainer = styled.div` 18 | display: block; 19 | position: relative; 20 | padding: ${scale(0.5)} ${scale(0.5)}; 21 | margin-bottom: ${scale(0.75)}; 22 | line-height: 1; 23 | ` 24 | 25 | const Bar = styled(animated.div)` 26 | background-color: white; 27 | bottom: 0; 28 | display: block; 29 | left: 0; 30 | position: absolute; 31 | right: 0; 32 | top: 0; 33 | transform-origin: 0% 50%; 34 | will-change: transform; 35 | opacity: 0.25; 36 | ` 37 | 38 | const Value = styled.div` 39 | z-index: 1; 40 | ${basicText}; 41 | ` 42 | 43 | const SensorLabel = React.memo( 44 | ({ serverAddress, deviceId, sensorIndex }) => { 45 | const serverConnection = useServerStore( 46 | serverConnectionByAddr(serverAddress) 47 | ) 48 | 49 | const sensorValue = useSensorValueSpring({ 50 | serverConnection, 51 | deviceId: deviceId, 52 | sensorIndex: sensorIndex 53 | }) 54 | 55 | return ( 56 | 57 | `scaleX(${value})`) 60 | }} 61 | /> 62 | Sensor {sensorIndex + 1} 63 | 64 | ) 65 | } 66 | ) 67 | 68 | export default SensorLabel 69 | -------------------------------------------------------------------------------- /client/src/components/Range.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | import styled from 'styled-components' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faArrowLeft, faArrowRight } from '@fortawesome/free-solid-svg-icons' 5 | import { smallText, basicText } from './Typography' 6 | import scale from '../utils/scale' 7 | import { colorValues } from '../utils/colors' 8 | 9 | const Container = styled.div` 10 | align-items: center; 11 | border-radius: 999px; 12 | border: 1px solid white; 13 | display: flex; 14 | overflow: hidden; 15 | ` 16 | 17 | const Button = styled.button` 18 | border: none; 19 | padding: ${scale(1)} ${scale(2)}; 20 | color: ${colorValues.white}; 21 | ${smallText}; 22 | outline: none; 23 | 24 | &:disabled { 25 | opacity: 0.25; 26 | } 27 | ` 28 | 29 | const Value = styled.span` 30 | flex-grow: 1; 31 | text-align: center; 32 | ${basicText}; 33 | ` 34 | 35 | interface Props { 36 | value: number 37 | valueText?: React.ReactNode 38 | min: number 39 | max: number 40 | onChange: (value: number) => void 41 | } 42 | 43 | const Range = React.memo(({ value, valueText, min, max, onChange }) => { 44 | const handleDecrement = useCallback(() => { 45 | onChange(value - 1) 46 | }, [onChange, value]) 47 | 48 | const handleIncrement = useCallback(() => { 49 | onChange(value + 1) 50 | }, [onChange, value]) 51 | 52 | return ( 53 | 54 | 57 | {valueText === undefined ? value : valueText} 58 | 61 | 62 | ) 63 | }) 64 | 65 | export default Range 66 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import 'sanitize.css' 2 | import 'sanitize.css/forms.css' 3 | import 'sanitize.css/typography.css' 4 | 5 | import React, { useEffect } from 'react' 6 | import styled, { createGlobalStyle } from 'styled-components' 7 | import { Helmet, HelmetProvider } from 'react-helmet-async' 8 | import { colors } from './utils/colors' 9 | import { BrowserRouter, Switch, Route } from 'react-router-dom' 10 | import DeviceView from './views/DeviceView/DeviceView' 11 | import MainMenu from './components/mainMenu/MainMenu' 12 | import LandingView from './views/LandingView' 13 | import config from './config' 14 | import useServerStore from './stores/useServerStore' 15 | 16 | const AppContainer = styled.div` 17 | height: 100%; 18 | width: 100%; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: stretch; 22 | ` 23 | 24 | const GlobalStyles = createGlobalStyle` 25 | html, body, #root { 26 | height: 100%; 27 | } 28 | 29 | body { 30 | overscroll-behavior: contain; 31 | background-color: ${colors.background}; 32 | font-family: 'Exo 2', sans-serif; 33 | } 34 | ` 35 | 36 | const App = () => { 37 | const init = useServerStore(store => store.init) 38 | useEffect(() => init(config.serverAddresses), [init]) 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ( 55 | 59 | )} 60 | /> 61 | 62 | 63 | 64 | 65 | 66 | 67 | ) 68 | } 69 | 70 | export default App 71 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /firmware/teensy2/ConfigStore.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "Config/DancePadConfig.h" 7 | #include "Pad.h" 8 | #include "ConfigStore.h" 9 | 10 | // just some random bytes to figure out what we have in eeprom 11 | // change these to reset configuration! 12 | static const uint8_t magicBytes[5] = {9, 74, 9, 48, 99}; 13 | 14 | // where magic bytes (which indicate that a pad configuration is, in fact, stored) exist 15 | #define MAGIC_BYTES_ADDRESS ((void *) 0x00) 16 | 17 | // where actual configration is stored 18 | #define CONFIGURATION_ADDRESS ((void *) (MAGIC_BYTES_ADDRESS + sizeof (magicBytes))) 19 | 20 | #define DEFAULT_NAME "Untitled Pad Device" 21 | 22 | static const Configuration DEFAULT_CONFIGURATION = { 23 | .padConfiguration = { 24 | .sensorThresholds = { [0 ... SENSOR_COUNT - 1] = 400 }, 25 | .releaseMultiplier = 0.9, 26 | .sensorToButtonMapping = { [0 ... SENSOR_COUNT - 1] = 1 } 27 | }, 28 | .nameAndSize = { 29 | .size = sizeof(DEFAULT_NAME) - 1, // we don't care about the null at the end. 30 | .name = DEFAULT_NAME 31 | } 32 | }; 33 | 34 | void ConfigStore_LoadConfiguration(Configuration* conf) { 35 | ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { 36 | // see if we have magic bytes stored 37 | uint8_t magicByteBuffer[sizeof (magicBytes)]; 38 | eeprom_read_block(magicByteBuffer, MAGIC_BYTES_ADDRESS, sizeof (magicBytes)); 39 | 40 | if (memcmp(magicByteBuffer, magicBytes, sizeof (magicBytes)) == 0) { 41 | // we had magic bytes, let's load the configuration! 42 | eeprom_read_block(conf, CONFIGURATION_ADDRESS, sizeof (Configuration)); 43 | } else { 44 | // we had some garbage on magic byte address, let's just use the default configuration 45 | memcpy(conf, &DEFAULT_CONFIGURATION, sizeof (Configuration)); 46 | } 47 | } 48 | } 49 | 50 | void ConfigStore_StoreConfiguration(const Configuration* conf) { 51 | ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { 52 | eeprom_update_block(conf, CONFIGURATION_ADDRESS, sizeof (Configuration)); 53 | eeprom_update_block(magicBytes, MAGIC_BYTES_ADDRESS, sizeof (magicBytes)); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /client/src/views/DeviceView/deviceButtons/DeviceButtons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Button from './Button' 4 | import { useSpring, animated } from 'react-spring' 5 | import { buttonsFromDeviceDescription } from '../../../domain/Button' 6 | import { DeviceDescription } from '../../../../../common-types/device' 7 | import toPercentage from '../../../utils/toPercentage' 8 | 9 | const ScalingContainer = styled(animated.div)` 10 | position: absolute; 11 | display: flex; 12 | flex-grow: 1; 13 | left: 0; 14 | top: 0; 15 | bottom: 0; 16 | transform-origin: 0% 50%; 17 | will-change: transform, width; 18 | 19 | > * { 20 | flex-grow: 1; 21 | } 22 | ` 23 | 24 | const Container = styled.div` 25 | height: 100%; 26 | overflow: hidden; 27 | position: relative; 28 | width: 100%; 29 | ` 30 | 31 | interface DeviceButtonsProps { 32 | serverAddress: string 33 | device: DeviceDescription 34 | } 35 | 36 | const DeviceButtons = React.memo( 37 | ({ serverAddress, device }) => { 38 | const buttons = React.useMemo(() => buttonsFromDeviceDescription(device), [ 39 | device 40 | ]) 41 | 42 | const [selectedButton, setSelectedButton] = React.useState( 43 | null 44 | ) 45 | 46 | const displayedItems = buttons.length 47 | 48 | const scalingContainerStyle = useSpring({ 49 | transform: `translateX(${ 50 | selectedButton === null 51 | ? '0%' 52 | : toPercentage(-(selectedButton / displayedItems)) 53 | }) scaleX(${selectedButton === null ? 1 / displayedItems : 1})`, 54 | width: toPercentage(displayedItems), 55 | config: { mass: 1, tension: 500, friction: 50 } 56 | }) 57 | 58 | return ( 59 | 60 | 61 | {buttons.map((button, i) => ( 62 |