diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..b98e20a --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "bonsai.sgen": { + "version": "0.1.0", + "commands": [ + "bonsai.sgen" + ] + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 046b416..1e3bfc3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,22 @@ The Project Aeon acquisition repository contains the set of standardized data acquisition systems, protocols, operation instructions and metadata necessary for reproducible task control and acquisition on the foraging arena assay. The scripts contained in this repository should always represent as accurately as possible the automation routines and operational instructions used to log the experimental raw data for Project Aeon. Each acquired dataset should have a reference to the specific hash or release from this repository which was used in the experiment. +## Build Instructions + +### Schema classes + +1. Install [`bonsai.sgen`](https://www.nuget.org/packages/Bonsai.Sgen) by running the `restore` command: + + ``` + dotnet tool restore + ``` + +2. Run the command to regenerate each schema class, e.g. for `ChannelMap.json`: + + ``` + dotnet bonsai.sgen --namespace Aeon.Environment --schema ChannelMap.json + ``` + ## Deployment Instructions The Project Aeon acquisition framework runs on the [Bonsai](https://bonsai-rx.org/) visual programming language. This repository includes installation scripts which will automatically download and configure a reproducible, self-contained, Bonsai environment to run all acquisition systems on the foraging arena. It is necessary, however, to install a few system dependencies and device drivers which need to be installed separately, before runnning the environment configuration script. diff --git a/src/Aeon.Acquisition/Aeon.Acquisition.csproj b/src/Aeon.Acquisition/Aeon.Acquisition.csproj index 7fad82f..9deee9d 100644 --- a/src/Aeon.Acquisition/Aeon.Acquisition.csproj +++ b/src/Aeon.Acquisition/Aeon.Acquisition.csproj @@ -6,7 +6,7 @@ Bonsai Rx Project Aeon Acquisition net472 0.5.0 - build230924 + build231005 @@ -30,7 +30,7 @@ - + diff --git a/src/Aeon.Acquisition/GetDateTime.cs b/src/Aeon.Acquisition/GetDateTime.cs new file mode 100644 index 0000000..76a73dc --- /dev/null +++ b/src/Aeon.Acquisition/GetDateTime.cs @@ -0,0 +1,30 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Reactive.Linq; +using Bonsai; +using Bonsai.Harp; + +namespace Aeon.Acquisition +{ + [Combinator] + [WorkflowElementCategory(ElementCategory.Transform)] + [Description("Converts a sequence of referenced Harp timestamps into system date-time objects.")] + public class GetDateTime + { + static DateTime FromSeconds(double seconds) + { + return GroupByTime.ReferenceTime.AddSeconds(seconds); + } + + public IObservable Process(IObservable source) + { + return source.Select(message => FromSeconds(message.GetTimestamp())); + } + + public IObservable Process(IObservable> source) + { + return source.Select(_ => FromSeconds(_.Seconds)); + } + } +} diff --git a/src/Aeon.Acquisition/GroupByTime.cs b/src/Aeon.Acquisition/GroupByTime.cs index 307db7e..5ba2b44 100644 --- a/src/Aeon.Acquisition/GroupByTime.cs +++ b/src/Aeon.Acquisition/GroupByTime.cs @@ -20,7 +20,7 @@ public GroupByTime() } // The default real-time reference is unix time in total seconds from 1904 - static readonly DateTime ReferenceTime = new DateTime(1904, 1, 1); + internal static readonly DateTime ReferenceTime = new(1904, 1, 1); [Description("The size of each chunk, in whole hours.")] public int ChunkSize { get; set; } diff --git a/src/Aeon.Environment/Aeon.Environment.Generated.cs b/src/Aeon.Environment/Aeon.Environment.Generated.cs new file mode 100644 index 0000000..4ac5715 --- /dev/null +++ b/src/Aeon.Environment/Aeon.Environment.Generated.cs @@ -0,0 +1,295 @@ +//---------------------- +// +// Generated using the NJsonSchema v10.9.0.0 (Newtonsoft.Json v9.0.0.0) (http://NJsonSchema.org) +// +//---------------------- + + +namespace Aeon.Environment +{ + #pragma warning disable // Disable all warnings + + /// + /// Specifies the channel map for every light fixture in the room. + /// + [System.ComponentModel.DescriptionAttribute("Specifies the channel map for every light fixture in the room.")] + [Bonsai.CombinatorAttribute()] + [Bonsai.WorkflowElementCategoryAttribute(Bonsai.ElementCategory.Source)] + public partial class RoomFixtures + { + + private Fixture _coldWhite; + + private Fixture _warmWhite; + + private Fixture _red; + + [System.Xml.Serialization.XmlIgnoreAttribute()] + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="coldWhite")] + public Fixture ColdWhite + { + get + { + return _coldWhite; + } + set + { + _coldWhite = value; + } + } + + [System.Xml.Serialization.XmlIgnoreAttribute()] + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="warmWhite")] + public Fixture WarmWhite + { + get + { + return _warmWhite; + } + set + { + _warmWhite = value; + } + } + + [System.Xml.Serialization.XmlIgnoreAttribute()] + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="red")] + public Fixture Red + { + get + { + return _red; + } + set + { + _red = value; + } + } + + public System.IObservable Process() + { + return System.Reactive.Linq.Observable.Defer(() => System.Reactive.Linq.Observable.Return( + new RoomFixtures + { + ColdWhite = _coldWhite, + WarmWhite = _warmWhite, + Red = _red + })); + } + } + + + [Bonsai.CombinatorAttribute()] + [Bonsai.WorkflowElementCategoryAttribute(Bonsai.ElementCategory.Source)] + public partial class Fixture + { + + private System.Collections.Generic.List _channels = new System.Collections.Generic.List(); + + private InterpolationMethod _interpolationMethod; + + private string _calibrationFile; + + /// + /// Specifies the collection of channels assigned to the fixture. + /// + [System.Xml.Serialization.XmlIgnoreAttribute()] + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="channels")] + [System.ComponentModel.DescriptionAttribute("Specifies the collection of channels assigned to the fixture.")] + public System.Collections.Generic.List Channels + { + get + { + return _channels; + } + set + { + _channels = value; + } + } + + /// + /// Specifies the method used to interpolate light values for a fixture. + /// + [System.Xml.Serialization.XmlIgnoreAttribute()] + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="interpolationMethod")] + [System.ComponentModel.DescriptionAttribute("Specifies the method used to interpolate light values for a fixture.")] + public InterpolationMethod InterpolationMethod + { + get + { + return _interpolationMethod; + } + set + { + _interpolationMethod = value; + } + } + + /// + /// Specifies the path to the calibration file for this fixture. + /// + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="calibrationFile")] + [System.ComponentModel.DescriptionAttribute("Specifies the path to the calibration file for this fixture.")] + public string CalibrationFile + { + get + { + return _calibrationFile; + } + set + { + _calibrationFile = value; + } + } + + public System.IObservable Process() + { + return System.Reactive.Linq.Observable.Defer(() => System.Reactive.Linq.Observable.Return( + new Fixture + { + Channels = _channels, + InterpolationMethod = _interpolationMethod, + CalibrationFile = _calibrationFile + })); + } + } + + + /// + /// Specifies the method used to interpolate light values for a fixture. + /// + public enum InterpolationMethod + { + + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="None")] + None = 0, + + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="Zero")] + Zero = 1, + + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="Linear")] + Linear = 2, + } + + + /// + /// Represents channel map configuration used by the light controller. + /// + [System.ComponentModel.DescriptionAttribute("Represents channel map configuration used by the light controller.")] + [Bonsai.CombinatorAttribute()] + [Bonsai.WorkflowElementCategoryAttribute(Bonsai.ElementCategory.Source)] + public partial class ChannelMap + { + + private System.Collections.Generic.IDictionary _rooms; + + /// + /// Specifies the collection of light channel maps for all rooms. + /// + [System.Xml.Serialization.XmlIgnoreAttribute()] + [YamlDotNet.Serialization.YamlMemberAttribute(Alias="rooms")] + [System.ComponentModel.DescriptionAttribute("Specifies the collection of light channel maps for all rooms.")] + public System.Collections.Generic.IDictionary Rooms + { + get + { + return _rooms; + } + set + { + _rooms = value; + } + } + + public System.IObservable Process() + { + return System.Reactive.Linq.Observable.Defer(() => System.Reactive.Linq.Observable.Return( + new ChannelMap + { + Rooms = _rooms + })); + } + } + + + /// + /// Serializes a sequence of data model objects into YAML strings. + /// + [Bonsai.CombinatorAttribute()] + [Bonsai.WorkflowElementCategoryAttribute(Bonsai.ElementCategory.Transform)] + [System.ComponentModel.DescriptionAttribute("Serializes a sequence of data model objects into YAML strings.")] + public partial class SerializeToYaml + { + + private System.IObservable Process(System.IObservable source) + { + return System.Reactive.Linq.Observable.Defer(() => + { + var serializer = new YamlDotNet.Serialization.SerializerBuilder().Build(); + return System.Reactive.Linq.Observable.Select(source, value => serializer.Serialize(value)); + }); + } + + public System.IObservable Process(System.IObservable source) + { + return Process(source); + } + + public System.IObservable Process(System.IObservable source) + { + return Process(source); + } + + public System.IObservable Process(System.IObservable source) + { + return Process(source); + } + } + + + /// + /// Deserializes a sequence of YAML strings into data model objects. + /// + [System.ComponentModel.DefaultPropertyAttribute("Type")] + [Bonsai.WorkflowElementCategoryAttribute(Bonsai.ElementCategory.Transform)] + [System.Xml.Serialization.XmlIncludeAttribute(typeof(Bonsai.Expressions.TypeMapping))] + [System.Xml.Serialization.XmlIncludeAttribute(typeof(Bonsai.Expressions.TypeMapping))] + [System.Xml.Serialization.XmlIncludeAttribute(typeof(Bonsai.Expressions.TypeMapping))] + [System.ComponentModel.DescriptionAttribute("Deserializes a sequence of YAML strings into data model objects.")] + public partial class DeserializeFromYaml : Bonsai.Expressions.SingleArgumentExpressionBuilder + { + + public DeserializeFromYaml() + { + Type = new Bonsai.Expressions.TypeMapping(); + } + + public Bonsai.Expressions.TypeMapping Type { get; set; } + + public override System.Linq.Expressions.Expression Build(System.Collections.Generic.IEnumerable arguments) + { + var typeMapping = (Bonsai.Expressions.TypeMapping)Type; + var returnType = typeMapping.GetType().GetGenericArguments()[0]; + return System.Linq.Expressions.Expression.Call( + typeof(DeserializeFromYaml), + "Process", + new System.Type[] { returnType }, + System.Linq.Enumerable.Single(arguments)); + } + + private static System.IObservable Process(System.IObservable source) + { + return System.Reactive.Linq.Observable.Defer(() => + { + var serializer = new YamlDotNet.Serialization.DeserializerBuilder().Build(); + return System.Reactive.Linq.Observable.Select(source, value => + { + var reader = new System.IO.StringReader(value); + var parser = new YamlDotNet.Core.MergingParser(new YamlDotNet.Core.Parser(reader)); + return serializer.Deserialize(parser); + }); + }); + } + } +} \ No newline at end of file diff --git a/src/Aeon.Environment/Aeon.Environment.csproj b/src/Aeon.Environment/Aeon.Environment.csproj index 2eb4217..d59592b 100644 --- a/src/Aeon.Environment/Aeon.Environment.csproj +++ b/src/Aeon.Environment/Aeon.Environment.csproj @@ -7,7 +7,7 @@ Bonsai Rx Project Aeon Environment net472 0.1.0 - build230927 + build231005 diff --git a/src/Aeon.Environment/ChannelMap.json b/src/Aeon.Environment/ChannelMap.json new file mode 100644 index 0000000..9a225e5 --- /dev/null +++ b/src/Aeon.Environment/ChannelMap.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://sainsburywellcome.org/aeon/2023-10/channelmap", + "description": "Represents channel map configuration used by the light controller.", + "title": "ChannelMap", + "type": "object", + "properties": { + "rooms": { + "type": "object", + "description": "Specifies the collection of light channel maps for all rooms.", + "additionalProperties": { "$ref": "#/definitions/roomFixtures" } + } + }, + "definitions": { + "roomFixtures": { + "type": "object", + "description": "Specifies the channel map for every light fixture in the room.", + "properties": { + "coldWhite": { "$ref": "#/definitions/fixture" }, + "warmWhite": { "$ref": "#/definitions/fixture" }, + "red": { "$ref": "#/definitions/fixture" } + } + }, + "fixture": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "description": "Specifies the collection of channels assigned to the fixture.", + "items": { "type": "integer" } + }, + "interpolationMethod": { + "description": "Specifies the method used to interpolate light values for a fixture.", + "$ref": "#/definitions/interpolationMethod" + }, + "calibrationFile": { + "type": "string", + "description": "Specifies the path to the calibration file for this fixture." + } + }, + "required": [ "channels", "interpolationMethod" ] + }, + "interpolationMethod": { + "type": "string", + "description": "Specifies the method used to interpolate light values for a fixture.", + "enum": ["None", "Zero", "Linear"] + } + } +} \ No newline at end of file diff --git a/src/Aeon.Environment/CreateRoomLightPreset.cs b/src/Aeon.Environment/CreateRoomLightPreset.cs new file mode 100644 index 0000000..337ec13 --- /dev/null +++ b/src/Aeon.Environment/CreateRoomLightPreset.cs @@ -0,0 +1,49 @@ +using System; +using System.ComponentModel; +using System.Reactive.Linq; +using Bonsai; + +namespace Aeon.Environment +{ + [TypeConverter(typeof(SettingsConverter))] + [Description("Creates a light controller preset.")] + public class CreateRoomLightPreset : Source + { + [Description("The normalized light level to set on the cold-white channels.")] + public float ColdWhite { get; set; } + + [Description("The normalized light level to set on the warm-white channels.")] + public float WarmWhite { get; set; } + + [Description("The normalized light level to set on the red light channels.")] + public float Red { get; set; } + + public override IObservable Generate() + { + return Observable.Return(new RoomLightPreset(ColdWhite, WarmWhite, Red)); + } + + public IObservable Generate(IObservable source) + { + return source.Select(value => new RoomLightPreset(ColdWhite, WarmWhite, Red)); + } + + class SettingsConverter : ExpandableObjectConverter + { + public override PropertyDescriptorCollection GetProperties( + ITypeDescriptorContext context, + object value, + Attribute[] attributes) + { + return base + .GetProperties(context, value, attributes) + .Sort(new[] + { + nameof(ColdWhite), + nameof(WarmWhite), + nameof(Red) + }); + } + } + } +} diff --git a/src/Aeon.Environment/InterpolateRoomLightPreset.cs b/src/Aeon.Environment/InterpolateRoomLightPreset.cs new file mode 100644 index 0000000..d2584b1 --- /dev/null +++ b/src/Aeon.Environment/InterpolateRoomLightPreset.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Xml.Serialization; +using Bonsai; +using MathNet.Numerics; +using MathNet.Numerics.Interpolation; + +namespace Aeon.Environment +{ + [Description("Maps room light presets to a sequence of channel-value messages to the room light controller.")] + public class InterpolateRoomLightPreset : Combinator + { + [XmlIgnore] + [Description("Specifies the channel map for the fixtures in the room.")] + public RoomFixtures Fixtures { get; set; } + + static IInterpolation CreateFixtureInterpolation(InterpolationMethod method, string calibrationFile) + { + switch (method) + { + case InterpolationMethod.None: return new AnonymousInterpolation(t => t); + case InterpolationMethod.Zero: return new AnonymousInterpolation(t => 0); + case InterpolationMethod.Linear: + var calibrationContents = File.ReadAllLines( + calibrationFile ?? + throw new ArgumentNullException(nameof(calibrationFile))); + var points = new List(); + var samples = new List(); + foreach (var row in calibrationContents.Skip(1)) + { + var values = row.Split(','); + if (values.Length != 2 || + !double.TryParse(values[0], out double level) || + !double.TryParse(values[1], out double lux)) + { + throw new ArgumentException( + "Calibration file should be in 2-column comma-separated text format.", + nameof(calibrationFile)); + } + + points.Add(lux); + samples.Add(level); + } + return Interpolate.Linear(points, samples); + default: throw new ArgumentException("Unsupported interpolation method.", nameof(method)); + } + } + + static void OnNextPreset( + float preset, + Fixture fixture, + IInterpolation interpolation, + IObserver observer) + { + if (fixture == null) throw new ArgumentNullException(nameof(fixture)); + if (fixture.Channels != null) + { + var value = (int)interpolation.Interpolate(preset); + for (int i = 0; i < fixture.Channels.Count; i++) + { + observer.OnNext(new RoomLightMessage( + channel: fixture.Channels[i], + value: value)); + } + } + } + + public override IObservable Process(IObservable source) + { + var fixtures = Fixtures ?? throw new InvalidOperationException("No fixtures have been specified."); + return Observable.Create(observer => + { + var coldWhiteLookup = CreateFixtureInterpolation( + fixtures.ColdWhite.InterpolationMethod, + fixtures.ColdWhite.CalibrationFile); + var warmWhiteLookup = CreateFixtureInterpolation( + fixtures.WarmWhite.InterpolationMethod, + fixtures.WarmWhite.CalibrationFile); + var redLookup = CreateFixtureInterpolation( + fixtures.Red.InterpolationMethod, + fixtures.Red.CalibrationFile); + + var presetObserver = Observer.Create( + value => + { + OnNextPreset(value.ColdWhite, fixtures.ColdWhite, coldWhiteLookup, observer); + OnNextPreset(value.WarmWhite, fixtures.WarmWhite, warmWhiteLookup, observer); + OnNextPreset(value.Red, fixtures.Red, redLookup, observer); + }, + observer.OnError, + observer.OnCompleted); + return source.SubscribeSafe(presetObserver); + }); + } + + class AnonymousInterpolation : IInterpolation + { + readonly Func interpolate; + + public AnonymousInterpolation(Func interpolator) + { + interpolate = interpolator; + } + + public double Interpolate(double t) => interpolate(t); + public bool SupportsDifferentiation => false; + public bool SupportsIntegration => false; + public double Differentiate(double t) => throw new NotSupportedException(); + public double Differentiate2(double t) => throw new NotSupportedException(); + public double Integrate(double t) => throw new NotSupportedException(); + public double Integrate(double a, double b) => throw new NotSupportedException(); + } + } +} diff --git a/src/Aeon.Environment/LightController.bonsai b/src/Aeon.Environment/LightClient.bonsai similarity index 60% rename from src/Aeon.Environment/LightController.bonsai rename to src/Aeon.Environment/LightClient.bonsai index f8d8332..6028246 100644 --- a/src/Aeon.Environment/LightController.bonsai +++ b/src/Aeon.Environment/LightClient.bonsai @@ -3,21 +3,23 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zmq="clr-namespace:Bonsai.ZeroMQ;assembly=Bonsai.ZeroMQ" xmlns:osc="clr-namespace:Bonsai.Osc;assembly=Bonsai.Osc" - xmlns:scr="clr-namespace:Bonsai.Scripting.Expressions;assembly=Bonsai.Scripting.Expressions" + xmlns:aeon-env="clr-namespace:Aeon.Environment;assembly=Aeon.Environment" xmlns:rx="clr-namespace:Bonsai.Reactive;assembly=Bonsai.Core" xmlns:harp="clr-namespace:Bonsai.Harp;assembly=Bonsai.Harp" - xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns="https://bonsai-rx.org/2018/workflow"> - Provides control and acquisition functionality for automated room lighting. + Provides a network client for automated room light control. + + + >tcp://localhost:4303 - LightEvents + @@ -27,10 +29,17 @@ /channel ii - - new( -Item1 as Channel, -Item2 as Value) + + + + + + + + + 0 + 0 + SynchronizerEvents @@ -48,26 +57,14 @@ Item2 as Value) LightEvents - - SetRedLightLevel - - - /red - - - SetColdWhiteLightLevel - - - /cold + + - - SetWarmWhiteLightLevel + + LightCommands - /warm - - - + /preset Buffer.Array @@ -78,30 +75,29 @@ Item2 as Value) >tcp://localhost:4304 - LightCommands + - - + + + - - - - - - + + + + + + + - - + - - - - + + \ No newline at end of file diff --git a/src/Aeon.Environment/LightCycle.bonsai b/src/Aeon.Environment/LightCycle.bonsai new file mode 100644 index 0000000..be345c6 --- /dev/null +++ b/src/Aeon.Environment/LightCycle.bonsai @@ -0,0 +1,74 @@ + + + Implements a simple light cycle model where light levels are sampled using the current time of day. + + + + + + + lightcycle.config + %i,%i,%i,%i + 1 + + + new( +Item1 as Minute, +Item2 as Red, +Item3 as ColdWhite, +Item4 as WarmWhite) + + + Minute + + + SynchronizerEvents + + + + + + Hour * 60 + Minute + + + + + + + + + + + + + + + 0 + 0 + 0 + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Aeon.Environment/LightPresetHandler.bonsai b/src/Aeon.Environment/LightPresetHandler.bonsai new file mode 100644 index 0000000..8850039 --- /dev/null +++ b/src/Aeon.Environment/LightPresetHandler.bonsai @@ -0,0 +1,151 @@ + + + Provides a default handler for light commands using the specified channel map config file. + + + + Source1 + + + + + + + + + Source1 + + + LightPresets + + + + + + + + + + + + + Rooms + + + + + + + + + + Source1 + + + Room + + + LightPresets + + + Room + + + + + + RoomPresets + + + + Source1 + + + Item1.Key,Item2.Key + + + + + + + + + + + + + Item1 + + + + + + Room + + + Value + + + + + + + + + + + Room + + + Key + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Aeon.Environment/LightServer.bonsai b/src/Aeon.Environment/LightServer.bonsai new file mode 100644 index 0000000..66a34e2 --- /dev/null +++ b/src/Aeon.Environment/LightServer.bonsai @@ -0,0 +1,198 @@ + + + Implements a router of light preset commands and room light message responses over the local network. + + + + + + + LightMessages + + + PackResponses + + + + Source1 + + + LightGroup + + + LightGroup + + + Key + + + + + + LightGroup + + + + + + /channel + + + Buffer.Array + + + + + + + + + + + + Source1 + + + Item1 + + + Item2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @tcp://*:4303 + + + + + + + + + @tcp://*:4304 + + + + + GroupRequests + + + + Source1 + + + First + + + + + + Last.Buffer + + + /preset + fff + + + + + + + + + + + 0 + 0 + 0 + + + + + + + Item1 + Item2 + + + + + + + + + + + + + + + + + + + + + + LightPresets + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Aeon.Environment/RoomLightController.bonsai b/src/Aeon.Environment/RoomLightController.bonsai new file mode 100644 index 0000000..49d8190 --- /dev/null +++ b/src/Aeon.Environment/RoomLightController.bonsai @@ -0,0 +1,49 @@ + + + Provides a centralized controller for automating grouped room light changes. + + + + Source1 + + + Item1 + + + + + + + + + PT0S + + + + Item2 + + + + + + Item2 + Item1 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Aeon.Environment/RoomLightPreset.cs b/src/Aeon.Environment/RoomLightPreset.cs new file mode 100644 index 0000000..e919c18 --- /dev/null +++ b/src/Aeon.Environment/RoomLightPreset.cs @@ -0,0 +1,21 @@ +namespace Aeon.Environment +{ + public struct RoomLightPreset + { + public float ColdWhite; + public float WarmWhite; + public float Red; + + public RoomLightPreset(float coldWhite, float warmWhite, float red) + { + ColdWhite = coldWhite; + WarmWhite = warmWhite; + Red = red; + } + + public override readonly string ToString() + { + return $"RoomLightPreset({ColdWhite}, {WarmWhite}, {Red})"; + } + } +}