changeset 2170:066f05ad7900

collector: even stronger types; repair test code (some are failing)
author drewp@bigasterisk.com
date Fri, 19 May 2023 12:11:26 -0700
parents 7c03342eff15
children df7615c1e0b9
files light9/collector/collector.py light9/collector/collector_test.py light9/collector/device.py light9/effect/settings_test.py light9/newtypes.py
diffstat 5 files changed, 108 insertions(+), 159 deletions(-) [+]
line wrap: on
line diff
--- a/light9/collector/collector.py	Fri May 19 12:09:51 2023 -0700
+++ b/light9/collector/collector.py	Fri May 19 12:11:26 2023 -0700
@@ -9,8 +9,8 @@
 from light9.collector.output import Output as OutputInstance
 from light9.collector.weblisteners import WebListeners
 from light9.namespaces import L9, RDF
-from light9.newtypes import (ClientSessionType, ClientType, DeviceAttr, DeviceClass, DeviceUri, DmxIndex, DmxMessageIndex, OutputAttr, OutputRange, OutputUri,
-                             OutputValue, UnixTime, typedValue)
+from light9.newtypes import (ClientSessionType, ClientType, DeviceAttr, DeviceClass, DeviceSetting, DeviceUri, DmxIndex, DmxMessageIndex, OutputAttr,
+                             OutputRange, OutputUri, OutputValue, UnixTime, VTUnion, typedValue)
 
 log = logging.getLogger('collector')
 
@@ -69,10 +69,10 @@
         self.graph.addHandler(self.rebuildOutputMap)
 
         # client : (session, time, {(dev,devattr): latestValue})
-        self.lastRequest: Dict[Tuple[ClientType, ClientSessionType], Tuple[UnixTime, Dict[Tuple[DeviceUri, DeviceAttr], float]]] = {}
+        self.lastRequest: Dict[Tuple[ClientType, ClientSessionType], Tuple[UnixTime, Dict[Tuple[DeviceUri, DeviceAttr], VTUnion]]] = {}
 
         # (dev, devAttr): value to use instead of 0
-        self.stickyAttrs: Dict[Tuple[DeviceUri, DeviceAttr], float] = {}
+        self.stickyAttrs: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}
 
     def rebuildOutputMap(self):
         self.outputMap = outputMap(self.graph, self.outputs)
@@ -102,8 +102,8 @@
             del self.lastRequest[c]
 
     # todo: move to settings.py
-    def resolvedSettingsDict(self, settingsList: List[Tuple[DeviceUri, DeviceAttr, float]]) -> Dict[Tuple[DeviceUri, DeviceAttr], float]:
-        out: Dict[Tuple[DeviceUri, DeviceAttr], float] = {}
+    def resolvedSettingsDict(self, settingsList: List[DeviceSetting]) -> Dict[Tuple[DeviceUri, DeviceAttr], VTUnion]:
+        out: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}
         for d, da, v in settingsList:
             if (d, da) in out:
                 out[(d, da)] = resolve(d, da, [out[(d, da)], v])
@@ -118,12 +118,12 @@
             log.warn('collector.setAttrs from %s is running %.1fms after the request was made', client, requestLag * 1000)
 
     def _merge(self, lastRequests):
-        deviceAttrs: Dict[DeviceUri, Dict[DeviceAttr, float]] = {}  # device: {deviceAttr: value}
+        deviceAttrs: Dict[DeviceUri, Dict[DeviceAttr, VTUnion]] = {}  # device: {deviceAttr: value}
         for _, lastSettings in lastRequests:
             for (device, deviceAttr), value in lastSettings.items():
                 if (device, deviceAttr) in self.remapOut:
                     start, end = self.remapOut[(device, deviceAttr)]
-                    value = Literal(start + float(value) * (end - start))
+                    value = start + float(value) * (end - start)
 
                 attrs = deviceAttrs.setdefault(device, {})
                 if deviceAttr in attrs:
@@ -143,7 +143,7 @@
 
         return deviceAttrs
 
-    def setAttrs(self, client: ClientType, clientSession: ClientSessionType, settings: List[Tuple[DeviceUri, DeviceAttr, float]], sendTime: UnixTime):
+    def setAttrs(self, client: ClientType, clientSession: ClientSessionType, settings: List[DeviceSetting], sendTime: UnixTime):
         """
         settings is a list of (device, attr, value). These attrs are
         device attrs. We resolve conflicting values, process them into
@@ -178,7 +178,7 @@
             except Exception as e:
                 log.error('failing toOutputAttrs on %s: %r', d, e)
 
-        pendingOut: Dict[OutputUri, Tuple[OutputInstance, bytearray]] = {}
+        pendingOut = cast(Dict[OutputUri, Tuple[OutputInstance, bytearray]], {})
         for out in self.outputs:
             pendingOut[OutputUri(out.uri)] = (out, bytearray(512))
 
--- a/light9/collector/collector_test.py	Fri May 19 12:09:51 2023 -0700
+++ b/light9/collector/collector_test.py	Fri May 19 12:11:26 2023 -0700
@@ -1,11 +1,14 @@
 import unittest
 import datetime, time
 from freezegun import freeze_time
+from light9.collector.output import Output
+from light9.collector.weblisteners import WebListeners
 from rdflib import Namespace
 
 from light9.namespaces import L9, DEV
 from light9.collector.collector import Collector, outputMap
-from rdfdb.mock_syncedgraph import MockSyncedGraph
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.newtypes import ClientSessionType, ClientType, DeviceAttr, DeviceUri, HexColor, UnixTime
 
 UDMX = Namespace('http://light9.bigasterisk.com/output/udmx/')
 DMX0 = Namespace('http://light9.bigasterisk.com/output/dmx0/')
@@ -35,10 +38,18 @@
 
 '''
 
-t0 = 0  # time
+t0 = UnixTime(0)
+client1 = ClientType('client1')
+client2 = ClientType('client2')
+session1 = ClientSessionType('sess1')
+session2 = ClientSessionType('sess2')
+colorStrip = DeviceUri(DEV['colorStrip'])
+color = DeviceAttr(L9['color'])
+brightness = DeviceAttr(L9['brightness'])
+inst1 = DeviceUri(DEV['inst1'])
 
 
-class MockOutput(object):
+class MockOutput(Output):
 
     def __init__(self, uri, connections):
         self.connections = connections
@@ -46,54 +57,14 @@
         self.uri = uri
         self.numChannels = 4
 
-    def allConnections(self):
-        return self.connections
-
     def update(self, values):
-        self.updates.append(values)
-
-    def flush(self):
-        self.updates.append('flush')
+        self.updates.append(list(values[:self.numChannels]))
 
 
-@unittest.skip("outputMap got rewritten and mostly doesn't raise on these cases"
-              )
-class TestOutputMap(unittest.TestCase):
-
-    def testWorking(self):
-        out0 = MockOutput(UDMX, [(0, DMX0['c1'])])
-        m = outputMap(
-            MockSyncedGraph(PREFIX + '''
-          dmx0:c1 :connectedTo dev:inst1Brightness .
-          dev:inst1 a :Device; :brightness dev:inst1Brightness .
-        '''), [out0])
-        self.assertEqual({(DEV['inst1'], L9['brightness']): (out0, 0)}, m)
+class MockWebListeners(WebListeners):
 
-    def testMissingOutput(self):
-        out0 = MockOutput(UDMX, [(0, DMX0['c1'])])
-        self.assertRaises(
-            KeyError, outputMap,
-            MockSyncedGraph(PREFIX + '''
-          dev:inst1 a :Device; :brightness dev:inst1Brightness .
-        '''), [out0])
-
-    def testMissingOutputConnection(self):
-        out0 = MockOutput(UDMX, [(0, DMX0['c1'])])
-        self.assertRaises(
-            ValueError, outputMap,
-            MockSyncedGraph(PREFIX + '''
-          dev:inst1 a :Device; :brightness dev:inst1Brightness .
-        '''), [out0])
-
-    def testMultipleOutputConnections(self):
-        out0 = MockOutput(UDMX, [(0, DMX0['c1'])])
-        self.assertRaises(
-            ValueError, outputMap,
-            MockSyncedGraph(PREFIX + '''
-          dmx0:c1 :connectedTo dev:inst1Brightness .
-          dmx0:c2 :connectedTo dev:inst1Brightness .
-          dev:inst1 a :Device; :brightness dev:inst1Brightness .
-        '''), [out0])
+    def __init__(self):
+        "do not init"
 
 
 class TestCollector(unittest.TestCase):
@@ -113,73 +84,58 @@
           :level dev:inst1Brightness .
         ''')
 
-        self.dmx0 = MockOutput(DMX0[None], [(0, DMX0['c1'])])
-        self.udmx = MockOutput(UDMX[None], [(0, UDMX['c1']), (1, UDMX['c2']),
-                                            (2, UDMX['c3']), (3, UDMX['c4'])])
+        self.dmx0 = MockOutput(DMX0, [(0, DMX0['c1'])])
+        self.udmx = MockOutput(UDMX, [(0, UDMX['c1']), (1, UDMX['c2']), (2, UDMX['c3']), (3, UDMX['c4'])])
 
     def testRoutesColorOutput(self):
-        c = Collector(self.config, outputs=[self.dmx0, self.udmx])
+        c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+
+        c.setAttrs(client1, session1, [(colorStrip, color, HexColor('#00ff00'))], t0)
 
-        c.setAttrs('client', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#00ff00')], t0)
-
-        self.assertEqual([[215, 0, 255, 0], 'flush'], self.udmx.updates)
-        self.assertEqual([[0, 0, 0, 0], 'flush'], self.dmx0.updates)
+        self.assertEqual([
+            [215, 0, 255, 0],
+        ], self.udmx.updates)
+        self.assertEqual([
+            [0, 0, 0, 0],
+        ], self.dmx0.updates)
 
     def testOutputMaxOfTwoClients(self):
-        c = Collector(self.config, outputs=[self.dmx0, self.udmx])
+        c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
 
-        c.setAttrs('client1', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#ff0000')], t0)
-        c.setAttrs('client2', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#333333')], t0)
+        c.setAttrs(client1, session1, [(colorStrip, color, HexColor('#ff0000'))], t0)
+        c.setAttrs(client2, session1, [(colorStrip, color, HexColor('#333333'))], t0)
 
-        self.assertEqual(
-            [[215, 255, 0, 0], 'flush', [215, 255, 51, 51], 'flush'],
-            self.udmx.updates)
-        self.assertEqual([[0, 0, 0, 0], 'flush', [0, 0, 0, 0], 'flush'],
-                         self.dmx0.updates)
+        self.assertEqual([[215, 255, 0, 0], [215, 255, 51, 51]], self.udmx.updates)
+        self.assertEqual([[0, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
 
     def testClientOnSameOutputIsRememberedOverCalls(self):
-        c = Collector(self.config, outputs=[self.dmx0, self.udmx])
+        c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
 
-        c.setAttrs('client1', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#080000')], t0)
-        c.setAttrs('client2', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#060000')], t0)
-        c.setAttrs('client1', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#050000')], t0)
+        c.setAttrs(client1, session1, [(colorStrip, color, HexColor('#080000'))], t0)
+        c.setAttrs(client2, session1, [(colorStrip, color, HexColor('#060000'))], t0)
+        c.setAttrs(client1, session1, [(colorStrip, color, HexColor('#050000'))], t0)
 
-        self.assertEqual([[215, 8, 0, 0], 'flush', [215, 8, 0, 0], 'flush',
-                          [215, 6, 0, 0], 'flush'], self.udmx.updates)
-        self.assertEqual([[0, 0, 0, 0], 'flush', [0, 0, 0, 0], 'flush',
-                          [0, 0, 0, 0], 'flush'], self.dmx0.updates)
+        self.assertEqual([[215, 8, 0, 0], [215, 8, 0, 0], [215, 6, 0, 0]], self.udmx.updates)
+        self.assertEqual([[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
 
     def testClientsOnDifferentOutputs(self):
-        c = Collector(self.config, outputs=[self.dmx0, self.udmx])
+        c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
 
-        c.setAttrs('client1', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#aa0000')], t0)
-        c.setAttrs('client2', 'sess1', [(DEV['inst1'], L9['brightness'], .5)],
-                   t0)
+        c.setAttrs(client1, session1, [(colorStrip, color, HexColor('#aa0000'))], t0)
+        c.setAttrs(client2, session1, [(inst1, brightness, .5)], t0)
 
         # ok that udmx is flushed twice- it can screen out its own duplicates
-        self.assertEqual([[215, 170, 0, 0], 'flush', [215, 170, 0, 0], 'flush'],
-                         self.udmx.updates)
-        self.assertEqual([[0, 0, 0, 0], 'flush', [127, 0, 0, 0], 'flush'],
-                         self.dmx0.updates)
+        self.assertEqual([[215, 170, 0, 0], [215, 170, 0, 0]], self.udmx.updates)
+        self.assertEqual([[0, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
 
     def testNewSessionReplacesPreviousOutput(self):
         # ..as opposed to getting max'd with it
-        c = Collector(self.config, outputs=[self.dmx0, self.udmx])
+        c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
 
-        c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], .8)],
-                   t0)
-        c.setAttrs('client1', 'sess2', [(DEV['inst1'], L9['brightness'], .5)],
-                   t0)
+        c.setAttrs(client1, session1, [(inst1, brightness, .8)], t0)
+        c.setAttrs(client1, session2, [(inst1, brightness, .5)], t0)
 
-        self.assertEqual([[204, 0, 0, 0], 'flush', [127, 0, 0, 0], 'flush'],
-                         self.dmx0.updates)
+        self.assertEqual([[204, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
 
     def testNewSessionDropsPreviousSettingsOfOtherAttrs(self):
         c = Collector(MockSyncedGraph(PREFIX + THEATER + '''
@@ -195,60 +151,48 @@
           :dmxUniverse dmx0:; :dmxBase 0;
           :level dev:inst1Brightness .
         '''),
-                      outputs=[self.dmx0, self.udmx])
+                      outputs=[self.dmx0, self.udmx],
+                      listeners=MockWebListeners())
 
-        c.setAttrs('client1', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#ff0000')], t0)
-        c.setAttrs('client1', 'sess2',
-                   [(DEV['colorStrip'], L9['color'], '#00ff00')], t0)
+        c.setAttrs(client1, session1, [(colorStrip, color, HexColor('#ff0000'))], t0)
+        c.setAttrs(client1, session2, [(colorStrip, color, HexColor('#00ff00'))], t0)
 
-        self.assertEqual([[215, 255, 0, 0], 'flush', [215, 0, 255, 0], 'flush'],
-                         self.udmx.updates)
+        self.assertEqual([[215, 255, 0, 0], [215, 0, 255, 0]], self.udmx.updates)
 
     def testClientIsForgottenAfterAWhile(self):
         with freeze_time(datetime.datetime.now()) as ft:
-            c = Collector(self.config, outputs=[self.dmx0, self.udmx])
-            c.setAttrs('cli1', 'sess1', [(DEV['inst1'], L9['brightness'], .5)],
-                       time.time())
+            c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+            c.setAttrs(client1, session1, [(inst1, brightness, .5)], UnixTime(time.time()))
             ft.tick(delta=datetime.timedelta(seconds=1))
             # this max's with cli1's value so we still see .5
-            c.setAttrs('cli2', 'sess1', [(DEV['inst1'], L9['brightness'], .2)],
-                       time.time())
+            c.setAttrs(client2, session1, [(inst1, brightness, .2)], UnixTime(time.time()))
             ft.tick(delta=datetime.timedelta(seconds=9.1))
             # now cli1 is forgotten, so our value appears
-            c.setAttrs('cli2', 'sess1', [(DEV['inst1'], L9['brightness'], .4)],
-                       time.time())
-            self.assertEqual([[127, 0, 0, 0], 'flush', [127, 0, 0, 0], 'flush',
-                              [102, 0, 0, 0], 'flush'], self.dmx0.updates)
+            c.setAttrs(client2, session1, [(inst1, brightness, .4)], UnixTime(time.time()))
+            self.assertEqual([[127, 0, 0, 0], [127, 0, 0, 0], [102, 0, 0, 0]], self.dmx0.updates)
 
     def testClientUpdatesAreNotMerged(self):
         # second call to setAttrs forgets the first
-        c = Collector(self.config, outputs=[self.dmx0, self.udmx])
-        t0 = time.time()
-        c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], .5)],
-                   t0)
-        c.setAttrs('client1', 'sess1', [(DEV['inst1'], L9['brightness'], 1)],
-                   t0)
-        c.setAttrs('client1', 'sess1',
-                   [(DEV['colorStrip'], L9['color'], '#00ff00')], t0)
+        c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
+        t0 = UnixTime(time.time())
+        c.setAttrs(client1, session1, [(inst1, brightness, .5)], t0)
+        c.setAttrs(client1, session1, [(inst1, brightness, 1)], t0)
+        c.setAttrs(client1, session1, [(colorStrip, color, HexColor('#00ff00'))], t0)
 
-        self.assertEqual([[215, 0, 0, 0], 'flush', [215, 0, 0, 0], 'flush',
-                          [215, 0, 255, 0], 'flush'], self.udmx.updates)
-        self.assertEqual([[127, 0, 0, 0], 'flush', [255, 0, 0, 0], 'flush',
-                          [0, 0, 0, 0], 'flush'], self.dmx0.updates)
+        self.assertEqual([[215, 0, 0, 0], [215, 0, 0, 0], [215, 0, 255, 0]], self.udmx.updates)
+        self.assertEqual([[127, 0, 0, 0], [255, 0, 0, 0], [0, 0, 0, 0]], self.dmx0.updates)
 
     def testRepeatedAttributesInOneRequestGetResolved(self):
-        c = Collector(self.config, outputs=[self.dmx0, self.udmx])
+        c = Collector(self.config, outputs=[self.dmx0, self.udmx], listeners=MockWebListeners())
 
-        c.setAttrs('client1', 'sess1', [
-            (DEV['inst1'], L9['brightness'], .5),
-            (DEV['inst1'], L9['brightness'], .3),
+        c.setAttrs(client1, session1, [
+            (inst1, brightness, .5),
+            (inst1, brightness, .3),
         ], t0)
-        self.assertEqual([[127, 0, 0, 0], 'flush'], self.dmx0.updates)
+        self.assertEqual([[127, 0, 0, 0]], self.dmx0.updates)
 
-        c.setAttrs('client1', 'sess1', [
-            (DEV['inst1'], L9['brightness'], .3),
-            (DEV['inst1'], L9['brightness'], .5),
+        c.setAttrs(client1, session1, [
+            (inst1, brightness, .3),
+            (inst1, brightness, .5),
         ], t0)
-        self.assertEqual([[127, 0, 0, 0], 'flush', [127, 0, 0, 0], 'flush'],
-                         self.dmx0.updates)
+        self.assertEqual([[127, 0, 0, 0], [127, 0, 0, 0]], self.dmx0.updates)
--- a/light9/collector/device.py	Fri May 19 12:09:51 2023 -0700
+++ b/light9/collector/device.py	Fri May 19 12:11:26 2023 -0700
@@ -5,7 +5,7 @@
 from webcolors import hex_to_rgb, rgb_to_hex
 from colormath.color_objects import sRGBColor, CMYColor
 import colormath.color_conversions
-from light9.newtypes import OutputAttr, OutputValue, DeviceUri, DeviceAttr
+from light9.newtypes import VT, HexColor, OutputAttr, OutputValue, DeviceUri, DeviceAttr, VTUnion
 
 log = logging.getLogger('device')
 
@@ -44,19 +44,16 @@
     return clamp255(int(f * 255))
 
 
-def _maxColor(values: List[str]) -> str:
+def _maxColor(values: List[HexColor]) -> HexColor:
     rgbs = [hex_to_rgb(v) for v in values]
     maxes = [max(component) for component in zip(*rgbs)]
-    return rgb_to_hex(tuple(maxes))
-
-
-VT = TypeVar('VT', float, int, str)
+    return cast(HexColor, rgb_to_hex(tuple(maxes)))
 
 
 def resolve(
         deviceType: DeviceUri,  # should be DeviceClass?
         deviceAttr: DeviceAttr,
-        values: List[VT]) -> Any:  # todo: return should be VT
+        values: List[VTUnion]) -> VTUnion:  # todo: return should be VT
     """
     return one value to use for this attr, given a set of them that
     have come in simultaneously. len(values) >= 1.
@@ -66,7 +63,7 @@
     if len(values) == 1:
         return values[0]
     if deviceAttr == DeviceAttr(L9['color']):
-        return _maxColor(cast(List[str], values))
+        return _maxColor(cast(List[HexColor], values))
     # incomplete. how-to-resolve should be on the DeviceAttr defs in the graph.
     if deviceAttr in map(DeviceAttr, [L9['rx'], L9['ry'], L9['zoom'], L9['focus'], L9['iris']]):
         floatVals = []
@@ -79,7 +76,7 @@
                 raise TypeError(repr(v))
 
         # averaging with zeros? not so good
-        return Literal(sum(floatVals) / len(floatVals))
+        return sum(floatVals) / len(floatVals)
     return max(values)
 
 
--- a/light9/effect/settings_test.py	Fri May 19 12:09:51 2023 -0700
+++ b/light9/effect/settings_test.py	Fri May 19 12:11:26 2023 -0700
@@ -1,7 +1,7 @@
 import unittest
 from rdflib import Literal
 from rdfdb.patch import Patch
-from rdfdb.localsyncedgraph import LocalSyncedGraph
+from light9.localsyncedgraph import LocalSyncedGraph
 from light9.namespaces import L9, DEV
 from light9.effect.settings import DeviceSettings
 
@@ -87,7 +87,7 @@
             (L9['light1'], L9['attr2'], 0.3),
             (L9['light1'], L9['attr1'], 0.5),
         ]
-        self.assertItemsEqual(sets, DeviceSettings(self.graph, sets).asList())
+        self.assertCountEqual(sets, DeviceSettings(self.graph, sets).asList())
 
     def testDevices(self):
         s = DeviceSettings(self.graph, [
@@ -95,7 +95,7 @@
             (DEV['aura2'], L9['rx'], 0.1),
         ])
         # aura1 is all defaults (zeros), so it doesn't get listed
-        self.assertItemsEqual([DEV['aura2']], s.devices())
+        self.assertCountEqual([DEV['aura2']], s.devices())
 
     def testAddStatements(self):
         s = DeviceSettings(self.graph, [
@@ -103,11 +103,12 @@
         ])
         stmts = s.statements(L9['foo'], L9['ctx1'], L9['s_'], set())
         self.maxDiff = None
-        self.assertItemsEqual([
-            (L9['foo'], L9['setting'], L9['s_set4350023'], L9['ctx1']),
-            (L9['s_set4350023'], L9['device'], DEV['aura2'], L9['ctx1']),
-            (L9['s_set4350023'], L9['deviceAttr'], L9['rx'], L9['ctx1']),
-            (L9['s_set4350023'], L9['value'], Literal(0.1), L9['ctx1']),
+        setting = sorted(stmts)[-1][0]
+        self.assertCountEqual([
+            (L9['foo'], L9['setting'], setting, L9['ctx1']),
+            (setting, L9['device'], DEV['aura2'], L9['ctx1']),
+            (setting, L9['deviceAttr'], L9['rx'], L9['ctx1']),
+            (setting, L9['value'], Literal(0.1), L9['ctx1']),
         ], stmts)
 
     def testDistanceTo(self):
--- a/light9/newtypes.py	Fri May 19 12:09:51 2023 -0700
+++ b/light9/newtypes.py	Fri May 19 12:11:26 2023 -0700
@@ -1,4 +1,4 @@
-from typing import Tuple, NewType, Type, TypeVar, cast
+from typing import Tuple, NewType, Type, TypeVar, Union, cast
 from rdflib import URIRef
 from rdflib.term import Node
 
@@ -17,6 +17,13 @@
 Song = NewType('Song', URIRef)
 UnixTime = NewType('UnixTime', float)
 
+VT = TypeVar('VT', float, int, str)  # remove
+HexColor = NewType('HexColor', str)
+VTUnion = Union[float, int, HexColor]  # rename to ValueType
+DeviceSetting = Tuple[DeviceUri, DeviceAttr,
+                      # currently, floats and hex color strings
+                      VTUnion]
+
 # Alternate output range for a device. Instead of outputting 0.0 to
 # 1.0, you can map that range into, say, 0.2 to 0.7
 OutputRange = NewType('OutputRange', Tuple[float, float])