diff --git a/interface/package-lock.json b/interface/package-lock.json index 9276cc79..350213f4 100644 --- a/interface/package-lock.json +++ b/interface/package-lock.json @@ -1404,6 +1404,11 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" }, + "@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==" + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2459,6 +2464,15 @@ "csstype": "^3.0.2" } }, + "@types/react-color": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.6.tgz", + "integrity": "sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==", + "requires": { + "@types/react": "*", + "@types/reactcss": "*" + } + }, "@types/react-dom": { "version": "17.0.11", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", @@ -2483,6 +2497,14 @@ "@types/react": "*" } }, + "@types/reactcss": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.6.tgz", + "integrity": "sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==", + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", @@ -9565,6 +9587,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash._reinterpolate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", @@ -9685,6 +9712,11 @@ "object-visit": "^1.0.0" } }, + "material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -12030,6 +12062,20 @@ } } }, + "react-color": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", + "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", + "requires": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.15", + "lodash-es": "^4.17.15", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + } + }, "react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", @@ -12304,6 +12350,14 @@ "prop-types": "^15.6.2" } }, + "reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "requires": { + "lodash": "^4.0.1" + } + }, "read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -14159,6 +14213,11 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "tinycolor2": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz", + "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==" + }, "tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/interface/package.json b/interface/package.json index dd78ab52..f9fa4b58 100644 --- a/interface/package.json +++ b/interface/package.json @@ -11,6 +11,7 @@ "@types/lodash": "^4.14.176", "@types/node": "^12.20.36", "@types/react": "^17.0.34", + "@types/react-color": "^3.0.6", "@types/react-dom": "^17.0.11", "async-validator": "^4.0.7", "axios": "^0.24.0", @@ -22,6 +23,7 @@ "parse-ms": "^3.0.0", "react": "^17.0.2", "react-app-rewired": "^2.1.8", + "react-color": "^2.19.3", "react-dom": "^17.0.2", "react-dropzone": "^11.4.2", "react-router-dom": "^6.0.2", diff --git a/interface/src/project/AudioLightSettingsForm.tsx b/interface/src/project/AudioLightSettingsForm.tsx new file mode 100644 index 00000000..60673652 --- /dev/null +++ b/interface/src/project/AudioLightSettingsForm.tsx @@ -0,0 +1,85 @@ +import { FC } from 'react'; +import { WEB_SOCKET_ROOT } from '../api/endpoints'; +import { useSnackbar } from 'notistack'; + +import { TextField, MenuItem, Button, Container } from '@mui/material'; +import SaveIcon from '@mui/icons-material/Save'; +import LoadIcon from '@mui/icons-material/SaveAlt'; + +import * as ProjectApi from "./api"; +import { ButtonRow, FormLoader, SectionContent } from '../components'; +import { AudioLightModeType, AudioLightMode, AUDIO_LIGHT_MODE_METADATA } from './types'; +import { extractErrorMessage, useWs } from '../utils'; +import PaletteSettingsLoader from './PaletteSettingsLoader'; + +export const AUDIO_LIGHT_SETTINGS_WEBSOCKET_URL = WEB_SOCKET_ROOT + "audioLightSettings"; + +const AudioLightSettingsForm: FC = () => { + const { connected, data, updateData } = useWs>(AUDIO_LIGHT_SETTINGS_WEBSOCKET_URL); + const { enqueueSnackbar } = useSnackbar(); + + if (!connected || !data) { + return (); + } + + const saveMode = async () => { + try { + await ProjectApi.saveModeSettings(); + enqueueSnackbar("Settings saved", { variant: "success" }); + } catch (error: any) { + enqueueSnackbar(extractErrorMessage(error, "Failed to save config, please try again"), { variant: "error" }); + } + }; + + const loadMode = async () => { + try { + await ProjectApi.loadModeSettings(); + enqueueSnackbar("Settings loaded", { variant: "success" }); + } catch (error: any) { + enqueueSnackbar(extractErrorMessage(error, "Failed to load config, please try again"), { variant: "error" }); + } + }; + + const selectModeComponent = () => data.mode_id && AUDIO_LIGHT_MODE_METADATA[data.mode_id].renderer; + + const ModeComponent = selectModeComponent(); + + return ( + + + + updateData({ mode_id: event.target.value as AudioLightModeType }, true, true)} + fullWidth + margin="normal" + variant="outlined" + select + > + { + Object.entries(AudioLightModeType).map(([, mode_id]) => ( + + {AUDIO_LIGHT_MODE_METADATA[mode_id].label} + + )) + } + + {console.log(data)} + {data.settings && ModeComponent && } + + + + + + + + ); +}; + +export default AudioLightSettingsForm; diff --git a/interface/src/project/DemoInformation.tsx b/interface/src/project/DemoInformation.tsx deleted file mode 100644 index faa8552f..00000000 --- a/interface/src/project/DemoInformation.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { FC } from 'react'; - -import { Typography, Box, List, ListItem, ListItemText } from '@mui/material'; - -import { SectionContent } from '../components'; - -const DemoInformation: FC = () => ( - - - This simple demo project allows you to control the built-in LED. - It demonstrates how the esp8266-react framework may be extended for your own IoT project. - - - It is recommended that you keep your project interface code under the project directory. - This serves to isolate your project code from the from the rest of the user interface which should - simplify merges should you wish to update your project with future framework changes. - - - The demo project interface code is stored in the 'interface/src/project' directory: - - - - - - - - - - - - - - - - - - - - - - - - - - - See the project README for a full description of the demo project. - - - -); - -export default DemoInformation; diff --git a/interface/src/project/DemoProject.tsx b/interface/src/project/DemoProject.tsx deleted file mode 100644 index 92f4ce2f..00000000 --- a/interface/src/project/DemoProject.tsx +++ /dev/null @@ -1,37 +0,0 @@ - -import React, { FC } from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; - -import { Tab } from '@mui/material'; - -import { RouterTabs, useRouterTab, useLayoutTitle } from '../components'; - -import DemoInformation from './DemoInformation'; -import LightStateRestForm from './LightStateRestForm'; -import LightMqttSettingsForm from './LightMqttSettingsForm'; -import LightStateWebSocketForm from './LightStateWebSocketForm'; - -const DemoProject: FC = () => { - useLayoutTitle("Demo Project"); - const { routerTab } = useRouterTab(); - - return ( - <> - - - - - - - - } /> - } /> - } /> - } /> - } /> - - - ); -}; - -export default DemoProject; diff --git a/interface/src/project/LedSettingsForm.tsx b/interface/src/project/LedSettingsForm.tsx new file mode 100644 index 00000000..62c3b363 --- /dev/null +++ b/interface/src/project/LedSettingsForm.tsx @@ -0,0 +1,68 @@ +import { FC } from 'react'; + +import { Box, Button, Container, FormLabel, Slider } from '@mui/material'; +import SaveIcon from '@mui/icons-material/Save'; + +import * as ProjectApi from "./api"; +import { ButtonRow, FormLoader, SectionContent } from '../components'; +import { LedSettings } from './types'; +import { useRest } from '../utils'; + +const milliwatsToWatts = (milliwatts: number) => milliwatts / 1000; + +const LedSettingsForm: FC = () => { + const { loadData, data, setData, saveData, errorMessage } = useRest( + { read: ProjectApi.readLedSettings, update: ProjectApi.updateLedSettings } + ); + + if (!data) { + return (); + } + + const handleSliderChange = (name: keyof LedSettings) => (event: Event, value: number | number[]) => { + setData({ ...data, [name]: value }); + }; + + return ( + + + + LED Max Power in Watts (0 = unlimited) + + Audio Dead Zone + + Audio Smoothing Factor + + + + + + + + ); + +}; + +export default LedSettingsForm; diff --git a/interface/src/project/LightMqttSettingsForm.tsx b/interface/src/project/LightMqttSettingsForm.tsx deleted file mode 100644 index 23981ed0..00000000 --- a/interface/src/project/LightMqttSettingsForm.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { FC, useState } from "react"; -import { ValidateFieldsError } from "async-validator"; - -import { Button } from "@mui/material"; -import SaveIcon from '@mui/icons-material/Save'; - -import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedTextField } from "../components"; -import { validate } from "../validators"; -import { useRest, updateValue } from "../utils"; - -import * as DemoApi from './api'; -import { LightMqttSettings } from "./types"; -import { LIGHT_MQTT_SETTINGS_VALIDATOR } from "./validators"; - -const LightMqttSettingsForm: FC = () => { - const [fieldErrors, setFieldErrors] = useState(); - const { - loadData, saveData, saving, setData, data, errorMessage - } = useRest({ read: DemoApi.readBrokerSettings, update: DemoApi.updateBrokerSettings }); - - const updateFormValue = updateValue(setData); - - const content = () => { - if (!data) { - return (); - } - - const validateAndSubmit = async () => { - try { - setFieldErrors(undefined); - await validate(LIGHT_MQTT_SETTINGS_VALIDATOR, data); - saveData(); - } catch (errors: any) { - setFieldErrors(errors); - } - }; - - return ( - <> - - - - - - - - - ); - }; - - return ( - - {content()} - - ); -}; - -export default LightMqttSettingsForm; diff --git a/interface/src/project/LightStateRestForm.tsx b/interface/src/project/LightStateRestForm.tsx deleted file mode 100644 index 5df43602..00000000 --- a/interface/src/project/LightStateRestForm.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { FC } from 'react'; - -import { Button, Checkbox } from '@mui/material'; -import SaveIcon from '@mui/icons-material/Save'; - -import { SectionContent, FormLoader, BlockFormControlLabel, ButtonRow, MessageBox } from '../components'; -import { updateValue, useRest } from '../utils'; - -import * as DemoApi from './api'; -import { LightState } from './types'; - -const LightStateRestForm: FC = () => { - const { - loadData, saveData, saving, setData, data, errorMessage - } = useRest({ read: DemoApi.readLightState, update: DemoApi.updateLightState }); - - const updateFormValue = updateValue(setData); - - const content = () => { - if (!data) { - return (); - } - - return ( - <> - - - } - label="LED State?" - /> - - - - - ); - }; - - return ( - - {content()} - - ); -}; - -export default LightStateRestForm; diff --git a/interface/src/project/LightStateWebSocketForm.tsx b/interface/src/project/LightStateWebSocketForm.tsx deleted file mode 100644 index 8e88cf53..00000000 --- a/interface/src/project/LightStateWebSocketForm.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { FC } from 'react'; - -import { Switch } from '@mui/material'; - -import { WEB_SOCKET_ROOT } from '../api/endpoints'; -import { BlockFormControlLabel, FormLoader, MessageBox, SectionContent } from '../components'; -import { updateValue, useWs } from '../utils'; - -import { LightState } from './types'; - -export const LIGHT_SETTINGS_WEBSOCKET_URL = WEB_SOCKET_ROOT + "lightState"; - -const LightStateWebSocketForm: FC = () => { - const { connected, updateData, data } = useWs(LIGHT_SETTINGS_WEBSOCKET_URL); - - const updateFormValue = updateValue(updateData); - - const content = () => { - if (!connected || !data) { - return (); - } - return ( - <> - - - } - label="LED State?" - /> - - ); - }; - - return ( - - {content()} - - ); -}; - -export default LightStateWebSocketForm; diff --git a/interface/src/project/LightsProject.tsx b/interface/src/project/LightsProject.tsx new file mode 100644 index 00000000..a9ff178e --- /dev/null +++ b/interface/src/project/LightsProject.tsx @@ -0,0 +1,36 @@ +import React, { FC } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; + +import { Tab } from '@mui/material'; + +import { RouterTabs, useRouterTab, useLayoutTitle } from '../components'; + +import AudioLightSettingsForm from './AudioLightSettingsForm'; +import PaletteSettingsForm from './PaletteSettingsForm'; +import LedSettingsForm from './LedSettingsForm'; +import SpectrumAnalyzer from './SpectrumAnalyzer'; + +const LightsProject: FC = () => { + useLayoutTitle("Christmas Lights"); + const { routerTab } = useRouterTab(); + + return ( + <> + + + + + + + + } /> + } /> + } /> + } /> + } /> + + + ); +}; + +export default LightsProject; diff --git a/interface/src/project/PaletteForm.tsx b/interface/src/project/PaletteForm.tsx new file mode 100644 index 00000000..f00080d5 --- /dev/null +++ b/interface/src/project/PaletteForm.tsx @@ -0,0 +1,112 @@ +import React, { FC, useEffect, useState } from 'react'; +import { ChromePicker, ColorResult } from 'react-color'; + +import { Button, Dialog, DialogTitle, DialogContent, DialogActions, Box, Slider } from '@mui/material'; + +import { ValidatedTextField } from '../components'; +import { generateGradient, Palette } from './types'; +import Schema, { ValidateFieldsError } from 'async-validator'; +import { updateValue } from '../utils'; +import { validate } from '../validators'; + +interface PaletteFormProps { + creating: boolean; + validator: Schema; + + palette?: Palette; + setPalette: React.Dispatch>; + + onDoneEditing: () => void; + onCancelEditing: () => void; +} + +const PaletteForm: FC = ({ creating, validator, palette, setPalette, onDoneEditing, onCancelEditing }) => { + + const [color, setColor] = useState(0); + const [fieldErrors, setFieldErrors] = useState(); + + const updateFormValue = updateValue(setPalette); + + const selectColor = (event: Event, value: number | number[]) => setColor(value as number); + + const open = !!palette; + + useEffect(() => { + if (open) { + setFieldErrors(undefined); + } + }, [open]); + + const renderContent = () => { + if (!palette) { + return null; + } + + const changeColor = (result: ColorResult) => { + const colors = [...palette.colors]; + colors[color] = result.hex; + setPalette({ ...palette, colors }); + }; + + const validateAndDone = async () => { + try { + setFieldErrors(undefined); + await validate(validator, palette); + onDoneEditing(); + } catch (errors: any) { + setFieldErrors(errors); + } + }; + + return ( + <> + {creating ? 'Add' : 'Modify'} Palette + + + + + + + + + + + + ); + }; + + return ( + + {renderContent()} + + ); + +}; + +export default PaletteForm; diff --git a/interface/src/project/PaletteSettingsContext.tsx b/interface/src/project/PaletteSettingsContext.tsx new file mode 100644 index 00000000..df3da935 --- /dev/null +++ b/interface/src/project/PaletteSettingsContext.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { PaletteSettings } from "./types"; + +export type PaletteSettingsContextType = { + paletteSettingsUpdated: (paletteSettings: PaletteSettings) => void; + paletteSettings: PaletteSettings; +} + +const PaletteSettingsContextDefaultValue = {} as PaletteSettingsContextType; +export const PaletteSettingsContext = React.createContext( + PaletteSettingsContextDefaultValue +); diff --git a/interface/src/project/PaletteSettingsForm.tsx b/interface/src/project/PaletteSettingsForm.tsx new file mode 100644 index 00000000..967a06bb --- /dev/null +++ b/interface/src/project/PaletteSettingsForm.tsx @@ -0,0 +1,122 @@ +import { FC, useState } from 'react'; + +import { Button, Container, IconButton, Table, TableBody, TableCell, TableFooter, TableHead, TableRow } from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import SaveIcon from '@mui/icons-material/Save'; +import PersonAddIcon from '@mui/icons-material/PersonAdd'; + +import { ButtonRow, FormLoader, SectionContent } from '../components'; +import { useRest } from '../utils'; + +import * as ProjectApi from "./api"; +import { DEFAULT_PALETTE, generateGradient, Palette, PaletteSettings } from './types'; +import PaletteForm from './PaletteForm'; +import { createPaletteValidator } from './validators'; + +const PaletteSettingsForm: FC = () => { + const { loadData, data, setData, saveData, errorMessage } = useRest( + { read: ProjectApi.readPaletteSettings, update: ProjectApi.updatePaletteSettings } + ); + + const [creating, setCreating] = useState(false); + const [palette, setPalette] = useState(); + + if (!data) { + return (); + } + + const removePalette = (toRemove: Palette) => { + const palettes = data.palettes.filter((p) => p.name !== toRemove.name); + setData({ ...data, palettes }); + }; + + const startEditingPalette = (toEdit: Palette) => { + setCreating(false); + setPalette(toEdit); + }; + + const cancelEditingPalette = () => { + setPalette(undefined); + }; + + const doneEditingPalette = () => { + if (palette) { + const palettes = data.palettes.filter((p) => p.name !== palette.name); + palettes.push(palette); + setData({ ...data, palettes }); + setPalette(undefined); + } + }; + + const createPalette = () => { + setCreating(true); + setPalette({ + name: "", + colors: [...DEFAULT_PALETTE] + }); + }; + + const renderPalettes = () => { + return data.palettes.sort((a, b) => a.name.localeCompare(b.name)).map((p) => ( + + + {p.name} + + + + removePalette(p)}> + + + startEditingPalette(p)}> + + + + + )); + }; + + return ( + + + + + + Name + Palette + + + + + {renderPalettes()} + + + + + + + + + +
+ + + + +
+
+ ); +}; + +export default PaletteSettingsForm; diff --git a/interface/src/project/PaletteSettingsLoader.tsx b/interface/src/project/PaletteSettingsLoader.tsx new file mode 100644 index 00000000..6c1845ed --- /dev/null +++ b/interface/src/project/PaletteSettingsLoader.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; + +import { useRest } from '../utils'; + +import * as ProjectApi from "./api"; +import { PaletteSettings } from './types'; +import { PaletteSettingsContext } from './PaletteSettingsContext'; +import { FormLoader } from '../components'; + +const PaletteSettingsLoader: FC = ({ children }) => { + const { data, setData, loadData, errorMessage } = useRest({ read: ProjectApi.readPaletteSettings }); + + if (!data) { + return (); + } + + return ( + + {children} + + ); +}; + +export default PaletteSettingsLoader; diff --git a/interface/src/project/ProjectMenu.tsx b/interface/src/project/ProjectMenu.tsx index cd4550fa..88217b83 100644 --- a/interface/src/project/ProjectMenu.tsx +++ b/interface/src/project/ProjectMenu.tsx @@ -1,14 +1,14 @@ import { FC } from 'react'; import { List } from '@mui/material'; -import SettingsRemoteIcon from '@mui/icons-material/SettingsRemote'; +import FlareIcon from '@mui/icons-material/Flare'; import { PROJECT_PATH } from '../api/env'; import LayoutMenuItem from '../components/layout/LayoutMenuItem'; const ProjectMenu: FC = () => ( - + ); diff --git a/interface/src/project/ProjectRouting.tsx b/interface/src/project/ProjectRouting.tsx index 6b34b274..42eada6f 100644 --- a/interface/src/project/ProjectRouting.tsx +++ b/interface/src/project/ProjectRouting.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { Navigate, Routes, Route } from 'react-router-dom'; -import DemoProject from './DemoProject'; +import LightsProject from './LightsProject'; const ProjectRouting: FC = () => { return ( @@ -9,11 +9,11 @@ const ProjectRouting: FC = () => { { // Add the default route for your project below } - } /> + } /> { // Add your project page routes below. } - } /> + } /> ); }; diff --git a/interface/src/project/SpectrumAnalyzer.tsx b/interface/src/project/SpectrumAnalyzer.tsx new file mode 100644 index 00000000..a3a18e9b --- /dev/null +++ b/interface/src/project/SpectrumAnalyzer.tsx @@ -0,0 +1,98 @@ +import { FC, useEffect, useRef } from 'react'; + +import { Container } from '@mui/material'; + +import { WEB_SOCKET_ROOT } from '../api/endpoints'; +import { FormLoader, SectionContent } from '../components'; +import { FrequencyData } from './types'; +import { useWs } from '../utils'; + +export const FREQUENCIES_WEBSOCKET_URL = WEB_SOCKET_ROOT + "frequencies"; + +const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; +}; + +const hslToRgb = (h: number, s: number, l: number) => { + var r, g, b; + if (s === 0) { + r = g = b = l; + } else { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +}; + +const calculateColorForPercentage = (percentage: number) => { + var hue = (60 - (percentage * 60)) / 360; + var rgb = hslToRgb(hue, 1, 0.5); + return 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')'; +}; + +const SpectrumAnalyzer: FC = () => { + const { connected, data } = useWs(FREQUENCIES_WEBSOCKET_URL); + const canvas = useRef(null); + + useEffect(() => { + const currentCanvas = canvas.current; + const canvasContext = currentCanvas?.getContext("2d"); + + if (!currentCanvas || !canvasContext || !data) { + return; + } + const visualizerData = data.bands; + + // Make it visually fill the positioned parent + currentCanvas.style.backgroundColor = "#C5CAE9"; + currentCanvas.style.width = '100%'; + currentCanvas.style.height = '300px'; + + // ...then set the internal size to match + currentCanvas.width = currentCanvas.offsetWidth; + currentCanvas.height = currentCanvas.offsetHeight; + + if (!visualizerData) { + return; + } + + // clear the canvas + canvasContext.clearRect(0, 0, currentCanvas.width, currentCanvas.height); + + // calculate bar width + var barWidth = currentCanvas.width / visualizerData.length; + + // output the data + for (var i = 0; i < visualizerData.length; i++) { + var barOffset = i * barWidth; + var barValue = visualizerData[i]; + var barPercentage = barValue > 0 ? barValue / 4096 : 0; + var barHeight = -currentCanvas.height * barPercentage; + canvasContext.fillStyle = calculateColorForPercentage(barPercentage); + canvasContext.fillRect(barOffset, currentCanvas.height, barWidth, barHeight); + } + }); + + if (!connected || !data) { + return (); + } + + return ( + + + + + + ); + +}; + +export default SpectrumAnalyzer; diff --git a/interface/src/project/api.ts b/interface/src/project/api.ts index ff3b88cc..c978c80e 100644 --- a/interface/src/project/api.ts +++ b/interface/src/project/api.ts @@ -1,20 +1,28 @@ import { AxiosPromise } from "axios"; import { AXIOS } from "../api/endpoints"; -import { LightMqttSettings, LightState } from "./types"; +import { LedSettings, PaletteSettings } from "./types"; -export function readLightState(): AxiosPromise { - return AXIOS.get('/lightState'); +export function readPaletteSettings(): AxiosPromise { + return AXIOS.get('/paletteSettings'); } -export function updateLightState(lightState: LightState): AxiosPromise { - return AXIOS.post('/lightState', lightState); +export function updatePaletteSettings(paletteSettings: PaletteSettings): AxiosPromise { + return AXIOS.post('/paletteSettings', paletteSettings); } -export function readBrokerSettings(): AxiosPromise { - return AXIOS.get('/brokerSettings'); +export function readLedSettings(): AxiosPromise { + return AXIOS.get('/ledSettings'); } -export function updateBrokerSettings(lightMqttSettings: LightMqttSettings): AxiosPromise { - return AXIOS.post('/brokerSettings', lightMqttSettings); +export function updateLedSettings(ledSettings: LedSettings): AxiosPromise { + return AXIOS.post('/ledSettings', ledSettings); +} + +export function loadModeSettings(): AxiosPromise { + return AXIOS.post('/loadModeSettings'); +} + +export function saveModeSettings(): AxiosPromise { + return AXIOS.post('/saveModeSettings'); } diff --git a/interface/src/project/components/ColorPicker.tsx b/interface/src/project/components/ColorPicker.tsx new file mode 100644 index 00000000..b2186101 --- /dev/null +++ b/interface/src/project/components/ColorPicker.tsx @@ -0,0 +1,34 @@ +import { Box } from '@mui/material'; +import { FC, Fragment } from 'react'; + +import { ColorChangeHandler, HuePicker, TwitterPicker } from 'react-color'; + +import { SimpleColors } from './Colors'; + +interface ColorPickerProps { + color: string; + onChange: ColorChangeHandler; +} + +const ColorPicker: FC = ({ color, onChange }) => ( + + + + + + + + +); + +export default ColorPicker; diff --git a/interface/src/project/components/Colors.ts b/interface/src/project/components/Colors.ts new file mode 100644 index 00000000..bd06beb4 --- /dev/null +++ b/interface/src/project/components/Colors.ts @@ -0,0 +1,5 @@ +export const SimpleColors = [ + "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#CD5C5C", + "#00FF7F", "#4682B4", "#B22222", "#00BFFF", "#4B0082", + "#FF7F50", "#40E0D0", "#800080", "#FFFF00", "#00008B", + "#FF1493"]; diff --git a/interface/src/project/components/IncludedBands.tsx b/interface/src/project/components/IncludedBands.tsx new file mode 100644 index 00000000..0da3cb2b --- /dev/null +++ b/interface/src/project/components/IncludedBands.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { Switch } from '@mui/material'; + +interface IncludedBandProps { + value: boolean[]; + onChange: (value: boolean[]) => void; +} +const IncludedBands: FC = ({ value, onChange }) => { + + const handleChange = (ordinal: number) => (event: React.ChangeEvent) => { + const newValue = [...value]; + newValue[ordinal] = event.target.checked; + onChange(newValue); + }; + + return ( +
+ {value.map((v, i) => ( + + ))} +
+ ); + +}; + +export default IncludedBands; diff --git a/interface/src/project/components/ModeTransferList.tsx b/interface/src/project/components/ModeTransferList.tsx new file mode 100644 index 00000000..d5e9b456 --- /dev/null +++ b/interface/src/project/components/ModeTransferList.tsx @@ -0,0 +1,145 @@ +import React, { FC, useEffect } from 'react'; +import { Button, Checkbox, Grid, List, ListItem, ListItemText, Paper, styled } from '@mui/material'; + +import { ROTATE_AUDIO_LIGHT_MODES, AudioLightModeType, AUDIO_LIGHT_MODE_METADATA } from '../types'; + +const MiniButton = styled(Button)({ + my: .5, + minWidth: 35 +}); + +function not(a: AudioLightModeType[], b: AudioLightModeType[]) { + return a.filter((value) => b.indexOf(value) === -1); +} + +const intersection = (a: AudioLightModeType[], b: AudioLightModeType[]) => { + return a.filter((type) => b.includes(type)); +}; + +interface TranserListProps { + selected: AudioLightModeType[]; + onSelectionChanged: (selected: AudioLightModeType[]) => void; +} + +const ModeTransferList: FC = ({ selected, onSelectionChanged }) => { + const [checked, setChecked] = React.useState([]); + const [left, setLeft] = React.useState(not(selected, ROTATE_AUDIO_LIGHT_MODES)); + const [right, setRight] = React.useState(selected); + + const leftChecked = intersection(checked, left); + const rightChecked = intersection(checked, right); + + useEffect(() => { + setLeft(not(ROTATE_AUDIO_LIGHT_MODES, selected)); + setRight(selected); + }, [selected]); + + const handleToggle = (value: AudioLightModeType) => () => { + const currentIndex = checked.indexOf(value); + const newChecked = [...checked]; + + if (currentIndex === -1) { + newChecked.push(value); + } else { + newChecked.splice(currentIndex, 1); + } + + setChecked(newChecked); + }; + + const handleAllRight = () => { + setChecked(not(checked, leftChecked)); + onSelectionChanged(ROTATE_AUDIO_LIGHT_MODES); + }; + + const handleCheckedRight = () => { + onSelectionChanged(right.concat(leftChecked)); + setChecked(not(checked, leftChecked)); + }; + + const handleCheckedLeft = () => { + onSelectionChanged(not(right, rightChecked)); + setChecked(not(checked, rightChecked)); + }; + + const handleAllLeft = () => { + setChecked(not(checked, rightChecked)); + onSelectionChanged([]); + }; + + const customList = (items: AudioLightModeType[]) => ( + + + {items.map((value) => { + return ( + + + + + ); + })} + + + + ); + + return ( + + {customList(left)} + + + + >> + + + > + + + < + + + << + + + + {customList(right)} + + ); +}; + +export default ModeTransferList; diff --git a/interface/src/project/components/PalettePicker.tsx b/interface/src/project/components/PalettePicker.tsx new file mode 100644 index 00000000..16ab2538 --- /dev/null +++ b/interface/src/project/components/PalettePicker.tsx @@ -0,0 +1,41 @@ +import React, { FC, useContext } from 'react'; +import { Box, MenuItem, TextField, ListItemText } from '@mui/material'; + +import { PaletteSettingsContext } from '../PaletteSettingsContext'; +import { generateGradient } from '../types'; + +interface PalettePickerProps { + name: string; + label: string; + value: string; + onChange: (event: React.ChangeEvent) => void; +} + +const PalettePicker: FC = ({ name, label, value, onChange }) => { + const context = useContext(PaletteSettingsContext); + return ( + + {context.paletteSettings.palettes.map((palette) => ( + + + + + + + + + ))} + + ); +}; + +export default PalettePicker; diff --git a/interface/src/project/modes/AudioLightColorMode.tsx b/interface/src/project/modes/AudioLightColorMode.tsx new file mode 100644 index 00000000..42161799 --- /dev/null +++ b/interface/src/project/modes/AudioLightColorMode.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; + +import { Box, FormLabel, Slider, Switch } from '@mui/material'; + +import { ColorModeSettings } from '../types'; +import ColorPicker from '../components/ColorPicker'; +import IncludedBands from '../components/IncludedBands'; + +import { useAudioLightMode, AudioLightModeRendererProps } from './AudioLightMode'; + +const AudioLightColorMode: FC = ({ data, updateData }) => { + const { + settings, handleChange, handleValueChange, handleSliderChange, handleColorChange + } = useAudioLightMode(data, updateData); + + return ( +
+ + Audio Enabled + + + + Color + + + + Brightness + + + Included Bands + +
+ ); +}; + +export default AudioLightColorMode; diff --git a/interface/src/project/modes/AudioLightConfettiMode.tsx b/interface/src/project/modes/AudioLightConfettiMode.tsx new file mode 100644 index 00000000..3899bfb7 --- /dev/null +++ b/interface/src/project/modes/AudioLightConfettiMode.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; + +import { Box, FormLabel, Slider } from '@mui/material'; + +import { ConfettiModeSettings } from '../types'; +import PalettePicker from '../components/PalettePicker'; + +import { useAudioLightMode, AudioLightModeRendererProps } from './AudioLightMode'; + +const AudioLightConfettiMode: FC = ({ data, updateData }) => { + const { settings, handleValueChange, handleSliderChange } = useAudioLightMode(data, updateData); + + return ( +
+ + + + + Palette Changes (per cycle) + + Brightness + + Delay + + +
+ ); +}; + +export default AudioLightConfettiMode; diff --git a/interface/src/project/modes/AudioLightFireMode.tsx b/interface/src/project/modes/AudioLightFireMode.tsx new file mode 100644 index 00000000..be5f6154 --- /dev/null +++ b/interface/src/project/modes/AudioLightFireMode.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; + +import { Box, FormLabel, Slider, Switch } from '@mui/material'; + +import { FireModeSettings } from '../types'; +import PalettePicker from '../components/PalettePicker'; + +import { useAudioLightMode, AudioLightModeRendererProps } from './AudioLightMode'; + +const AudioLightFireMode: FC = ({ data, updateData }) => { + const { settings, handleValueChange, handleSliderChange } = useAudioLightMode(data, updateData); + + return ( +
+ + + Reverse + + + + Cooling + + Sparking + + +
+ ); +}; + +export default AudioLightFireMode; diff --git a/interface/src/project/modes/AudioLightLightningMode.tsx b/interface/src/project/modes/AudioLightLightningMode.tsx new file mode 100644 index 00000000..8a7048fd --- /dev/null +++ b/interface/src/project/modes/AudioLightLightningMode.tsx @@ -0,0 +1,60 @@ +import { FC } from 'react'; + +import { Box, FormLabel, Slider } from '@mui/material'; + +import { LightningModeSettings } from '../types'; +import IncludedBands from '../components/IncludedBands'; +import ColorPicker from '../components/ColorPicker'; + +import { AudioLightModeRendererProps, useAudioLightMode } from './AudioLightMode'; + +const AudioLightLightningMode: FC = ({ data, updateData }) => { + const { settings, handleChange, handleColorChange, handleSliderChange } = useAudioLightMode(data, updateData); + + return ( +
+ + Color + + + + Brightness + + Flashes + + Threshold + + + + Included Bands + + +
+ ); +}; + +export default AudioLightLightningMode; diff --git a/interface/src/project/modes/AudioLightMode.tsx b/interface/src/project/modes/AudioLightMode.tsx new file mode 100644 index 00000000..5f0be769 --- /dev/null +++ b/interface/src/project/modes/AudioLightMode.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { ColorResult } from "react-color"; +import { extractEventValue } from "../../utils"; +import { AudioLightMode, AudioLightModeSettings } from "../types"; + +export interface AudioLightModeRendererProps { + data: AudioLightMode; + updateData: (newData: AudioLightMode, transmitData?: boolean, clearData?: boolean) => void; +} + +export const useAudioLightMode = ( + data: AudioLightMode, + updateData: (newData: AudioLightMode, transmitData?: boolean, clearData?: boolean) => void +) => { + + const handleChange = (name: keyof D) => (value: T) => { + updateData({ ...data, settings: { ...data.settings, [name]: value } }); + }; + + const handleValueChange = (name: keyof D) => (event: React.ChangeEvent) => { + updateData({ ...data, settings: { ...data.settings, [name]: extractEventValue(event) } }); + }; + + const handleSliderChange = (name: keyof D) => (event: Event, value: number | number[]) => { + updateData({ ...data, settings: { ...data.settings, [name]: value } }); + }; + + const handleColorChange = (name: keyof D) => (value: ColorResult) => { + updateData({ ...data, settings: { ...data.settings, [name]: value.hex } }); + }; + + return { settings: data.settings as D, handleChange, handleValueChange, handleSliderChange, handleColorChange } as const; +}; + diff --git a/interface/src/project/modes/AudioLightPacificaMode.tsx b/interface/src/project/modes/AudioLightPacificaMode.tsx new file mode 100644 index 00000000..8c98cca4 --- /dev/null +++ b/interface/src/project/modes/AudioLightPacificaMode.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; + +import { PacificaModeSettings } from '../types'; +import PalettePicker from '../components/PalettePicker'; + +import { useAudioLightMode, AudioLightModeRendererProps } from './AudioLightMode'; + +const AudioLightPacificaMode: FC = ({ data, updateData }) => { + const { settings, handleValueChange } = useAudioLightMode(data, updateData); + + return ( +
+ + + +
+ ); + +}; + +export default AudioLightPacificaMode; diff --git a/interface/src/project/modes/AudioLightPrideMode.tsx b/interface/src/project/modes/AudioLightPrideMode.tsx new file mode 100644 index 00000000..cd38fc19 --- /dev/null +++ b/interface/src/project/modes/AudioLightPrideMode.tsx @@ -0,0 +1,67 @@ +import { Box, FormLabel, Slider } from '@mui/material'; +import { FC } from 'react'; + +import { PrideModeSettings } from '../types'; + +import { AudioLightModeRendererProps, useAudioLightMode } from './AudioLightMode'; + +const AudioLightPrideMode: FC = ({ data, updateData }) => { + const { settings, handleSliderChange } = useAudioLightMode(data, updateData); + + return ( +
+ + Brightness BPM + + Brightness Freq Min + + Brightness Freq Max + + Hue BPM + + Hue Delta Min + + Hue Delta Max + + +
+ ); +}; + +export default AudioLightPrideMode; diff --git a/interface/src/project/modes/AudioLightRainbowMode.tsx b/interface/src/project/modes/AudioLightRainbowMode.tsx new file mode 100644 index 00000000..a97ca2a4 --- /dev/null +++ b/interface/src/project/modes/AudioLightRainbowMode.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; + +import { Box, FormLabel, Slider, Switch } from '@mui/material'; + +import { RainbowModeSettings } from '../types'; + +import { useAudioLightMode, AudioLightModeRendererProps } from './AudioLightMode'; + +const AudioLightRotateMode: FC = ({ data, updateData }) => { + const { settings, handleSliderChange, handleValueChange } = useAudioLightMode(data, updateData); + + return ( +
+ + Audio Enabled + + + + Brightness + + Rotate Speed + + Hue Delta + + +
+ ); +}; + +export default AudioLightRotateMode; diff --git a/interface/src/project/modes/AudioLightRotateMode.tsx b/interface/src/project/modes/AudioLightRotateMode.tsx new file mode 100644 index 00000000..d83afc33 --- /dev/null +++ b/interface/src/project/modes/AudioLightRotateMode.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react'; + +import { Box, FormLabel, Slider } from '@mui/material'; + +import { RotateModeSettings } from '../types'; +import ModeTransferList from '../components/ModeTransferList'; + +import { useAudioLightMode, AudioLightModeRendererProps } from './AudioLightMode'; + +const millisToMinutesAndSeconds = (millis: number) => { + var minutes = Math.floor(millis / 60000); + var seconds = Math.floor((millis % 60000) / 1000); + return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; +}; + +const AudioLightRotateMode: FC = ({ data, updateData }) => { + const { settings, handleSliderChange, handleChange } = useAudioLightMode(data, updateData); + + return ( +
+ + Delay + + + + Modes + + +
+ ); +}; + +export default AudioLightRotateMode; diff --git a/interface/src/project/types.ts b/interface/src/project/types.ts index da9c95d6..28a66b6f 100644 --- a/interface/src/project/types.ts +++ b/interface/src/project/types.ts @@ -1,9 +1,199 @@ -export interface LightState { - led_on: boolean; +import AudioLightColorMode from "./modes/AudioLightColorMode"; +import AudioLightConfettiMode from "./modes/AudioLightConfettiMode"; +import AudioLightFireMode from "./modes/AudioLightFireMode"; +import AudioLightLightningMode from "./modes/AudioLightLightningMode"; +import { AudioLightModeRendererProps } from "./modes/AudioLightMode"; +import AudioLightPacificaMode from "./modes/AudioLightPacificaMode"; +import AudioLightPrideMode from "./modes/AudioLightPrideMode"; +import AudioLightRainbowMode from "./modes/AudioLightRainbowMode"; +import AudioLightRotateMode from "./modes/AudioLightRotateMode"; + +export interface FrequencyData { + bands: number[]; } -export interface LightMqttSettings { - unique_id: string; +export interface Palette { name: string; - mqtt_path: string; + colors: string[]; +} + +export enum AudioLightModeType { + OFF = "off", + COLOR = "color", + RAINBOW = "rainbow", + LIGHTNING = "lightning", + CONFETTI = "confetti", + FIRE = "fire", + PACIFICA = "pacifica", + PRIDE = "pride", + ROTATE = "rotate" +} + +export interface OffModeSettings { +} + +export interface ColorModeSettings { + color: string; + brightness: number; + audio_enabled: boolean; + included_bands: boolean[] +} + +export interface RainbowModeSettings { + brightness: number; + rotate_speed: 32; + audio_enabled: boolean; + hue_delta: number; +} + +export interface LightningModeSettings { + color: string; + brightness: number; + threshold: number; + flashes: number; + audio_enabled: boolean; + included_bands: boolean[] +} + +export interface ConfettiModeSettings { + palette1: string; + palette2: string; + palette3: string; + max_changes: number; + brightness: number; + delay: number; +} + +export interface FireModeSettings { + palette: string; + cooling: number; + sparking: number; + reverse: boolean; +} + +export interface PacificaModeSettings { + palette1: string; + palette2: string; + palette3: string; } + +export interface PrideModeSettings { + brightness_bpm: number; + brightness_freq_min: number; + brightness_freq_max: number; + hue_bpm: number; + hue_delta_min: number; + hue_delta_max: number; +} + +export interface RotateModeSettings { + modes: AudioLightModeType[]; + delay: number; +} + +export interface PaletteSettings { + palettes: Palette[]; +} + +export interface LedSettings { + max_power_milliwatts: number; + dead_zone: number; + smoothing_factor: number; +} + +export type AudioLightModeSettings = ( + OffModeSettings | + ColorModeSettings | + RainbowModeSettings | + LightningModeSettings | + ConfettiModeSettings | + FireModeSettings | + PacificaModeSettings | + PrideModeSettings | + RotateModeSettings +) + +export interface AudioLightMode { + mode_id: AudioLightModeType; + settings: AudioLightModeSettings; +} + +export const generateGradient = (palette: Palette) => { + return `linear-gradient(0.25turn, ${palette.colors.join(', ')})`; +}; + +export const DEFAULT_PALETTE = [ + "#ff0000", + "#d52a00", + "#ab5500", + "#ab7f00", + "#abab00", + "#56d500", + "#00ff00", + "#00d52a", + "#00ab55", + "#0056aa", + "#0000ff", + "#2a00d5", + "#5500ab", + "#7f0081", + "#ab0055", + "#d5002b" +]; + +export interface AudioLightModeMetadata { + label: string; + renderer?: React.ComponentType; + rotate: boolean; +} + +export const AUDIO_LIGHT_MODE_METADATA: Record = { + off: { + label: "Off", + rotate: false + }, + color: { + label: "Color", + renderer: AudioLightColorMode, + rotate: true + }, + rainbow: { + label: "Rainbow", + renderer: AudioLightRainbowMode, + rotate: true + }, + lightning: { + label: "Lightning", + renderer: AudioLightLightningMode, + rotate: true + }, + confetti: { + label: "Confetti", + renderer: AudioLightConfettiMode, + rotate: true + }, + fire: { + label: "Fire", + renderer: AudioLightFireMode, + rotate: true + }, + pacifica: { + label: "Pacifica", + renderer: AudioLightPacificaMode, + rotate: true + }, + pride: { + label: "Pride", + renderer: AudioLightPrideMode, + rotate: true + }, + rotate: { + label: "Rotate", + renderer: AudioLightRotateMode, + rotate: false + } +}; + +export const AUDIO_LIGHT_MODES = Object.entries(AudioLightModeType).map((value) => value[1]); +export const ROTATE_AUDIO_LIGHT_MODES = Object.entries(AudioLightModeType) + .map((value) => value[1]).filter((value) => AUDIO_LIGHT_MODE_METADATA[value].rotate); diff --git a/interface/src/project/validators.ts b/interface/src/project/validators.ts index a978abd9..d8af6ab0 100644 --- a/interface/src/project/validators.ts +++ b/interface/src/project/validators.ts @@ -1,13 +1,20 @@ -import Schema from "async-validator"; +import Schema, { InternalRuleItem } from "async-validator"; +import { Palette } from "./types"; -export const LIGHT_MQTT_SETTINGS_VALIDATOR = new Schema({ - unique_id: { - required: true, message: "Please provide an id" - }, - name: { - required: true, message: "Please provide a name" - }, - mqtt_path: { - required: true, message: "Please provide an MQTT path" +export const createPaletteValidator = (palettes: Palette[], creating: boolean) => new Schema({ + name: [ + { required: true, message: "Name is required" }, + { type: "string", pattern: /^[a-zA-Z0-9 ]{1,24}$/, message: "Must be 1-24 characters: alpha numeric" }, + ...(creating ? [createUniqueNameValidator(palettes)] : []) + ] +}); + +export const createUniqueNameValidator = (palettes: Palette[]) => ({ + validator(rule: InternalRuleItem, name: string, callback: (error?: string) => void) { + if (palettes && palettes.find((p) => p.name === name)) { + callback("Name already in use"); + } else { + callback(); } + } }); diff --git a/platformio.ini b/platformio.ini index e0059548..54998203 100644 --- a/platformio.ini +++ b/platformio.ini @@ -2,8 +2,8 @@ extra_configs = factory_settings.ini features.ini -default_envs = esp12e -;default_envs = node32s +;default_envs = esp12e +default_envs = node32s [env] build_flags= @@ -36,6 +36,7 @@ lib_deps = ArduinoJson@>=6.0.0,<7.0.0 ESP Async WebServer@>=1.2.0,<2.0.0 AsyncMqttClient@>=0.8.2,<1.0.0 + FastLED [env:esp12e] platform = espressif8266 diff --git a/src/AudioLightMode.h b/src/AudioLightMode.h new file mode 100644 index 00000000..3507d5c3 --- /dev/null +++ b/src/AudioLightMode.h @@ -0,0 +1,105 @@ +#ifndef AudioLightMode_h +#define AudioLightMode_h + +#include +#include +#include + +#define AUDIO_LIGHT_MODE_FILE_PATH_PREFIX "/modes/" +#define AUDIO_LIGHT_MODE_FILE_PATH_SUFFIX ".json" +#define AUDIO_LIGHT_MODE_SERVICE_PATH_PREFIX "/rest/modes/" + +class AudioLightMode { + public: + virtual const String& getId() = 0; + virtual void begin() = 0; + virtual void readFromFS() = 0; + virtual void writeToFS() = 0; + virtual void tick(CRGB* leds, const uint16_t numLeds) = 0; + virtual void enable() = 0; + virtual void sampleComplete(){}; + virtual bool canRotate() { + return true; + }; + virtual void readAsJson(JsonObject& root) = 0; + virtual StateUpdateResult updateFromJson(JsonObject& root, const String& originId) = 0; +}; + +template +class AudioLightModeImpl : public StatefulService, public AudioLightMode { + protected: + String _id; + String _filePath; + String _servicePath; + JsonStateReader _stateReader; + JsonStateUpdater _stateUpdater; + PaletteSettingsService* _paletteSettingsService; + FrequencySampler* _frequencySampler; + HttpEndpoint _httpEndpoint; + FSPersistence _fsPersistence; + + public: + AudioLightModeImpl(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler, + JsonStateReader stateReader, + JsonStateUpdater stateUpdater, + const String& id) : + _id(id), + _filePath(AUDIO_LIGHT_MODE_SERVICE_PATH_PREFIX + id), + _servicePath(AUDIO_LIGHT_MODE_FILE_PATH_PREFIX + id + AUDIO_LIGHT_MODE_FILE_PATH_SUFFIX), + _stateReader(stateReader), + _stateUpdater(stateUpdater), + _paletteSettingsService(paletteSettingsService), + _frequencySampler(frequencySampler), + _httpEndpoint(stateReader, stateUpdater, this, server, _filePath, securityManager), + _fsPersistence(stateReader, stateUpdater, this, fs, _servicePath.c_str()) { + } + + /* + * Get the code for the mode as a string + */ + const String& getId() { + return _id; + } + + /* + * Read the config from the file system and disable the update handler + */ + void begin() { + _fsPersistence.disableUpdateHandler(); + _fsPersistence.readFromFS(); + } + + /* + * Load the mode config from the file system + */ + void readFromFS() { + _fsPersistence.readFromFS(); + } + + /* + * Save the mode config to the file system + */ + void writeToFS() { + _fsPersistence.writeToFS(); + } + + /* + * Read the mode settings as json + */ + void readAsJson(JsonObject& root) { + StatefulService::read(root, _stateReader); + } + + /* + * Update the mode settings from json + */ + StateUpdateResult updateFromJson(JsonObject& root, const String& originId) { + return StatefulService::update(root, _stateUpdater, originId); + } +}; + +#endif // end AudioLightMode_h diff --git a/src/AudioLightSettingsService.cpp b/src/AudioLightSettingsService.cpp new file mode 100644 index 00000000..11b95f9b --- /dev/null +++ b/src/AudioLightSettingsService.cpp @@ -0,0 +1,149 @@ +#include + +AudioLightSettingsService::AudioLightSettingsService(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + LedSettingsService* ledSettingsService, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + _httpEndpoint(std::bind(&AudioLightSettingsService::read, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&AudioLightSettingsService::update, this, std::placeholders::_1, std::placeholders::_2), + this, + server, + AUDIO_LIGHT_SERVICE_PATH, + securityManager, + AuthenticationPredicates::IS_AUTHENTICATED), + _audioLightModeTxRx( + std::bind(&AudioLightSettingsService::read, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&AudioLightSettingsService::update, this, std::placeholders::_1, std::placeholders::_2), + this, + server, + AUDIO_LIGHT_WS_PATH, + securityManager, + AuthenticationPredicates::IS_AUTHENTICATED), + _ledSettingsService(ledSettingsService) { + server->on( + AUDIO_LIGHT_SAVE_MODE_PATH, + HTTP_POST, + securityManager->wrapRequest(std::bind(&AudioLightSettingsService::saveModeConfig, this, std::placeholders::_1), + AuthenticationPredicates::IS_AUTHENTICATED)); + server->on( + AUDIO_LIGHT_LOAD_MODE_PATH, + HTTP_POST, + securityManager->wrapRequest(std::bind(&AudioLightSettingsService::loadModeConfig, this, std::placeholders::_1), + AuthenticationPredicates::IS_AUTHENTICATED)); + addUpdateHandler([&](const String& originId) { enableMode(); }, false); + frequencySampler->addUpdateHandler([&](const String& originId) { handleSample(); }, false); + ledSettingsService->addUpdateHandler([&](const String& originId) { updateSettings(); }, false); + paletteSettingsService->addUpdateHandler([&](const String& originId) { enableMode(); }, false); + _ledController = &FastLED.addLeds(_leds, NUM_LEDS); + _modes[0] = new OffMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[1] = new ColorMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[2] = new RainbowMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[3] = new LightningMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[4] = new ConfettiMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[5] = new FireMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[6] = new PacificaMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[7] = new PrideMode(server, fs, securityManager, paletteSettingsService, frequencySampler); + _modes[8] = new RotateMode(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + std::bind(&AudioLightSettingsService::getMode, this, std::placeholders::_1)); +} + +void AudioLightSettingsService::begin() { + // configure current mode + _state.currentMode = _modes[8]; + + // initialize all modes + for (uint8_t i = 0; i < NUM_MODES; i++) { + _modes[i]->begin(); + } + + // update the settings + updateSettings(); +} + +void AudioLightSettingsService::updateSettings() { + _ledSettingsService->read([&](LedSettings& ledSettings) { + FastLED.setMaxPowerInMilliWatts(ledSettings.maxPowerMilliwatts == 0 ? 0xFFFFFFFF : ledSettings.maxPowerMilliwatts); + }); + enableMode(); +} + +void AudioLightSettingsService::loop() { + _state.currentMode->tick(_leds, NUM_LEDS); +} + +AudioLightMode* AudioLightSettingsService::getMode(const String& modeId) { + for (uint8_t i = 0; i < NUM_MODES; i++) { + AudioLightMode* mode = _modes[i]; + if (mode->getId() == modeId) { + return mode; + } + } + return nullptr; +} + +void AudioLightSettingsService::enableMode() { + _state.currentMode->enable(); +} + +void AudioLightSettingsService::handleSample() { + _state.currentMode->sampleComplete(); +} + +void AudioLightSettingsService::read(AudioLightSettings& settings, JsonObject& root) { + if (settings.currentMode) { + root["mode_id"] = settings.currentMode->getId(); + JsonObject settingsRoot = root.createNestedObject("settings"); + settings.currentMode->readAsJson(settingsRoot); + } +} + +StateUpdateResult AudioLightSettingsService::update(JsonObject& root, AudioLightSettings& settings) { + // we must have a mode id + if (!root["mode_id"].is()) { + return StateUpdateResult::ERROR; + } + + // change mode if required, exit early on error + StateUpdateResult modeUpdateResult = StateUpdateResult::UNCHANGED; + String modeId = root["mode_id"]; + if (settings.currentMode->getId() != modeId) { + AudioLightMode* mode = getMode(modeId); + if (!mode) { + return StateUpdateResult::ERROR; + } + settings.currentMode = mode; + modeUpdateResult = StateUpdateResult::CHANGED; + } + + // change settings, exit early on error + StateUpdateResult settingsUpdateResult = StateUpdateResult::UNCHANGED; + if (root["settings"].is()) { + JsonObject modeSettings = root["settings"].as(); + settingsUpdateResult = settings.currentMode->updateFromJson(modeSettings, LOCAL_ORIGIN); + if (settingsUpdateResult == StateUpdateResult::ERROR) { + return StateUpdateResult::ERROR; + } + } + + // calculate update state + return modeUpdateResult == StateUpdateResult::CHANGED || settingsUpdateResult == StateUpdateResult::CHANGED + ? StateUpdateResult::CHANGED + : StateUpdateResult::UNCHANGED; +} + +void AudioLightSettingsService::saveModeConfig(AsyncWebServerRequest* request) { + _state.currentMode->writeToFS(); + request->send(200, "text/plain", "Saved"); +} + +void AudioLightSettingsService::loadModeConfig(AsyncWebServerRequest* request) { + _state.currentMode->readFromFS(); + _state.currentMode->enable(); + request->send(200, "text/plain", "Loaded"); +} diff --git a/src/AudioLightSettingsService.h b/src/AudioLightSettingsService.h new file mode 100644 index 00000000..3a6a6254 --- /dev/null +++ b/src/AudioLightSettingsService.h @@ -0,0 +1,66 @@ +#ifndef AudioLightSettingsService_h +#define AudioLightSettingsService_h + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NUM_MODES 9 + +#define AUDIO_LIGHT_SERVICE_PATH "/rest/audioLightSettings" +#define AUDIO_LIGHT_WS_PATH "/ws/audioLightSettings" + +#define AUDIO_LIGHT_SAVE_MODE_PATH "/rest/saveModeSettings" +#define AUDIO_LIGHT_LOAD_MODE_PATH "/rest/loadModeSettings" + +#define LOCAL_ORIGIN "local" + +class AudioLightSettings { + public: + AudioLightMode* currentMode = nullptr; +}; + +class AudioLightSettingsService : public StatefulService { + public: + AudioLightSettingsService(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + LedSettingsService* ledSettingsService, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + + void begin(); + void loop(); + + private: + HttpEndpoint _httpEndpoint; + WebSocketTxRx _audioLightModeTxRx; + LedSettingsService* _ledSettingsService; + CRGB _leds[NUM_LEDS]; + CLEDController* _ledController; + AudioLightMode* _modes[NUM_MODES]; + + void read(AudioLightSettings& settings, JsonObject& root); + StateUpdateResult update(JsonObject& root, AudioLightSettings& settings); + AudioLightMode* getMode(const String& modeId); + + void updateSettings(); + void enableMode(); + void handleSample(); + void saveModeConfig(AsyncWebServerRequest* request); + void loadModeConfig(AsyncWebServerRequest* request); +}; + +#endif // end AudioLightSettingsService_h \ No newline at end of file diff --git a/src/ColorMode.cpp b/src/ColorMode.cpp new file mode 100644 index 00000000..0f6244e9 --- /dev/null +++ b/src/ColorMode.cpp @@ -0,0 +1,31 @@ +#include + +ColorMode::ColorMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + ColorModeSettings::read, + ColorModeSettings::update, + COLOR_MODE_ID) { + addUpdateHandler([&](const String& originId) { enable(); }, false); +}; + +void ColorMode::enable() { + _refresh = true; +} + +void ColorMode::tick(CRGB* leds, const uint16_t numLeds) { + if (_refresh || _state.audioEnabled) { + FrequencyData* frequencyData = _frequencySampler->getFrequencyData(); + fill_solid(leds, numLeds, _state.color); + FastLED.show(_state.audioEnabled ? frequencyData->calculateEnergyFloat(_state.includedBands) * _state.brightness + : _state.brightness); + _refresh = false; + } +} diff --git a/src/ColorMode.h b/src/ColorMode.h new file mode 100644 index 00000000..3f381076 --- /dev/null +++ b/src/ColorMode.h @@ -0,0 +1,59 @@ +#ifndef ColorMode_h +#define ColorMode_h + +#include +#include +#include + +#ifndef FACTORY_COLOR_MODE_COLOR +#define FACTORY_COLOR_MODE_COLOR CRGB::White +#endif + +#ifndef FACTORY_COLOR_MODE_AUDIO_ENABLED +#define FACTORY_COLOR_MODE_AUDIO_ENABLED false +#endif + +#ifndef FACTORY_COLOR_MODE_BRIGHTNESS +#define FACTORY_COLOR_MODE_BRIGHTNESS 128 +#endif + +#define COLOR_MODE_ID "color" + +class ColorModeSettings { + public: + CRGB color; + uint8_t brightness; + bool audioEnabled; + bool includedBands[NUM_BANDS]; + + static void read(ColorModeSettings& settings, JsonObject& root) { + writeColorToJson(root, &settings.color); + writeByteToJson(root, &settings.brightness, "brightness"); + writeBoolToJson(root, &settings.audioEnabled, "audio_enabled"); + writeBooleanArrayToJson(root, settings.includedBands, NUM_BANDS, "included_bands"); + } + + static StateUpdateResult update(JsonObject& root, ColorModeSettings& settings) { + updateColorFromJson(root, &settings.color, FACTORY_COLOR_MODE_COLOR); + updateByteFromJson(root, &settings.brightness, FACTORY_COLOR_MODE_BRIGHTNESS, "brightness"); + updateBoolFromJson(root, &settings.audioEnabled, FACTORY_COLOR_MODE_AUDIO_ENABLED, "audio_enabled"); + updateBooleanArrayFromJson(root, settings.includedBands, NUM_BANDS, true, "included_bands"); + return StateUpdateResult::CHANGED; + } +}; + +class ColorMode : public AudioLightModeImpl { + private: + boolean _refresh = true; + + public: + ColorMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); +}; + +#endif diff --git a/src/ConfettiMode.cpp b/src/ConfettiMode.cpp new file mode 100644 index 00000000..257c51ef --- /dev/null +++ b/src/ConfettiMode.cpp @@ -0,0 +1,79 @@ +#include + +ConfettiMode::ConfettiMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + std::bind(&ConfettiMode::read, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&ConfettiMode::update, this, std::placeholders::_1, std::placeholders::_2), + CONFETTI_MODE_ID) { + addUpdateHandler([&](const String& originId) { enable(); }, false); +}; + +void ConfettiMode::enable() { + _refresh = true; + updateWithoutPropagation([&](ConfettiModeSettings& settings) { + _paletteSettingsService->refreshPalette(settings.palette1); + _paletteSettingsService->refreshPalette(settings.palette2); + _paletteSettingsService->refreshPalette(settings.palette3); + return StateUpdateResult::CHANGED; + }); +} + +void ConfettiMode::tick(CRGB* leds, const uint16_t numLeds) { + if (_refresh) { + fill_solid(leds, numLeds, CHSV(255, 0, 0)); + FastLED.show(); + _refresh = false; + } + + EVERY_N_MILLISECONDS(100) { + nblendPaletteTowardPalette(_currentPalette, _targetPalette, _state.maxChanges); + } + + uint8_t secondHand = (millis() / 1000) % 15; + static uint8_t lastSecond = 99; + if (lastSecond != secondHand) { + lastSecond = secondHand; + switch (secondHand) { + case 0: + _targetPalette = _state.palette1.colors; + _inc = 1; + _hue = 192; + _fade = 2; + _hueDelta = 255; + break; + case 5: + _targetPalette = _state.palette2.colors; + _inc = 2; + _hue = 128; + _fade = 8; + _hueDelta = 64; + break; + case 10: + _targetPalette = _state.palette3.colors; + _inc = 1; + _hue = random16(255); + _fade = 1; + _hueDelta = 16; + break; + case 15: + break; + } + } + + EVERY_N_MILLIS_I(confettiTimer, FACTORY_CONFETTI_MODE_DELAY) { + fadeToBlackBy(leds, numLeds, _fade); + int pos = random16(numLeds); + leds[pos] = ColorFromPalette(_currentPalette, _hue + random16(_hueDelta) / 4, _state.brightness, _currentBlending); + _hue = _hue + _inc; + FastLED.show(); + confettiTimer.setPeriod(_state.delay); + } +} diff --git a/src/ConfettiMode.h b/src/ConfettiMode.h new file mode 100644 index 00000000..d43b9b59 --- /dev/null +++ b/src/ConfettiMode.h @@ -0,0 +1,90 @@ +#ifndef ConfettiMode_h +#define ConfettiMode_h + +#include +#include + +#ifndef FACTORY_CONFETTI_MODE_MAX_CHANGES +#define FACTORY_CONFETTI_MODE_MAX_CHANGES 24 +#endif + +#ifndef FACTORY_CONFETTI_MODE_BRIGHTNESS +#define FACTORY_CONFETTI_MODE_BRIGHTNESS 255 +#endif + +#ifndef FACTORY_CONFETTI_MODE_DELAY +#define FACTORY_CONFETTI_MODE_DELAY 5 +#endif + +#ifndef FACTORY_CONFETTI_MODE_PALETTE1 +#define FACTORY_CONFETTI_MODE_PALETTE1 "Ocean" +#endif + +#ifndef FACTORY_CONFETTI_MODE_PALETTE2 +#define FACTORY_CONFETTI_MODE_PALETTE2 "Lava" +#endif + +#ifndef FACTORY_CONFETTI_MODE_PALETTE3 +#define FACTORY_CONFETTI_MODE_PALETTE3 "Forest" +#endif + +#define CONFETTI_MODE_ID "confetti" + +// could make palettes configurable, pick from a list perhaps... +class ConfettiModeSettings { + public: + uint8_t maxChanges; // number of changes per cycle + uint8_t brightness; // Brightness of a sequence. Remember, max_brightnessght is the overall limiter. + uint8_t delay; // We don't need much delay (if any) + + Palette palette1; + Palette palette2; + Palette palette3; +}; + +class ConfettiMode : public AudioLightModeImpl { + private: + // Variables used by the sequences. + uint8_t _fade = 8; // How quickly does it fade? Lower = slower fade rate. + int _hue = 50; // Starting hue. + uint8_t _inc = 1; // Incremental value for rotating hues + int _hueDelta = 255; // Range of random #'s to use for hue + + CRGBPalette16 _currentPalette; // Current primary target + CRGBPalette16 _targetPalette; // Pallette we are blending to + TBlendType _currentBlending = LINEARBLEND; // NOBLEND or LINEARBLEND + + bool _refresh = true; // For applying config updates or enabling the mode + + void read(ConfettiModeSettings& settings, JsonObject& root) { + writeByteToJson(root, &settings.maxChanges, "max_changes"); + writeByteToJson(root, &settings.brightness, "brightness"); + writeByteToJson(root, &settings.delay, "delay"); + + root["palette1"] = settings.palette1.name; + root["palette2"] = settings.palette2.name; + root["palette3"] = settings.palette3.name; + } + + StateUpdateResult update(JsonObject& root, ConfettiModeSettings& settings) { + updateByteFromJson(root, &settings.maxChanges, FACTORY_CONFETTI_MODE_MAX_CHANGES, "max_changes"); + updateByteFromJson(root, &settings.brightness, FACTORY_CONFETTI_MODE_BRIGHTNESS, "brightness"); + updateByteFromJson(root, &settings.delay, FACTORY_CONFETTI_MODE_DELAY, "delay"); + + settings.palette1 = _paletteSettingsService->getPalette(root["palette1"] | FACTORY_CONFETTI_MODE_PALETTE1); + settings.palette2 = _paletteSettingsService->getPalette(root["palette2"] | FACTORY_CONFETTI_MODE_PALETTE2); + settings.palette3 = _paletteSettingsService->getPalette(root["palette3"] | FACTORY_CONFETTI_MODE_PALETTE3); + return StateUpdateResult::CHANGED; + } + + public: + ConfettiMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); +}; + +#endif diff --git a/src/FastLEDSettings.h b/src/FastLEDSettings.h new file mode 100644 index 00000000..1d7e4f05 --- /dev/null +++ b/src/FastLEDSettings.h @@ -0,0 +1,20 @@ +#ifndef FastLEDSettings_h +#define FastLEDSettings_h + +#ifndef LED_DATA_PIN +#define LED_DATA_PIN 21 +#endif + +#ifndef COLOR_ORDER +#define COLOR_ORDER GRB +#endif // RGB + +#ifndef LED_TYPE +#define LED_TYPE WS2812 +#endif // WS2811 + +#ifndef NUM_LEDS +#define NUM_LEDS 9 +#endif // 100 + +#endif diff --git a/src/FireMode.cpp b/src/FireMode.cpp new file mode 100644 index 00000000..ba4c9635 --- /dev/null +++ b/src/FireMode.cpp @@ -0,0 +1,68 @@ +#include + +FireMode::FireMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + std::bind(&FireMode::read, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&FireMode::update, this, std::placeholders::_1, std::placeholders::_2), + FIRE_MODE_ID){}; + +void FireMode::enable() { + _refresh = true; + updateWithoutPropagation([&](FireModeSettings& settings) { + selectPalette(settings.palette.name, settings); + return StateUpdateResult::CHANGED; + }); +} + +void FireMode::tick(CRGB* leds, const uint16_t numLeds) { + if (_refresh) { + fill_solid(leds, numLeds, CHSV(255, 0, 0)); // clear leds + FastLED.show(); // render all leds black + memset(_heatMap, 0, sizeof(uint8_t) * numLeds); // clear heat map + _refresh = false; // clear refresh flag + } + // make firey stuff at ~100FPS + EVERY_N_MILLIS(10) { + // Step 1. Cool down every cell a little + for (int i = 0; i < numLeds; i++) { + _heatMap[i] = qsub8(_heatMap[i], random8(0, ((_state.cooling * 10) / numLeds) + 2)); + } + + // Step 2. Heat from each cell drifts 'up' and diffuses a little + for (int k = numLeds - 1; k >= 2; k--) { + _heatMap[k] = (_heatMap[k - 1] + _heatMap[k - 2] + _heatMap[k - 2]) / 3; + } + + // Step 3. Randomly ignite new 'sparks' of heat near the bottom + if (random8() < _state.sparking) { + int y = random8(7); + _heatMap[y] = qadd8(_heatMap[y], random8(160, 255)); + } + + // Step 4. Map from heat cells to LED colors + for (int j = 0; j < numLeds; j++) { + // Scale the heat value from 0-255 down to 0-240 + // for best results with color palettes. + byte colorindex = scale8(_heatMap[j], 240); + CRGB color = ColorFromPalette(_state.palette.colors, colorindex); + int pixelnumber; + if (_state.reverse) { + pixelnumber = (numLeds - 1) - j; + } else { + pixelnumber = j; + } + leds[pixelnumber] = color; + } + + // Step 5. Render the update + FastLED.show(); + } +} diff --git a/src/FireMode.h b/src/FireMode.h new file mode 100644 index 00000000..28cd768d --- /dev/null +++ b/src/FireMode.h @@ -0,0 +1,71 @@ +#ifndef FireMode_h +#define FireMode_h + +#include +#include + +#ifndef FACTORY_FIRE_MODE_SPARKING +#define FACTORY_FIRE_MODE_SPARKING 120 +#endif + +#ifndef FACTORY_FIRE_MODE_REVERSE +#define FACTORY_FIRE_MODE_REVERSE false +#endif + +#ifndef FACTORY_FIRE_MODE_COOLING +#define FACTORY_FIRE_MODE_COOLING 80 +#endif + +#ifndef FACTORY_FIRE_MODE_PALETTE +#define FACTORY_FIRE_MODE_PALETTE "heat" +#endif + +#define FIRE_MODE_ID "fire" + +// audio enable +// make palette customizable +class FireModeSettings { + public: + Palette palette; + bool reverse; + uint8_t cooling; + uint8_t sparking; +}; + +class FireMode : public AudioLightModeImpl { + private: + bool _refresh = true; // For applying config updates or enabling the mode + uint8_t _heatMap[NUM_LEDS]; // Intensity map the led strip - statically allocated ATM + + void read(FireModeSettings& settings, JsonObject& root) { + writeByteToJson(root, &settings.cooling, "cooling"); + writeByteToJson(root, &settings.sparking, "sparking"); + writeBoolToJson(root, &settings.reverse, "reverse"); + root["palette"] = settings.palette.name; + } + + StateUpdateResult update(JsonObject& root, FireModeSettings& settings) { + updateByteFromJson(root, &settings.sparking, FACTORY_FIRE_MODE_SPARKING, "sparking"); + updateByteFromJson(root, &settings.cooling, FACTORY_FIRE_MODE_COOLING, "cooling"); + updateBoolFromJson(root, &settings.reverse, FACTORY_FIRE_MODE_REVERSE, "reverse"); + selectPalette(root["palette"] | FACTORY_FIRE_MODE_PALETTE, settings); + return StateUpdateResult::CHANGED; + } + + void selectPalette(const String& name, FireModeSettings& settings) { + settings.palette = _paletteSettingsService->getPalette(name); + settings.palette.colors[0] = 0x000000; + } + + public: + FireMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); + void refreshPalettes(const String& originId); +}; + +#endif diff --git a/src/FrequencySampler.cpp b/src/FrequencySampler.cpp new file mode 100644 index 00000000..1df5b399 --- /dev/null +++ b/src/FrequencySampler.cpp @@ -0,0 +1,64 @@ +#include + +FrequencySampler::FrequencySampler(LedSettingsService* ledSettingsService) : _ledSettingsService(ledSettingsService) { + ledSettingsService->addUpdateHandler([&](const String& originId) { updateSettings(); }, false); +} + +void FrequencySampler::begin() { + pinMode(FREQUENCY_SAMPLER_RESET_PIN, OUTPUT); + pinMode(FREQUENCY_SAMPLER_STROBE_PIN, OUTPUT); + pinMode(FREQUENCY_SAMPLER_ANALOG_PIN, INPUT); + digitalWrite(FREQUENCY_SAMPLER_RESET_PIN, LOW); + digitalWrite(FREQUENCY_SAMPLER_STROBE_PIN, HIGH); + updateSettings(); +} + +void FrequencySampler::loop() { + // take samples 100 times a second (max) + EVERY_N_MILLIS(10) { + update( + [&](FrequencyData& frequencyData) { + // Reset MSGEQ7 IC + digitalWrite(FREQUENCY_SAMPLER_RESET_PIN, HIGH); + delayMicroseconds(5); + digitalWrite(FREQUENCY_SAMPLER_RESET_PIN, LOW); + + // read samples + for (uint8_t i = 0; i < NUM_BANDS; i++) { + // trigger each value in turn + digitalWrite(FREQUENCY_SAMPLER_STROBE_PIN, LOW); + + // allow the output to settle + delayMicroseconds(36); + + // read frequency for pin + uint16_t value = analogRead(FREQUENCY_SAMPLER_ANALOG_PIN); + + // re-map frequency to eliminate low level noise + value = value > _deadZone ? map(value - _deadZone, 0, ADC_MAX_VALUE - _deadZone, 0, ADC_MAX_VALUE) : 0; + + // crappy smoothing to avoid crazy flickering + frequencyData.bands[i] = _smoothingFactor * frequencyData.bands[i] + (1 - _smoothingFactor) * value; + + // strobe pin high again for next loop + digitalWrite(FREQUENCY_SAMPLER_STROBE_PIN, HIGH); + + // wait 72 microseconds + delayMicroseconds(36); + } + return StateUpdateResult::CHANGED; + }, + "loop"); + } +} + +void FrequencySampler::updateSettings() { + _ledSettingsService->read([&](LedSettings& ledSettings) { + _smoothingFactor = ledSettings.smoothingFactor; + _deadZone = ledSettings.deadZone; + }); +} + +FrequencyData* FrequencySampler::getFrequencyData() { + return &_state; +} diff --git a/src/FrequencySampler.h b/src/FrequencySampler.h new file mode 100644 index 00000000..413c3332 --- /dev/null +++ b/src/FrequencySampler.h @@ -0,0 +1,61 @@ +#ifndef FrequencySampler_h +#define FrequencySampler_h + +#include +#include +#include +#include + +#define FREQUENCY_SAMPLER_DEAD_ZONE 700 +#define FREQUENCY_SAMPLER_RESET_PIN 4 +#define FREQUENCY_SAMPLER_STROBE_PIN 5 +#define FREQUENCY_SAMPLER_ANALOG_PIN 36 + +#define NUM_BANDS 7 +#define ADC_MAX_VALUE 4096 + +class FrequencyData { + public: + uint16_t bands[NUM_BANDS]; + + float calculateEnergyFloat(bool* includedBands = NULL) { + uint16_t currentLevel = 0; + uint16_t numBands = 0; + for (uint16_t i = 0; i < NUM_BANDS; i++) { + if (includedBands == NULL || includedBands[i]) { + currentLevel += bands[i]; + numBands++; + } + } + return (float)currentLevel / (numBands * ADC_MAX_VALUE); + } + + uint8_t calculateEnergyByte(bool* includedBands = NULL) { + return calculateEnergyFloat(includedBands) * 255; + } + + static void read(FrequencyData& settings, JsonObject& root) { + JsonArray array = root.createNestedArray("bands"); + for (uint16_t i = 0; i < NUM_BANDS; i++) { + array.add(settings.bands[i]); + } + } +}; + +class FrequencySampler : public StatefulService { + public: + FrequencySampler(LedSettingsService* ledSettingsService); + + void begin(); + void loop(); + FrequencyData* getFrequencyData(); + + private: + LedSettingsService* _ledSettingsService; + float _smoothingFactor = 0; + float _deadZone = 0; + + void updateSettings(); +}; + +#endif // end FrequencySampler_h diff --git a/src/FrequencyTransmitter.cpp b/src/FrequencyTransmitter.cpp new file mode 100644 index 00000000..3516bf82 --- /dev/null +++ b/src/FrequencyTransmitter.cpp @@ -0,0 +1,26 @@ +#include + +FrequencyTransmitter::FrequencyTransmitter(AsyncWebServer* server, + SecurityManager* securityManager, + FrequencySampler* frequencySampler) : + _frequencySampler(frequencySampler), + _webSocketTx(FrequencyData::read, + this, + server, + FREQUENCY_TRANSMITTER_WS_PATH, + securityManager, + AuthenticationPredicates::IS_AUTHENTICATED) { + frequencySampler->addUpdateHandler([&](const String& originId) { handleSample(); }, false); +} + +void FrequencyTransmitter::handleSample() { + EVERY_N_MILLISECONDS(200) { + update( + [&](FrequencyData& state) { + FrequencyData* frequencyData = _frequencySampler->getFrequencyData(); + std::copy(std::begin(frequencyData->bands), std::end(frequencyData->bands), std::begin(state.bands)); + return StateUpdateResult::CHANGED; + }, + "system"); + } +} diff --git a/src/FrequencyTransmitter.h b/src/FrequencyTransmitter.h new file mode 100644 index 00000000..73d0c6a7 --- /dev/null +++ b/src/FrequencyTransmitter.h @@ -0,0 +1,20 @@ +#ifndef FrequencyTransmitter_h +#define FrequencyTransmitter_h + +#define FREQUENCY_TRANSMITTER_WS_PATH "/ws/frequencies" + +#include +#include +#include + +class FrequencyTransmitter : public StatefulService { + public: + FrequencyTransmitter(AsyncWebServer* server, SecurityManager* securityManager, FrequencySampler* frequencySampler); + + private: + FrequencySampler* _frequencySampler; + WebSocketTx _webSocketTx; + void handleSample(); +}; + +#endif // end FrequencyTransmitter_h diff --git a/src/JsonUtil.cpp b/src/JsonUtil.cpp new file mode 100644 index 00000000..3476119d --- /dev/null +++ b/src/JsonUtil.cpp @@ -0,0 +1,90 @@ +#include + +void updateBooleanArrayFromJson(JsonObject& root, bool booleanArray[], uint16_t len, bool def, String key) { + for (uint8_t i = 0; i < len; i++) { + booleanArray[i] = def; + } + if (root.containsKey(key) && root[key].is()) { + JsonArray jsonArray = root[key]; + for (uint8_t i = 0; i < len && i < jsonArray.size(); i++) { + if (jsonArray[i].is()) { + booleanArray[i] = jsonArray[i]; + } + } + } +} + +void writeBooleanArrayToJson(JsonObject& root, bool booleanArray[], uint16_t len, String key) { + JsonArray array = root.createNestedArray(key); + for (uint8_t i = 0; i < len; i++) { + array.add(booleanArray[i]); + } +} + +void updatePaletteFromJson(JsonObject& root, CRGBPalette16* palette, CRGB def, String key) { + for (uint8_t i = 0; i < 16; i++) { + palette->entries[i] = def; + } + if (root.containsKey(key) && root[key].is()) { + JsonArray jsonArray = root[key]; + for (uint8_t i = 0; i < 16 && i < jsonArray.size(); i++) { + if (jsonArray[i].is()) { + String colorString = jsonArray[i]; + if (colorString.length() == 7) { + palette->entries[i] = CRGB(strtoll(&colorString[1], NULL, 16)); + } + } + } + } +} + +void writePaletteToJson(JsonObject& root, const CRGBPalette16* palette, String key) { + JsonArray array = root.createNestedArray(key); + for (uint8_t i = 0; i < 16; i++) { + CRGB color = palette->entries[i]; + char colorString[8]; + sprintf(colorString, "#%02x%02x%02x", color.r, color.g, color.b); + array.add(colorString); + } +} + +void updateColorFromJson(JsonObject& root, CRGB* color, CRGB def, String key) { + if (root.containsKey(key) && root[key].is()) { + String colorString = root[key]; + if (colorString.length() == 7) { + *color = CRGB(strtoll(&colorString[1], NULL, 16)); + } + } else { + *color = def; + } +} + +void writeColorToJson(JsonObject& root, CRGB* color, String key) { + char colorString[8]; + sprintf(colorString, "#%02x%02x%02x", color->r, color->g, color->b); + root[key] = colorString; +} + +void updateByteFromJson(JsonObject& root, uint8_t* value, uint8_t def, String key) { + if (root.containsKey(key) && root[key].is()) { + *value = (uint8_t)root[key]; + } else { + *value = def; + } +} + +void writeByteToJson(JsonObject& root, uint8_t* value, String key) { + root[key] = (uint8_t)*value; +} + +void updateBoolFromJson(JsonObject& root, bool* value, bool def, String key) { + if (root.containsKey(key) && root[key].is()) { + *value = (bool)root[key]; + } else { + *value = def; + } +} + +void writeBoolToJson(JsonObject& root, bool* value, String key) { + root[key] = (bool)*value; +} diff --git a/src/JsonUtil.h b/src/JsonUtil.h new file mode 100644 index 00000000..c2c846b6 --- /dev/null +++ b/src/JsonUtil.h @@ -0,0 +1,22 @@ +#ifndef JsonUtil_h +#define JsonUtil_h + +#include +#include + +void updateBooleanArrayFromJson(JsonObject& root, bool booleanArray[], uint16_t len, bool def, String key); +void writeBooleanArrayToJson(JsonObject& root, bool booleanArray[], uint16_t maxSize, String key); + +void updateColorFromJson(JsonObject& root, CRGB* color, CRGB def, String key = "color"); +void writeColorToJson(JsonObject& root, CRGB* color, String key = "color"); + +void updatePaletteFromJson(JsonObject& root, CRGBPalette16* palette, CRGB def, String key = "colors"); +void writePaletteToJson(JsonObject& root, const CRGBPalette16* palette, String key = "colors"); + +void updateByteFromJson(JsonObject& root, uint8_t* value, uint8_t def, String key); +void writeByteToJson(JsonObject& root, uint8_t* value, String key); + +void updateBoolFromJson(JsonObject& root, bool* value, bool def, String key); +void writeBoolToJson(JsonObject& root, bool* value, String key); + +#endif diff --git a/src/LedSettingsService.cpp b/src/LedSettingsService.cpp new file mode 100644 index 00000000..02c56206 --- /dev/null +++ b/src/LedSettingsService.cpp @@ -0,0 +1,16 @@ +#include + +LedSettingsService::LedSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : + _httpEndpoint(LedSettings::read, + LedSettings::update, + this, + server, + LED_SETTINGS_SERVICE_PATH, + securityManager, + AuthenticationPredicates::IS_AUTHENTICATED), + _fsPersistence(LedSettings::read, LedSettings::update, this, fs, LED_SETTINGS_FILE) { +} + +void LedSettingsService::begin() { + _fsPersistence.readFromFS(); +} diff --git a/src/LedSettingsService.h b/src/LedSettingsService.h new file mode 100644 index 00000000..421433b3 --- /dev/null +++ b/src/LedSettingsService.h @@ -0,0 +1,54 @@ +#ifndef LedSettingsService_h +#define LedSettingsService_h + +#define LED_SETTINGS_FILE "/config/ledSettings.json" +#define LED_SETTINGS_SERVICE_PATH "/rest/ledSettings" + +#include +#include +#include + +#ifndef FACTORY_LED_MAX_POWER_MILLIWATTS +#define FACTORY_LED_MAX_POWER_MILLIWATTS 0 +#endif + +#ifndef FACTORY_LED_SMOOTHING_FACTOR +#define FACTORY_LED_SMOOTHING_FACTOR 0.15 +#endif + +#ifndef FACTORY_LED_DEAD_ZONE +#define FACTORY_LED_DEAD_ZONE 700 +#endif + +class LedSettings { + public: + uint32_t maxPowerMilliwatts; + uint16_t deadZone; + float smoothingFactor; + + static void read(LedSettings& settings, JsonObject& root) { + root["max_power_milliwatts"] = settings.maxPowerMilliwatts; + root["dead_zone"] = settings.deadZone; + root["smoothing_factor"] = settings.smoothingFactor; + } + + static StateUpdateResult update(JsonObject& root, LedSettings& settings) { + settings.maxPowerMilliwatts = root["max_power_milliwatts"] | FACTORY_LED_MAX_POWER_MILLIWATTS; + settings.deadZone = root["dead_zone"] | FACTORY_LED_DEAD_ZONE; + settings.smoothingFactor = root["smoothing_factor"] | FACTORY_LED_SMOOTHING_FACTOR; + return StateUpdateResult::CHANGED; + } +}; + +class LedSettingsService : public StatefulService { + public: + LedSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager); + + void begin(); + + private: + HttpEndpoint _httpEndpoint; + FSPersistence _fsPersistence; +}; + +#endif // end LedSettingsService_h diff --git a/src/LightMqttSettingsService.cpp b/src/LightMqttSettingsService.cpp deleted file mode 100644 index bddd9098..00000000 --- a/src/LightMqttSettingsService.cpp +++ /dev/null @@ -1,16 +0,0 @@ -#include - -LightMqttSettingsService::LightMqttSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : - _httpEndpoint(LightMqttSettings::read, - LightMqttSettings::update, - this, - server, - LIGHT_BROKER_SETTINGS_PATH, - securityManager, - AuthenticationPredicates::IS_AUTHENTICATED), - _fsPersistence(LightMqttSettings::read, LightMqttSettings::update, this, fs, LIGHT_BROKER_SETTINGS_FILE) { -} - -void LightMqttSettingsService::begin() { - _fsPersistence.readFromFS(); -} diff --git a/src/LightMqttSettingsService.h b/src/LightMqttSettingsService.h deleted file mode 100644 index e7b66b09..00000000 --- a/src/LightMqttSettingsService.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef LightMqttSettingsService_h -#define LightMqttSettingsService_h - -#include -#include -#include - -#define LIGHT_BROKER_SETTINGS_FILE "/config/brokerSettings.json" -#define LIGHT_BROKER_SETTINGS_PATH "/rest/brokerSettings" - -class LightMqttSettings { - public: - String mqttPath; - String name; - String uniqueId; - - static void read(LightMqttSettings& settings, JsonObject& root) { - root["mqtt_path"] = settings.mqttPath; - root["name"] = settings.name; - root["unique_id"] = settings.uniqueId; - } - - static StateUpdateResult update(JsonObject& root, LightMqttSettings& settings) { - settings.mqttPath = root["mqtt_path"] | SettingValue::format("homeassistant/light/#{unique_id}"); - settings.name = root["name"] | SettingValue::format("light-#{unique_id}"); - settings.uniqueId = root["unique_id"] | SettingValue::format("light-#{unique_id}"); - return StateUpdateResult::CHANGED; - } -}; - -class LightMqttSettingsService : public StatefulService { - public: - LightMqttSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager); - void begin(); - - private: - HttpEndpoint _httpEndpoint; - FSPersistence _fsPersistence; -}; - -#endif // end LightMqttSettingsService_h diff --git a/src/LightStateService.cpp b/src/LightStateService.cpp deleted file mode 100644 index 81696222..00000000 --- a/src/LightStateService.cpp +++ /dev/null @@ -1,73 +0,0 @@ -#include - -LightStateService::LightStateService(AsyncWebServer* server, - SecurityManager* securityManager, - AsyncMqttClient* mqttClient, - LightMqttSettingsService* lightMqttSettingsService) : - _httpEndpoint(LightState::read, - LightState::update, - this, - server, - LIGHT_SETTINGS_ENDPOINT_PATH, - securityManager, - AuthenticationPredicates::IS_AUTHENTICATED), - _mqttPubSub(LightState::haRead, LightState::haUpdate, this, mqttClient), - _webSocket(LightState::read, - LightState::update, - this, - server, - LIGHT_SETTINGS_SOCKET_PATH, - securityManager, - AuthenticationPredicates::IS_AUTHENTICATED), - _mqttClient(mqttClient), - _lightMqttSettingsService(lightMqttSettingsService) { - // configure led to be output - pinMode(LED_PIN, OUTPUT); - - // configure MQTT callback - _mqttClient->onConnect(std::bind(&LightStateService::registerConfig, this)); - - // configure update handler for when the light settings change - _lightMqttSettingsService->addUpdateHandler([&](const String& originId) { registerConfig(); }, false); - - // configure settings service update handler to update LED state - addUpdateHandler([&](const String& originId) { onConfigUpdated(); }, false); -} - -void LightStateService::begin() { - _state.ledOn = DEFAULT_LED_STATE; - onConfigUpdated(); -} - -void LightStateService::onConfigUpdated() { - digitalWrite(LED_PIN, _state.ledOn ? LED_ON : LED_OFF); -} - -void LightStateService::registerConfig() { - if (!_mqttClient->connected()) { - return; - } - String configTopic; - String subTopic; - String pubTopic; - - DynamicJsonDocument doc(256); - _lightMqttSettingsService->read([&](LightMqttSettings& settings) { - configTopic = settings.mqttPath + "/config"; - subTopic = settings.mqttPath + "/set"; - pubTopic = settings.mqttPath + "/state"; - doc["~"] = settings.mqttPath; - doc["name"] = settings.name; - doc["unique_id"] = settings.uniqueId; - }); - doc["cmd_t"] = "~/set"; - doc["stat_t"] = "~/state"; - doc["schema"] = "json"; - doc["brightness"] = false; - - String payload; - serializeJson(doc, payload); - _mqttClient->publish(configTopic.c_str(), 0, false, payload.c_str()); - - _mqttPubSub.configureTopics(pubTopic, subTopic); -} diff --git a/src/LightStateService.h b/src/LightStateService.h deleted file mode 100644 index e9c9a9cd..00000000 --- a/src/LightStateService.h +++ /dev/null @@ -1,88 +0,0 @@ -#ifndef LightStateService_h -#define LightStateService_h - -#include - -#include -#include -#include - -#define LED_PIN 2 -#define PRINT_DELAY 5000 - -#define DEFAULT_LED_STATE false -#define OFF_STATE "OFF" -#define ON_STATE "ON" - -// Note that the built-in LED is on when the pin is low on most NodeMCU boards. -// This is because the anode is tied to VCC and the cathode to the GPIO 4 (Arduino pin 2). -#ifdef ESP32 -#define LED_ON 0x1 -#define LED_OFF 0x0 -#elif defined(ESP8266) -#define LED_ON 0x0 -#define LED_OFF 0x1 -#endif - -#define LIGHT_SETTINGS_ENDPOINT_PATH "/rest/lightState" -#define LIGHT_SETTINGS_SOCKET_PATH "/ws/lightState" - -class LightState { - public: - bool ledOn; - - static void read(LightState& settings, JsonObject& root) { - root["led_on"] = settings.ledOn; - } - - static StateUpdateResult update(JsonObject& root, LightState& lightState) { - boolean newState = root["led_on"] | DEFAULT_LED_STATE; - if (lightState.ledOn != newState) { - lightState.ledOn = newState; - return StateUpdateResult::CHANGED; - } - return StateUpdateResult::UNCHANGED; - } - - static void haRead(LightState& settings, JsonObject& root) { - root["state"] = settings.ledOn ? ON_STATE : OFF_STATE; - } - - static StateUpdateResult haUpdate(JsonObject& root, LightState& lightState) { - String state = root["state"]; - // parse new led state - boolean newState = false; - if (state.equals(ON_STATE)) { - newState = true; - } else if (!state.equals(OFF_STATE)) { - return StateUpdateResult::ERROR; - } - // change the new state, if required - if (lightState.ledOn != newState) { - lightState.ledOn = newState; - return StateUpdateResult::CHANGED; - } - return StateUpdateResult::UNCHANGED; - } -}; - -class LightStateService : public StatefulService { - public: - LightStateService(AsyncWebServer* server, - SecurityManager* securityManager, - AsyncMqttClient* mqttClient, - LightMqttSettingsService* lightMqttSettingsService); - void begin(); - - private: - HttpEndpoint _httpEndpoint; - MqttPubSub _mqttPubSub; - WebSocketTxRx _webSocket; - AsyncMqttClient* _mqttClient; - LightMqttSettingsService* _lightMqttSettingsService; - - void registerConfig(); - void onConfigUpdated(); -}; - -#endif diff --git a/src/LightningMode.cpp b/src/LightningMode.cpp new file mode 100644 index 00000000..ffc9c303 --- /dev/null +++ b/src/LightningMode.cpp @@ -0,0 +1,73 @@ +#include + +LightningMode::LightningMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + LightningModeSettings::read, + LightningModeSettings::update, + LIGHTNING_MODE_ID) { + addUpdateHandler([&](const String& originId) { enable(); }, false); +}; + +void LightningMode::tick(CRGB* leds, const uint16_t numLeds) { + if (_refresh) { + _status = Status::IDLE; // assert idle mode + fill_solid(leds, numLeds, CHSV(255, 0, 0)); // clear leds + FastLED.show(); // render all leds black + _refresh = false; // clear refresh flag + return; // eager return + } + + if (_status == Status::TRIGGERED) { + _ledstart = random8(numLeds); // Determine starting location of flash + _ledlen = random8(numLeds - _ledstart); // Determine length of flash (not to go beyond numLeds-1) + _numFlashes = random8(3, _state.flashes); // Calculate the random number of flashes we will show + _flashCounter = 0; // The number of flashes we have shown + _status = Status::RUNNING; + } + + if (_status == Status::RUNNING && isWaitTimeElapsed()) { + _dimmer = (_flashCounter == 0) ? 5 : random8(1, 3); // leader scaled by a 5, return strokes brighter + fill_solid(leds + _ledstart, _ledlen, _state.color); // draw the flash + FastLED.show(_state.brightness / _dimmer); // show the flash + delay(random8(4, 10)); // wait a small amount of time (use non blocking delay?) + fill_solid(leds + _ledstart, _ledlen, CHSV(255, 0, 0)); // hide flash + FastLED.show(); // draw hidden leds + resetWaitTime(); // reset wait time for next flash + + // decrement flash counter, and reset to idle if done + if (++_flashCounter >= _numFlashes) { + _status = Status::IDLE; + } + } +} + +void LightningMode::enable() { + _refresh = true; +} + +void LightningMode::sampleComplete() { + if (_status == Status::IDLE) { + FrequencyData* frequencyData = _frequencySampler->getFrequencyData(); + if (frequencyData->calculateEnergyByte(_state.includedBands) >= _state.threshold) { + _status = Status::TRIGGERED; + } + } +} + +bool LightningMode::isWaitTimeElapsed() { + unsigned long waitTimeElapsed = (unsigned long)(millis() - _waitStartedAt); + return waitTimeElapsed >= _waitDuration; +} + +void LightningMode::resetWaitTime() { + _waitStartedAt = millis(); // mark current time for the non blocking delay + _waitDuration = (_flashCounter == 0 ? 200 : 50) + random8(100); // set wait, lead flash longer (make configurable) +} diff --git a/src/LightningMode.h b/src/LightningMode.h new file mode 100644 index 00000000..a78bf2bf --- /dev/null +++ b/src/LightningMode.h @@ -0,0 +1,88 @@ +#ifndef LightningMode_h +#define LightningMode_h + +#include +#include + +#ifndef FACTORY_LIGHTNING_MODE_COLOR +#define FACTORY_LIGHTNING_MODE_COLOR CRGB::White +#endif + +#ifndef FACTORY_LIGHTNING_MODE_FLASHES +#define FACTORY_LIGHTNING_MODE_FLASHES 8 +#endif + +#ifndef FACTORY_LIGHTNING_MODE_THRESHOLD +#define FACTORY_LIGHTNING_MODE_THRESHOLD 128 +#endif + +#ifndef FACTORY_LIGHTNING_MODE_BRIGHTNESS +#define FACTORY_LIGHTNING_MODE_BRIGHTNESS 255 +#endif + +#ifndef FACTORY_LIGHTNING_AUDIO_ENABLED +#define FACTORY_LIGHTNING_AUDIO_ENABLED false +#endif + +#define LIGHTNING_MODE_ID "lightning" + +class LightningModeSettings { + public: + CRGB color; + uint8_t brightness; + uint8_t threshold; + uint8_t flashes; + bool audioEnabled; + bool includedBands[NUM_BANDS]; + + static void read(LightningModeSettings& settings, JsonObject& root) { + writeColorToJson(root, &settings.color, "color"); + writeByteToJson(root, &settings.brightness, "brightness"); + writeByteToJson(root, &settings.threshold, "threshold"); + writeByteToJson(root, &settings.flashes, "flashes"); + writeBoolToJson(root, &settings.audioEnabled, "audio_enabled"); + writeBooleanArrayToJson(root, settings.includedBands, NUM_BANDS, "included_bands"); + } + + static StateUpdateResult update(JsonObject& root, LightningModeSettings& settings) { + updateColorFromJson(root, &settings.color, FACTORY_LIGHTNING_MODE_COLOR, "color"); + updateByteFromJson(root, &settings.brightness, FACTORY_LIGHTNING_MODE_BRIGHTNESS, "brightness"); + updateByteFromJson(root, &settings.threshold, FACTORY_LIGHTNING_MODE_THRESHOLD, "threshold"); + updateByteFromJson(root, &settings.flashes, FACTORY_LIGHTNING_MODE_FLASHES, "flashes"); + updateBoolFromJson(root, &settings.audioEnabled, FACTORY_LIGHTNING_AUDIO_ENABLED, "audio_enabled"); + updateBooleanArrayFromJson(root, settings.includedBands, NUM_BANDS, true, "included_bands"); + return StateUpdateResult::CHANGED; + } +}; + +class LightningMode : public AudioLightModeImpl { + private: + enum class Status { IDLE, TRIGGERED, RUNNING }; + + // State variables + Status _status = Status::IDLE; + uint8_t _ledstart; // Starting location of a flash + uint8_t _ledlen; // Length of a flash + uint16_t _dimmer = 1; // State for dimming initial flash by a factor + uint8_t _flashCounter = 0; // Keeps track of how many flashes we have done + uint8_t _numFlashes = 0; // Keeps track of how many flashes we will do + bool _refresh = true; // For applying config updates or enabling the mode + + // Pause tracking + unsigned long _waitStartedAt; + uint16_t _waitDuration; + + public: + LightningMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); + void sampleComplete(); + bool isWaitTimeElapsed(); + void resetWaitTime(); +}; + +#endif diff --git a/src/OffMode.cpp b/src/OffMode.cpp new file mode 100644 index 00000000..e5d6b69c --- /dev/null +++ b/src/OffMode.cpp @@ -0,0 +1,31 @@ +#include + +OffMode::OffMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + OffModeSettings::read, + OffModeSettings::update, + OFF_MODE_ID){}; + +void OffMode::enable() { + _refresh = true; +} + +void OffMode::tick(CRGB* leds, const uint16_t numLeds) { + if (_refresh) { + fill_solid(leds, numLeds, CHSV(255, 0, 0)); // clear leds + FastLED.show(); // render all leds black + _refresh = false; // clear refresh flag + } +} + +bool OffMode::canRotate() { + return false; +}; diff --git a/src/OffMode.h b/src/OffMode.h new file mode 100644 index 00000000..1c679d79 --- /dev/null +++ b/src/OffMode.h @@ -0,0 +1,33 @@ +#ifndef OffMode_h +#define OffMode_h + +#include +#include + +#define OFF_MODE_ID "off" + +class OffModeSettings { + public: + static void read(OffModeSettings& settings, JsonObject& root) { + } + + static StateUpdateResult update(JsonObject& root, OffModeSettings& settings) { + return StateUpdateResult::CHANGED; + } +}; + +class OffMode : public AudioLightModeImpl { + private: + bool _refresh = true; // For applying config updates or enabling the mode + public: + OffMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); + bool canRotate(); +}; + +#endif diff --git a/src/PacificaMode.cpp b/src/PacificaMode.cpp new file mode 100644 index 00000000..41f6151f --- /dev/null +++ b/src/PacificaMode.cpp @@ -0,0 +1,125 @@ +#include + +PacificaMode::PacificaMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + std::bind(&PacificaMode::read, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&PacificaMode::update, this, std::placeholders::_1, std::placeholders::_2), + PACIFICA_MODE_ID) { + addUpdateHandler([&](const String& originId) { enable(); }, false); +}; + +void PacificaMode::enable() { + _refresh = true; + updateWithoutPropagation([&](PacificaModeSettings& settings) { + _paletteSettingsService->refreshPalette(settings.palette1); + _paletteSettingsService->refreshPalette(settings.palette2); + _paletteSettingsService->refreshPalette(settings.palette3); + return StateUpdateResult::CHANGED; + }); +} + +void PacificaMode::tick(CRGB* leds, const uint16_t numLeds) { + // Increment the four "color index start" counters, one for each wave layer. + // Each is incremented at a different speed, and the speeds vary over time. + static uint16_t sCIStart1, sCIStart2, sCIStart3, sCIStart4; + static uint32_t sLastms = 0; + uint32_t ms = millis(); + uint32_t deltams = ms - sLastms; + sLastms = ms; + uint16_t speedfactor1 = beatsin16(3, 179, 269); + uint16_t speedfactor2 = beatsin16(4, 179, 269); + uint32_t deltams1 = (deltams * speedfactor1) / 256; + uint32_t deltams2 = (deltams * speedfactor2) / 256; + uint32_t deltams21 = (deltams1 + deltams2) / 2; + sCIStart1 += (deltams1 * beatsin88(1011, 10, 13)); + sCIStart2 -= (deltams21 * beatsin88(777, 8, 11)); + sCIStart3 -= (deltams1 * beatsin88(501, 5, 7)); + sCIStart4 -= (deltams2 * beatsin88(257, 4, 6)); + + // Clear out the LED array to a dim background blue-green + fill_solid(leds, numLeds, CRGB(2, 6, 10)); + + // Render each of four layers, with different scales and speeds, that vary over time + pacifica_one_layer(leds, + numLeds, + _state.palette1.colors, + sCIStart1, + beatsin16(3, 11 * 256, 14 * 256), + beatsin8(10, 70, 130), + 0 - beat16(301)); + pacifica_one_layer(leds, + numLeds, + _state.palette2.colors, + sCIStart2, + beatsin16(4, 6 * 256, 9 * 256), + beatsin8(17, 40, 80), + beat16(401)); + pacifica_one_layer(leds, numLeds, _state.palette3.colors, sCIStart3, 6 * 256, beatsin8(9, 10, 38), 0 - beat16(503)); + pacifica_one_layer(leds, numLeds, _state.palette3.colors, sCIStart4, 5 * 256, beatsin8(8, 10, 28), beat16(601)); + + // Add brighter 'whitecaps' where the waves lines up more + pacifica_add_whitecaps(leds, numLeds); + + // Deepen the blues and greens a bit + pacifica_deepen_colors(leds, numLeds); + + // Show the leds + FastLED.show(); +} + +// Add one layer of waves into the led array +void PacificaMode::pacifica_one_layer(CRGB* leds, + const uint16_t numLeds, + CRGBPalette16& p, + uint16_t cistart, + uint16_t wavescale, + uint8_t bri, + uint16_t ioff) { + uint16_t ci = cistart; + uint16_t waveangle = ioff; + uint16_t wavescale_half = (wavescale / 2) + 20; + for (uint16_t i = 0; i < numLeds; i++) { + waveangle += 250; + uint16_t s16 = sin16(waveangle) + 32768; + uint16_t cs = scale16(s16, wavescale_half) + wavescale_half; + ci += cs; + uint16_t sindex16 = sin16(ci) + 32768; + uint8_t sindex8 = scale16(sindex16, 240); + CRGB c = ColorFromPalette(p, sindex8, bri, LINEARBLEND); + leds[i] += c; + } +} + +// Add extra 'white' to areas where the four layers of light have lined up brightly +void PacificaMode::pacifica_add_whitecaps(CRGB* leds, const uint16_t numLeds) { + uint8_t basethreshold = beatsin8(9, 55, 65); + uint8_t wave = beat8(7); + + for (uint16_t i = 0; i < numLeds; i++) { + uint8_t threshold = scale8(sin8(wave), 20) + basethreshold; + wave += 7; + uint8_t l = leds[i].getAverageLight(); + if (l > threshold) { + uint8_t overage = l - threshold; + uint8_t overage2 = qadd8(overage, overage); + leds[i] += CRGB(overage, overage2, qadd8(overage2, overage2)); + } + } +} + +// Deepen the blues and greens +void PacificaMode::pacifica_deepen_colors(CRGB* leds, const uint16_t numLeds) { + for (uint16_t i = 0; i < numLeds; i++) { + leds[i].blue = scale8(leds[i].blue, 145); + leds[i].green = scale8(leds[i].green, 200); + leds[i] |= CRGB(2, 5, 7); + } +} diff --git a/src/PacificaMode.h b/src/PacificaMode.h new file mode 100644 index 00000000..db7e7f7e --- /dev/null +++ b/src/PacificaMode.h @@ -0,0 +1,66 @@ +#ifndef PacificaMode_h +#define PacificaMode_h + +#include +#include +#include + +#ifndef FACTORY_PACIFICA_MODE_PALETTE1 +#define FACTORY_PACIFICA_MODE_PALETTE1 "Pacifica 1" +#endif + +#ifndef FACTORY_PACIFICA_MODE_PALETTE2 +#define FACTORY_PACIFICA_MODE_PALETTE2 "Pacifica 2" +#endif + +#ifndef FACTORY_PACIFICA_MODE_PALETTE3 +#define FACTORY_PACIFICA_MODE_PALETTE3 "Pacifica 3" +#endif + +#define PACIFICA_MODE_ID "pacifica" + +class PacificaModeSettings { + public: + Palette palette1; + Palette palette2; + Palette palette3; +}; + +class PacificaMode : public AudioLightModeImpl { + private: + boolean _refresh = true; + + void read(PacificaModeSettings& settings, JsonObject& root) { + root["palette1"] = settings.palette1.name; + root["palette2"] = settings.palette2.name; + root["palette3"] = settings.palette3.name; + } + + StateUpdateResult update(JsonObject& root, PacificaModeSettings& settings) { + settings.palette1 = _paletteSettingsService->getPalette(root["palette1"] | FACTORY_PACIFICA_MODE_PALETTE1); + settings.palette2 = _paletteSettingsService->getPalette(root["palette2"] | FACTORY_PACIFICA_MODE_PALETTE2); + settings.palette3 = _paletteSettingsService->getPalette(root["palette3"] | FACTORY_PACIFICA_MODE_PALETTE3); + return StateUpdateResult::CHANGED; + } + + void pacifica_one_layer(CRGB* leds, + const uint16_t numLeds, + CRGBPalette16& p, + uint16_t cistart, + uint16_t wavescale, + uint8_t bri, + uint16_t ioff); + void pacifica_deepen_colors(CRGB* leds, const uint16_t numLeds); + void pacifica_add_whitecaps(CRGB* leds, const uint16_t numLeds); + + public: + PacificaMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); +}; + +#endif diff --git a/src/PaletteSettingsService.h b/src/PaletteSettingsService.h new file mode 100644 index 00000000..26a1a862 --- /dev/null +++ b/src/PaletteSettingsService.h @@ -0,0 +1,160 @@ +#ifndef PaletteSettingsService_h +#define PaletteSettingsService_h + +#include +#include +#include +#include +#include +#include + +#define PALETTE_SETTINGS_FILE "/config/paletteSettings.json" +#define PALETTE_SETTINGS_SERVICE_PATH "/rest/paletteSettings" + +const TProgmemRGBPalette16 PacificaColors1_p FL_PROGMEM = {0x000507, + 0x000409, + 0x00030B, + 0x00030D, + 0x000210, + 0x000212, + 0x000114, + 0x000117, + 0x000019, + 0x00001C, + 0x000026, + 0x000031, + 0x00003B, + 0x000046, + 0x14554B, + 0x28AA50}; + +const TProgmemRGBPalette16 PacificaColors2_p FL_PROGMEM = {0x000507, + 0x000409, + 0x00030B, + 0x00030D, + 0x000210, + 0x000212, + 0x000114, + 0x000117, + 0x000019, + 0x00001C, + 0x000026, + 0x000031, + 0x00003B, + 0x000046, + 0x0C5F52, + 0x19BE5F}; + +const TProgmemRGBPalette16 PacificaColors3_p FL_PROGMEM = {0x000208, + 0x00030E, + 0x000514, + 0x00061A, + 0x000820, + 0x000927, + 0x000B2D, + 0x000C33, + 0x000E39, + 0x001040, + 0x001450, + 0x001860, + 0x001C70, + 0x002080, + 0x1040BF, + 0x2060FF}; + +class Palette { + public: + String name; + CRGBPalette16 colors; + + // the default palette - rainbow + Palette() : name("Rainbow"), colors(RainbowColors_p) { + } + + // custom palettes + Palette(String name, CRGBPalette16 colors) : name(name), colors(colors) { + } +}; + +class PaletteSettings { + public: + std::list palettes; + + static void read(PaletteSettings& settings, JsonObject& root) { + JsonArray palettes = root.createNestedArray("palettes"); + for (Palette palette : settings.palettes) { + JsonObject paletteRoot = palettes.createNestedObject(); + paletteRoot["name"] = palette.name; + writePaletteToJson(paletteRoot, &palette.colors); + } + } + + static StateUpdateResult update(JsonObject& root, PaletteSettings& settings) { + settings.palettes.clear(); + if (root["palettes"].is()) { + for (JsonObject paletteRoot : root["palettes"].as()) { + String name = paletteRoot["name"]; + if (!name.isEmpty()) { + Palette palette = Palette(); + palette.name = name; + updatePaletteFromJson(paletteRoot, &palette.colors, CRGB::Black); + settings.palettes.push_back(palette); + } + } + } else { + settings.palettes.push_back(Palette()); + settings.palettes.push_back(Palette("Party", PartyColors_p)); + settings.palettes.push_back(Palette("Heat", HeatColors_p)); + settings.palettes.push_back(Palette("Rainbow Stripe", RainbowStripeColors_p)); + settings.palettes.push_back(Palette("Cloud", CloudColors_p)); + settings.palettes.push_back(Palette("Lava", LavaColors_p)); + settings.palettes.push_back(Palette("Ocean", OceanColors_p)); + settings.palettes.push_back(Palette("Forest", ForestColors_p)); + settings.palettes.push_back(Palette("Pacifica 1", PacificaColors1_p)); + settings.palettes.push_back(Palette("Pacifica 2", PacificaColors2_p)); + settings.palettes.push_back(Palette("Pacifica 3", PacificaColors3_p)); + } + return StateUpdateResult::CHANGED; + } +}; + +/** + * May be extended to support a palette editor in the UI. + */ +class PaletteSettingsService : public StatefulService { + private: + HttpEndpoint _httpEndpoint; + FSPersistence _fsPersistence; + + public: + PaletteSettingsService(AsyncWebServer* server, FS* fs, SecurityManager* securityManager) : + _httpEndpoint(PaletteSettings::read, + PaletteSettings::update, + this, + server, + PALETTE_SETTINGS_SERVICE_PATH, + securityManager, + AuthenticationPredicates::IS_AUTHENTICATED, + 10240), + _fsPersistence(PaletteSettings::read, PaletteSettings::update, this, fs, PALETTE_SETTINGS_FILE, 10240) { + } + + void begin() { + _fsPersistence.readFromFS(); + } + + void refreshPalette(Palette& palette) { + palette = getPalette(palette.name); + } + + Palette getPalette(const String& name) { + for (Palette palette : _state.palettes) { + if (palette.name == name) { + return palette; + } + } + return Palette(); + } +}; + +#endif diff --git a/src/PrideMode.cpp b/src/PrideMode.cpp new file mode 100644 index 00000000..af9dc1e2 --- /dev/null +++ b/src/PrideMode.cpp @@ -0,0 +1,63 @@ +#include + +PrideMode::PrideMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + PrideModeSettings::read, + PrideModeSettings::update, + PRIDE_MODE_ID) { + addUpdateHandler([&](const String& originId) { enable(); }, false); +}; + +void PrideMode::enable() { + _refresh = true; +} + +void PrideMode::tick(CRGB* leds, const uint16_t numLeds) { + static uint16_t sPseudotime = 0; + static uint16_t sLastMillis = 0; + static uint16_t sHue16 = 0; + + uint8_t sat8 = beatsin88(87, 220, 250); + uint8_t brightdepth = beatsin88(341, 96, 224); + uint16_t brightnessthetainc16 = beatsin88(203, (25 * 256), (40 * 256)); + uint8_t msmultiplier = beatsin88(_state.brightnessBpm, _state.brightnessFreqMin, _state.brightnessFreqMax); + + uint16_t hue16 = sHue16; // gHue * 256; + uint16_t hueinc16 = beatsin88(_state.hueBpm, _state.hueDeltaMin, _state.hueDeltaMax); + + uint16_t ms = millis(); + uint16_t deltams = ms - sLastMillis; + sLastMillis = ms; + sPseudotime += deltams * msmultiplier; + sHue16 += deltams * beatsin88(400, 5, 9); + uint16_t brightnesstheta16 = sPseudotime; + + for (uint16_t i = 0; i < numLeds; i++) { + hue16 += hueinc16; + uint8_t hue8 = hue16 / 256; + + brightnesstheta16 += brightnessthetainc16; + uint16_t b16 = sin16(brightnesstheta16) + 32768; + + uint16_t bri16 = (uint32_t)((uint32_t)b16 * (uint32_t)b16) / 65536; + uint8_t bri8 = (uint32_t)(((uint32_t)bri16) * brightdepth) / 65536; + bri8 += (255 - brightdepth); + + CRGB newcolor = CHSV(hue8, sat8, bri8); + + uint16_t pixelnumber = i; + pixelnumber = (numLeds - 1) - pixelnumber; + + nblend(leds[pixelnumber], newcolor, 64); + } + + FastLED.show(); +} diff --git a/src/PrideMode.h b/src/PrideMode.h new file mode 100644 index 00000000..68c772ff --- /dev/null +++ b/src/PrideMode.h @@ -0,0 +1,80 @@ +#ifndef PrideMode_h +#define PrideMode_h + +#include +#include +#include + +#define PRIDE_MODE_ID "pride" + +#ifndef FACTORY_PRIDE_MODE_BRIGHTNESS_MULTIPLIER_MIN +#define FACTORY_PRIDE_MODE_BRIGHTNESS_MULTIPLIER_MIN 23 +#endif + +#ifndef FACTORY_PRIDE_MODE_BRIGHTNESS_MULTIPLIER_MAX +#define FACTORY_PRIDE_MODE_BRIGHTNESS_MULTIPLIER_MAX 60 +#endif + +#ifndef FACTORY_PRIDE_MODE_BRIGHTNESS_BPM +#define FACTORY_PRIDE_MODE_BRIGHTNESS_BPM 147 +#endif + +#ifndef FACTORY_PRIDE_MODE_HUE_BPM +#define FACTORY_PRIDE_MODE_HUE_BPM 113 +#endif + +#ifndef FACTORY_PRIDE_MODE_HUDE_DELTA_MIN +#define FACTORY_PRIDE_MODE_HUDE_DELTA_MIN 1 +#endif + +#ifndef FACTORY_PRIDE_MODE_HUDE_DELTA_MAX +#define FACTORY_PRIDE_MODE_HUDE_DELTA_MAX 3000 +#endif + +class PrideModeSettings { + public: + uint8_t brightnessBpm; + uint8_t brightnessFreqMin; + uint8_t brightnessFreqMax; + + uint8_t hueBpm; + uint16_t hueDeltaMin; + uint16_t hueDeltaMax; + + static void read(PrideModeSettings& settings, JsonObject& root) { + writeByteToJson(root, &settings.brightnessBpm, "brightness_bpm"); + writeByteToJson(root, &settings.brightnessFreqMin, "brightness_freq_min"); + writeByteToJson(root, &settings.brightnessFreqMax, "brightness_freq_max"); + writeByteToJson(root, &settings.hueBpm, "hue_bpm"); + root["hue_delta_min"] = settings.hueDeltaMin; + root["hue_delta_max"] = settings.hueDeltaMax; + } + + static StateUpdateResult update(JsonObject& root, PrideModeSettings& settings) { + updateByteFromJson(root, &settings.brightnessBpm, FACTORY_PRIDE_MODE_BRIGHTNESS_BPM, "brightness_bpm"); + updateByteFromJson( + root, &settings.brightnessFreqMin, FACTORY_PRIDE_MODE_BRIGHTNESS_MULTIPLIER_MIN, "brightness_freq_min"); + updateByteFromJson( + root, &settings.brightnessFreqMax, FACTORY_PRIDE_MODE_BRIGHTNESS_MULTIPLIER_MAX, "brightness_freq_max"); + updateByteFromJson(root, &settings.hueBpm, FACTORY_PRIDE_MODE_HUE_BPM, "hue_bpm"); + settings.hueDeltaMin = root["hue_delta_min"] | FACTORY_PRIDE_MODE_HUDE_DELTA_MIN; + settings.hueDeltaMax = root["hue_delta_max"] | FACTORY_PRIDE_MODE_HUDE_DELTA_MAX; + return StateUpdateResult::CHANGED; + } +}; + +class PrideMode : public AudioLightModeImpl { + private: + boolean _refresh = true; + + public: + PrideMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); +}; + +#endif diff --git a/src/RainbowMode.cpp b/src/RainbowMode.cpp new file mode 100644 index 00000000..76c5213f --- /dev/null +++ b/src/RainbowMode.cpp @@ -0,0 +1,53 @@ +#include + +RainbowMode::RainbowMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + RainbowModeSettings::read, + RainbowModeSettings::update, + RAINBOW_MODE_ID) { + _ledsPerBand = NUM_LEDS / NUM_BANDS; + _remainingLeds = NUM_LEDS % NUM_BANDS; +}; + +void RainbowMode::enable() { + _lastFrameMicros = micros(); +} + +void RainbowMode::tick(CRGB* leds, const uint16_t numLeds) { + FrequencyData* frequencyData = _frequencySampler->getFrequencyData(); + + // rotate hue in time based manner + if (_state.rotateSpeed > 0) { + unsigned long rotateDelayMicros = 1000000 / _state.rotateSpeed; + unsigned long currentMicros = micros(); + unsigned long microsElapsed = (unsigned long)(currentMicros - _lastFrameMicros); + if (microsElapsed >= rotateDelayMicros) { + _lastFrameMicros = currentMicros; + _initialhue++; + } + } + + // fill the rainbow + fill_rainbow(leds, numLeds, _initialhue, _state.hueDelta); + + // fade each segment if audio-enabled + if (_state.audioEnabled) { + CRGB* startLed = leds; + for (uint8_t i = 0; i < NUM_BANDS; i++) { + uint16_t numLeds = _ledsPerBand + (i == NUM_BANDS - 1 ? _remainingLeds : 0); + fadeToBlackBy(startLed, numLeds, 255 - map(frequencyData->bands[i], 0, ADC_MAX_VALUE, 0, 255)); + startLed += _ledsPerBand; + } + } + + // update the leds + FastLED.show(_state.brightness); +} diff --git a/src/RainbowMode.h b/src/RainbowMode.h new file mode 100644 index 00000000..da54a436 --- /dev/null +++ b/src/RainbowMode.h @@ -0,0 +1,70 @@ +#ifndef RainbowMode_h +#define RainbowMode_h + +#include +#include + +#ifndef FACTORY_RAINBOW_MODE_HUE_DELTA +#define FACTORY_RAINBOW_MODE_HUE_DELTA 250 +#endif + +#ifndef FACTORY_RAINBOW_MODE_BRIGHTNESS +#define FACTORY_RAINBOW_MODE_BRIGHTNESS 128 +#endif + +#ifndef FACTORY_RAINBOW_MODE_ROTATE_SPEED +#define FACTORY_RAINBOW_MODE_ROTATE_SPEED 32 +#endif + +#ifndef FACTORY_RAINBOW_MODE_AUDIO_ENABLED +#define FACTORY_RAINBOW_MODE_AUDIO_ENABLED false +#endif + +#define RAINBOW_MODE_ID "rainbow" + +class RainbowModeSettings { + public: + uint8_t brightness; + uint8_t rotateSpeed; + bool audioEnabled; + uint8_t hueDelta; + + static void read(RainbowModeSettings& settings, JsonObject& root) { + writeByteToJson(root, &settings.brightness, "brightness"); + writeByteToJson(root, &settings.rotateSpeed, "rotate_speed"); + writeBoolToJson(root, &settings.audioEnabled, "audio_enabled"); + writeByteToJson(root, &settings.hueDelta, "hue_delta"); + } + + static StateUpdateResult update(JsonObject& root, RainbowModeSettings& settings) { + updateByteFromJson(root, &settings.brightness, FACTORY_RAINBOW_MODE_BRIGHTNESS, "brightness"); + updateByteFromJson(root, &settings.rotateSpeed, FACTORY_RAINBOW_MODE_ROTATE_SPEED, "rotate_speed"); + updateBoolFromJson(root, &settings.audioEnabled, FACTORY_RAINBOW_MODE_AUDIO_ENABLED, "audio_enabled"); + updateByteFromJson(root, &settings.hueDelta, FACTORY_RAINBOW_MODE_HUE_DELTA, "hue_delta"); + return StateUpdateResult::CHANGED; + } +}; + +class RainbowMode : public AudioLightModeImpl { + private: + // various state and settings for rainbow mode + uint16_t _ledsPerBand; + uint16_t _remainingLeds; + + // delay state for rotation + unsigned long _lastFrameMicros = 0; + + uint8_t _initialhue = 0; + boolean _refresh = true; + + public: + RainbowMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); +}; + +#endif diff --git a/src/RotateMode.cpp b/src/RotateMode.cpp new file mode 100644 index 00000000..5896b303 --- /dev/null +++ b/src/RotateMode.cpp @@ -0,0 +1,94 @@ +#include + +RotateMode::RotateMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler, + ModeFetcher modeFetcher) : + AudioLightModeImpl(server, + fs, + securityManager, + paletteSettingsService, + frequencySampler, + std::bind(&RotateMode::read, this, std::placeholders::_1, std::placeholders::_2), + std::bind(&RotateMode::update, this, std::placeholders::_1, std::placeholders::_2), + ROTATE_MODE_ID), + _modeFetcher(modeFetcher) { + addUpdateHandler([&](const String& originId) { enable(); }, false); +}; + +void RotateMode::read(RotateModeSettings& settings, JsonObject& root) { + JsonArray modes = root.createNestedArray("modes"); + for (String mode : settings.modes) { + modes.add(mode); + } + root["delay"] = settings.delay; +} + +StateUpdateResult RotateMode::update(JsonObject& root, RotateModeSettings& settings) { + settings.modes.clear(); + if (root["modes"].is()) { + for (String mode : root["modes"].as()) { + AudioLightMode* candidateMode = _modeFetcher(mode); + if (candidateMode && candidateMode->canRotate()) { + settings.modes.push_back(mode); + } + } + } + settings.delay = root["delay"] | FACTORY_ROTATE_MODE_DELAY; + return StateUpdateResult::CHANGED; +} + +void RotateMode::enable() { + _refresh = true; +} + +void RotateMode::tick(CRGB* leds, const uint16_t numLeds) { + unsigned long currentMillis = millis(); + + // refresh if we are required to + if (_refresh) { + selectMode(0, leds, numLeds); + _modeChangedAt = millis(); + _refresh = false; + } + + // hand off to the selected mode to do its thing + if (_selectedMode) { + _selectedMode->tick(leds, numLeds); + } + + // change mode if it's time + if ((unsigned long)(currentMillis - _modeChangedAt) >= _state.delay) { + selectMode(_currentMode + 1, leds, numLeds); + _modeChangedAt = millis(); + } +} + +void RotateMode::selectMode(uint8_t newMode, CRGB* leds, const uint16_t numLeds) { + AudioLightMode* nextMode = nullptr; + // select the next mode + if (_state.modes.size() > 0) { + // increment mode, resetting to zero if we've gone past the end of the array + _currentMode = newMode < _state.modes.size() ? newMode : 0; + // find the next mode + nextMode = _modeFetcher(*std::next(_state.modes.begin(), _currentMode)); + } + // activate the next mode if it's differnt from the selected mode + if (nextMode != _selectedMode || _refresh) { + _selectedMode = nextMode; + if (_selectedMode) { + // new mode selected, enable it + _selectedMode->enable(); + } else { + // no mode selected, clear LEDs + fill_solid(leds, numLeds, CHSV(255, 0, 0)); // clear leds + FastLED.show(); // render all leds black + } + } +} + +bool RotateMode::canRotate() { + return false; +}; diff --git a/src/RotateMode.h b/src/RotateMode.h new file mode 100644 index 00000000..d98af3d4 --- /dev/null +++ b/src/RotateMode.h @@ -0,0 +1,46 @@ +#ifndef RotateMode_h +#define RotateMode_h + +#include +#include +#include + +#ifndef FACTORY_ROTATE_MODE_DELAY +#define FACTORY_ROTATE_MODE_DELAY 30000 +#endif + +#define ROTATE_MODE_ID "rotate" + +using ModeFetcher = std::function; + +class RotateModeSettings { + public: + std::list modes; + uint32_t delay; +}; + +class RotateMode : public AudioLightModeImpl { + private: + boolean _refresh = true; + uint8_t _currentMode = 0; + unsigned long _modeChangedAt = 0; + ModeFetcher _modeFetcher; + AudioLightMode* _selectedMode; + + void read(RotateModeSettings& settings, JsonObject& root); + StateUpdateResult update(JsonObject& root, RotateModeSettings& settings); + void selectMode(uint8_t newMode, CRGB* leds, const uint16_t numLeds); + + public: + RotateMode(AsyncWebServer* server, + FS* fs, + SecurityManager* securityManager, + PaletteSettingsService* paletteSettingsService, + FrequencySampler* frequencySampler, + ModeFetcher modeFetcher); + void tick(CRGB* leds, const uint16_t numLeds); + void enable(); + bool canRotate(); +}; + +#endif diff --git a/src/main.cpp b/src/main.cpp index 0b1081a4..b63ceaa9 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,17 +1,24 @@ #include -#include -#include +#include +#include +#include +#include #define SERIAL_BAUD_RATE 115200 AsyncWebServer server(80); ESP8266React esp8266React(&server); -LightMqttSettingsService lightMqttSettingsService = - LightMqttSettingsService(&server, esp8266React.getFS(), esp8266React.getSecurityManager()); -LightStateService lightStateService = LightStateService(&server, - esp8266React.getSecurityManager(), - esp8266React.getMqttClient(), - &lightMqttSettingsService); + +LedSettingsService ledSettingsService(&server, esp8266React.getFS(), esp8266React.getSecurityManager()); +PaletteSettingsService paletteSettingsService(&server, esp8266React.getFS(), esp8266React.getSecurityManager()); +FrequencySampler frequencySampler(&ledSettingsService); +FrequencyTransmitter frequencyTransmitter(&server, esp8266React.getSecurityManager(), &frequencySampler); +AudioLightSettingsService audioLightSettingsService(&server, + esp8266React.getFS(), + esp8266React.getSecurityManager(), + &ledSettingsService, + &paletteSettingsService, + &frequencySampler); void setup() { // start serial and filesystem @@ -20,11 +27,17 @@ void setup() { // start the framework and demo project esp8266React.begin(); - // load the initial light settings - lightStateService.begin(); + // load general settings + ledSettingsService.begin(); + + // set up the sampler + frequencySampler.begin(); + + // load the palettes + paletteSettingsService.begin(); - // start the light service - lightMqttSettingsService.begin(); + // load all of the defaults + audioLightSettingsService.begin(); // start the server server.begin(); @@ -33,4 +46,10 @@ void setup() { void loop() { // run the framework's loop function esp8266React.loop(); + + // allow the sampler to run if needed + frequencySampler.loop(); + + // refresh the LEDs + audioLightSettingsService.loop(); }