diff --git a/backend/app/api/frames.py b/backend/app/api/frames.py index f804272d..d09fc351 100644 --- a/backend/app/api/frames.py +++ b/backend/app/api/frames.py @@ -1106,6 +1106,7 @@ async def api_frame_import( "controlCode": "control_code", "network": "network", "agent": "agent", + "palette": "palette", "scenes": "scenes", } for src, dest in mapping.items(): diff --git a/backend/app/models/frame.py b/backend/app/models/frame.py index fcd66da8..9d01a2bf 100644 --- a/backend/app/models/frame.py +++ b/backend/app/models/frame.py @@ -58,6 +58,7 @@ class Frame(Base): gpio_buttons = mapped_column(JSON, nullable=True) network = mapped_column(JSON, nullable=True) agent = mapped_column(JSON, nullable=True) + palette = mapped_column(JSON, nullable=True) # not used apps = mapped_column(JSON, nullable=True) @@ -102,6 +103,7 @@ def to_dict(self): 'gpio_buttons': self.gpio_buttons, 'network': self.network, 'agent': self.agent, + 'palette': self.palette, 'last_successful_deploy': self.last_successful_deploy, 'last_successful_deploy_at': self.last_successful_deploy_at.replace(tzinfo=timezone.utc).isoformat() if self.last_successful_deploy_at else None, } @@ -246,6 +248,7 @@ def get_frame_json(db: Session, frame: Frame) -> dict: for button in (frame.gpio_buttons or []) if int(button.get("pin", 0)) > 0 ], + "palette": frame.palette or {}, "controlCode": { "enabled": frame.control_code.get('enabled', 'true') == 'true', "position": frame.control_code.get('position', 'top-right'), diff --git a/backend/app/schemas/frames.py b/backend/app/schemas/frames.py index 0aaf4043..c6e1b038 100644 --- a/backend/app/schemas/frames.py +++ b/backend/app/schemas/frames.py @@ -41,6 +41,7 @@ class FrameBase(BaseModel): gpio_buttons: Optional[List[Dict[str, Any]]] network: Optional[Dict[str, Any]] agent: Optional[Dict[str, Any]] + palette: Optional[Dict[str, Any]] last_successful_deploy: Optional[Dict[str, Any]] last_successful_deploy_at: Optional[datetime] active_connections: Optional[int] = None @@ -90,6 +91,7 @@ class FrameUpdateRequest(BaseModel): gpio_buttons: Optional[List[Dict[str, Any]]] = None network: Optional[Dict[str, Any]] = None agent: Optional[Dict[str, Any]] = None + palette: Optional[Dict[str, Any]] = None next_action: Optional[str] = None class FrameLogsResponse(BaseModel): diff --git a/backend/migrations/versions/1a4ece62d617_palette.py b/backend/migrations/versions/1a4ece62d617_palette.py new file mode 100644 index 00000000..93bf07f8 --- /dev/null +++ b/backend/migrations/versions/1a4ece62d617_palette.py @@ -0,0 +1,28 @@ +"""palette + +Revision ID: 1a4ece62d617 +Revises: d1257bdc91fd +Create Date: 2025-06-21 00:46:44.593367 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +# revision identifiers, used by Alembic. +revision = '1a4ece62d617' +down_revision = 'd1257bdc91fd' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('frame', sa.Column('palette', sqlite.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('frame', 'palette') + # ### end Alembic commands ### diff --git a/frameos/src/drivers/waveshare/waveshare.nim b/frameos/src/drivers/waveshare/waveshare.nim index 94000ff3..985ee031 100644 --- a/frameos/src/drivers/waveshare/waveshare.nim +++ b/frameos/src/drivers/waveshare/waveshare.nim @@ -1,4 +1,4 @@ -import pixie, json, times, locks +import pixie, json, times, locks, options, sequtils import frameos/types import frameos/utils/image @@ -12,6 +12,7 @@ type Driver* = ref object of FrameOSDriver height: int lastImageData: seq[ColorRGBX] lastRenderAt: float + palette: Option[seq[(int, int, int)]] var lastFloatImageLock: Lock @@ -53,7 +54,23 @@ proc init*(frameOS: FrameOS): Driver = logger: logger, width: width, height: height, + palette: none(seq[(int, int, int)]), ) + + if waveshareDriver.colorOption == ColorOption.SpectraSixColor and len(frameOS.frameConfig.palette.colors) == 6: + let c = frameOS.frameConfig.palette.colors + result.palette = some(@[ + (c[0][0], c[0][1], c[0][2]), + (c[1][0], c[1][1], c[1][2]), + (c[2][0], c[2][1], c[2][2]), + (c[3][0], c[3][1], c[3][2]), + (999, 999, 999), + (c[4][0], c[4][1], c[4][2]), + (c[5][0], c[5][1], c[5][2]), + ]) + else: + result.palette = some(spectra6ColorPalette) + except Exception as e: logger.log(%*{"event": "driver:waveshare", "error": "Failed to initialize driver", "exception": e.msg, @@ -136,7 +153,7 @@ proc renderSevenColor*(self: Driver, image: Image) = waveshareDriver.renderImage(pixels) proc renderSpectraSixColor*(self: Driver, image: Image) = - let pixels = ditherPaletteIndexed(image, spectra6ColorPalette) + let pixels = ditherPaletteIndexed(image, if self.palette.isSome(): self.palette.get() else: spectra6ColorPalette) setLastPixels(pixels) self.notifyImageAvailable() waveshareDriver.renderImage(pixels) diff --git a/frameos/src/frameos/config.nim b/frameos/src/frameos/config.nim index f044fc7b..6d4da611 100644 --- a/frameos/src/frameos/config.nim +++ b/frameos/src/frameos/config.nim @@ -69,6 +69,24 @@ proc loadNetwork*(data: JsonNode): NetworkConfig = wifiHostpotTimeoutSeconds: data{"wifiHotspotTimeoutSeconds"}.getFloat(600), ) +proc loadPalette*(data: JsonNode): PaletteConfig = + if data == nil or data.kind != JObject or data["colors"] == nil or data["colors"].kind != JArray: + result = PaletteConfig(colors: @[]) + else: + result = PaletteConfig(colors: @[]) + for color in data["colors"].items: + try: + let color = parseHtmlColor(color.getStr()) + result.colors.add(( + int(color.r * 255), + int(color.g * 255), + int(color.b * 255), + )) + except: + echo "Warning: Invalid color in palette: ", color.getStr() + result.colors = @[] + return result + proc loadAgent*(data: JsonNode): AgentConfig = if data == nil or data.kind != JObject: result = AgentConfig(agentEnabled: false) @@ -106,6 +124,7 @@ proc loadConfig*(filename: string = "frame.json"): FrameConfig = gpioButtons: loadGPIOButtons(data{"gpioButtons"}), controlCode: loadControlCode(data{"controlCode"}), network: loadNetwork(data{"network"}), + palette: loadPalette(data{"palette"}), ) if result.assetsPath.endswith("/"): result.assetsPath = result.assetsPath.strip(leading = false, trailing = true, chars = {'/'}) diff --git a/frameos/src/frameos/frameos.nim b/frameos/src/frameos/frameos.nim index f483d570..f26a9998 100644 --- a/frameos/src/frameos/frameos.nim +++ b/frameos/src/frameos/frameos.nim @@ -46,7 +46,7 @@ proc start*(self: FrameOS) {.async.} = "logToFile": self.frameConfig.logToFile, "debug": self.frameConfig.debug, "timeZone": self.frameConfig.timeZone, - "gpioButtons": self.frameConfig.gpioButtons, + "gpioButtons": self.frameConfig.gpioButtons }} self.logger.log(message) netportal.setLogger(self.logger) diff --git a/frameos/src/frameos/types.nim b/frameos/src/frameos/types.nim index 9f0b7206..17f5523d 100644 --- a/frameos/src/frameos/types.nim +++ b/frameos/src/frameos/types.nim @@ -27,6 +27,7 @@ type controlCode*: ControlCode network*: NetworkConfig agent*: AgentConfig + palette*: PaletteConfig GPIOButton* = ref object pin*: int @@ -56,6 +57,9 @@ type agentRunCommands*: bool agentSharedSecret*: string + PaletteConfig* = ref object + colors*: seq[(int, int, int)] + FrameSchedule* = ref object events*: seq[ScheduledEvent] diff --git a/frontend/src/devices.ts b/frontend/src/devices.ts index e47c658c..38249595 100644 --- a/frontend/src/devices.ts +++ b/frontend/src/devices.ts @@ -1,4 +1,5 @@ import { Option } from './components/Select' +import { Palette } from './types' // To generate a new version: // cd backend && python3 list_devices.py @@ -98,3 +99,63 @@ export const devices: Option[] = [ { value: 'waveshare.EPD_13in3k', label: 'Waveshare 13.3" (K) 960x680 Black/White' }, { value: 'waveshare.EPD_13in3e', label: 'Waveshare 13.3" (E) 1600x1200 Spectra 6 Color' }, ] + +const colorNames = ['Black', 'White', 'Yellow', 'Red', 'Blue', 'Green'] +export const spectraPalettes: Palette[] = [ + { + name: 'Default', + colorNames, + colors: [ + '#000000', // Black + '#ffffff', // White + '#fff338', // Yellow + '#bf0000', // Red + '#6440ff', // Blue + '#438a1c', // Green + ], + }, + { + name: 'Desaturated', + colorNames, + colors: [ + '#000000', // Black + '#ffffff', // White + '#ffff00', // Yellow + '#ff0000', // Red + '#0000ff', // Blue + '#00ff00', // Green + ], + }, + { + name: 'Pimoroni Saturated', + colorNames, + colors: [ + '#000000', // Black + '#a1a4a5', // Gray + '#d0be47', // Yellow + '#9c484b', // Red + '#3d3b5e', // Blue + '#3a5b46', // Green + ], + }, + { + name: 'Measured', + colorNames, + colors: [ + '#3C3542', // Black + '#DCDDD4', // White + '#EDD600', // Yellow + '#C12117', // Red + '#2461C5', // Blue + '#548B79', // Green + ], + }, +] + +// SATURATED_PALETTE = [ + +export const withCustomPalette: Record = { + 'waveshare.EPD_13in3e': spectraPalettes[0], + 'waveshare.EPD_7in3e': spectraPalettes[0], + 'waveshare.EPD_4in0e': spectraPalettes[0], +} diff --git a/frontend/src/scenes/frame/Frame.tsx b/frontend/src/scenes/frame/Frame.tsx index 15bf5311..86959daf 100644 --- a/frontend/src/scenes/frame/Frame.tsx +++ b/frontend/src/scenes/frame/Frame.tsx @@ -29,8 +29,8 @@ export function Frame(props: FrameSceneProps) { restartAgent, } = useActions(frameLogic(frameLogicProps)) const { openLogs } = useActions(panelsLogic(frameLogicProps)) - const canDeployAgent = !!(frame.agent && frame.agent.agentEnabled && frame.agent.agentSharedSecret) - const agentExtra = canDeployAgent ? (frame.agent?.agentRunCommands ? ' (via agent)' : ' (via ssh)') : '' + const canDeployAgent = !!(frame?.agent && frame.agent.agentEnabled && frame.agent.agentSharedSecret) + const agentExtra = canDeployAgent ? (frame?.agent?.agentRunCommands ? ' (via agent)' : ' (via ssh)') : '' return ( diff --git a/frontend/src/scenes/frame/frameLogic.ts b/frontend/src/scenes/frame/frameLogic.ts index f8398721..15c466f4 100644 --- a/frontend/src/scenes/frame/frameLogic.ts +++ b/frontend/src/scenes/frame/frameLogic.ts @@ -45,6 +45,7 @@ const FRAME_KEYS: (keyof FrameType)[] = [ 'gpio_buttons', 'network', 'agent', + 'palette', ] const FRAME_KEYS_REQUIRE_RECOMPILE: (keyof FrameType)[] = ['device', 'scenes', 'reboot'] diff --git a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx index b9ac1f5f..c01288a3 100644 --- a/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx +++ b/frontend/src/scenes/frame/panels/FrameSettings/FrameSettings.tsx @@ -7,7 +7,7 @@ import { Select } from '../../../../components/Select' import { frameLogic } from '../../frameLogic' import { downloadJson } from '../../../../utils/downloadJson' import { Field } from '../../../../components/Field' -import { devices } from '../../../../devices' +import { devices, spectraPalettes, withCustomPalette } from '../../../../devices' import { secureToken } from '../../../../utils/secureToken' import { appsLogic } from '../Apps/appsLogic' import { frameSettingsLogic } from './frameSettingsLogic' @@ -19,6 +19,7 @@ import { PlusIcon, TrashIcon } from '@heroicons/react/24/solid' import { panelsLogic } from '../panelsLogic' import { Switch } from '../../../../components/Switch' import { NumberTextInput } from '../../../../components/NumberTextInput' +import { Palette } from '../../../../types' export interface FrameSettingsProps { className?: string @@ -33,6 +34,8 @@ export function FrameSettings({ className }: FrameSettingsProps) { const { buildCacheLoading } = useValues(frameSettingsLogic({ frameId })) const { openLogs } = useActions(panelsLogic({ frameId })) + const palette = withCustomPalette[frame.device || ''] + return (
{!frame ? ( @@ -147,6 +150,7 @@ export function FrameSettings({ className }: FrameSettingsProps) { )}
+
Connection
@@ -431,6 +435,7 @@ export function FrameSettings({ className }: FrameSettingsProps) { )}
+
Defaults
@@ -467,6 +472,71 @@ export function FrameSettings({ className }: FrameSettingsProps) { />
+ +
Palette
+ {frame.device && withCustomPalette[frame.device] ? ( +
+ + {({ value, onChange }: { value: Palette; onChange: (v: Palette) => void }) => ( +
+
+ Set to +