changeset 2453:b23afde50bc2

blender addons get thier own pdm setup for now. fix time_from_graph startup race
author drewp@bigasterisk.com
date Sun, 18 May 2025 20:08:35 -0700
parents f21b61884b0f
children 405abed9a45c
files blender/__init__.py blender/asyncio_thread.py blender/light_control/__init__.py blender/light_control/send_to_collector.py blender/pdm.lock blender/pyproject.toml blender/run.sh blender/time_sync/__init__.py blender/time_sync/time_from_graph.py src/light9/blender/__init__.py src/light9/blender/asyncio_thread.py src/light9/blender/light_control/__init__.py src/light9/blender/light_control/send_to_collector.py src/light9/blender/time_sync/__init__.py src/light9/blender/time_sync/time_from_graph.py
diffstat 15 files changed, 795 insertions(+), 318 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/__init__.py	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,34 @@
+import logging
+import sys
+
+from . import light_control, time_sync
+
+bl_info = {
+    "name": "light9_sync",
+    "description": "light9 sync",
+    "version": (0, 0, 1),
+    "blender": (4, 4, 0),
+    "category": "Object",
+}
+
+modules = (time_sync, light_control)
+
+
+def register():
+    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
+    logging.getLogger('syncedgraph').setLevel(logging.INFO)
+    for p in [
+            '/home/drewp/.local/share/pdm/venvs/blender-XpnfiNSq-3.11/lib/python3.11/site-packages',
+            '/home/drewp/projects/light9/src/',
+            '/my/proj/rdfdb',
+    ]:
+        if p not in sys.path:
+            sys.path.append(p)
+
+    for module in modules:
+        module.register()
+
+
+def unregister():
+    for module in reversed(modules):
+        module.unregister()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/asyncio_thread.py	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,16 @@
+import asyncio
+from threading import Thread
+from typing import Coroutine
+
+
+def startLoopInThread(task: Coroutine) -> asyncio.AbstractEventLoop:
+
+    def start_background_loop(loop: asyncio.AbstractEventLoop) -> None:
+        asyncio.set_event_loop(loop)
+        loop.run_forever()
+
+    loop = asyncio.new_event_loop()
+    t = Thread(target=start_background_loop, args=(loop,), daemon=True)
+    t.start()
+    asyncio.run_coroutine_threadsafe(task, loop)
+    return loop
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/light_control/__init__.py	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,16 @@
+"""
+watch blender lights, output to real lights
+"""
+
+sender: object = None
+
+
+def register():
+    global sender
+    from .send_to_collector import Sender
+
+    sender = Sender()
+
+
+def unregister():
+    raise NotImplementedError
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/light_control/send_to_collector.py	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,138 @@
+import asyncio
+import logging
+from dataclasses import dataclass, field
+from os import sync
+from typing import cast
+
+import bpy  # type: ignore
+from light9_sync.time_sync.time_from_graph import clamp
+from light9.effect.effecteval import literalColor
+from rdfdb.patch import Patch
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import RDF, ConjunctiveGraph, Literal, URIRef
+from rdflib.graph import _ContextType
+
+from light9 import networking
+from light9_sync.asyncio_thread import startLoopInThread
+from light9.namespaces import FUNC, L9
+from light9.newtypes import DeviceUri
+from light9.showconfig import showUri
+
+log = logging.getLogger('sendcoll')
+
+UPDATE_PERIOD = 1 / 20
+
+
+async def waitForConnection(graph: SyncedGraph):
+    # workaround for patch() quietly failing before connection. See SyncedGraph.
+    while not graph._isConnected():
+        await asyncio.sleep(.5)
+
+@dataclass
+class BlenderDev:
+    ctx: URIRef
+    obj: bpy.types.Object
+    uri: DeviceUri
+    setting: URIRef
+    devSets: URIRef
+
+    color: tuple[float, float, float] = (0, 0, 0)
+
+    def updateColor(self):
+        self.color = self.obj.data.color
+
+    def effectGraphStmts(self, bset0) -> list:
+        return [
+            (self.devSets, L9['setting'], self.setting, self.ctx),
+            (self.setting, L9['device'], self.uri, self.ctx),
+            (self.setting, L9['deviceAttr'], L9['color'], self.ctx),
+        ]
+
+    def makePatch(self, graph: SyncedGraph) -> Patch:
+        c = literalColor(*[clamp(x, 0, 1) for x in self.color])
+        return graph.getObjectPatch(self.ctx, self.setting, L9['value'], c)
+
+
+@dataclass
+class Sender:
+    syncedObjects: dict[str, BlenderDev] = field(default_factory=dict)
+
+    ctx = URIRef(showUri() + '/blender')
+    devSets = L9['blenderControlDevSets']
+
+    def __post_init__(self):
+        bpy.app.timers.register(self.findLightsInScene, first_interval=0.1)  # todo: what event to use?
+        startLoopInThread(self.task())
+
+    async def task(self):
+        log.info('start task')
+        try:
+            self.graph = SyncedGraph(networking.rdfdb.url, "blender_light_control")
+            await waitForConnection(self.graph)
+            self.patchInitialGraph()
+
+            while True:
+                try:
+                    await self.patchCurrentLightColors()
+                except Exception:
+                    log.error('skip', exc_info=True)
+                    await asyncio.sleep(1)
+                # todo: this could be triggered by onColorChanges instead of running constantly
+                await asyncio.sleep(UPDATE_PERIOD)
+        except Exception:
+            log.error('task', exc_info=True)
+
+    def patchInitialGraph(self):
+        g = self.effectGraph()
+        for d in self.syncedObjects.values():
+            for stmt in d.effectGraphStmts(self.devSets):
+                g.add(stmt)
+        for stmt in g:
+            log.info("adding %s", stmt)
+        self.graph.patchSubgraph(self.ctx, g)
+
+    async def patchCurrentLightColors(self):
+        p = Patch(addQuads=[], delQuads=[])
+        for d in self.syncedObjects.values():
+            p = p.update(d.makePatch(self.graph))
+        if p:
+            await self.graph.patch(p)
+
+    def effectGraph(self) -> ConjunctiveGraph:
+        g = ConjunctiveGraph()
+        ctx = cast(_ContextType, self.ctx)
+        g.add((L9['blenderControl'], RDF.type, L9['Effect'], ctx))
+        g.add((L9['blenderControl'], L9['effectFunction'], FUNC['scale'], ctx))
+        g.add((L9['blenderControl'], L9['publishAttr'], L9['strength'], ctx))
+        setting = L9['blenderControl/set0']
+        g.add((L9['blenderControl'], L9['setting'], setting, ctx))
+        g.add((setting, L9['value'], self.devSets, ctx))
+        g.add((setting, L9['effectAttr'], L9['deviceSettings'], ctx))
+        return g
+
+    def findLightsInScene(self):
+        self.syncedObjects.clear()
+        for obj in sorted(bpy.data.objects, key=lambda x: x.name):
+            if obj.get('l9dev'):
+                uri = DeviceUri(URIRef(obj['l9dev']))
+                self.syncedObjects[obj.name] = BlenderDev(
+                    self.ctx,
+                    obj,
+                    uri,
+                    setting=L9['blenderControl/set1'],
+                    devSets=self.devSets,
+                )
+                log.info(f'found {self.syncedObjects[obj.name]}')
+
+        self.watchForUpdates()
+
+    def watchForUpdates(self):
+        bpy.app.handlers.depsgraph_update_post.append(self.onColorChanges)
+        bpy.app.handlers.frame_change_post.append(self.onColorChanges)
+
+    def onColorChanges(self, scene, deps):
+        for obj in deps.objects:
+            dev = self.syncedObjects.get(obj.name)
+            if dev is None:
+                continue
+            dev.updateColor()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/pdm.lock	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,449 @@
+# This file is @generated by PDM.
+# It is not intended for manual editing.
+
+[metadata]
+groups = ["default"]
+strategy = ["inherit_metadata"]
+lock_version = "4.5.0"
+content_hash = "sha256:80b75d833f60f5d286742dfe9131cbda6859436c581622d5fbf664d9f13e5c1e"
+
+[[metadata.targets]]
+requires_python = "==3.11.*"
+
+[[package]]
+name = "aiohttp"
+version = "3.9.5"
+requires_python = ">=3.8"
+summary = "Async http client/server framework (asyncio)"
+groups = ["default"]
+dependencies = [
+    "aiosignal>=1.1.2",
+    "async-timeout<5.0,>=4.0; python_version < \"3.11\"",
+    "attrs>=17.3.0",
+    "frozenlist>=1.1.1",
+    "multidict<7.0,>=4.5",
+    "yarl<2.0,>=1.0",
+]
+files = [
+    {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ae53e33ee7476dd3d1132f932eeb39bf6125083820049d06edcdca4381f342"},
+    {file = "aiohttp-3.9.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c088c4d70d21f8ca5c0b8b5403fe84a7bc8e024161febdd4ef04575ef35d474d"},
+    {file = "aiohttp-3.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:639d0042b7670222f33b0028de6b4e2fad6451462ce7df2af8aee37dcac55424"},
+    {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f26383adb94da5e7fb388d441bf09c61e5e35f455a3217bfd790c6b6bc64b2ee"},
+    {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66331d00fb28dc90aa606d9a54304af76b335ae204d1836f65797d6fe27f1ca2"},
+    {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff550491f5492ab5ed3533e76b8567f4b37bd2995e780a1f46bca2024223233"},
+    {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f22eb3a6c1080d862befa0a89c380b4dafce29dc6cd56083f630073d102eb595"},
+    {file = "aiohttp-3.9.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a81b1143d42b66ffc40a441379387076243ef7b51019204fd3ec36b9f69e77d6"},
+    {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f64fd07515dad67f24b6ea4a66ae2876c01031de91c93075b8093f07c0a2d93d"},
+    {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:93e22add827447d2e26d67c9ac0161756007f152fdc5210277d00a85f6c92323"},
+    {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:55b39c8684a46e56ef8c8d24faf02de4a2b2ac60d26cee93bc595651ff545de9"},
+    {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4715a9b778f4293b9f8ae7a0a7cef9829f02ff8d6277a39d7f40565c737d3771"},
+    {file = "aiohttp-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:afc52b8d969eff14e069a710057d15ab9ac17cd4b6753042c407dcea0e40bf75"},
+    {file = "aiohttp-3.9.5-cp311-cp311-win32.whl", hash = "sha256:b3df71da99c98534be076196791adca8819761f0bf6e08e07fd7da25127150d6"},
+    {file = "aiohttp-3.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:88e311d98cc0bf45b62fc46c66753a83445f5ab20038bcc1b8a1cc05666f428a"},
+    {file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"},
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.3.2"
+requires_python = ">=3.9"
+summary = "aiosignal: a list of registered asynchronous callbacks"
+groups = ["default"]
+dependencies = [
+    "frozenlist>=1.1.0",
+]
+files = [
+    {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"},
+    {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"},
+]
+
+[[package]]
+name = "attrs"
+version = "25.3.0"
+requires_python = ">=3.8"
+summary = "Classes Without Boilerplate"
+groups = ["default"]
+files = [
+    {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"},
+    {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"},
+]
+
+[[package]]
+name = "automat"
+version = "25.4.16"
+requires_python = ">=3.9"
+summary = "Self-service finite-state machines for the programmer on the go."
+groups = ["default"]
+dependencies = [
+    "typing-extensions; python_version < \"3.10\"",
+]
+files = [
+    {file = "automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1"},
+    {file = "automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0"},
+]
+
+[[package]]
+name = "coloredlogs"
+version = "15.0.1"
+requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+summary = "Colored terminal output for Python's logging module"
+groups = ["default"]
+dependencies = [
+    "humanfriendly>=9.1",
+]
+files = [
+    {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
+    {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
+]
+
+[[package]]
+name = "constantly"
+version = "23.10.4"
+requires_python = ">=3.8"
+summary = "Symbolic constants in Python"
+groups = ["default"]
+files = [
+    {file = "constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9"},
+    {file = "constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd"},
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.6.0"
+requires_python = ">=3.9"
+summary = "A list-like structure which implements collections.abc.MutableSequence"
+groups = ["default"]
+files = [
+    {file = "frozenlist-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d"},
+    {file = "frozenlist-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0"},
+    {file = "frozenlist-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe"},
+    {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba"},
+    {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595"},
+    {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a"},
+    {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626"},
+    {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff"},
+    {file = "frozenlist-1.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a"},
+    {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0"},
+    {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606"},
+    {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584"},
+    {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a"},
+    {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1"},
+    {file = "frozenlist-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e"},
+    {file = "frozenlist-1.6.0-cp311-cp311-win32.whl", hash = "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860"},
+    {file = "frozenlist-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603"},
+    {file = "frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191"},
+    {file = "frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68"},
+]
+
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+summary = "Human friendly output for text interfaces using Python"
+groups = ["default"]
+dependencies = [
+    "monotonic; python_version == \"2.7\"",
+    "pyreadline3; sys_platform == \"win32\" and python_version >= \"3.8\"",
+    "pyreadline; sys_platform == \"win32\" and python_version < \"3.8\"",
+]
+files = [
+    {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
+    {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
+]
+
+[[package]]
+name = "hyperlink"
+version = "21.0.0"
+requires_python = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+summary = "A featureful, immutable, and correct URL for Python."
+groups = ["default"]
+dependencies = [
+    "idna>=2.5",
+    "typing; python_version < \"3.5\"",
+]
+files = [
+    {file = "hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"},
+    {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"},
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+requires_python = ">=3.6"
+summary = "Internationalized Domain Names in Applications (IDNA)"
+groups = ["default"]
+files = [
+    {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
+    {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
+]
+
+[[package]]
+name = "incremental"
+version = "24.7.2"
+requires_python = ">=3.8"
+summary = "A small library that versions your Python projects."
+groups = ["default"]
+dependencies = [
+    "setuptools>=61.0",
+    "tomli; python_version < \"3.11\"",
+]
+files = [
+    {file = "incremental-24.7.2-py3-none-any.whl", hash = "sha256:8cb2c3431530bec48ad70513931a760f446ad6c25e8333ca5d95e24b0ed7b8fe"},
+    {file = "incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9"},
+]
+
+[[package]]
+name = "isodate"
+version = "0.6.1"
+summary = "An ISO 8601 date/time/duration parser and formatter"
+groups = ["default"]
+dependencies = [
+    "six",
+]
+files = [
+    {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"},
+    {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"},
+]
+
+[[package]]
+name = "multidict"
+version = "6.4.3"
+requires_python = ">=3.9"
+summary = "multidict implementation"
+groups = ["default"]
+dependencies = [
+    "typing-extensions>=4.1.0; python_version < \"3.11\"",
+]
+files = [
+    {file = "multidict-6.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f6f19170197cc29baccd33ccc5b5d6a331058796485857cf34f7635aa25fb0cd"},
+    {file = "multidict-6.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f2882bf27037eb687e49591690e5d491e677272964f9ec7bc2abbe09108bdfb8"},
+    {file = "multidict-6.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fbf226ac85f7d6b6b9ba77db4ec0704fde88463dc17717aec78ec3c8546c70ad"},
+    {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e329114f82ad4b9dd291bef614ea8971ec119ecd0f54795109976de75c9a852"},
+    {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1f4e0334d7a555c63f5c8952c57ab6f1c7b4f8c7f3442df689fc9f03df315c08"},
+    {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:740915eb776617b57142ce0bb13b7596933496e2f798d3d15a20614adf30d229"},
+    {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255dac25134d2b141c944b59a0d2f7211ca12a6d4779f7586a98b4b03ea80508"},
+    {file = "multidict-6.4.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4e8535bd4d741039b5aad4285ecd9b902ef9e224711f0b6afda6e38d7ac02c7"},
+    {file = "multidict-6.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c433a33be000dd968f5750722eaa0991037be0be4a9d453eba121774985bc8"},
+    {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4eb33b0bdc50acd538f45041f5f19945a1f32b909b76d7b117c0c25d8063df56"},
+    {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:75482f43465edefd8a5d72724887ccdcd0c83778ded8f0cb1e0594bf71736cc0"},
+    {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce5b3082e86aee80b3925ab4928198450d8e5b6466e11501fe03ad2191c6d777"},
+    {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e413152e3212c4d39f82cf83c6f91be44bec9ddea950ce17af87fbf4e32ca6b2"},
+    {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aac2eeff69b71f229a405c0a4b61b54bade8e10163bc7b44fcd257949620618"},
+    {file = "multidict-6.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ab583ac203af1d09034be41458feeab7863c0635c650a16f15771e1386abf2d7"},
+    {file = "multidict-6.4.3-cp311-cp311-win32.whl", hash = "sha256:1b2019317726f41e81154df636a897de1bfe9228c3724a433894e44cd2512378"},
+    {file = "multidict-6.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:43173924fa93c7486402217fab99b60baf78d33806af299c56133a3755f69589"},
+    {file = "multidict-6.4.3-py3-none-any.whl", hash = "sha256:59fe01ee8e2a1e8ceb3f6dbb216b09c8d9f4ef1c22c4fc825d045a147fa2ebc9"},
+    {file = "multidict-6.4.3.tar.gz", hash = "sha256:3ada0b058c9f213c5f95ba301f922d402ac234f1111a7d8fd70f1b99f3c281ec"},
+]
+
+[[package]]
+name = "noise"
+version = "1.2.2"
+summary = "Perlin noise for Python"
+groups = ["default"]
+files = [
+    {file = "noise-1.2.2.tar.gz", hash = "sha256:57a2797436574391ff63a111e852e53a4164ecd81ad23639641743cd1a209b65"},
+    {file = "noise-1.2.2.zip", hash = "sha256:36036cdaca131ddd2ab4397fba649af7f074ec08031e1e0a51031d0ae23b509a"},
+]
+
+[[package]]
+name = "pillow"
+version = "10.3.0"
+requires_python = ">=3.8"
+summary = "Python Imaging Library (Fork)"
+groups = ["default"]
+files = [
+    {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"},
+    {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"},
+    {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"},
+    {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"},
+    {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"},
+    {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"},
+    {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"},
+    {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"},
+    {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"},
+    {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"},
+    {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"},
+    {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"},
+]
+
+[[package]]
+name = "propcache"
+version = "0.3.1"
+requires_python = ">=3.9"
+summary = "Accelerated property cache"
+groups = ["default"]
+files = [
+    {file = "propcache-0.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5"},
+    {file = "propcache-0.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371"},
+    {file = "propcache-0.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da"},
+    {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744"},
+    {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0"},
+    {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5"},
+    {file = "propcache-0.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256"},
+    {file = "propcache-0.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073"},
+    {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d"},
+    {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f"},
+    {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0"},
+    {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a"},
+    {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a"},
+    {file = "propcache-0.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9"},
+    {file = "propcache-0.3.1-cp311-cp311-win32.whl", hash = "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005"},
+    {file = "propcache-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7"},
+    {file = "propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40"},
+    {file = "propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf"},
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.2.3"
+requires_python = ">=3.9"
+summary = "pyparsing module - Classes and methods to define and execute parsing grammars"
+groups = ["default"]
+files = [
+    {file = "pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf"},
+    {file = "pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be"},
+]
+
+[[package]]
+name = "pyreadline3"
+version = "3.5.4"
+requires_python = ">=3.8"
+summary = "A python implementation of GNU readline."
+groups = ["default"]
+marker = "sys_platform == \"win32\" and python_version >= \"3.8\""
+files = [
+    {file = "pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"},
+    {file = "pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7"},
+]
+
+[[package]]
+name = "rdflib"
+version = "7.0.0"
+requires_python = ">=3.8.1,<4.0.0"
+summary = "RDFLib is a Python library for working with RDF, a simple yet powerful language for representing information."
+groups = ["default"]
+dependencies = [
+    "isodate<0.7.0,>=0.6.0",
+    "pyparsing<4,>=2.1.0",
+]
+files = [
+    {file = "rdflib-7.0.0-py3-none-any.whl", hash = "sha256:0438920912a642c866a513de6fe8a0001bd86ef975057d6962c79ce4771687cd"},
+    {file = "rdflib-7.0.0.tar.gz", hash = "sha256:9995eb8569428059b8c1affd26b25eac510d64f5043d9ce8c84e0d0036e995ae"},
+]
+
+[[package]]
+name = "setuptools"
+version = "80.7.1"
+requires_python = ">=3.9"
+summary = "Easily download, build, install, upgrade, and uninstall Python packages"
+groups = ["default"]
+files = [
+    {file = "setuptools-80.7.1-py3-none-any.whl", hash = "sha256:ca5cc1069b85dc23070a6628e6bcecb3292acac802399c7f8edc0100619f9009"},
+    {file = "setuptools-80.7.1.tar.gz", hash = "sha256:f6ffc5f0142b1bd8d0ca94ee91b30c0ca862ffd50826da1ea85258a06fd94552"},
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+summary = "Python 2 and 3 compatibility utilities"
+groups = ["default"]
+files = [
+    {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
+    {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
+]
+
+[[package]]
+name = "twisted"
+version = "24.11.0"
+requires_python = ">=3.8.0"
+summary = "An asynchronous networking framework written in Python"
+groups = ["default"]
+dependencies = [
+    "attrs>=22.2.0",
+    "automat>=24.8.0",
+    "constantly>=15.1",
+    "hyperlink>=17.1.1",
+    "incremental>=24.7.0",
+    "typing-extensions>=4.2.0",
+    "zope-interface>=5",
+]
+files = [
+    {file = "twisted-24.11.0-py3-none-any.whl", hash = "sha256:fe403076c71f04d5d2d789a755b687c5637ec3bcd3b2b8252d76f2ba65f54261"},
+    {file = "twisted-24.11.0.tar.gz", hash = "sha256:695d0556d5ec579dcc464d2856b634880ed1319f45b10d19043f2b57eb0115b5"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.13.2"
+requires_python = ">=3.8"
+summary = "Backported and Experimental Type Hints for Python 3.8+"
+groups = ["default"]
+files = [
+    {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"},
+    {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"},
+]
+
+[[package]]
+name = "webcolors"
+version = "24.11.1"
+requires_python = ">=3.9"
+summary = "A library for working with the color formats defined by HTML and CSS."
+groups = ["default"]
+files = [
+    {file = "webcolors-24.11.1-py3-none-any.whl", hash = "sha256:515291393b4cdf0eb19c155749a096f779f7d909f7cceea072791cb9095b92e9"},
+    {file = "webcolors-24.11.1.tar.gz", hash = "sha256:ecb3d768f32202af770477b8b65f318fa4f566c22948673a977b00d589dd80f6"},
+]
+
+[[package]]
+name = "yarl"
+version = "1.20.0"
+requires_python = ">=3.9"
+summary = "Yet another URL library"
+groups = ["default"]
+dependencies = [
+    "idna>=2.0",
+    "multidict>=4.0",
+    "propcache>=0.2.1",
+]
+files = [
+    {file = "yarl-1.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fdb5204d17cb32b2de2d1e21c7461cabfacf17f3645e4b9039f210c5d3378bf3"},
+    {file = "yarl-1.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eaddd7804d8e77d67c28d154ae5fab203163bd0998769569861258e525039d2a"},
+    {file = "yarl-1.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:634b7ba6b4a85cf67e9df7c13a7fb2e44fa37b5d34501038d174a63eaac25ee2"},
+    {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d409e321e4addf7d97ee84162538c7258e53792eb7c6defd0c33647d754172e"},
+    {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ea52f7328a36960ba3231c6677380fa67811b414798a6e071c7085c57b6d20a9"},
+    {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8703517b924463994c344dcdf99a2d5ce9eca2b6882bb640aa555fb5efc706a"},
+    {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:077989b09ffd2f48fb2d8f6a86c5fef02f63ffe6b1dd4824c76de7bb01e4f2e2"},
+    {file = "yarl-1.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0acfaf1da020253f3533526e8b7dd212838fdc4109959a2c53cafc6db611bff2"},
+    {file = "yarl-1.20.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4230ac0b97ec5eeb91d96b324d66060a43fd0d2a9b603e3327ed65f084e41f8"},
+    {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6a1e6ae21cdd84011c24c78d7a126425148b24d437b5702328e4ba640a8902"},
+    {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:86de313371ec04dd2531f30bc41a5a1a96f25a02823558ee0f2af0beaa7ca791"},
+    {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dd59c9dd58ae16eaa0f48c3d0cbe6be8ab4dc7247c3ff7db678edecbaf59327f"},
+    {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a0bc5e05f457b7c1994cc29e83b58f540b76234ba6b9648a4971ddc7f6aa52da"},
+    {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c9471ca18e6aeb0e03276b5e9b27b14a54c052d370a9c0c04a68cefbd1455eb4"},
+    {file = "yarl-1.20.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40ed574b4df723583a26c04b298b283ff171bcc387bc34c2683235e2487a65a5"},
+    {file = "yarl-1.20.0-cp311-cp311-win32.whl", hash = "sha256:db243357c6c2bf3cd7e17080034ade668d54ce304d820c2a58514a4e51d0cfd6"},
+    {file = "yarl-1.20.0-cp311-cp311-win_amd64.whl", hash = "sha256:8c12cd754d9dbd14204c328915e23b0c361b88f3cffd124129955e60a4fbfcfb"},
+    {file = "yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124"},
+    {file = "yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307"},
+]
+
+[[package]]
+name = "zope-interface"
+version = "7.2"
+requires_python = ">=3.8"
+summary = "Interfaces for Python"
+groups = ["default"]
+dependencies = [
+    "setuptools",
+]
+files = [
+    {file = "zope.interface-7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2"},
+    {file = "zope.interface-7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22"},
+    {file = "zope.interface-7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7"},
+    {file = "zope.interface-7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c"},
+    {file = "zope.interface-7.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a"},
+    {file = "zope.interface-7.2-cp311-cp311-win_amd64.whl", hash = "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1"},
+    {file = "zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe"},
+]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/pyproject.toml	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,23 @@
+[project]
+name = "light9_sync"
+version = "0.1.0"
+description = "Default template for PDM package"
+authors = [
+    {name = "drew", email = "drewp@bigasterisk.com"},
+]
+dependencies = [
+    "rdflib==7.0.0",
+    "aiohttp==3.9.5",
+    "twisted>=24.11.0",
+    "coloredlogs>=15.0.1",
+    "noise>=1.2.2",
+    "pillow==10.3.0",
+    "webcolors>=24.11.1",
+]
+requires-python = "==3.11.*"
+readme = "README.md"
+license = {text = "MIT"}
+
+
+[tool.pdm]
+distribution = false
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/run.sh	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,7 @@
+#!/bin/zsh
+HERE=$(realpath $PWD)
+ADDONS=$HOME/.config/blender/4.4/scripts/addons
+rm -f $ADDONS/light9_sync/
+ln -sf $HERE $ADDONS/light9_sync
+
+/home/drewp/own/tool/blender-4.4.3-linux-x64/blender --addons light9_sync $LIGHT9_SHOW/blender/01.blend $@
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/time_sync/__init__.py	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,16 @@
+"""
+sets blender time to track ascoltami time;
+blender-side time changes are sent back to ascoltami
+"""
+
+sync: object = None
+
+
+def register():
+    global sync
+    from .time_from_graph import Sync
+    sync = Sync()
+
+
+def unregister():
+    raise NotImplementedError
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/blender/time_sync/time_from_graph.py	Sun May 18 20:08:35 2025 -0700
@@ -0,0 +1,96 @@
+import threading
+import time
+
+import bpy  # type: ignore
+from bpy.app.handlers import persistent  # type: ignore
+from light9_sync.asyncio_thread import startLoopInThread
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+
+from light9 import networking
+from light9.namespaces import L9
+from light9.run_local import log
+from light9.typedgraph import typedValue
+
+
+def clamp(lo, hi, x):
+    return max(lo, min(hi, x))
+
+
+UPDATE_PERIOD = 1 / 20
+
+
+class Sync:
+    lock = threading.Lock()
+    duration: float = 1
+    wallStartTime: float | None = None
+    pausedSongTime: float | None = None
+    playing = False
+    latestTime: float
+
+    def __init__(self):
+        # main thread
+        self.lastSetFrame = -1
+        self.lastGraphFrame = -1
+        startLoopInThread(self.task())
+        # need persistent because `blender --addons ...` seems to start addon
+        # before loading scene.
+        bpy.app.timers.register(self.update, persistent=True)
+        bpy.app.handlers.frame_change_post.append(self.on_frame_change_post)
+
+    ## updates from graph -> self
+
+    async def task(self):
+        # bg thread with asyncio loop
+        self.graph = SyncedGraph(networking.rdfdb.url, "time_sync")
+        self.graph.addHandler(self.syncFromGraph)
+
+    def syncFromGraph(self):
+        # bg thread
+        with self.lock:
+            asco = L9['ascoltami']
+            self.wallStartTime = typedValue(float | None, self.graph, asco, L9['wallStartTime'])
+            self.pausedSongTime = typedValue(float | None, self.graph, asco, L9['pausedSongTime'])
+            self.duration = typedValue(float | None, self.graph, asco, L9['duration']) or 1.0
+            self.playing = typedValue(bool | None, self.graph, asco, L9['playing']) or False
+
+    def currentTime(self) -> float | None:
+        if self.wallStartTime is not None:
+            return time.time() - self.wallStartTime
+        if self.pausedSongTime is not None:
+            return self.pausedSongTime
+        log.warn('no time data')
+        return None
+
+    ## graph time -> blender time
+
+    # @persistent
+    def update(self):
+        # main thread? wherever blender runs timers
+        if self.playing:
+            with self.lock:
+                t = self.currentTime()
+            if t is not None:
+                self.setBlenderTime(t, self.duration)
+        return UPDATE_PERIOD
+
+    def setBlenderTime(self, t: float, duration: float):
+        scene = bpy.context.scene
+        fps = scene.render.fps
+        scene.frame_start = 0
+        scene.frame_end = int(duration * fps)
+        fr = int(clamp(t, 0, duration) * fps)
+        self.lastSetFrame = fr
+        scene.frame_set(fr)
+
+    ## blender time -> graph time
+
+    @persistent
+    def on_frame_change_post(self, scene, deps):
+        if scene.frame_current != self.lastSetFrame:
+            # self.setGraphTime(scene.frame_current / scene.render.fps, self.duration)
+            self.lastSetFrame = scene.frame_current
+            t = scene.frame_current / scene.render.fps
+            self.setInGraph(t)
+
+    def setInGraph(self, t: float):
+        log.warning(f'todo: set graph to {t:.2f}')
--- a/src/light9/blender/__init__.py	Sun May 18 14:37:06 2025 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,37 +0,0 @@
-# ln -s ./ ~/.config/blender/4.2/scripts/addons/light9_sync/
-
-#~/own/tool/blender $LIGHT9_SHOW/song1.blend --addons light9_sync
-import logging
-import sys
-
-from . import light_control, time_sync
-
-bl_info = {
-    "name": "light9_sync",
-    "description": "light9 sync",
-    "version": (0, 0, 1),
-    "blender": (4, 2, 0),
-    "category": "Object",
-}
-
-modules = (time_sync, light_control)
-
-
-def register():
-    logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
-    logging.getLogger('syncedgraph').setLevel(logging.INFO)
-    for p in [
-            '/home/drewp/.local/share/pdm/venvs/light9-zxQNOBNq-3.11/lib/python3.11/site-packages',
-            '/home/drewp/projects/light9/src/',
-            '/my/proj/rdfdb',
-    ]:
-        if p not in sys.path:
-            sys.path.append(p)
-
-    for module in modules:
-        module.register()
-
-
-def unregister():
-    for module in reversed(modules):
-        module.unregister()
--- a/src/light9/blender/asyncio_thread.py	Sun May 18 14:37:06 2025 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-import asyncio
-from threading import Thread
-from typing import Coroutine
-
-
-def startLoopInThread(task: Coroutine) -> asyncio.AbstractEventLoop:
-
-    def start_background_loop(loop: asyncio.AbstractEventLoop) -> None:
-        asyncio.set_event_loop(loop)
-        loop.run_forever()
-
-    loop = asyncio.new_event_loop()
-    t = Thread(target=start_background_loop, args=(loop,), daemon=True)
-    t.start()
-    asyncio.run_coroutine_threadsafe(task, loop)
-    return loop
--- a/src/light9/blender/light_control/__init__.py	Sun May 18 14:37:06 2025 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-"""
-watch blender lights, output to real lights
-"""
-
-sender: object = None
-
-
-def register():
-    global sender
-    from .send_to_collector import Sender
-
-    sender = Sender()
-
-
-def unregister():
-    raise NotImplementedError
--- a/src/light9/blender/light_control/send_to_collector.py	Sun May 18 14:37:06 2025 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,138 +0,0 @@
-import asyncio
-import logging
-from dataclasses import dataclass, field
-from os import sync
-from typing import cast
-
-import bpy  # type: ignore
-from light9.blender.time_sync.time_from_graph import clamp
-from light9.effect.effecteval import literalColor
-from rdfdb.patch import Patch
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import RDF, ConjunctiveGraph, Literal, URIRef
-from rdflib.graph import _ContextType
-
-from light9 import networking
-from light9.blender.asyncio_thread import startLoopInThread
-from light9.namespaces import FUNC, L9
-from light9.newtypes import DeviceUri
-from light9.showconfig import showUri
-
-log = logging.getLogger('sendcoll')
-
-UPDATE_PERIOD = 1 / 20
-
-
-async def waitForConnection(graph: SyncedGraph):
-    # workaround for patch() quietly failing before connection. See SyncedGraph.
-    while not graph._isConnected():
-        await asyncio.sleep(.5)
-
-@dataclass
-class BlenderDev:
-    ctx: URIRef
-    obj: bpy.types.Object
-    uri: DeviceUri
-    setting: URIRef
-    devSets: URIRef
-
-    color: tuple[float, float, float] = (0, 0, 0)
-
-    def updateColor(self):
-        self.color = self.obj.data.color
-
-    def effectGraphStmts(self, bset0) -> list:
-        return [
-            (self.devSets, L9['setting'], self.setting, self.ctx),
-            (self.setting, L9['device'], self.uri, self.ctx),
-            (self.setting, L9['deviceAttr'], L9['color'], self.ctx),
-        ]
-
-    def makePatch(self, graph: SyncedGraph) -> Patch:
-        c = literalColor(*[clamp(x, 0, 1) for x in self.color])
-        return graph.getObjectPatch(self.ctx, self.setting, L9['value'], c)
-
-
-@dataclass
-class Sender:
-    syncedObjects: dict[str, BlenderDev] = field(default_factory=dict)
-
-    ctx = URIRef(showUri() + '/blender')
-    devSets = L9['blenderControlDevSets']
-
-    def __post_init__(self):
-        bpy.app.timers.register(self.findLightsInScene, first_interval=0.1)  # todo: what event to use?
-        startLoopInThread(self.task())
-
-    async def task(self):
-        log.info('start task')
-        try:
-            self.graph = SyncedGraph(networking.rdfdb.url, "blender_light_control")
-            await waitForConnection(self.graph)
-            self.patchInitialGraph()
-
-            while True:
-                try:
-                    await self.patchCurrentLightColors()
-                except Exception:
-                    log.error('skip', exc_info=True)
-                    await asyncio.sleep(1)
-                # todo: this could be triggered by onColorChanges instead of running constantly
-                await asyncio.sleep(UPDATE_PERIOD)
-        except Exception:
-            log.error('task', exc_info=True)
-
-    def patchInitialGraph(self):
-        g = self.effectGraph()
-        for d in self.syncedObjects.values():
-            for stmt in d.effectGraphStmts(self.devSets):
-                g.add(stmt)
-        for stmt in g:
-            log.info("adding %s", stmt)
-        self.graph.patchSubgraph(self.ctx, g)
-
-    async def patchCurrentLightColors(self):
-        p = Patch(addQuads=[], delQuads=[])
-        for d in self.syncedObjects.values():
-            p = p.update(d.makePatch(self.graph))
-        if p:
-            await self.graph.patch(p)
-
-    def effectGraph(self) -> ConjunctiveGraph:
-        g = ConjunctiveGraph()
-        ctx = cast(_ContextType, self.ctx)
-        g.add((L9['blenderControl'], RDF.type, L9['Effect'], ctx))
-        g.add((L9['blenderControl'], L9['effectFunction'], FUNC['scale'], ctx))
-        g.add((L9['blenderControl'], L9['publishAttr'], L9['strength'], ctx))
-        setting = L9['blenderControl/set0']
-        g.add((L9['blenderControl'], L9['setting'], setting, ctx))
-        g.add((setting, L9['value'], self.devSets, ctx))
-        g.add((setting, L9['effectAttr'], L9['deviceSettings'], ctx))
-        return g
-
-    def findLightsInScene(self):
-        self.syncedObjects.clear()
-        for obj in sorted(bpy.data.objects, key=lambda x: x.name):
-            if obj.get('l9dev'):
-                uri = DeviceUri(URIRef(obj['l9dev']))
-                self.syncedObjects[obj.name] = BlenderDev(
-                    self.ctx,
-                    obj,
-                    uri,
-                    setting=L9['blenderControl/set1'],
-                    devSets=self.devSets,
-                )
-                log.info(f'found {self.syncedObjects[obj.name]}')
-
-        self.watchForUpdates()
-
-    def watchForUpdates(self):
-        bpy.app.handlers.depsgraph_update_post.append(self.onColorChanges)
-        bpy.app.handlers.frame_change_post.append(self.onColorChanges)
-
-    def onColorChanges(self, scene, deps):
-        for obj in deps.objects:
-            dev = self.syncedObjects.get(obj.name)
-            if dev is None:
-                continue
-            dev.updateColor()
--- a/src/light9/blender/time_sync/__init__.py	Sun May 18 14:37:06 2025 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,16 +0,0 @@
-"""
-sets blender time to track ascoltami time;
-blender-side time changes are sent back to ascoltami
-"""
-
-sync: object = None
-
-
-def register():
-    global sync
-    from .time_from_graph import Sync
-    sync = Sync()
-
-
-def unregister():
-    raise NotImplementedError
--- a/src/light9/blender/time_sync/time_from_graph.py	Sun May 18 14:37:06 2025 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,95 +0,0 @@
-import threading
-import time
-
-import bpy  # type: ignore
-from bpy.app.handlers import persistent  # type: ignore
-from light9.typedgraph import typedValue
-from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import URIRef
-
-from light9 import networking
-from light9.blender.asyncio_thread import startLoopInThread
-from light9.namespaces import L9
-from light9.run_local import log
-
-
-def clamp(lo, hi, x):
-    return max(lo, min(hi, x))
-
-
-UPDATE_PERIOD = 1 / 20
-
-
-class Sync:
-    lock = threading.Lock()
-    duration: float = 1
-    wallStartTime: float | None = None
-    pausedSongTime: float | None = None
-    playing = False
-    latestTime: float
-
-    def __init__(self):
-        # main thread
-        self.lastSetFrame = -1
-        self.lastGraphFrame = -1
-        startLoopInThread(self.task())
-        bpy.app.timers.register(self.update)
-        bpy.app.handlers.frame_change_post.append(self.on_frame_change_post)
-
-    ## updates from graph -> self
-
-    async def task(self):
-        # bg thread with asyncio loop
-        self.graph = SyncedGraph(networking.rdfdb.url, "time_sync")
-        self.graph.addHandler(self.syncFromGraph)
-
-    def syncFromGraph(self):
-        # bg thread
-        with self.lock:
-            asco = L9['ascoltami']
-            self.wallStartTime = typedValue(float | None, self.graph, asco, L9['wallStartTime'])
-            self.pausedSongTime = typedValue(float | None, self.graph, asco, L9['pausedSongTime'])
-            self.duration = typedValue(float | None, self.graph, asco, L9['duration']) or 1.0
-            self.playing = typedValue(bool | None, self.graph, asco, L9['playing']) or False
-
-    def currentTime(self) -> float | None:
-        if self.wallStartTime is not None:
-            return time.time() - self.wallStartTime
-        if self.pausedSongTime is not None:
-            return self.pausedSongTime
-        log.warn('no time data')
-        return None
-
-    ## graph time -> blender time
-
-    @persistent
-    def update(self):
-        # main thread? wherever blender runs timers
-        if self.playing:
-            with self.lock:
-                t = self.currentTime()
-            if t is not None:
-                self.setBlenderTime(t, self.duration)
-        return UPDATE_PERIOD
-
-    def setBlenderTime(self, t: float, duration: float):
-        scene = bpy.context.scene
-        fps = scene.render.fps
-        scene.frame_start = 0
-        scene.frame_end = int(duration * fps)
-        fr = int(clamp(t, 0, duration) * fps)
-        self.lastSetFrame = fr
-        scene.frame_set(fr)
-
-    ## blender time -> graph time
-
-    @persistent
-    def on_frame_change_post(self, scene, deps):
-        if scene.frame_current != self.lastSetFrame:
-            # self.setGraphTime(scene.frame_current / scene.render.fps, self.duration)
-            self.lastSetFrame = scene.frame_current
-            t = scene.frame_current / scene.render.fps
-            self.setInGraph(t)
-
-    def setInGraph(self, t: float):
-        log.warning(f'todo: set graph to {t:.2f}')