"""Property definitions.
The object NAME is no longer the configuration. quadpanel.py parsed
"bgr-<slot>-<ip>" out of object names, which meant renaming an object silently
repointed it at a different fixture -- and broke outright for any host whose
name contains a hyphen (e.g. "show-light01"). Here the custom properties are
the source of truth and names are merely kept in sync for readability and for
compatibility with older .blend files.
"""
import bpy
from bpy.props import (BoolProperty, CollectionProperty, EnumProperty,
FloatProperty, IntProperty, PointerProperty, StringProperty)
from bpy.types import Object, PropertyGroup, Scene
from . import protocol
from .net import SENDER
class CthuloidLightProps(PropertyGroup):
"""Attached to every light object the addon drives."""
is_fixture_light: BoolProperty(
name="Cthuloid Light", default=False,
description="This light drives a channel triple on a hardware fixture")
fixture_id: StringProperty(
name="Fixture", default="",
description="Which fixture this light belongs to")
slot: IntProperty(
name="Slot", default=1, min=1, max=protocol.SLOTS_PER_FIXTURE,
description="Which of the fixture's four channel triples this light drives")
def _on_enabled_changed(fixture, context):
# push_scene() already skips a disabled fixture, but that only stops NEW
# payloads. The sender would keep the target and its reconnect loop, so an
# unplugged unit kept retrying -- and paying a ~5 s .local lookup -- until
# Blender restarted. Release it here. Ticking it while connected brings it
# up straight away rather than at the next scene change.
if not fixture.enabled:
SENDER.forget(fixture.host, fixture.port)
elif _connected:
from . import engine
engine.push_fixture(fixture.id_data, fixture, force=True)
# --- the Connect switch -----------------------------------------------------
#
# Session state, deliberately NOT stored in the .blend. It used to be a saved
# scene property, so a file saved while live came back live on open, and the
# button (PLAY/PAUSE icons) gave no clear sign either way. Now every session
# opens disconnected, and this one flag decides whether Blender talks to lights
# at all: connecting opens the ticked fixtures and pushes the current scene,
# disconnecting closes every socket and stops every retry.
_connected = False
def _get_live(self):
return _connected
def _set_live(self, value):
global _connected
value = bool(value)
if value == _connected:
return
_connected = value
from . import engine
engine.reset_state()
if value:
engine.push_scene(self.id_data, force=True)
else:
SENDER.forget_all()
def reset_connection():
"""Disconnect without a scene at hand (file load, addon unregister)."""
global _connected
_connected = False
SENDER.forget_all()
class CthuloidFixture(PropertyGroup):
"""One ESP32 running esp324xlight: 4 slots x 3 channels."""
id: StringProperty(name="ID", default="")
host: StringProperty(
name="Host", default="showlight01.local",
description="Hostname (mDNS .local works) or IP address of the fixture")
port: IntProperty(
name="Port", default=1337, min=1, max=65535,
description="TCP port of the firmware's command server")
enabled: BoolProperty(
name="Enabled", default=True,
description="Send to this fixture while Live is armed",
update=_on_enabled_changed)
# --- output shaping ---------------------------------------------------
red_max_pwm: IntProperty(
name="Red Max PWM",
default=30000, min=0, soft_max=protocol.FIRMWARE_RED_MAX_PWM,
max=protocol.FIRMWARE_RED_MAX_PWM,
description=(
"Ceiling for the red channel of every slot. Red diodes have a lower "
"voltage tolerance than green/blue, so they are capped to keep them "
"from being driven to full. 30000 is the value the original Blender "
"rig shipped with; the firmware itself hard-rejects anything above "
"45000 and will discard the entire command, so that is the maximum"))
# PROVISIONAL -- off by default, and physically unmotivated as it stands.
# Blender Power is watts against an arbitrary reference, whereas a diode's
# luminous output is fixed by the part: PWM duty sets perceived brightness,
# it cannot raise the ceiling. Normalising Power against energy_ref
# therefore maps nothing real.
#
# TODO: identify the exact diode type per channel and derive a measured
# per-channel PWM ceiling from its spec -- the same shape as red_max_pwm,
# but grounded in the part rather than picked by hand. That is the correct
# way to express "how bright can this channel actually get", and it should
# replace this switch rather than sit beside it.
use_energy: BoolProperty(
name="Scale by Energy (provisional)", default=False,
description=(
"Multiply each light's colour by its Power, normalised against "
"Energy Reference. Provisional and off by default: a diode's output "
"ceiling is fixed by the part, so Blender Power does not map to "
"anything physical. Pending per-channel ceilings derived from the "
"actual diode spec. Off matches the original script, which read "
"colour only"))
energy_ref: FloatProperty(
name="Energy Reference", default=1000.0, min=0.001,
description="Light Power that counts as full brightness when scaling by energy")
# --- motion -----------------------------------------------------------
fade_mode: EnumProperty(
name="Fade",
default='KEYFRAME',
items=[
('OFF', "Off",
"Send a plain pwm at the current value on every update. Simplest, "
"but steps rather than glides between sparse keyframes", 0),
('FIXED', "Fixed",
"Send pwm2 with a constant fade duration. Smooths coarse frame "
"steps at the cost of trailing the timeline slightly", 1),
('KEYFRAME', "To Next Keyframe",
"During playback, evaluate the light curves at the next keyframe "
"and send ONE pwm2 faded to it, then stay quiet until the segment "
"ends. The firmware interpolates in perceptual space, so output is "
"smooth without streaming a frame-rate command stream it cannot "
"absorb. Scrubbing and pausing fall back to a plain pwm", 2),
])
fade_ms: IntProperty(
name="Fade Time", default=60, min=1, max=1000, subtype='TIME',
description="Fade duration used by Fixed mode, in milliseconds")
# --- firmware-side settings pushed on demand --------------------------
gamma_b: FloatProperty(name="Gamma B", default=1.0, min=0.5, max=5.0)
gamma_g: FloatProperty(name="Gamma G", default=1.0, min=0.5, max=5.0)
gamma_r: FloatProperty(name="Gamma R", default=1.0, min=0.5, max=5.0)
freq: IntProperty(
name="PWM Frequency", default=1000, min=1, max=40000,
description=(
"Firmware PWM frequency in Hz. 1 kHz matches the bandwidth of an "
"ESP32-driven MOSFET with no gate driver; expect dimming above "
"roughly 1.2 kHz on that hardware"))
class CthuloidSceneProps(PropertyGroup):
fixtures: CollectionProperty(type=CthuloidFixture)
active_index: IntProperty(name="Active Fixture", default=0)
live: BoolProperty(
name="Connected",
description=(
"Connect to the ticked fixtures and drive them live from the scene. "
"Off: no connections, nothing sent. Not saved -- every session "
"opens disconnected"),
get=_get_live, set=_set_live)
send_on_frame: BoolProperty(
name="On Frame Change", default=True,
description="Send when the timeline frame changes (playback and scrubbing)")
send_on_edit: BoolProperty(
name="On Viewport Edit", default=True,
description="Send when a light's colour or power is changed interactively")
max_hz: IntProperty(
name="Max Rate", default=25, min=1, max=60,
description=(
"Upper bound on frames per second sent to any one fixture. The "
"firmware is single-threaded and a full-rate 30 fps stream will "
"overwhelm it"))
CLASSES = (CthuloidLightProps, CthuloidFixture, CthuloidSceneProps)
def register():
for cls in CLASSES:
bpy.utils.register_class(cls)
Scene.cthuloid = PointerProperty(type=CthuloidSceneProps)
Object.cthuloid_light = PointerProperty(type=CthuloidLightProps)
def unregister():
del Object.cthuloid_light
del Scene.cthuloid
for cls in reversed(CLASSES):
bpy.utils.unregister_class(cls)