Преглед изворни кода

Merge branch 'master' of github.com:profplump/UberZach-Integration

Zachary Kotlarek пре 10 година
родитељ
комит
3f17cc943b
73 измењених фајлова са 3613 додато и 1208 уклоњено
  1. 2 1
      dmx/Audio.pm
  2. 3 0
      dmx/DMX.pm
  3. 125 0
      dmx/HDMIctrl.pl
  4. 70 88
      dmx/amplifierPower.pl
  5. 162 17
      dmx/announce.pl
  6. 2 0
      dmx/audio.pl
  7. 12 6
      dmx/bias.pl
  8. 21 5
      dmx/channels.txt
  9. 113 0
      dmx/comfort.pl
  10. 73 24
      dmx/dmx.py
  11. 129 0
      dmx/door.pl
  12. 46 42
      dmx/garage.pl
  13. 213 0
      dmx/garage_leds.pl
  14. 111 0
      dmx/garage_shelf.pl
  15. 35 0
      dmx/launchd/com.uberzach.HDMIctrl.plist
  16. 1 6
      dmx/launchd/com.uberzach.amplifierPower.plist
  17. 0 5
      dmx/launchd/com.uberzach.ola.bias.plist
  18. 24 0
      dmx/launchd/com.uberzach.ola.comfort.plist
  19. 8 5
      dmx/launchd/com.uberzach.ola.dmx.plist
  20. 24 0
      dmx/launchd/com.uberzach.ola.door.plist
  21. 0 5
      dmx/launchd/com.uberzach.ola.fan.plist
  22. 0 7
      dmx/launchd/com.uberzach.ola.garage.plist
  23. 24 0
      dmx/launchd/com.uberzach.ola.garage_leds.plist
  24. 24 0
      dmx/launchd/com.uberzach.ola.garage_shelf.plist
  25. 0 5
      dmx/launchd/com.uberzach.ola.leds.plist
  26. 24 0
      dmx/launchd/com.uberzach.ola.oil.plist
  27. 0 5
      dmx/launchd/com.uberzach.ola.overhead.plist
  28. 0 5
      dmx/launchd/com.uberzach.ola.rave.plist
  29. 0 5
      dmx/launchd/com.uberzach.ola.rope.plist
  30. 24 0
      dmx/launchd/com.uberzach.ola.rumble.plist
  31. 0 5
      dmx/launchd/com.uberzach.ola.stairs.plist
  32. 24 0
      dmx/launchd/com.uberzach.ola.vr.plist
  33. 35 0
      dmx/launchd/com.uberzach.projectorCtrl.plist
  34. 0 9
      dmx/launchd/com.uberzach.qt_player.plist
  35. 2 2
      dmx/launchd/com.uberzach.rifftrax.plist
  36. 35 0
      dmx/launchd/com.uberzach.rumbleCtl.plist
  37. 3 3
      dmx/launchd/com.uberzach.state.plist
  38. 6 8
      dmx/launchd/com.uberzach.tvPower.plist
  39. 17 14
      dmx/leds.pl
  40. 0 6
      dmx/modules.txt
  41. 129 0
      dmx/oil.pl
  42. 104 126
      dmx/projectorCtrl.pl
  43. 19 11
      dmx/qt_player.pl
  44. 54 32
      dmx/rave.pl
  45. 1 2
      dmx/reset.sh
  46. 2 2
      dmx/rope.pl
  47. 174 0
      dmx/rumble.pl
  48. 153 0
      dmx/rumbleCtl.pl
  49. 4 11
      dmx/stairs.pl
  50. 735 623
      dmx/state.pl
  51. 249 0
      dmx/tvPower.pl
  52. 115 0
      dmx/vr.pl
  53. 20 0
      motion/launchd/com.uberzach.motion.garage.plist
  54. 1 9
      motion/launchd/com.uberzach.motion.projector.plist
  55. 1 9
      motion/launchd/com.uberzach.motion.stairs.plist
  56. 20 0
      motion/launchd/com.uberzach.motion.understairs.plist
  57. 20 0
      motion/launchd/com.uberzach.motion.upstairs.plist
  58. 33 5
      pms/PMSCheck.sh
  59. 30 11
      pms/PMSHelper.sh
  60. 4 11
      pms/com.uberzach.PMS.check.plist
  61. 1 0
      pms/pms-interactive
  62. 36 0
      serial/findDevice.sh
  63. 1 0
      serial/hdmi.pl
  64. 1 7
      serial/launchd/com.uberzach.amplifier.plist
  65. 33 0
      serial/launchd/com.uberzach.hdmi.plist
  66. 10 6
      serial/launchd/com.uberzach.projector.plist
  67. 33 0
      serial/launchd/com.uberzach.tv.plist
  68. 184 63
      serial/serial.pl
  69. 1 0
      serial/tv.pl
  70. 6 1
      speak/com.uberzach.speak.plist
  71. 17 1
      speak/speak.m
  72. 6 0
      wol/wol-game.sh
  73. 24 0
      wol/wol.sh

+ 2 - 1
dmx/Audio.pm

@@ -146,8 +146,9 @@ sub play($) {
 
 	my @cmd = ('tell application "QuickTime Player"');
 	push(@cmd, 'play document ' . $FILES{$name});
+	push(@cmd, 'delay 0.05');
 	push(@cmd, 'repeat while playing of document ' . $FILES{$name} . ' = true');
-	push(@cmd, 'delay 0.1');
+	push(@cmd, 'delay 0.05');
 	push(@cmd, 'end repeat');
 	push(@cmd, 'end tell');
 	runApplescript(join("\n", @cmd));

+ 3 - 0
dmx/DMX.pm

@@ -28,6 +28,9 @@ my %CHANNEL_ADJ = (
 	'13' => 1.00,
 	'14' => 1.14,
 	'15' => 1.19,
+	'21' => 1.19,
+	'22' => 1.23,
+	'23' => 1.00,
 );
 
 # Sanity check

+ 125 - 0
dmx/HDMIctrl.pl

@@ -0,0 +1,125 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+use IPC::System::Simple qw( system capture );
+use Time::HiRes qw( usleep sleep time );
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# Prototypes
+sub sendCmd($$);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'HDMI_CTRL';
+my $HDMI_SOCK    = 'HDMI';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 10;
+my $PULL_TIMEOUT = 60;
+my $DELAY        = $PULL_TIMEOUT / 2;
+my $CMD_DELAY    = 1.0;
+my @INIT_CMDS    = ('POD_OFF');
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+my $hdmi = DMX::clientSock($HDMI_SOCK);
+
+# State
+my $state     = 'PLEX';
+my $stateLast = $state;
+my %exists    = ();
+my $pushLast  = 0;
+my $pullLast  = time();
+my $update    = 0;
+
+# Init
+foreach my $cmd (@INIT_CMDS) {
+	$hdmi->send($cmd)
+	  or die('Unable to write command to HDMI socket: ' . $cmd . ": ${!}\n");
+}
+
+# Loop forever
+while (1) {
+
+	# State is calculated; use newState to gather data
+	my $newState = $state;
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, undef(), undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
+	if (defined($cmdState)) {
+		$newState = $cmdState;
+		$pullLast = time();
+	}
+
+	# Die if we don't see regular updates
+	if ($now - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Calculate the new state
+	$stateLast = $state;
+	if ($exists{'GAME'}) {
+		$state = 'GAME';
+	} else {
+		$state = 'PLEX';
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+		if ($DEBUG) {
+			print STDERR "Forcing periodic update\n";
+		}
+		$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Rate-limit commands, in case we go dumb somewhere in the chain
+	if ($update && $now < $pushLast + $CMD_DELAY) {
+		if ($DEBUG) {
+			print STDERR 'Ignoring overrate update: ' . $state . "\n";
+		}
+		$update = 0;
+	}
+
+	# Update the switch
+	if ($update) {
+
+		# Extra debugging to record pushes
+		if ($DEBUG) {
+			print STDERR 'State: ' . $state . "\n";
+		}
+
+		# Send state
+		$hdmi->send($state)
+		  or die('Unable to write command to HDMI socket: ' . $state . ": ${!}\n");
+
+		# Update the push time
+		$pushLast = $now;
+
+		# Clear the update flag
+		$update = 0;
+	}
+}

+ 70 - 88
dmx/amplifierPower.pl

@@ -11,7 +11,7 @@ use lib dirname(abs_path($0));
 use DMX;
 
 # Prototypes
-sub sendCmd($$);
+sub sendCmd($);
 
 # App config
 my $DATA_DIR     = DMX::dataDir();
@@ -33,35 +33,34 @@ if ($ENV{'DEBUG'}) {
 # Sockets
 DMX::stateSocket($STATE_SOCK);
 DMX::stateSubscribe($STATE_SOCK);
-my $amp = DMX::clientSock($AMP_SOCK);
+my $AMP = DMX::clientSock($AMP_SOCK);
 
 # State
-my $state     = 'OFF';
-my $mode      = 'SURROUND';
-my $input     = 'TV';
-my $stateLast = $state;
+my $power     = 'OFF';
 my %exists    = ();
 my $pushLast  = 0;
 my $pullLast  = time();
-my $update    = 0;
 my $lastMode  = 0;
+my $modeCmd   = undef();
 my $lastInput = 0;
+my $inputCmd  = undef();
 my $lastPower = 0;
+my $powerCmd  = undef();
 
 # Loop forever
 while (1) {
 
-	# State is calculated; use newState to gather data
-	my $newState = $state;
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, undef(), undef());
 
 	# Avoid repeated calls to time()
 	my $now = time();
 
-	# Wait for state updates
-	my $cmdState = DMX::readState($DELAY, \%exists, undef(), undef());
+	# Record only valid states
+	my $newState = undef();
 	if (defined($cmdState)) {
 		$newState = $cmdState;
-		$pullLast = $now;
+		$pullLast = time();
 	}
 
 	# Die if we don't see regular updates
@@ -69,112 +68,95 @@ while (1) {
 		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
 	}
 
-	# Calculate the new state
-	$stateLast = $state;
-	if ($newState eq 'PLAY') {
-		$state = 'ON';
-	} elsif ($exists{'RAVE'}) {
-		$state = 'RAVE';
-	} elsif ($newState eq 'PAUSE') {
-		$state = 'ON';
-	} else {
-		$state = 'OFF';
-	}
-
-	# Calculate the channel mode
-	if ($exists{'STEREO_CMD'} || $exists{'PLAYING_TYPE'} eq 'audio' || $state eq 'RAVE') {
-		$mode = 'STEREO';
-	} else {
-		$mode = 'SURROUND';
-	}
-
-	# Force updates on a periodic basis
-	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
-
-		# Not for the amp
-		#if ($DEBUG) {
-		#	print STDERR "Forcing periodic update\n";
-		#}
-		#$update = 1;
-	}
-
-	# Force updates on any state change
-	if (!$update && $stateLast ne $state) {
-		if ($DEBUG) {
-			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+	# Set the power state as needed
+	my $powerCmd = undef();
+	{
+		my $power = 'OFF';
+		if ($newState eq 'PLAY') {
+			$power = 'ON';
+		} elsif ($exists{'RAVE'}) {
+			$power = 'ON';
+		} elsif ($newState eq 'PAUSE') {
+			$power = 'ON';
 		}
-		$update = 1;
-	}
-
-	# Force updates when there is a physical state mistmatch
-	if (!$update && (($state eq 'OFF' && $exists{'AMPLIFIER'}) || ($state eq 'ON' && !$exists{'AMPLIFIER'}))) {
-		if ($DEBUG) {
-			print STDERR 'Physical state mismatch: ' . $state . ':' . $exists{'AMPLIFIER'} . "\n";
+		if (($power eq 'OFF' && $exists{'AMPLIFIER'}) || ($power eq 'ON' && !$exists{'AMPLIFIER'})) {
+			$powerCmd = $power;
 		}
-		$update = 1;
 	}
 
 	# Set the channel mode as needed
-	if ($exists{'AMPLIFIER'} && $exists{'AMPLIFIER_MODE'} ne $mode && $lastMode < $now - $CMD_DELAY) {
-		$lastMode = $now;
-		if ($DEBUG) {
-			print STDERR 'Setting mode to: ' . $mode . "\n";
+	my $modeCmd = undef();
+	{
+		my $mode = 'SURROUND';
+		if ($exists{'STEREO_CMD'} || $exists{'PLAYING_TYPE'} eq 'audio' || $exists{'RAVE'}) {
+			$mode = 'STEREO';
 		}
-		DMX::say('Amplifier mode: ' . $mode);
-		sendCmd($amp, $mode);
-
-		# Reset the input mode anytime we switch to SURROUND
-		if ($mode eq 'SURROUND') {
-			sendCmd($amp, $AUTO_CMD);
+		if ($exists{'AMPLIFIER'} && $exists{'AMPLIFIER_MODE'} ne $mode) {
+			$modeCmd = $mode;
 		}
 	}
 
-	# Set the amplifier input as needed
-	if ($exists{'AMPLIFIER'} && $exists{'AMPLIFIER_INPUT'} ne $input && $lastInput < $now - $CMD_DELAY) {
-		$lastInput = $now;
-		if ($DEBUG) {
-			print STDERR 'Setting input to: ' . $input . "\n";
+	# Set the input as needed
+	my $inputCmd = undef();
+	{
+		my $input = 'PLEX';
+		if ($exists{'GAME'}) {
+			$input = 'GAME';
+		}
+		if ($exists{'AMPLIFIER'} && $exists{'AMPLIFIER_INPUT'} ne $input) {
+			$inputCmd = $input;
 		}
-		DMX::say('Amplifier input: ' . $input);
-		sendCmd($amp, $input);
 	}
 
 	# Update the amp
-	if ($update) {
+	if ($powerCmd || $inputCmd || $modeCmd) {
 
-		# Extra debugging to record pushes
-		if ($DEBUG) {
-			print STDERR 'State: ' . $state . "\n";
+		# Send master power state
+		if (defined($powerCmd) && $lastPower < $now - $CMD_DELAY) {
+			$lastPower = $now;
+			if ($DEBUG) {
+				print STDERR 'State: ' . $power . "\n";
+			}
+			sendCmd($powerCmd);
 		}
 
-		# Send master power state
-		my $cmd = undef();
-		if ($state eq 'OFF' || $state eq 'ON') {
-			$cmd = $state;
-		} elsif ($state eq 'RAVE') {
-			$cmd = 'ON';
+		# Send the output state
+		if (defined($modeCmd) && $lastMode < $now - $CMD_DELAY) {
+			$lastMode = $now;
+			if ($DEBUG) {
+				print STDERR 'Setting mode to: ' . $modeCmd . "\n";
+			}
+			sendCmd($modeCmd);
+
+			# Reset the input mode anytime we switch to SURROUND
+			if ($modeCmd eq 'SURROUND') {
+				sendCmd($AUTO_CMD);
+			}
 		}
-		if (defined($cmd) && $lastPower < $now - $CMD_DELAY) {
-			$lastPower = $now;
-			sendCmd($amp, $cmd);
+
+		# Send the input state
+		if ($inputCmd && $lastInput < $now - $CMD_DELAY) {
+			$lastInput = $now;
+			if ($DEBUG) {
+				print STDERR 'Setting input to: ' . $inputCmd . "\n";
+			}
+			sendCmd($inputCmd);
 		}
 
+
 		# No output file
 
 		# Update the push time
 		$pushLast = $now;
-
-		# Clear the update flag
-		$update = 0;
 	}
 }
 
 # Send the requested command and enforce an inter-command delay
 sub sendCmd($$) {
-	my ($amp, $cmd) = @_;
+	my ($cmd) = @_;
 
 	# Send the command
-	$amp->send($cmd)
+	$AMP->send($cmd)
 	  or die('Unable to write command to amp socket: ' . $cmd . ": ${!}\n");
 
 	# A tiny delay to keep the serial port stable in repeated calls

+ 162 - 17
dmx/announce.pl

@@ -1,10 +1,13 @@
 #!/usr/bin/perl
 use strict;
 use warnings;
+use Email::MIME;
+use Email::Sender::Simple qw(sendmail);
+use File::Basename qw(dirname);
+use Sys::Hostname;
 
 # Local modules
 use Cwd qw(abs_path);
-use File::Basename qw(dirname);
 use lib dirname(abs_path($0));
 use DMX;
 
@@ -15,6 +18,10 @@ my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
 my $PUSH_TIMEOUT = 20;
 my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
 my $DELAY        = $PULL_TIMEOUT / 2;
+my $ALARM_DELAY  = 10;
+my $MODE_DELAY   = 7.5;
+my $PROJ_DELAY   = 59;
+my $PROJ_FACTOR  = 0.95;
 
 # Debug
 my $DEBUG = 0;
@@ -25,6 +32,10 @@ if ($ENV{'DEBUG'}) {
 # State
 my %exists    = ();
 my %last      = ();
+my %mtime     = ();
+my $alarmLast = 0;
+my $modeLast  = 0;
+my $projLast  = 0;
 my $pullLast  = time();
 
 # Sockets
@@ -38,40 +49,174 @@ while (1) {
 	%last = %exists;
 
 	# Wait for state updates
-	my $cmdState = DMX::readState($DELAY, \%exists, undef(), undef());
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
 	if (defined($cmdState)) {
-		$pullLast = time();
+		$pullLast = $now;
 	}
 
 	# Die if we don't see regular updates
-	if (time() - $pullLast > $PULL_TIMEOUT) {
+	if ($now - $pullLast > $PULL_TIMEOUT) {
 		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
 	}
 
-	# Speak when BRIGHT changes
-	if (exists($exists{'BRIGHT'}) && exists($last{'BRIGHT'}) && $exists{'BRIGHT'} ne $last{'BRIGHT'}) {
-		if ($exists{'BRIGHT'}) {
-			DMX::say('Lights - Full power');
-		} else {
-			DMX::say('Lights - Nominal power');
+	# Speak when the theater mode changes
+	if (exists($exists{'GAME'}) && exists($last{'GAME'}) && $exists{'GAME'} ne $last{'GAME'}) {
+		my $mode = 'PLEX';
+		if ($exists{'GAME'}) {
+			$mode = 'GAME';
+		}
+
+		# Wake the input system when we switch to it
+		my $wol = $ENV{'HOME'} . '/bin/video/wol/wol-' . lc($mode) . '.sh';
+		if (-x $wol) {
+			system($wol);
 		}
+
+		# Allow other checks to opt-out if they are mode-switch related
+		$modeLast = $now;
+
+		DMX::say('Theater mode: ' . lc($mode));
 	}
 
 	# Speak when LIGHTS changes
 	if (exists($exists{'LIGHTS'}) && exists($last{'LIGHTS'}) && $exists{'LIGHTS'} ne $last{'LIGHTS'}) {
-		if ($exists{'LIGHTS'}) {
-			DMX::say('Lights up');
-		} else {
-			DMX::say('Lights down');
+		if ($now - $modeLast > $MODE_DELAY) {
+			my $mode = 'DOWN';
+			if ($exists{'LIGHTS'}) {
+				$mode = 'UP';
+			}
+
+			DMX::say('Lights ' . lc($mode));
+		}
+	}
+
+	# Speak when AMPLIFIER_INPUT changes
+	if (   exists($exists{'AMPLIFIER_INPUT'})
+		&& exists($last{'AMPLIFIER_INPUT'})
+		&& $exists{'AMPLIFIER_INPUT'} ne $last{'AMPLIFIER_INPUT'})
+	{
+		if ($now - $modeLast > $MODE_DELAY) {
+			DMX::say('Amplifier input: ' . lc($exists{'AMPLIFIER_INPUT'}));
+		}
+	}
+
+	# Speak when AMPLIFIER_MODE changes
+	if (   exists($exists{'AMPLIFIER_MODE'})
+		&& exists($last{'AMPLIFIER_MODE'})
+		&& $exists{'AMPLIFIER_MODE'} ne $last{'AMPLIFIER_MODE'})
+	{
+		DMX::say('Amplifier mode: ' . lc($exists{'AMPLIFIER_MODE'}));
+	}
+
+	# Speak when PROJECTOR_CTRL_TIMER changes
+	if (exists($exists{'PROJECTOR_CTRL_TIMER'}) && $exists{'PROJECTOR_CTRL_TIMER'}) {
+		if ($now - $projLast > $PROJ_DELAY) {
+
+			# Determine the unit
+			my $unit     = 'second';
+			my $timeLeft = $exists{'PROJECTOR_CTRL_TIMER'};
+			if ($exists{'PROJECTOR_CTRL_TIMER'} > 60) {
+				$timeLeft = $exists{'PROJECTOR_CTRL_TIMER'} / 60;
+				$unit     = 'minute';
+			}
+
+			# Avoid saying "0" unless we *really* mean it
+			$timeLeft = ceil($timeLeft);
+
+			# Add an "s" as needed
+			my $plural = 's';
+			if ($timeLeft == 1) {
+				$plural = '';
+			}
+			$unit .= $plural;
+
+			# Speak
+			DMX::say('Projector shutdown in about ' . $timeLeft . ' ' . $unit);
+
+			# Update the delay timer
+			$projLast = $now;
+		}
+	}
+
+	# Speak when PROJECTOR_CTRL_POWER changes
+	if (   exists($exists{'PROJECTOR_CTRL_POWER'})
+		&& exists($last{'PROJECTOR_CTRL_POWER'})
+		&& $exists{'PROJECTOR_CTRL_POWER'} ne $last{'PROJECTOR_CTRL_POWER'})
+	{
+		if ($exists{'PROJECTOR_CTRL_POWER'} eq 'OFF' || $exists{'PROJECTOR_CTRL_POWER'} eq 'ON') {
+			$projLast = 0;
+			DMX::say('Projector: ' . lc($exists{'PROJECTOR_CTRL_POWER'}));
+
+			# Announce lamp life status, if near/past $LAMP_LIFE
+			if (exists($exists{'PROJECTOR_CTRL_LIFE'}) && $exists{'PROJECTOR_CTRL_LIFE'} > $PROJ_FACTOR) {
+				my $chance = ($exists{'PROJECTOR_CTRL_LIFE'} - $PROJ_FACTOR) * (1 / (1 - $PROJ_FACTOR));
+				if (rand(1) <= $chance) {
+					DMX::say('Projector lamp has run for ' . int($exists{'PROJECTOR_CTRL_LIFE'} * 100) . '% of its estimated life.');
+				}
+			}
+		}
+	}
+
+	# Speak when BRIGHT changes
+	if (exists($exists{'BRIGHT'}) && exists($last{'BRIGHT'}) && $exists{'BRIGHT'} ne $last{'BRIGHT'}) {
+		my $mode = 'NOMINAL';
+		if ($exists{'BRIGHT'}) {
+			$mode = 'FULL';
 		}
+		DMX::say('Lights - ' . lc($mode) . ' power');
 	}
 
 	# Speak when NO_MOTION changes
 	if (exists($exists{'NO_MOTION'}) && exists($last{'NO_MOTION'}) && $exists{'NO_MOTION'} ne $last{'NO_MOTION'}) {
+		my $mode = 'ENABLED';
 		if ($exists{'NO_MOTION'}) {
-			DMX::say('Motion detectors: Disabled');
-		} else {
-			DMX::say('Motion detectors: Enabled');
+			$mode = 'DISABLED';
+		}
+		DMX::say('Motion detectors: ' . lc($mode));
+	}
+
+	# Speak when LOCK changes
+	if (exists($exists{'LOCK'}) && exists($last{'LOCK'}) && $exists{'LOCK'} ne $last{'LOCK'}) {
+		my $mode = 'UNLOCKED';
+		if ($exists{'LOCK'}) {
+			$mode = 'LOCKED';
+		}
+
+		DMX::say('System ' . lc($mode));
+	}
+
+	# Email when ALARM is asserted
+	if (exists($exists{'ALARM'}) && exists($last{'ALARM'}) && $exists{'ALARM'} && !$last{'ALARM'}) {
+		my @exists_tmp = ();
+		foreach my $key (sort(keys(%exists))) {
+			push(@exists_tmp, $key . ":\t" . $exists{$key} . ' @ ' . $mtime{$key});
+		}
+
+		my $message = Email::MIME->create(
+			header_str => [
+				To      => 'zach@kotlarek.com',
+				From    => $ENV{'USER'} . '@' . Sys::Hostname::hostname(),
+				Subject => 'Alarm Activated',
+			],
+			attributes => {
+				encoding => 'quoted-printable',
+				charset  => 'ISO-8859-1',
+			},
+			body_str => 'Alarm activated: ' . localtime() . "\n\nState: " . $cmdState . "\n" . join("\n", @exists_tmp) . "\n",
+		);
+		sendmail($message);
+	}
+
+	# Ongoing spoken ALARM
+	if ($exists{'ALARM'}) {
+		if ($now - $alarmLast > $ALARM_DELAY) {
+			$alarmLast = $now;
+			DMX::say('Unauthorized access detected.');
 		}
 	}
 }

+ 2 - 0
dmx/audio.pl

@@ -100,6 +100,8 @@ while (1) {
 		$state = 'RAVE';
 	} elsif ($exists{'AUDIO_AMP'}) {
 		$state = 'AMP';
+	} elsif ($exists{'GAME'}) {
+		$state = 'DEFAULT';
 	} elsif (!$exists{'PLEX'}) {
 		$state = 'AMP';
 	} else {

+ 12 - 6
dmx/bias.pl

@@ -10,10 +10,11 @@ use DMX;
 
 # User config
 my %DIM = (
-	'OFF'    => [ { 'channel' => 12, 'value' => 0,   'time' => 60000 }, ],
-	'PLAY'   => [ { 'channel' => 12, 'value' => 255, 'time' => 0 } ],
-	'PAUSE'  => [ { 'channel' => 12, 'value' => 0,   'time' => 0, 'delay' => 10000 } ],
-	'MOTION' => [ { 'channel' => 12, 'value' => 0,   'time' => 0 } ],
+	'OFF'    => [ { 'channel' => 10, 'value' => 0,   'time' => 60000 }, ],
+	'PLAY'   => [ { 'channel' => 10, 'value' => 14,  'time' => 250 } ],
+	'PAUSE'  => [ { 'channel' => 10, 'value' => 32,  'time' => 5000, 'delay' => 5000 } ],
+	'MOTION' => [ { 'channel' => 10, 'value' => 48,  'time' => 1500 } ],
+	'BRIGHT' => [ { 'channel' => 10, 'value' => 255, 'time' => 1500 } ],
 );
 
 # App config
@@ -70,9 +71,14 @@ while (1) {
 		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
 	}
 
-	# Accept the new state directly
+	# Calculate the new state
 	$stateLast = $state;
-	$state     = $newState;
+	if ($exists{'BRIGHT'}) {
+		$newState = 'BRIGHT';
+	} elsif ($exists{'LIGHTS'}) {
+		$newState = 'MOTION';
+	}
+	$state = $newState;
 
 	# Force updates on a periodic basis
 	if (!$update && time() - $pushLast > $PUSH_TIMEOUT) {

+ 21 - 5
dmx/channels.txt

@@ -8,11 +8,27 @@
 #       6  => Bar-side bar-shelves
 #       7  => Bar-side exposed
 #       8  => Bar-side ceiling
-#       9  => Behind screen
-#       10 => <Unused>
-#       11 => Fan
-#       12 => Bias Light
+#       9  => Behind screen (track)
+#       10 => Behind screen (LED)
+#       11 => Fan (Switch)
+#       12 => VR Front (Switch)
 #	13 => Blue
 #	14 => Red (+14%)
 #	15 => Green (+19%)
-#	16 => Garage
+#	16 => Garage Door
+#	17 => Garage Lights
+#	18 => <Unused>
+#	19 => <Unused> (Swtich)
+#	20 => Oil Heater (Switch)
+#	21 => Garage Green (+19%)
+#	22 => Garage Red (+23%)
+#	23 => Garage Blue
+#	24 => Garage Shelf
+#	25 => VR Breakout (Switch) 
+#	26 => Rear Comfort (Switch)
+#	27 => <Unused>
+#	28 => <Unused>
+#	64 => Rumble
+#	65 => <Unused>
+#	66 => VR Back (Switch)
+#	67 => <Unused> (Switch)

+ 113 - 0
dmx/comfort.pl

@@ -0,0 +1,113 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# User config
+my %DIM = (
+    'OFF' => [
+        { 'channel' => 26, 'value' => 0, 'time' => 0 },
+        { 'channel' => 67, 'value' => 0, 'time' => 0 },
+    ],
+    'ON' => [
+        { 'channel' => 26, 'value' => 255, 'time' => 0 },
+        { 'channel' => 67, 'value' => 255, 'time' => 0 },
+    ],
+);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'COMFORT';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $DELAY        = $PULL_TIMEOUT / 2;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+    $DEBUG = 1;
+}
+
+# State
+my $state       = 'OFF';
+my $stateLast   = $state;
+my $masterState = 'OFF';
+my %exists      = ();
+my %mtime       = ();
+my $pushLast    = 0;
+my $pullLast    = time();
+my $update      = 0;
+
+# Always force the heater into OFF at launch
+$state = 'OFF';
+DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+
+# Loop forever
+while (1) {
+
+    # Wait for state updates
+    my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+    # Avoid repeated calls to time()
+    my $now = time();
+
+    # Record only valid states
+    if (defined($cmdState)) {
+        $masterState = $cmdState;
+        $pullLast    = $now;
+    }
+
+    # Die if we don't see regular updates
+    if ($now - $pullLast > $PULL_TIMEOUT) {
+        die(    'No update on state socket in past '
+              . $PULL_TIMEOUT
+              . " seconds. Exiting...\n");
+    }
+
+    # Calculate the new state
+    $stateLast = $state;
+    if ($masterState != 'OFF') {
+        $state = 'ON';
+    } else {
+        $state = 'OFF';
+    }
+
+    # Force updates on a periodic basis
+    if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+        if ($DEBUG) {
+            print STDERR "Forcing periodic update\n";
+        }
+        $update = 1;
+    }
+
+    # Force updates on any state change
+    if (!$update && $stateLast ne $state) {
+        if ($DEBUG) {
+            print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+        }
+        $update = 1;
+    }
+
+    # Update the DMX chain
+    if ($update) {
+
+        # Update
+        DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+        # Update the push time
+        $pushLast = $now;
+
+        # Clear the update flag
+        $update = 0;
+    }
+}

+ 73 - 24
dmx/dmx.py

@@ -6,6 +6,7 @@ sys.path.append('/opt/local/lib/python2.7/site-packages')
 sys.path.append('/opt/local/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/site-packages')
 
 import os
+import time
 import array
 import select
 import socket
@@ -14,20 +15,32 @@ import subprocess
 from ola.ClientWrapper import ClientWrapper
 
 # ====================================
-# Globals
+# Defaults
 # ====================================
+interval     = 10
+universe     = 0
+max_value    = 255
+max_delay    = 300000
 max_channels = 512
-wrapper = None
-state = [ 0 ] * max_channels
-cmds = { 'value' : [ 0 ] * max_channels, 'ticks' : [ 0 ] * max_channels, 'delay' : [ 0 ] * max_channels }
-sock = None
+min_delta    = interval / (5 * 1000)
+allow_dups   = False
 max_mesg_len = 1024
+DEBUG        = False
 
 # ====================================
-# Defaults
+# Globals
 # ====================================
-interval = 50
-universe = 0
+wrapper   = None
+sock      = None
+lastTick  = time.time()
+state_len = 0
+state     = [ 0 ] * max_channels
+cmds      = {
+  'value' : [ 0 ] * max_channels,
+  'ticks' : [ 0 ] * max_channels,
+  'delay' : [ 0 ] * max_channels,
+  'orig'  : [ "" ] * max_channels
+}
 
 # ====================================
 # Wrapper callback -- exit on errors
@@ -41,8 +54,22 @@ def DmxSent(state):
 # Main calculation
 # ====================================
 def SendDMXFrame():
+  global lastTick
+  global wrapper
+  global sock
+  global cmds
+  global state
+  global state_len
+
   # Re-schedule ourselves in interval ms (do this first to keep the timing consistent)
   wrapper.AddEvent(interval, SendDMXFrame)
+
+  # Avoid repeated calls to time()
+  now = time.time()
+
+  # Adjust our tick count to match reality
+  ticks = ((now - lastTick) * 1000) / interval
+  lastTick = now
   
   # Check for new commands
   while (True):
@@ -64,40 +91,57 @@ def SendDMXFrame():
       delay = 0
     
     # Save valid commands
-    if (channel >= 0 and channel <= 512 and intensity >= 0 and intensity <= 255 and duration >= 0 and duration <= 300000 and delay >= 0 and delay < 300000):
+    if (
+      channel >= 0 and channel <= max_channels and
+      intensity >= 0 and intensity <= max_value and
+      duration >= 0 and duration <= max_delay and
+      delay >= 0 and delay < max_delay
+    ):
       if (channel > 0):
-        cmds['value'][channel - 1] = intensity
-        cmds['ticks'][channel - 1] = duration / interval
-        cmds['delay'][channel - 1] = delay / interval
+        if (channel > state_len):
+          state_len = channel
+        if (allow_dups or cmd != cmds['orig'][channel - 1]):
+          cmds['orig'][channel - 1] = cmd
+          cmds['value'][channel - 1] = intensity
+          cmds['ticks'][channel - 1] = duration / interval
+          cmds['delay'][channel - 1] = delay / interval
       else:
-        for i in range(len(state)):
-          cmds['value'][i] = intensity
-          cmds['ticks'][i] = duration / interval
-          cmds['delay'][i] = delay / interval
+        for i in range(state_len):
+          if (allow_dups or cmd != cmds['orig'][channel - 1]):
+            cmds['orig'][i] = cmd
+            cmds['value'][i] = intensity
+            cmds['ticks'][i] = duration / interval
+            cmds['delay'][i] = delay / interval
     else:
       print 'Invalid command parameters:', channel, duration, intensity, delay
   
   # Update values for each channel
-  for i in range(len(cmds['value'])):
+  for i in range(state_len):
     delta = 0
     if (cmds['value'][i] != state[i]):
       if (cmds['delay'][i] > 0):
-        cmds['delay'][i] -= 1
+        if (cmds['delay'][i] >= ticks):        
+          cmds['delay'][i] -= ticks
+        else:
+          cmds['delay'][i] = 0
       else:
         diff = cmds['value'][i] - state[i]
         if (cmds['ticks'][i] < 1):
           delta = diff
         else:
           delta = float(diff) / float(cmds['ticks'][i])
+        if (abs(delta) < min_delta):
+          delta = diff
         state[i] += delta
-        cmds['ticks'][i] -= 1
-      if (0):
-        print 'Channel:', (i + 1)
-        print "\tDelay:", cmds['delay'][i], "\tValue:", state[i], "\tDelta:", delta, "\tTicks:", cmds['ticks'][i]
+        cmds['ticks'][i] -= ticks
+      if (DEBUG):
+        print '(', now, ') Channel:', (i + 1)
+        print "\tDelay:", '%.3f' % cmds['delay'][i], "\tValue:", '%.3f' % state[i], \
+          "\tDelta:", '%.3f' % delta, "\tTicks:", '%.3f' % cmds['ticks'][i]
     
   # Send all DMX channels
   data = array.array('B')
-  for i in range(len(state)):
+  for i in range(state_len):
     data.append(int(state[i]))
   wrapper.Client().SendDmx(universe, data, DmxSent)
 
@@ -105,13 +149,18 @@ def SendDMXFrame():
 # Main
 # ====================================
 
+# Debug
+DEBUG = None
+if 'DEBUG' in os.environ:
+  DEBUG = os.environ['DEBUG']
+
 # Pick a universe (default from above, or as specified in the environment)
 if 'UNIVERSE' in os.environ:
   universe = int(os.environ['UNIVERSE'])
 
 # Pick a tick interval (default from above, or as specified in the environment)
 if 'INTERVAL' in os.environ:
-  universe = int(os.environ['INTERVAL'])
+  interval = int(os.environ['INTERVAL'])
 
 # Pick a socket file ($TMPDIR/plexMonitor/DMX.socket, or as specified in the environment)
 cmd_file = None

+ 129 - 0
dmx/door.pl

@@ -0,0 +1,129 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+use Time::HiRes qw( usleep sleep time );
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# User config
+my $RELAY_DELAY = 0.20;
+my %DIM         = (
+	'OFF'      => [ { 'channel' => 16, 'value' => 0,   'time' => 0 } ],
+	'ACTIVATE' => [ { 'channel' => 16, 'value' => 255, 'time' => 0 } ],
+);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'DOOR';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $DELAY        = $PULL_TIMEOUT / 2;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# State
+my $state     = 'OFF';
+my $stateLast = $state;
+my %exists    = ();
+my $pushLast  = 0;
+my $pullLast  = time();
+my $update    = 0;
+
+# Always force lights into OFF at launch
+$state = 'OFF';
+DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+
+# Loop forever
+while (1) {
+
+	# State is calculated; use newState to gather data
+	my $newState = 'OFF';
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, undef(), undef());
+	if (defined($cmdState)) {
+		$newState = $cmdState;
+		$pullLast = time();
+	}
+
+	# Die if we don't see regular updates
+	if (time() - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Calculate the new state
+	$stateLast = $state;
+	if ($newState eq 'ACTIVATE' || $exists{'DOOR_CMD'}) {
+		$state = 'ACTIVATE';
+	} else {
+		$state = 'OFF';
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && time() - $pushLast > $PUSH_TIMEOUT) {
+		if ($DEBUG) {
+			print STDERR "Forcing periodic update\n";
+		}
+		$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Special handling for toggle systems: only push if the state is "ACTIVATE"
+	if ($update && $state ne 'ACTIVATE') {
+		if ($DEBUG) {
+			print STDERR 'Skipping non-activate state: ' . $state . "\n";
+		}
+		$update = 0;
+	}
+
+	# Do not operate when LOCKed
+	if ($update && $exists{'LOCK'}) {
+		DMX::say('Door locked');
+		if ($exists{'DOOR_CMD'}) {
+			DMX::say('Access denied to: ' . $exists{'DOOR_CMD'});
+		}
+		$update = 0;
+	}
+
+	# Update the relay
+	if ($update) {
+
+		# Annouce the command
+		if ($exists{'DOOR_CMD'}) {
+			DMX::say('Door activated by ' . $exists{'DOOR_CMD'});
+		}
+
+		# Toggle
+		$state = 'ACTIVATE';
+		DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+		usleep($RELAY_DELAY * 1000000);
+		$state = 'OFF';
+		DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+		# Update the push time
+		$pushLast = time();
+
+		# Clear the update flag
+		$update = 0;
+	}
+}

+ 46 - 42
dmx/garage.pl

@@ -1,7 +1,6 @@
 #!/usr/bin/perl
 use strict;
 use warnings;
-use Time::HiRes qw( usleep sleep time );
 
 # Local modules
 use Cwd qw(abs_path);
@@ -10,10 +9,15 @@ use lib dirname(abs_path($0));
 use DMX;
 
 # User config
-my $RELAY_DELAY = 0.20;
-my %DIM         = (
-	'OFF'      => [ { 'channel' => 16, 'value' => 0,   'time' => 0 } ],
-	'ACTIVATE' => [ { 'channel' => 16, 'value' => 255, 'time' => 0 } ],
+my $MOTION_TIMEOUT     = 30;
+my $POSTMOTION_TIMEOUT = 60;
+my %DIM                = (
+	'OFF'        => [ { 'channel' => 17, 'value' => 0,   'time' => 60000 }, ],
+	'PREMOTION'  => [ { 'channel' => 17, 'value' => 64,  'time' => 2500 }, ],
+	'MOTION'     => [ { 'channel' => 17, 'value' => 255, 'time' => 750 }, ],
+	'POSTMOTION' => [ { 'channel' => 17, 'value' => 64,  'time' => $POSTMOTION_TIMEOUT * 1000 }, ],
+	'BRIGHT'     => [ { 'channel' => 17, 'value' => 255, 'time' => 1000 }, ],
+	'ERROR'      => [ { 'channel' => 17, 'value' => 255, 'time' => 100 }, ],
 );
 
 # App config
@@ -31,15 +35,17 @@ if ($ENV{'DEBUG'}) {
 }
 
 # State
-my $state     = 'OFF';
-my $stateLast = $state;
-my %exists    = ();
-my $pushLast  = 0;
-my $pullLast  = time();
-my $update    = 0;
-
-# Always force lights into OFF at launch
-$state = 'OFF';
+my $state      = 'OFF';
+my $stateLast  = $state;
+my %exists     = ();
+my %mtime      = ();
+my $pushLast   = 0;
+my $pullLast   = time();
+my $update     = 0;
+my $lastMotion = 0;
+
+# Always force lights into ERROR at launch
+$state = 'ERROR';
 DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
 
 # Sockets
@@ -50,30 +56,45 @@ DMX::stateSubscribe($STATE_SOCK);
 while (1) {
 
 	# State is calculated; use newState to gather data
-	my $newState = 'OFF';
+	my $newState = $state;
 
 	# Wait for state updates
-	my $cmdState = DMX::readState($DELAY, \%exists, undef(), undef());
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
 	if (defined($cmdState)) {
 		$newState = $cmdState;
-		$pullLast = time();
+		$pullLast = $now;
 	}
 
 	# Die if we don't see regular updates
-	if (time() - $pullLast > $PULL_TIMEOUT) {
+	if ($now - $pullLast > $PULL_TIMEOUT) {
 		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
 	}
 
 	# Calculate the new state
 	$stateLast = $state;
-	if ($newState eq 'ACTIVATE' || $exists{'GARAGE_CMD'}) {
-		$state = 'ACTIVATE';
+	if ($exists{'BRIGHT'}) {
+		$newState = 'BRIGHT';
+	} elsif ($mtime{'MOTION_GARAGE'} > $now - $MOTION_TIMEOUT) {
+		$newState   = 'MOTION';
+		$lastMotion = $now;
+	} elsif ($newState eq 'PLAY' || $newState eq 'PAUSE' || $newState eq 'MOTION') {
+		if ($now > $lastMotion + $POSTMOTION_TIMEOUT) {
+			$newState = 'PREMOTION';
+		} else {
+			$newState = 'POSTMOTION';
+		}
 	} else {
-		$state = 'OFF';
+		$newState = 'OFF';
 	}
+	$state = $newState;
 
 	# Force updates on a periodic basis
-	if (!$update && time() - $pushLast > $PUSH_TIMEOUT) {
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
 		if ($DEBUG) {
 			print STDERR "Forcing periodic update\n";
 		}
@@ -88,31 +109,14 @@ while (1) {
 		$update = 1;
 	}
 
-	# Special handling for toggle systems: only push if the state is "ACTIVATE"
-	if ($update && $state ne 'ACTIVATE') {
-		if ($DEBUG) {
-			print STDERR 'Skipping non-activate state: ' . $state . "\n";
-		}
-		$update = 0;
-	}
-
-	# Update the relay
+	# Update the lighting
 	if ($update) {
 
-		# Annouce the command
-		if ($exists{'GARAGE_CMD'}) {
-			DMX::say('Garage door activated by ' . $exists{'GARAGE_CMD'});
-		}
-
-		# Toggle
-		$state = 'ACTIVATE';
-		DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
-		usleep($RELAY_DELAY * 1000000);
-		$state = 'OFF';
+		# Update
 		DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
 
 		# Update the push time
-		$pushLast = time();
+		$pushLast = $now;
 
 		# Clear the update flag
 		$update = 0;

+ 213 - 0
dmx/garage_leds.pl

@@ -0,0 +1,213 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+use POSIX;
+use Math::Random;
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# User config
+my $COLOR_TIMEOUT  = 10;
+my $COLOR_TIME_MIN = int($COLOR_TIMEOUT / 2);
+my %COLOR_VAR      = (
+	'PREMOTION'  => 0.75,
+	'MOTION'     => 0.35,
+	'POSTMOTION' => 0.75,
+);
+my $MOTION_TIMEOUT     = 30;
+my $POSTMOTION_TIMEOUT = 60;
+my %DIM                = (
+	'OFF' =>         [
+			{ 'channel' => 21, 'value' => 0,   'time' => 60000 },
+			{ 'channel' => 22, 'value' => 0,   'time' => 60000 },
+			{ 'channel' => 23, 'value' => 0,   'time' => 60000 },
+	],
+	'PREMOTION'  => [
+			{ 'channel' => 21, 'value' => 5,   'time' => 2500 },
+			{ 'channel' => 22, 'value' => 5,   'time' => 2500 },
+			{ 'channel' => 23, 'value' => 5,   'time' => 2500 },
+			],
+	'MOTION'     => [
+			{ 'channel' => 21, 'value' => 128,  'time' => 750 },
+			{ 'channel' => 22, 'value' => 128,  'time' => 750 },
+			{ 'channel' => 23, 'value' => 128,  'time' => 750 },
+	],
+	'POSTMOTION' => [
+			{ 'channel' => 21, 'value' => 8,   'time' => $POSTMOTION_TIMEOUT * 1000 },
+			{ 'channel' => 22, 'value' => 8,   'time' => $POSTMOTION_TIMEOUT * 1000 },
+			{ 'channel' => 23, 'value' => 8,   'time' => $POSTMOTION_TIMEOUT * 1000 },
+	],
+	'BRIGHT'     => [
+			{ 'channel' => 21, 'value' => 255,  'time' => 1000 },
+			{ 'channel' => 22, 'value' => 255,  'time' => 1000 },
+			{ 'channel' => 23, 'value' => 255,  'time' => 1000 },
+	],
+	'ERROR'      => [
+			{ 'channel' => 21, 'value' => 112,  'time' => 100 },
+			{ 'channel' => 22, 'value' => 255,  'time' => 100 },
+			{ 'channel' => 23, 'value' => 112,  'time' => 100 },
+	],
+);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'GARAGE_LEDS';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $DELAY        = $PULL_TIMEOUT / 2;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# Reset the timeouts if the color delay demands it
+if ($PUSH_TIMEOUT < $COLOR_TIMEOUT) {
+	$PUSH_TIMEOUT = $COLOR_TIMEOUT / 2;
+}
+if ($DELAY > $COLOR_TIMEOUT / 2) {
+	$DELAY = $COLOR_TIMEOUT / 2;
+}
+
+# State
+my $state       = 'OFF';
+my $stateLast   = $state;
+my $masterState = 'OFF';
+my %exists      = ();
+my %mtime       = ();
+my $pushLast    = 0;
+my $pullLast    = time();
+my $update      = 0;
+my $lastMotion  = 0;
+my @COLOR       = ();
+my $colorChange = 0;
+
+# Always force lights into ERROR at launch
+$state = 'ERROR';
+DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+
+# Loop forever
+while (1) {
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
+	if (defined($cmdState)) {
+		$masterState = $cmdState;
+		$pullLast    = $now;
+	}
+
+	# Die if we don't see regular updates
+	if ($now - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Calculate the new state
+	$stateLast = $state;
+	if ($exists{'BRIGHT'}) {
+		$state = 'BRIGHT';
+	} elsif ($mtime{'MOTION_GARAGE'} > $now - $MOTION_TIMEOUT) {
+		$state      = 'MOTION';
+		$lastMotion = $now;
+	} elsif ($masterState eq 'PLAY' || $masterState eq 'PAUSE' || $masterState eq 'MOTION') {
+		if ($now > $lastMotion + $POSTMOTION_TIMEOUT) {
+			$state = 'PREMOTION';
+		} else {
+			$state = 'POSTMOTION';
+		}
+	} else {
+		$state = 'OFF';
+	}
+
+	# Color changes
+	if ($COLOR_VAR{$state} && $now - $colorChange > $COLOR_TIMEOUT) {
+		@COLOR = ();
+
+		# Grab the default (white) data
+		my $lums = 0;
+		foreach my $data (@{ $DIM{$state} }) {
+			$lums += $data->{'value'};
+		}
+		my $numChans = scalar(@{ $DIM{$state} });
+		my $max      = $lums / $numChans;
+
+		# Pick the change interval
+		my $time = int((rand($COLOR_TIMEOUT - $COLOR_TIME_MIN) + $COLOR_TIME_MIN) * 1000);
+
+		# Assign each channel
+		my @vals = random_normal($numChans, $max, $max * $COLOR_VAR{$state});
+		foreach my $data (@{ $DIM{$state} }) {
+			my $color = pop(@vals);
+			push(@COLOR, { 'channel' => $data->{'channel'}, 'value' => $color, 'time' => $time });
+		}
+
+		# Update
+		$update      = 1;
+		$colorChange = $now;
+		if ($DEBUG) {
+			print STDERR "New color\n";
+		}
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+		if ($DEBUG) {
+			print STDERR "Forcing periodic update\n";
+		}
+		$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Update the lighting
+	if ($update) {
+
+		# Reset the color change sequence on any state change, so we always spend 1 cycle at white
+		if ($stateLast ne $state) {
+			if ($DEBUG) {
+				print STDERR "Reset color sequence\n";
+			}
+			@COLOR       = ();
+			$colorChange = $now + $COLOR_TIME_MIN;
+		}
+
+		# Select a data set (color or standard)
+		my @data_set    = ();
+		my $local_state = $state;
+		if (scalar(@COLOR)) {
+			@data_set = @COLOR;
+			$local_state .= ' (Color)';
+		} else {
+			@data_set = @{ $DIM{$state} };
+		}
+
+		# Update
+		DMX::applyDataset(\@data_set, $local_state, $OUTPUT_FILE);
+
+		# Update the push time
+		$pushLast = $now;
+
+		# Clear the update flag
+		$update = 0;
+	}
+}

+ 111 - 0
dmx/garage_shelf.pl

@@ -0,0 +1,111 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# User config
+my $MOTION_TIMEOUT     = 30;
+my $POSTMOTION_TIMEOUT = 60;
+my %DIM                = (
+	'OFF'    => [ { 'channel' => 24, 'value' => 0,   'time' => 60000 }, ],
+	'MOTION' => [ { 'channel' => 24, 'value' => 32,  'time' => 750 }, ],
+	'BRIGHT' => [ { 'channel' => 24, 'value' => 255, 'time' => 1000 }, ],
+	'ERROR'  => [ { 'channel' => 24, 'value' => 32,  'time' => 100 }, ],
+);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'GARAGE_SHELF';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $DELAY        = $PULL_TIMEOUT / 2;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# State
+my $state       = 'OFF';
+my $stateLast   = $state;
+my $masterState = 'OFF';
+my %exists      = ();
+my %mtime       = ();
+my $pushLast    = 0;
+my $pullLast    = time();
+my $update      = 0;
+
+# Always force lights into ERROR at launch
+$state = 'ERROR';
+DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+
+# Loop forever
+while (1) {
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
+	if (defined($cmdState)) {
+		$masterState = $cmdState;
+		$pullLast    = $now;
+	}
+
+	# Die if we don't see regular updates
+	if ($now - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Calculate the new state
+	$stateLast = $state;
+	if ($exists{'BRIGHT'}) {
+		$state = 'BRIGHT';
+	} elsif ($mtime{'MOTION_GARAGE'} > $now - $MOTION_TIMEOUT) {
+		$state = 'MOTION';
+	} else {
+		$state = 'OFF';
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+		if ($DEBUG) {
+			print STDERR "Forcing periodic update\n";
+		}
+		$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Update the lighting
+	if ($update) {
+
+		# Update
+		DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+		# Update the push time
+		$pushLast = $now;
+
+		# Clear the update flag
+		$update = 0;
+	}
+}

+ 35 - 0
dmx/launchd/com.uberzach.HDMIctrl.plist

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.HDMIctrl</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.hdmi</key>
+			<true/>
+			<key>com.uberzach.state</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/HDMIctrl.pl</string>
+	</array>
+	<!--
+		<key>EnvironmentVariables</key>
+		<dict>
+			<key>DEBUG</key>
+			<string>1</string>
+		</dict>
+		<key>StandardErrorPath</key>
+		<string>/Users/tv/Library/Logs/com.uberzach.HDMIctrl.log</string>
+	-->
+</dict>
+</plist>

+ 1 - 6
dmx/launchd/com.uberzach.amplifierPower.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>loki.uberzach.com</string>
-		<string>Loki.local</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
@@ -34,7 +29,7 @@
 			<string>1</string>
 		</dict>
 		<key>StandardErrorPath</key>
-		<string>/Users/tv/com.uberzach.amplifierPower_errlog.txt</string>
+		<string>/Users/tv/Library/Logs/com.uberzach.amplifierPower.log</string>
 	-->
 </dict>
 </plist>

+ 0 - 5
dmx/launchd/com.uberzach.ola.bias.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 24 - 0
dmx/launchd/com.uberzach.ola.comfort.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.ola.comfort</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.ola.dmx</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/comfort.pl</string>
+	</array>
+</dict>
+</plist>

+ 8 - 5
dmx/launchd/com.uberzach.ola.dmx.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
@@ -25,5 +20,13 @@
 	<array>
 		<string>/Users/tv/bin/video/dmx/dmx.py</string>
 	</array>
+
+	<!--
+	<key>EnvironmentVariables</key>
+	<dict>
+		<key>DEBUG</key>
+		<string>1</string>
+	</dict>
+	-->
 </dict>
 </plist>

+ 24 - 0
dmx/launchd/com.uberzach.ola.door.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.ola.door</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.ola.dmx</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/door.pl</string>
+	</array>
+</dict>
+</plist>

+ 0 - 5
dmx/launchd/com.uberzach.ola.fan.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 0 - 7
dmx/launchd/com.uberzach.ola.garage.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
@@ -19,8 +14,6 @@
 		<dict>
 			<key>com.uberzach.ola.dmx</key>
 			<true/>
-			<key>com.uberzach.projector</key>
-			<true/>
 		</dict>
 	</dict>
 	<key>ProgramArguments</key>

+ 24 - 0
dmx/launchd/com.uberzach.ola.garage_leds.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.ola.garage_leds</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.ola.dmx</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/garage_leds.pl</string>
+	</array>
+</dict>
+</plist>

+ 24 - 0
dmx/launchd/com.uberzach.ola.garage_shelf.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.ola.garage_shelf</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.ola.dmx</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/garage_shelf.pl</string>
+	</array>
+</dict>
+</plist>

+ 0 - 5
dmx/launchd/com.uberzach.ola.leds.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 24 - 0
dmx/launchd/com.uberzach.ola.oil.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.ola.oil</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.ola.dmx</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/oil.pl</string>
+	</array>
+</dict>
+</plist>

+ 0 - 5
dmx/launchd/com.uberzach.ola.overhead.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 0 - 5
dmx/launchd/com.uberzach.ola.rave.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 0 - 5
dmx/launchd/com.uberzach.ola.rope.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 24 - 0
dmx/launchd/com.uberzach.ola.rumble.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.ola.rumble</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.ola.dmx</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/rumble.pl</string>
+	</array>
+</dict>
+</plist>

+ 0 - 5
dmx/launchd/com.uberzach.ola.stairs.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 24 - 0
dmx/launchd/com.uberzach.ola.vr.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.ola.vr</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.ola.dmx</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/vr.pl</string>
+	</array>
+</dict>
+</plist>

+ 35 - 0
dmx/launchd/com.uberzach.projectorCtrl.plist

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.projectorCtrl</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.projector</key>
+			<true/>
+			<key>com.uberzach.state</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/projectorCtrl.pl</string>
+	</array>
+	<!--
+		<key>EnvironmentVariables</key>
+		<dict>
+			<key>DEBUG</key>
+			<string>1</string>
+		</dict>
+		<key>StandardErrorPath</key>
+		<string>/Users/tv/Library/Logs/com.uberzach.projectorCtrl.log</string>
+	-->
+</dict>
+</plist>

+ 0 - 9
dmx/launchd/com.uberzach.qt_player.plist

@@ -4,15 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-		<string>Beddy.local</string>
-		<string>beddy.uberzach.com</string>
-		<string>Heady.local</string>
-		<string>heady.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>

+ 2 - 2
dmx/launchd/com.uberzach.rifftrax.plist

@@ -8,10 +8,10 @@
 	<array>
 		<string>Loki.local</string>
 		<string>loki.uberzach.com</string>
-		<string>Beddy.local</string>
-		<string>beddy.uberzach.com</string>
 		<string>Heady.local</string>
 		<string>heady.uberzach.com</string>
+		<string>Beddy.local</string>
+		<string>beddy.uberzach.com</string>
 	</array>
 	<key>RunAtLoad</key>
 	<true/>

+ 35 - 0
dmx/launchd/com.uberzach.rumbleCtl.plist

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.rumbleCtl</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>OtherJobEnabled</key>
+		<dict>
+			<key>com.uberzach.amplifier</key>
+			<true/>
+			<key>com.uberzach.state</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/dmx/rumbleCtl.pl</string>
+	</array>
+	<!--
+		<key>EnvironmentVariables</key>
+		<dict>
+			<key>DEBUG</key>
+			<string>1</string>
+		</dict>
+		<key>StandardErrorPath</key>
+		<string>/Users/tv/Library/Logs/com.uberzach.rumbleCtl.log</string>
+	-->
+</dict>
+</plist>

+ 3 - 3
dmx/launchd/com.uberzach.state.plist

@@ -8,10 +8,10 @@
 	<array>
 		<string>Loki.local</string>
 		<string>loki.uberzach.com</string>
-		<string>Beddy.local</string>
-		<string>beddy.uberzach.com</string>
 		<string>Heady.local</string>
 		<string>heady.uberzach.com</string>
+		<string>Beddy.local</string>
+		<string>beddy.uberzach.com</string>
 	</array>
 	<key>RunAtLoad</key>
 	<true/>
@@ -22,7 +22,7 @@
 	<key>ProgramArguments</key>
 	<array>
 		<string>/Users/tv/bin/video/dmx/state.pl</string>
-		<string>0.25</string>
+		<string>0.10</string>
 	</array>
 </dict>
 </plist>

+ 6 - 8
dmx/launchd/com.uberzach.projectorPower.plist → dmx/launchd/com.uberzach.tvPower.plist

@@ -6,18 +6,18 @@
 	<string>Aqua</string>
 	<key>LimitLoadToHosts</key>
 	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
+		<string>Heady.local</string>
+		<string>heady.uberzach.com</string>
 	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
-	<string>com.uberzach.projectorPower</string>
+	<string>com.uberzach.tvPower</string>
 	<key>KeepAlive</key>
 	<dict>
 		<key>OtherJobEnabled</key>
 		<dict>
-			<key>com.uberzach.projector</key>
+			<key>com.uberzach.tv</key>
 			<true/>
 			<key>com.uberzach.state</key>
 			<true/>
@@ -25,7 +25,7 @@
 	</dict>
 	<key>ProgramArguments</key>
 	<array>
-		<string>/Users/tv/bin/video/dmx/projectorPower.pl</string>
+		<string>/Users/tv/bin/video/dmx/tvPower.pl</string>
 	</array>
 	<!--
 		<key>EnvironmentVariables</key>
@@ -33,10 +33,8 @@
 			<key>DEBUG</key>
 			<string>1</string>
 		</dict>
-		<key>StandardOutPath</key>
-		<string>/Users/tv/com.uberzach.projectorPower_log.txt</string>
 		<key>StandardErrorPath</key>
-		<string>/Users/tv/com.uberzach.projectorPower_errlog.txt</string>
+		<string>/Users/tv/Library/Logs/com.uberzach.tvPower.log</string>
 	-->
 </dict>
 </plist>

+ 17 - 14
dmx/leds.pl

@@ -93,7 +93,7 @@ my $pushLast    = 0;
 my $pullLast    = time();
 my $update      = 0;
 my @COLOR       = ();
-my $colorChange = time();
+my $colorChange = 0;
 
 # Always force lights into ERROR at launch
 $state = 'ERROR';
@@ -111,13 +111,18 @@ while (1) {
 
 	# Wait for state updates
 	my $cmdState = DMX::readState($DELAY, \%exists, undef(), \%VALID);
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
 	if (defined($cmdState)) {
 		$newState = $cmdState;
-		$pullLast = time();
+		$pullLast = $now;
 	}
 
 	# Die if we don't see regular updates
-	if (time() - $pullLast > $PULL_TIMEOUT) {
+	if ($now - $pullLast > $PULL_TIMEOUT) {
 		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
 	}
 
@@ -132,21 +137,19 @@ while (1) {
 
 	# Calculate the new state
 	$stateLast = $state;
-	if ($exists{'BRIGHT'} || $exists{'LIGHTS'}) {
+	if ($exists{'BRIGHT'}) {
+		$newState = 'BRIGHT';
+	} elsif ($exists{'LIGHTS'}) {
 		if ($newState eq 'PLAY') {
 			$newState = 'PLAY_HIGH';
 		} else {
-			if ($exists{'BRIGHT'}) {
-				$newState = 'BRIGHT';
-			} else {
-				$newState = 'MOTION';
-			}
+			$newState = 'MOTION';
 		}
 	}
 	$state = $newState;
 
 	# Color changes
-	if ($COLOR_VAR{$state} && time() - $colorChange > $COLOR_TIMEOUT) {
+	if ($COLOR_VAR{$state} && $now - $colorChange > $COLOR_TIMEOUT) {
 		@COLOR = ();
 
 		# Grab the default (white) data
@@ -169,14 +172,14 @@ while (1) {
 
 		# Update
 		$update      = 1;
-		$colorChange = time();
+		$colorChange = $now;
 		if ($DEBUG) {
 			print STDERR "New color\n";
 		}
 	}
 
 	# Force updates on a periodic basis
-	if (!$update && time() - $pushLast > $PUSH_TIMEOUT) {
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
 		if ($DEBUG) {
 			print STDERR "Forcing periodic update\n";
 		}
@@ -200,7 +203,7 @@ while (1) {
 				print STDERR "Reset color sequence\n";
 			}
 			@COLOR       = ();
-			$colorChange = time() + $COLOR_TIME_MIN;
+			$colorChange = $now + $COLOR_TIME_MIN;
 		}
 
 		# Select a data set (color or standard)
@@ -217,7 +220,7 @@ while (1) {
 		DMX::applyDataset(\@data_set, $local_state, $OUTPUT_FILE);
 
 		# Update the push time
-		$pushLast = time();
+		$pushLast = $now;
 
 		# Clear the update flag
 		$update = 0;

+ 0 - 6
dmx/modules.txt

@@ -1,6 +0,0 @@
-IPC::System::Simple
-Math::Random
-Time::Out
-File::Touch
-Device::SerialPort
-JSON

+ 129 - 0
dmx/oil.pl

@@ -0,0 +1,129 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# User config
+my $PREHEAT_DELAY   = 5;
+my $PREHEAT_TIMEOUT = $PREHEAT_DELAY + 60;
+my $MOTION_TIMEOUT  = 300;
+my %DIM             = (
+	'OFF'     => [ { 'channel' => 20, 'value' => 0,   'time' => 0 }, ],
+	'ON'      => [ { 'channel' => 20, 'value' => 255, 'time' => 0 }, ],
+	'PREHEAT' => [ { 'channel' => 20, 'value' => 255, 'time' => 0 }, ],
+);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'OIL';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $MAX_DELAY    = $PULL_TIMEOUT / 2;
+my $MIN_DELAY    = 0.25;
+my $DELAY        = $MAX_DELAY;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# State
+my $state       = 'OFF';
+my $stateLast   = $state;
+my $masterState = 'OFF';
+my %exists      = ();
+my %mtime       = ();
+my $pushLast    = 0;
+my $pullLast    = time();
+my $update      = 0;
+my $lastPlay    = 0;
+
+# Always force the heater into OFF at launch
+$state = 'OFF';
+DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+
+# Loop forever
+while (1) {
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
+	if (defined($cmdState)) {
+		$masterState = $cmdState;
+		$pullLast = $now;
+	}
+
+	# Die if we don't see regular updates
+	if ($now - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Remember the last master PLAY mtime
+	if ($masterState eq 'PLAY') {
+		$lastPlay = $now;
+	}
+
+	# Reduce our minimum update rate to make timer-based modes more accurate
+	my $elapsed = $now - $lastPlay;
+	if ($elapsed && $elapsed < $PREHEAT_TIMEOUT) {
+		$DELAY = $MIN_DELAY;
+	} else {
+		$DELAY = $MAX_DELAY;
+	}
+
+	# Calculate the new state
+	$stateLast = $state;
+	if ($mtime{'MOTION_GARAGE'} > $now - $MOTION_TIMEOUT) {
+		$state = 'ON';
+	} elsif (($masterState eq 'PAUSE' || $masterState eq 'MOTION')
+		&& $PREHEAT_TIMEOUT > $elapsed && $PREHEAT_DELAY < $elapsed)
+	{
+		$state = 'PREHEAT';
+	} else {
+		$state = 'OFF';
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+		if ($DEBUG) {
+			print STDERR "Forcing periodic update\n";
+		}
+		$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Update the lighting
+	if ($update) {
+
+		# Update
+		DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+		# Update the push time
+		$pushLast = $now;
+
+		# Clear the update flag
+		$update = 0;
+	}
+}

+ 104 - 126
dmx/projectorPower.pl → dmx/projectorCtrl.pl

@@ -3,16 +3,18 @@ use strict;
 use warnings;
 
 # Local modules
-use Cwd qw(abs_path);
-use File::Basename qw(dirname);
+use Cwd qw( abs_path );
+use File::Temp qw( tempfile );
+use File::Basename qw( dirname );
 use lib dirname(abs_path($0));
 use DMX;
 
 # Config
-my $TIMEOUT   = 900;
-my $COUNTDOWN = 119;
-my $OFF_DELAY = 15;
-my $CMD_DELAY = 20;
+my $TIMEOUT     = 900;
+my $COUNTDOWN   = 119;
+my $OFF_DELAY   = 15;
+my $CMD_DELAY   = 20;
+my $PAUSE_DELAY = 10;
 
 # Available color modes, bright to dark:
 #	DYNAMIC
@@ -21,9 +23,9 @@ my $CMD_DELAY = 20;
 #	THEATER_BLACK_1
 #
 # Color modes by proportion of lamp life
-my $LAMP_LIFE = 1500;
+my $LAMP_LIFE = 1625;
 my %COLORS    = (
-	'0.500' => {
+	'0.332' => {
 		'high' => 'THEATER',
 		'play' => 'THEATER_BLACK_1',
 		'low'  => 'THEATER_BLACK_1',
@@ -47,16 +49,17 @@ my %COLORS    = (
 
 # App config
 my $DATA_DIR     = DMX::dataDir();
-my $STATE_SOCK   = 'PROJECTOR_POWER';
+my $STATE_SOCK   = 'PROJECTOR_CTRL';
 my $PROJ_SOCK    = 'PROJECTOR';
 my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $POWER_FILE   = $OUTPUT_FILE . '_POWER';
+my $LIFE_FILE    = $OUTPUT_FILE . '_LIFE';
+my $TIMER_FILE   = $OUTPUT_FILE . '_TIMER';
+my $COLOR_FILE   = $OUTPUT_FILE . '_COLOR';
 my $PUSH_TIMEOUT = 20;
 my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
 my $DELAY        = $PULL_TIMEOUT / 2;
 
-# Prototypes
-sub sayShutdown($);
-
 # Debug
 my $DEBUG = 0;
 if ($ENV{'DEBUG'}) {
@@ -73,16 +76,22 @@ my $state        = 'OFF';
 my $stateLast    = $state;
 my %exists       = ();
 my %mtime        = ();
-my $pushLast     = 0;
 my $pullLast     = time();
-my $update       = 0;
 my $lastAnnounce = 0;
 my $lastUser     = time();
 my $shutdown     = 0;
 my $life         = 0;
+my $lifeLast     = 0;
 my @color_sets   = sort { $a <=> $b } keys(%COLORS);
 my $color_set    = $color_sets[0];
 my $color        = $COLORS{$color_set}{'low'};
+my $lastPlay     = 0;
+my $timeLeft     = 0;
+my $timeLeftLast = 0;
+my $colorCmd     = undef();
+my $colorLast    = 0;
+my $powerCmd     = undef();
+my $powerLast    = 0;
 
 # Loop forever
 while (1) {
@@ -90,16 +99,16 @@ while (1) {
 	# State is calculated; use newState to gather data
 	my $newState = $state;
 
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
 	# Avoid repeated calls to time()
 	my $now = time();
 
-	# Wait for state updates
-	{
-		my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
-		if (defined($cmdState)) {
-			$newState = $cmdState;
-			$pullLast = $now;
-		}
+	# Record only valid states
+	if (defined($cmdState)) {
+		$newState = $cmdState;
+		$pullLast = $now;
 	}
 
 	# Die if we don't see regular updates
@@ -124,6 +133,21 @@ while (1) {
 		$lastUser = $now;
 	}
 
+	# Motion counts as activity when GAME is active
+	if ($exists{'GAME'} && $mtime{'MOTION'} > $lastUser) {
+		$lastUser = $mtime{'MOTION'};
+	}
+
+	# Motion counts as activity when PLEX is not active
+	if (!$exists{'PLEX'} && $mtime{'MOTION'} > $lastUser) {
+		$lastUser = $mtime{'MOTION'};
+	}
+
+	# Explict no-motion counts as activity when Plex is not foreground
+	if (!$exists{'PLEX'} && $exists{'NO_MOTION'} && $lastUser < $now) {
+		$lastUser = $now;
+	}
+
 	# Clear the shutdown timestamp if there is new user activity
 	if ($shutdown && (!$exists{'PROJECTOR'} || $lastUser > $shutdown)) {
 		$shutdown = 0;
@@ -145,7 +169,6 @@ while (1) {
 	}
 
 	# Calculate the new state
-	$stateLast = $state;
 	if ($exists{'PROJECTOR'}) {
 		if ($shutdown) {
 			$state = 'SHUTDOWN';
@@ -195,6 +218,9 @@ while (1) {
 	# PLAY if we haven't figured out what else to do
 	my $playLights = 0;
 	if ($newState eq 'PLAY') {
+		$lastPlay   = $now;
+		$playLights = 1;
+	} elsif ($newState eq 'PAUSE' && $lastPlay + $PAUSE_DELAY > $now) {
 		$playLights = 1;
 	} elsif ($exists{'PLAYING_TYPE'} eq 'audio') {
 		$color = $COLORS{$color_set}{'low'};
@@ -217,134 +243,86 @@ while (1) {
 		print STDERR 'Selected color: ' . $color . "\n";
 	}
 
-	# Force updates on a periodic basis
-	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
-
-		# Not for the projector
-		#if ($DEBUG) {
-		#	print STDERR "Forcing periodic update\n";
-		#}
-		#$update = 1;
+	# Calculate the shutdown counter
+	$timeLeft = 0;
+	if ($state eq 'SHUTDOWN') {
+		$timeLeft = $OFF_DELAY - $elapsed;
+	} elsif ($state eq 'COUNTDOWN') {
+		$timeLeft = ($TIMEOUT + $COUNTDOWN) - $elapsed;
 	}
 
-	# Force updates on any state change
-	if (!$update && $stateLast ne $state) {
+	# Update power when there is a state mismatch
+	if (($state eq 'OFF' && $exists{'PROJECTOR'}) || ($state eq 'ON' && !$exists{'PROJECTOR'})) {
 		if ($DEBUG) {
-			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+			print STDERR 'Power state mismatch: ' . $state . ':' . $exists{'PROJECTOR'} . "\n";
 		}
-		$update = 1;
+		$powerCmd = $state;
 	}
 
-	# Force updates when there is a physical state mistmatch
-	if (!$update && $state eq 'OFF' && $exists{'PROJECTOR'}) {
+	# Update color when there is a state mismatch and the projector is on
+	if ($exists{'PROJECTOR'} && $exists{'PROJECTOR_COLOR'} ne $color) {
 		if ($DEBUG) {
-			print STDERR 'Physical state mismatch: ' . $state . ':' . $exists{'PROJECTOR'} . "\n";
+			print STDERR 'Color state mismatch: ' . $color . ':' . $exists{'PROJECTOR_COLOR'} . "\n";
 		}
-		$update = 1;
+		$colorCmd = $color;
 	}
 
-	# Set the color mode as needed
-	if ($exists{'PROJECTOR'} && $exists{'PROJECTOR_COLOR'} ne $color) {
-		if ($DEBUG) {
-			print STDERR 'Setting color to: ' . $color . "\n";
-		}
-		$proj->send($color)
-		  or die('Unable to write command to projector socket: ' . $color . ": ${!}\n");
+	# Save the power state to disk
+	if ($state ne $stateLast) {
+		my ($fh, $tmp) = tempfile($POWER_FILE . '.XXXXXXXX', 'UNLINK' => 0);
+		print $fh $state . "\n";
+		close($fh);
+		rename($tmp, $POWER_FILE);
+		$stateLast = $state;
 	}
 
-	# Announce a pending shutdown
-	if ($state eq 'COUNTDOWN' || $state eq 'SHUTDOWN') {
-		my $timeLeft = 0;
-		if ($state eq 'SHUTDOWN') {
-			$timeLeft = $OFF_DELAY - $elapsed;
-		} else {
-			$timeLeft = ($TIMEOUT + $COUNTDOWN) - $elapsed;
-		}
-		sayShutdown($timeLeft / 60);
+	# Save the color state to disk
+	if ($color ne $colorLast) {
+		my ($fh, $tmp) = tempfile($COLOR_FILE . '.XXXXXXXX', 'UNLINK' => 0);
+		print $fh $color . "\n";
+		close($fh);
+		rename($tmp, $COLOR_FILE);
+		$colorLast = $color;
 	}
 
-	# Only allow updates to "ON" or "OFF" -- the projector knows no other states
-	if ($update && ($state ne 'ON' && $state ne 'OFF')) {
-		if ($DEBUG) {
-			print STDERR 'Ignoring update state other than ON/OFF: ' . $state . "\n";
-		}
-		$update = 0;
+	# Save the lamp life to disk
+	if ($life != $lifeLast) {
+		my ($fh, $tmp) = tempfile($LIFE_FILE . '.XXXXXXXX', 'UNLINK' => 0);
+		print $fh $life . "\n";
+		close($fh);
+		rename($tmp, $LIFE_FILE);
+		$lifeLast = $life;
 	}
 
-	# Only allow updates every few seconds -- the projector goes dumb during power state changes
-	if ($update && $now < $pushLast + $CMD_DELAY) {
+	# Save the shutdown time to disk
+	if ($timeLeft != $timeLeftLast) {
+		my ($fh, $tmp) = tempfile($TIMER_FILE . '.XXXXXXXX', 'UNLINK' => 0);
+		print $fh $timeLeft . "\n";
+		close($fh);
+		rename($tmp, $TIMER_FILE);
+		$timeLeftLast = $timeLeft;
+	}
+
+	# Send the color mode, if requested
+	if ($colorCmd) {
 		if ($DEBUG) {
-			print STDERR 'Ignoring overrate update: ' . $state . "\n";
+			print STDERR 'Setting color mode: ' . $color . "\n";
 		}
-		$update = 0;
+		$proj->send($color)
+		  or die('Unable to write command to projector socket: ' . $color . ": ${!}\n");
+		$colorCmd = undef();
 	}
 
-	# Update the projector
-	if ($update) {
-
-		# Extra debugging to record pushes
+	# Send master power state, if requested
+	# Only allow updates every few seconds -- the projector goes dumb during power state changes
+	if ($powerCmd && $now > $powerLast + $CMD_DELAY) {
 		if ($DEBUG) {
-			print STDERR 'State: ' . $state . "\n";
+			print STDERR 'Setting power state: ' . $state . "\n";
 		}
 
-		# Send master power state
 		$proj->send($state)
 		  or die('Unable to write command to proj socket: ' . $state . ": ${!}\n");
-
-		# Annouce the state change, after the fact
-		DMX::say('Projector ' . $state);
-
-		# Announce lamp life status, if near/past $LAMP_LIFE
-		if (exists($exists{'PROJECTOR_LAMP'})) {
-			if ($life > 0.9) {
-				my $chance = ($life - 0.9) * 10;
-				if (rand(1) <= $chance) {
-					DMX::say('Projector lamp has run for ' . int($life * 100) . '% of its estimated life.');
-				}
-			}
-		}
-
-		# No output file
-
-		# Update the push time
-		$pushLast = $now;
-
-		# Clear the lastAnnounce timer
-		$lastAnnounce = 0;
-
-		# Clear the update flag
-		$update = 0;
-	}
-}
-
-sub sayShutdown($) {
-	my ($minutesLeft) = @_;
-
-	# Only allow annoucements once per minute
-	my $now = time();
-	if ($now < $lastAnnounce + 60) {
-		return;
-	}
-	$lastAnnounce = $now;
-
-	# Determine the unit
-	my $unit     = 'minute';
-	my $timeLeft = $minutesLeft;
-	if ($minutesLeft < 1) {
-		$timeLeft = $minutesLeft * 60;
-		$unit     = 'second';
-	}
-
-	# Avoid saying "0" unless we *really* mean it
-	$timeLeft = ceil($timeLeft);
-
-	# Add an "s" as needed
-	my $plural = 's';
-	if ($timeLeft == 1) {
-		$plural = '';
+		$powerLast = $now;
+		$powerCmd  = undef();
 	}
-	$unit .= $plural;
-
-	# Speak
-	DMX::say('Projector shutdown in about ' . $timeLeft . ' ' . $unit);
 }

+ 19 - 11
dmx/qt_player.pl

@@ -14,7 +14,17 @@ use Audio;
 
 # User config
 my $MEDIA_PATH = `~/bin/video/mediaPath`;
-my %FILES      = ();
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'QT_PLAYER';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $DELAY        = 5;
+my $MAX_CMD_LEN  = 16384;
+my $TIMEOUT_OPEN = 10;
+
+# Globals
+my %FILES = ();
 
 # Prototypes
 sub init();
@@ -25,20 +35,18 @@ sub printFiles();
 sub cmdApp($$);
 sub openQT($);
 
-# App config
-my $DATA_DIR     = DMX::dataDir();
-my $STATE_SOCK   = 'QT_PLAYER';
-my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
-my $DELAY        = 5;
-my $MAX_CMD_LEN  = 16384;
-my $TIMEOUT_OPEN = 10;
-
 # Debug
 my $DEBUG = 0;
 if ($ENV{'DEBUG'}) {
 	$DEBUG = 1;
 }
 
+# Ensure the media is ready before we do anything else
+if (!$MEDIA_PATH || !-d $MEDIA_PATH) {
+	sleep($TIMEOUT_OPEN);
+	exit(0);
+}
+
 # Clear our output file
 if (-e $OUTPUT_FILE) {
 	unlink($OUTPUT_FILE);
@@ -251,13 +259,13 @@ sub cmdApp($$) {
 # It would be nice to use AS here to get a more definitive document name, but the open() call is QT player is borked
 sub openQT($) {
 	my ($doc) = @_;
-	
+
 	# Build the open command
 	my @cmd = ('open', '-a', 'QuickTime Player');
 	if ($doc) {
 		push(@cmd, $doc);
 	}
-	
+
 	# Execute, dying on timeout
 	timeout $TIMEOUT_OPEN => sub {
 		IPC::System::Simple::system(@cmd);

+ 54 - 32
dmx/rave.pl

@@ -50,6 +50,9 @@ my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
 my $DELAY        = $PULL_TIMEOUT / 2;
 my $AMP_DELAY    = 6;
 my $AMP_BOOTING  = 0;
+my $AMP_START    = undef();
+my $AMP_TIMEOUT  = $PUSH_TIMEOUT / 2;
+my $CLEANUP_TIME = undef();
 
 # Debug
 my $DEBUG = 0;
@@ -126,31 +129,34 @@ while (1) {
 	}
 
 	# Reap zombie children
-	if (defined($PID)) {
-		my $kid = waitpid($PID, WNOHANG);
-		if ($kid > 0) {
-			if ($DEBUG) {
-				print STDERR 'Reaped child: ' . $PID . "\n";
-			}
+	if (defined($PID) && waitpid($PID, WNOHANG) > 0) {
+		if ($DEBUG) {
+			print STDERR 'Reaped child: ' . $PID . "\n";
+		}
+		$CLEANUP_TIME = Time::HiRes::time();
+	}
 
-			# Run the cleanup routine (if any)
-			if ($EFFECTS{$NAME}{'done'}) {
-				$EFFECTS{$NAME}{'done'}($NAME, \%exists, $EFFECTS{$NAME});
-			}
+	# Cleanup on timer or child exit
+	if (defined($CLEANUP_TIME) && $CLEANUP_TIME <= Time::HiRes::time()) {
 
-			# Forget our local bypass state
-			$PID      = undef();
-			$NAME     = undef();
-			$NEXT     = undef();
-			$PID_DATA = undef();
+		# Run the cleanup routine (if any)
+		if ($EFFECTS{$NAME}{'done'}) {
+			$EFFECTS{$NAME}{'done'}($NAME, \%exists, $EFFECTS{$NAME});
+		}
 
-			# Clear the RAVE and EFFECT flags
-			if (-e $RAVE_FILE) {
-				unlink($RAVE_FILE);
-			}
-			if (-e $EFFECT_FILE) {
-				unlink($EFFECT_FILE);
-			}
+		# Forget our local bypass state
+		$PID          = undef();
+		$CLEANUP_TIME = undef();
+		$NAME         = undef();
+		$NEXT         = undef();
+		$PID_DATA     = undef();
+
+		# Clear the RAVE and EFFECT flags
+		if (-e $RAVE_FILE) {
+			unlink($RAVE_FILE);
+		}
+		if (-e $EFFECT_FILE) {
+			unlink($EFFECT_FILE);
 		}
 	}
 
@@ -160,6 +166,11 @@ while (1) {
 		next;
 	}
 
+	# Alert continuously on ALARM
+	if ($exists{'ALARM'}) {
+		$newState = 'RED_ALERT';
+	}
+
 	# Handle effects by name
 	if (defined($EFFECTS{$newState})) {
 
@@ -214,6 +225,13 @@ sub ampWait($$$) {
 		print STDERR "ampWait()\n";
 	}
 
+	# Ensure we don't loop forever
+	if (!defined($AMP_START)) {
+		$AMP_START = Time::HiRes::time();
+	} elsif (Time::HiRes::time() - $AMP_START > $AMP_TIMEOUT) {
+		die("Timeout waiting for amp to start\n");
+	}
+
 	# Just loop until the amp is up, then set a new "next" handler
 	if ($exists{'AMPLIFIER'}) {
 		if ($DEBUG) {
@@ -245,8 +263,9 @@ sub ampWait($$$) {
 			sleep($AMP_DELAY);
 		}
 
-		# Reset the amp boot delay, now that it's running
+		# Reset the amp boot delay and amp start time now that it's running
 		$AMP_BOOTING = 0;
+		undef($AMP_START);
 	} else {
 
 		# The amp wasn't up when we checked, so it will need a boot delay
@@ -337,15 +356,18 @@ sub lsr_run($$$) {
 		print STDERR "lsr_run()\n";
 	}
 
+	# Wait just a second for annoucements
+	sleep(1);
+
 	# Play a short burst of silence to get all the audio devices in-sync
 	Audio::play('SILENCE');
 
-	# Play the sound in a child (i.e. in the background)
-	$PID = fork();
-	if (defined($PID) && $PID == 0) {
-		Audio::play($name);
-		exit(0);
-	}
+	# Play the sound in the background
+	Audio::background($name);
+
+	# Set a cleanup timer
+	# Now + playback + fade + buffer
+	$CLEANUP_TIME = Time::HiRes::time() + 45.60 + 2 + 1;
 
 	# Record our start time
 	$PID_DATA->{'start'} = Time::HiRes::time();
@@ -367,10 +389,10 @@ sub lsr_loop($$$) {
 	my $max_dur  = 375;
 	my $max_val  = 255;
 	my $reserve  = 0.75;
-	my $ramp_dur = 10.20;
-	my $hit_pos  = 43.15;
+	my $ramp_dur = 10.40;
+	my $hit_pos  = 43.35;
 	my $hit_dur  = 100;
-	my $fade_pos = 43.65;
+	my $fade_pos = 43.85;
 	my $fade_dur = 2000;
 
 	# How long have we been playing

+ 1 - 2
dmx/reset.sh

@@ -1,6 +1,5 @@
 #!/bin/bash
-
-PROCS="amplifierPower.pl audio.pl bias.pl color.pl debug.pl fan.pl garage.pl leds.pl overhead.pl projectorPower.pl rave.pl rifftrax.pl rope.pl frontApp stairs.pl announce.pl"
+PROCS="amplifierPower.pl audio.pl bias.pl color.pl comfort.pl debug.pl door.pl fan.pl garage.pl garage_leds.pl garage_shelf.pl leds.pl oil.pl overhead.pl projectorPower.pl rave.pl rifftrax.pl rope.pl frontApp stairs.pl announce.pl rumbleCtl.pl HDMIctrl.pl vr.pl"
 
 # Find the PID for each named process
 PIDS=""

+ 2 - 2
dmx/rope.pl

@@ -43,7 +43,7 @@ my %DIM = (
 		{ 'channel' => 5,  'value' => 255, 'time' => 1000  },
 		{ 'channel' => 6,  'value' => 104, 'time' => 6000, 'delay' => 3000 },
 		{ 'channel' => 7,  'value' => 192, 'time' => 10000 },
-		{ 'channel' => 8,  'value' => 96,  'time' => 6000, 'delay' => 6000 },
+		{ 'channel' => 8,  'value' => 64,  'time' => 6000, 'delay' => 6000 },
 		{ 'channel' => 9,  'value' => 96,  'time' => 6000, 'delay' => 3000 },
 	],
 	'MOTION'    => [
@@ -52,7 +52,7 @@ my %DIM = (
 		{ 'channel' => 5,  'value' => 192, 'time' => 750  },
 		{ 'channel' => 6,  'value' => 104, 'time' => 1000 },
 		{ 'channel' => 7,  'value' => 192, 'time' => 1000 },
-		{ 'channel' => 8,  'value' => 96,  'time' => 2000 },
+		{ 'channel' => 8,  'value' => 64,  'time' => 2000 },
 		{ 'channel' => 9,  'value' => 96,  'time' => 1500 },
 	],
 	'PREMOTION' => [

+ 174 - 0
dmx/rumble.pl

@@ -0,0 +1,174 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# User config
+my $MIN_OUT = 40;
+my $MAX_OUT = 180;
+my $MAX_DMX = 255;
+my $STEPS   = 8;
+my $EXP     = 2.375;
+my %DIM     = (
+	'OFF'    => [ { 'channel' => 64, 'value' => 0,   'time' => 0 } ],
+	'ON'     => [ { 'channel' => 64, 'value' => 255, 'time' => 0 } ],
+	'RAW'    => [ { 'channel' => 64, 'value' => 0,   'time' => 0 } ],
+	'RANDOM' => [ { 'channel' => 64, 'value' => 0,   'time' => 100 } ],
+);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'RUMBLE';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $DELAY        = $PULL_TIMEOUT / 2;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# State
+my $state     = 'OFF';
+my $stateLast = $state;
+my $pushLast  = 0;
+my $pullLast  = time();
+my $update    = 0;
+
+# Always force rumble into OFF at launch
+$state = 'OFF';
+DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+# Sockets
+# We don't actually subscribe for state, but the framework is handy
+DMX::stateSocket($STATE_SOCK);
+
+# Loop forever
+while (1) {
+
+	# State is calculated; use newState to gather data
+	my $newState = $state;
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, undef(), undef(), undef());
+	if (defined($cmdState)) {
+		$newState = $cmdState;
+		$pullLast = time();
+	}
+
+	# Die if we don't see regular updates
+	if (time() - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Calculate the new state
+	my $newValue = undef();
+	$stateLast = $state;
+	if ($newState eq 'OFF' || $newState eq 'ON') {
+		$state = $newState;
+	} elsif ($newState eq 'RANDOM' || $newState eq 'RANDOM_FULL') {
+		$state    = 'RANDOM';
+		$newValue = int(rand($STEPS) + 1);
+	} elsif ($newState =~ /^RANDOM_(HIGH|MED|LOW|MIN)$/) {
+		$state = 'RANDOM';
+		my $parts = ceil($STEPS / 3);
+		$newValue = int(rand($parts) + 1);
+		if ($1 eq 'HIGH') {
+			$newValue += 2 * $parts;
+		} elsif ($1 eq 'MED') {
+			$newValue += $parts;
+		} elsif ($1 eq 'OFF') {
+			if ($newValue < $parts) {
+				$newValue = 0;
+			} else {
+				$newValue += $parts;
+			}
+		}
+	} elsif ($newState =~ /^(LEVEL|RAW)_(\d{1,3})$/) {
+		if ($1 eq 'LEVEL') {
+			$state = 'RANDOM';
+		} else {
+			$state = 'RAW';
+		}
+		$newValue = $2;
+	}
+
+	# In RAW mode, set the output directly (but safely)
+	if (defined($newValue) && $state eq 'RAW') {
+		if ($newValue > $MAX_DMX) {
+			$newValue = $MAX_DMX;
+		}
+		if ($newValue < $MIN_OUT) {
+			$newValue = 0;
+		}
+		$DIM{$state}[0]->{'value'} = $newValue;
+		$update = 1;
+	}
+
+	# In RANDOM mode, convert our level number into a DMX value
+	if (defined($newValue) && $state eq 'RANDOM') {
+
+		# Level 0 and 1 are the same -- OFF
+		if ($newValue <= 1) {
+			$newValue = 0;
+		}
+		if ($newValue > $STEPS) {
+			$newValue = $STEPS;
+		}
+
+		if ($DEBUG) {
+			print STDERR 'Random value: ' . $newValue . ' (' . int($newValue * 100 / $STEPS) . "%)\n";
+		}
+
+		# Always update in RANDOM mode
+		$update = 1;
+
+		my $value = int($newValue**$EXP) + $MIN_OUT;
+		if ($value <= $MIN_OUT) {
+			$value = 0;
+		} elsif ($value > $MAX_OUT) {
+			$value = $MAX_DMX;
+		}
+		$DIM{$state}[0]->{'value'} = $value;
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && time() - $pushLast > $PUSH_TIMEOUT) {
+		if ($DEBUG) {
+			print STDERR "Forcing periodic update\n";
+		}
+		$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Update the fan
+	if ($update) {
+
+		# Update
+		my $text = $state . '-' . $DIM{$state}[0]->{'value'};
+		if (defined($newValue)) {
+			$text .= ' (' . $newValue . ')';
+		}
+		DMX::applyDataset($DIM{$state}, $text, $OUTPUT_FILE);
+
+		# Update the push time
+		$pushLast = time();
+
+		# Clear the update flag
+		$update = 0;
+	}
+}

+ 153 - 0
dmx/rumbleCtl.pl

@@ -0,0 +1,153 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+use IPC::System::Simple qw( system capture );
+use Time::HiRes qw( usleep sleep time );
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# App config
+my $DATA_DIR      = DMX::dataDir();
+my $STATE_SOCK    = 'RUMBLE_CTL';
+my $OUT_SOCK      = 'RUMBLE';
+my $OUTPUT_FILE   = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT  = 20;
+my $PULL_TIMEOUT  = $PUSH_TIMEOUT * 3;
+my $DELAY         = 1;
+my $RAND_MAX_INT  = 30;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+my $out = DMX::clientSock($OUT_SOCK);
+
+# State
+my $state     = 'OFF';
+my $stateLast = $state;
+my %exists    = ();
+my $pushLast  = 0;
+my $pullLast  = time();
+my $update    = 0;
+my $stateEnd  = 0;
+my $bumpNext  = 0;
+
+# Loop forever
+while (1) {
+
+	# State is calculated; use newState to gather data
+	my $newState = $state;
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, undef(), undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
+	if (defined($cmdState)) {
+		$newState = $cmdState;
+		$pullLast = time();
+	}
+
+	# Die if we don't see regular updates
+	if ($now - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Calculate the new state, but only after the timer has elapsed
+	if ($stateEnd <= $now) {
+		# Pick a new delay window
+		my $delay = 0;
+		if (exists($exists{'RUMBLE_DELAY_MIN'})) {
+			$delay = int($exists{'RUMBLE_DELAY_MIN'});
+		}
+		if (exists($exists{'RUMBLE_DELAY_MAX'}) && $exists{'RUMBLE_DELAY_MAX'} > $delay) {
+			$delay = int(rand($exists{'RUMBLE_DELAY_MAX'} - $delay)) + $delay;
+		}
+		$stateEnd = $now + $delay;
+		$bumpNext = 0;
+
+		# Set the state
+		$state = 'OFF';
+		if ($exists{'RUMBLE_CMD'} && $exists{'RUMBLE_VALUE'}) {
+			if ($exists{'RUMBLE_CMD'} =~ /^(?:LEVEL|RAW)$/) {
+				$state = sprintf('%s_%d', $exists{'RUMBLE_CMD'}, int($exists{'RUMBLE_VALUE'}));
+			} elsif ($exists{'RUMBLE_CMD'} eq 'RANDOM') {
+				if ($exists{'RUMBLE_VALUE'} =~ /^(?:MIN|LOW|MED|FULL|HIGH)$/) {
+					$state = sprintf('RANDOM_%s', $exists{'RUMBLE_VALUE'});
+				}
+			}
+		}
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+		if ($DEBUG) {
+			print STDERR "Forcing periodic update\n";
+		}
+		$update = 1;
+	}
+
+	# Re-send the same command at randomized intervals based on user delay settings
+	if ($bumpNext <= $now) {
+		if ($DEBUG) {
+			print STDERR "Bumping\n";
+		}
+
+		my $max = $RAND_MAX_INT;
+		if ($exists{'RUMBLE_DELAY_MAX'} &&
+			$exists{'RUMBLE_DELAY_MAX'} < $RAND_MAX_INT &&
+			$exists{'RUMBLE_DELAY_MAX'} > 1) {
+				$max = int($exists{'RUMBLE_DELAY_MAX'});
+		}
+		my $min = 1;
+		if ($exists{'RUMBLE_DELAY_MIN'}) {
+			$min = int($exists{'RUMBLE_DELAY_MIN'} / 4);
+		}
+		if ($min > $max) {
+			$min = $max;
+		}
+
+		$bumpNext = int(rand($max - $min)) + $min - int(rand($min));
+		$bumpNext += $now;
+		$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Update the rumbler
+	if ($update) {
+
+		# Send master power state
+		$out->send($state)
+		  or die('Unable to write command to rumble socket: ' . $state . ": ${!}\n");
+		$stateLast = $state;
+
+		# No output file
+		if ($DEBUG) {
+			print STDERR 'Sending rumble state: ' . $state . "\n";
+		}
+
+		# Update the push time
+		$pushLast = $now;
+
+		# Clear the update flag
+		$update = 0;
+	}
+}

+ 4 - 11
dmx/stairs.pl

@@ -58,11 +58,13 @@ while (1) {
 	# State is calculated; use newState to gather data
 	my $newState = $state;
 
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
 	# Avoid repeated calls to time()
 	my $now = time();
 
-	# Wait for state updates
-	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+	# Record only valid states
 	if (defined($cmdState)) {
 		$newState = $cmdState;
 		$pullLast = $now;
@@ -73,15 +75,6 @@ while (1) {
 		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
 	}
 
-	# Skip processing when in RAVE or EFFECT mode
-	if ($exists{'RAVE'} || $exists{'EFFECT'}) {
-		if ($DEBUG) {
-			print STDERR "Suspending normal operation while in RAVE mode\n";
-		}
-		$update = 1;
-		next;
-	}
-
 	# Calculate the new state
 	$stateLast = $state;
 	if ($exists{'BRIGHT'}) {

+ 735 - 623
dmx/state.pl

@@ -10,14 +10,14 @@ use IPC::System::Simple qw( system capture );
 # Local modules
 use Cwd qw(abs_path);
 use File::Basename qw(dirname);
-use lib dirname(abs_path($0));
+use lib dirname( abs_path($0) );
 use DMX;
 
 # Prototypes
 sub mtime($);
 
 # User config
-my %DISPLAY_DEVS   = ('TV' => 1, 'PROJECTOR' => 1);
+my %DISPLAY_DEVS   = ( 'TV' => 1, 'PROJECTOR' => 1 );
 my $DISPLAY        = undef();
 my $STATE_TIMEOUT  = 180;
 my $EXISTS_TIMEOUT = 900;
@@ -27,99 +27,129 @@ my %MON_FILES      = ();
 # Host-specific config
 # This is necessary to avoid contention on shared disks
 my $HOST = Sys::Hostname::hostname();
-if ($HOST =~ /loki/i) {
-
-	# Ensure the media path is available before we get going
-	my $MEDIA_PATH = undef();
-	{
-		my $mount = $ENV{'HOME'} . '/bin/video/mediaPath';
-		$MEDIA_PATH = capture($mount);
-	}
-	if (!$MEDIA_PATH || !-d $MEDIA_PATH) {
-		die("Unable to access media path\n");
-	}
-
-	# Garage door opener
-	$MON_FILES{ $MEDIA_PATH . '/DMX/cmd/GARAGE_CMD' } = 'EXISTS-LINE-CLEAR';
+if ( $HOST =~ /loki/i ) {
+
+    # Ensure the media path is available before we get going
+    my $MEDIA_PATH = undef();
+    {
+        my $mount = $ENV{'HOME'} . '/bin/video/mediaPath';
+        $MEDIA_PATH = capture($mount);
+    }
+    if ( !$MEDIA_PATH || !-d $MEDIA_PATH ) {
+        die("Unable to access media path\n");
+    }
+
+    # Door remote command
+    $MON_FILES{ $MEDIA_PATH . '/DMX/cmd/DOOR_CMD' } = 'EXISTS-LINE-CLEAR';
+
+    # Rumble pack
+    $MON_FILES{'RUMBLE'}                                    = 'LINE-NOUPDATE';
+    $MON_FILES{ $MEDIA_PATH . '/DMX/cmd/RUMBLE_CMD' }       = 'LINE-NOUPDATE';
+    $MON_FILES{ $MEDIA_PATH . '/DMX/cmd/RUMBLE_VALUE' }     = 'LINE-NOUPDATE';
+    $MON_FILES{ $MEDIA_PATH . '/DMX/cmd/RUMBLE_DELAY_MIN' } = 'LINE-NOUPDATE';
+    $MON_FILES{ $MEDIA_PATH . '/DMX/cmd/RUMBLE_DELAY_MAX' } = 'LINE-NOUPDATE';
 }
 
 # Available state files
 {
 
-	# Plex
-	$MON_FILES{'PLEX'}    = 'NONE';
-	$MON_FILES{'GUI'}     = 'NONE';
-	$MON_FILES{'PLAYING'} = 'PLAYING';
-
-	# RiffTrax
-	$MON_FILES{'RIFF'} = 'LINE-NOUPDATE';
-
-	# Motion detection
-	$MON_FILES{'MOTION'}        = 'MTIME';
-	$MON_FILES{'MOTION_STAIRS'} = 'MTIME-NOUPDATE';
-	$MON_FILES{'NO_MOTION'}     = 'EXISTS-NOUPDATE';
-
-	# Projector
-	$MON_FILES{'PROJECTOR'}       = 'STATUS';
-	$MON_FILES{'PROJECTOR_COLOR'} = 'LINE-NOUPDATE';
-	$MON_FILES{'PROJECTOR_INPUT'} = 'LINE-NOUPDATE';
-	$MON_FILES{'PROJECTOR_LAMP'}  = 'LINE-NOUPDATE';
-
-	# Amplifier
-	$MON_FILES{'AMPLIFIER'}       = 'STATUS';
-	$MON_FILES{'AMPLIFIER_VOL'}   = 'LINE-NOUPDATE';
-	$MON_FILES{'AMPLIFIER_MODE'}  = 'LINE-NOUPDATE';
-	$MON_FILES{'AMPLIFIER_INPUT'} = 'LINE-NOUPDATE';
-	$MON_FILES{'AUDIO_AMP'}       = 'EXISTS-ON';
-	$MON_FILES{'STEREO_CMD'}      = 'EXISTS-ON';
-
-	# A/V Effects
-	$MON_FILES{'LIGHTS'} = 'EXISTS-TIMEOUT';
-	$MON_FILES{'BRIGHT'} = 'EXISTS-TIMEOUT';
-	$MON_FILES{'RAVE'}   = 'EXISTS';
-	$MON_FILES{'EFFECT'} = 'EXISTS';
-
-	# Equipment
-	$MON_FILES{'FAN'}     = 'LINE-NOUPDATE';
-	$MON_FILES{'FAN_CMD'} = 'EXISTS-ON';
-
-	# OS State
-	$MON_FILES{'FRONT_APP'}   = 'LINE-NOUPDATE';
-	$MON_FILES{'COLOR'}       = 'LINE-NOUPDATE';
-	$MON_FILES{'AUDIO'}       = 'LINE-NOUPDATE';
-	$MON_FILES{'AUDIO_STATE'} = 'LINE-NOUPDATE';
-
-	# Lights
-	$MON_FILES{'LED'}      = 'LINE-NOUPDATE';
-	$MON_FILES{'OVERHEAD'} = 'LINE-NOUPDATE';
-	$MON_FILES{'ROPE'}     = 'LINE-NOUPDATE';
-	$MON_FILES{'STAIRS'}   = 'LINE-NOUPDATE';
-
-	# TV
-	$MON_FILES{'TV'}     = 'STATUS';
-	$MON_FILES{'TV_VOL'} = 'LINE';
+    # Plex
+    $MON_FILES{'PLEX'}    = 'NONE';
+    $MON_FILES{'GUI'}     = 'NONE';
+    $MON_FILES{'PLAYING'} = 'PLAYING';
+
+    # RiffTrax
+    $MON_FILES{'RIFF'} = 'LINE-NOUPDATE';
+
+    # Motion detection
+    $MON_FILES{'MOTION'}        = 'MTIME';
+    $MON_FILES{'MOTION_STAIRS'} = 'MTIME-NOUPDATE';
+    $MON_FILES{'MOTION_GARAGE'} = 'MTIME-NOUPDATE';
+    $MON_FILES{'NO_MOTION'}     = 'EXISTS-NOUPDATE';
+
+    # Projector
+    $MON_FILES{'PROJECTOR'}       = 'STATUS';
+    $MON_FILES{'PROJECTOR_COLOR'} = 'LINE-NOUPDATE';
+    $MON_FILES{'PROJECTOR_INPUT'} = 'LINE-NOUPDATE';
+    $MON_FILES{'PROJECTOR_LAMP'}  = 'LINE-NOUPDATE';
+
+    # Projector Control
+    $MON_FILES{'PROJECTOR_CTRL_COLOR'} = 'LINE-NOUPDATE';
+    $MON_FILES{'PROJECTOR_CTRL_LIFE'}  = 'LINE-NOUPDATE';
+    $MON_FILES{'PROJECTOR_CTRL_POWER'} = 'LINE-NOUPDATE';
+    $MON_FILES{'PROJECTOR_CTRL_TIMER'} = 'LINE-NOUPDATE';
+
+    # Amplifier
+    $MON_FILES{'AMPLIFIER'}       = 'STATUS';
+    $MON_FILES{'AMPLIFIER_VOL'}   = 'LINE-NOUPDATE';
+    $MON_FILES{'AMPLIFIER_MODE'}  = 'LINE-NOUPDATE';
+    $MON_FILES{'AMPLIFIER_INPUT'} = 'LINE-NOUPDATE';
+    $MON_FILES{'AUDIO_AMP'}       = 'EXISTS-ON';
+    $MON_FILES{'STEREO_CMD'}      = 'EXISTS-ON';
+
+    # A/V Effects
+    $MON_FILES{'LIGHTS'} = 'EXISTS-TIMEOUT';
+    $MON_FILES{'BRIGHT'} = 'EXISTS-TIMEOUT';
+    $MON_FILES{'RAVE'}   = 'EXISTS';
+    $MON_FILES{'EFFECT'} = 'EXISTS';
+
+    # Fan
+    $MON_FILES{'FAN'}     = 'LINE-NOUPDATE';
+    $MON_FILES{'FAN_CMD'} = 'EXISTS-TIMEOUT';
+
+    # Garage
+    $MON_FILES{'OIL'}     = 'LINE-NOUPDATE';
+    $MON_FILES{'DOOR'}    = 'LINE-NOUPDATE';
+    $MON_FILES{'LOCK'}    = 'EXISTS-NOUPDATE';
+
+    # Alarm
+    $MON_FILES{'ALARM'}   = 'NONE';
+
+    # OS State
+    $MON_FILES{'FRONT_APP'}   = 'LINE-NOUPDATE';
+    $MON_FILES{'COLOR'}       = 'LINE-NOUPDATE';
+    $MON_FILES{'AUDIO'}       = 'LINE-NOUPDATE';
+    $MON_FILES{'AUDIO_STATE'} = 'LINE-NOUPDATE';
+
+    # Game System
+    $MON_FILES{'GAME'} = 'EXISTS';
+    $MON_FILES{'VR'}   = 'LINE-NOUPDATE';
+
+    # Lights
+    $MON_FILES{'LED'}          = 'LINE-NOUPDATE';
+    $MON_FILES{'OVERHEAD'}     = 'LINE-NOUPDATE';
+    $MON_FILES{'ROPE'}         = 'LINE-NOUPDATE';
+    $MON_FILES{'STAIRS'}       = 'LINE-NOUPDATE';
+    $MON_FILES{'GARAGE'}       = 'LINE-NOUPDATE';
+    $MON_FILES{'GARAGE_LEDS'}  = 'LINE-NOUPDATE';
+    $MON_FILES{'GARAGE_SHELF'} = 'LINE-NOUPDATE';
+
+    # TV
+    $MON_FILES{'TV'}       = 'STATUS';
+    $MON_FILES{'TV_VOL'}   = 'LINE-NOUPDATE';
+    $MON_FILES{'TV_INPUT'} = 'LINE-NOUPDATE';
 }
 my %EXTRAS = (
-	'PLAYING' => {
-		'URL'       => qr/^file:(.+)$/m,
-		'YEAR'      => qr/^year:(\d+)/m,
-		'SERIES'    => qr/^showtitle:(.+)$/m,
-		'SEASON'    => qr/^season:(\d+)/m,
-		'EPISODE'   => qr/^episode:(\d+)/m,
-		'TITLE'     => qr/^title:(.+)$/m,
-		'LENGTH'    => qr/^duration:(\d+)/m,
-		'POSITION'  => qr/^time:(\d+(?:\.\d+)?)/m,
-		'TYPE'      => qr/^type:(.+)$/m,
-		'ALBUM'     => qr/^album:(.+)$/m,
-		'ARTIST'    => qr/^artist:(.+)$/m,
-		'SELECTION' => qr/^selection:(.+)$/m,
-		'WINDOW'    => qr/^window:(.+)$/m,
-		'WINDOWID'  => qr/^windowid:(\d+)/m,
-
-		# Available, but big and mostly useless
-		#'IMAGE'     => qr/^fanart:(.+)$/m,
-		#'THUMB'     => qr/^thumbnail:(.+)$/m,
-	}
+    'PLAYING' => {
+        'URL'       => qr/^file:(.+)$/m,
+        'YEAR'      => qr/^year:(\d+)/m,
+        'SERIES'    => qr/^showtitle:(.+)$/m,
+        'SEASON'    => qr/^season:(\d+)/m,
+        'EPISODE'   => qr/^episode:(\d+)/m,
+        'TITLE'     => qr/^title:(.+)$/m,
+        'LENGTH'    => qr/^duration:(\d+)/m,
+        'POSITION'  => qr/^time:(\d+(?:\.\d+)?)/m,
+        'TYPE'      => qr/^type:(.+)$/m,
+        'ALBUM'     => qr/^album:(.+)$/m,
+        'ARTIST'    => qr/^artist:(.+)$/m,
+        'SELECTION' => qr/^selection:(.+)$/m,
+        'WINDOW'    => qr/^window:(.+)$/m,
+        'WINDOWID'  => qr/^windowid:(\d+)/m,
+
+        # Available, but big and mostly useless
+        #'IMAGE'     => qr/^fanart:(.+)$/m,
+        #'THUMB'     => qr/^thumbnail:(.+)$/m,
+    }
 );
 
 # App config
@@ -132,129 +162,134 @@ my $PUSH_TIMEOUT = 20;
 
 # Debug
 my $DEBUG = 0;
-if ($ENV{'DEBUG'}) {
-	$DEBUG = 1;
+if ( $ENV{'DEBUG'} ) {
+    $DEBUG = 1;
 }
 
 # Command-line arguments
 my ($DELAY) = @ARGV;
-if (!$DELAY) {
-	$DELAY = 0.5;
+if ( !$DELAY ) {
+    $DELAY = 0.5;
 }
 
 # Socket init
 my $select = DMX::stateSocket($CMD_FILE);
 
 # Add the extras to the main file list
-foreach my $file (keys(%EXTRAS)) {
-	if (exists($MON_FILES{$file})) {
-		foreach my $extra (keys(%{ $EXTRAS{$file} })) {
-			$MON_FILES{ $file . '_' . $extra } = 'EXTRA';
-		}
-	}
+foreach my $file ( keys(%EXTRAS) ) {
+    if ( exists( $MON_FILES{$file} ) ) {
+        foreach my $extra ( keys( %{ $EXTRAS{$file} } ) ) {
+            $MON_FILES{ $file . '_' . $extra } = 'EXTRA';
+        }
+    }
 }
 
 # Init the file tracking structure
 my %files = ();
-foreach my $name (keys(%MON_FILES)) {
-	my %file = (
-		'name'   => basename($name),
-		'type'   => $MON_FILES{$name},
-		'path'   => $DATA_DIR . $name,
-		'update' => 0,
-		'value'  => 0,
-		'last'   => 0,
-	);
-
-	# Allow absolute paths to override the $DATA_DIR path
-	if ($name =~ /^\//) {
-		$file{'path'} = $name;
-	}
-
-	# Record the directory name for folder monitoring
-	$file{'dir'} = dirname($file{'path'});
-
-	# Set the attribute bits, for use in later update handling logic
-	my %attr = (
-		'available' => 0,
-		'status'    => 0,
-		'exists'    => 0,
-		'value'     => 0,
-		'line'      => 0,
-		'clear'     => 0,
-		'clear_off' => 0,
-		'mtime'     => 0,
-		'playing'   => 0,
-		'no_update' => 0,
-		'no_value'  => 0,
-		'extra'     => 0,
-	);
-	if ($file{'type'} =~ /\bSTATUS\b/i) {
-		$attr{'status'} = 1;
-	}
-	if ($file{'type'} =~ /\bEXISTS\b/i) {
-		$attr{'exists'} = 1;
-	}
-	if ($file{'type'} =~ /\bVALUE\b/i) {
-		$attr{'value'} = 1;
-	}
-	if ($file{'type'} =~ /\bLINE\b/i) {
-		$attr{'line'} = 1;
-	}
-	if ($file{'type'} =~ /\bCLEAR\b/i) {
-		$attr{'clear'} = 1;
-	}
-	if ($file{'type'} =~ /\bON\b/i) {
-		$attr{'clear_off'} = 1;
-	}
-	if ($file{'type'} =~ /\bTIMEOUT\b/i) {
-		$attr{'clear_timeout'} = 1;
-	}
-	if ($file{'type'} =~ /\bMTIME\b/i) {
-		$attr{'mtime'} = 1;
-	}
-	if ($file{'type'} =~ /\bPLAYING\b/i) {
-		$attr{'playing'} = 1;
-	}
-	if ($file{'type'} =~ /\bNOUPDATE\b/i) {
-		$attr{'no_update'} = 1;
-	}
-	if ($file{'type'} =~ /\bNONE\b/i) {
-		$attr{'no_value'} = 1;
-	}
-	if ($file{'type'} =~ /\bEXTRA\b/i) {
-		$attr{'extra'} = 1;
-	}
-
-	# Cross-match some data types for easy of use
-	if ($attr{'extra'}) {
-		$attr{'no_value'} = 1;
-	}
-	if ($attr{'line'}) {
-		$attr{'value'} = 1;
-	}
-	if ($attr{'value'} || $attr{'playing'}) {
-		$attr{'status'} = 1;
-	}
-	if ($attr{'status'} || $attr{'exists'}) {
-		$attr{'mtime'} = 1;
-	}
-
-	# Push ATTR into the file hash
-	$file{'attr'} = \%attr;
-
-	# Push FILE into the files hash
-	$files{$name} = \%file;
+foreach my $name ( keys(%MON_FILES) ) {
+    my %file = (
+        'name'   => basename($name),
+        'type'   => $MON_FILES{$name},
+        'path'   => $DATA_DIR . $name,
+        'update' => 0,
+        'value'  => 0,
+        'last'   => 0,
+    );
+
+    # Allow absolute paths to override the $DATA_DIR path
+    if ( $name =~ /^\// ) {
+        $file{'path'} = $name;
+    }
+
+    # Record the directory name for folder monitoring
+    $file{'dir'} = dirname( $file{'path'} );
+
+    # Set the attribute bits, for use in later update handling logic
+    my %attr = (
+        'available' => 0,
+        'status'    => 0,
+        'exists'    => 0,
+        'value'     => 0,
+        'line'      => 0,
+        'clear'     => 0,
+        'clear_off' => 0,
+        'mtime'     => 0,
+        'playing'   => 0,
+        'no_update' => 0,
+        'no_value'  => 0,
+        'extra'     => 0,
+    );
+    if ( $file{'type'} =~ /\bSTATUS\b/i ) {
+        $attr{'status'} = 1;
+    }
+    if ( $file{'type'} =~ /\bEXISTS\b/i ) {
+        $attr{'exists'} = 1;
+    }
+    if ( $file{'type'} =~ /\bVALUE\b/i ) {
+        $attr{'value'} = 1;
+    }
+    if ( $file{'type'} =~ /\bLINE\b/i ) {
+        $attr{'line'} = 1;
+    }
+    if ( $file{'type'} =~ /\bCLEAR\b/i ) {
+        $attr{'clear'} = 1;
+    }
+    if ( $file{'type'} =~ /\bON\b/i ) {
+        $attr{'clear_off'} = 1;
+    }
+    if ( $file{'type'} =~ /\bTIMEOUT\b/i ) {
+        $attr{'clear_timeout'} = 1;
+    }
+    if ( $file{'type'} =~ /\bMTIME\b/i ) {
+        $attr{'mtime'} = 1;
+    }
+    if ( $file{'type'} =~ /\bPLAYING\b/i ) {
+        $attr{'playing'} = 1;
+    }
+    if ( $file{'type'} =~ /\bNOUPDATE\b/i ) {
+        $attr{'no_update'} = 1;
+    }
+    if ( $file{'type'} =~ /\bNONE\b/i ) {
+        $attr{'no_value'} = 1;
+    }
+    if ( $file{'type'} =~ /\bEXTRA\b/i ) {
+        $attr{'extra'} = 1;
+    }
+
+    # Cross-match some data types for easy of use
+    if ( $attr{'extra'} ) {
+        $attr{'no_value'} = 1;
+    }
+    if ( $attr{'no_value'} ) {
+        $attr{'no_update'} = 1;
+    }
+    if ( $attr{'line'} ) {
+        $attr{'value'} = 1;
+    }
+    if ( $attr{'value'} || $attr{'playing'} ) {
+        $attr{'status'} = 1;
+    }
+    if ( $attr{'status'} || $attr{'exists'} ) {
+        $attr{'mtime'} = 1;
+    }
+
+    # Push ATTR into the file hash
+    $file{'attr'} = \%attr;
+
+    # Push FILE into the files hash
+    $files{$name} = \%file;
 }
 
 # Delete any existing input files, to ensure our state is reset
-foreach my $file (values(%files)) {
-	if (-e $file->{'path'}) {
-		if ($DEBUG) {
-			print STDERR 'Deleting ' . $file->{'name'} . ': ' . $file->{'path'} . "\n";
-		}
-		unlink($file->{'path'});
-	}
+foreach my $file ( values(%files) ) {
+    if ( -e $file->{'path'} ) {
+        if ($DEBUG) {
+            print STDERR 'Deleting '
+              . $file->{'name'} . ': '
+              . $file->{'path'} . "\n";
+        }
+        unlink( $file->{'path'} );
+    }
 }
 
 # Reset all dependents at starts
@@ -273,439 +308,516 @@ my $timeSinceUpdate = 0;
 my $startTime       = time() + $START_DELAY;
 my $pushLast        = 0;
 
+# Announce startup
+DMX::say('System initializing');
+
 # Loop forever
 while (1) {
 
-	# Provide a method to force updates
-	my $update = 0;
-
-	# Avoid repeated calls to time()
-	my $now = time();
-
-	# Check for queued commands
-	my @ready_clients = $select->can_read($DELAY);
-	foreach my $fh (@ready_clients) {
-
-		# Grab the inbound text
-		my $path = undef();
-		$fh->recv($path, $MAX_CMD_LEN);
-		$path =~ s/^\s+//;
-		$path =~ s/\s+$//;
-		$path =~ s/\W/_/g;
-		if ($DEBUG) {
-			print STDERR 'Got path: ' . $path . "\n";
-		}
-
-		# Open the new socket
-		my $sub = eval { DMX::clientSock($path); };
-		if (!$sub) {
-			print STDERR 'Unable to open socket: ' . $path . ": ${@}\n";
-			next;
-		}
-
-		# Add the socket to our subscriber list
-		my %tmp = (
-			'path'   => $path,
-			'socket' => $sub,
-		);
-		push(@subscribers, \%tmp);
-
-		# Send immediately when someone subscribes
-		if (!$update) {
-			if ($DEBUG) {
-				print STDERR "New subscriber\n";
-			}
-			$update = 1;
-		}
-	}
-
-	# Use opendir/readdir in each folder to ensure fresh results
-	my %folders = ();
-	foreach my $file (values(%files)) {
-		if (!$folders{ $file->{'dir'} }) {
-			my $dh = undef();
-			opendir($dh, $file->{'dir'})
-			  or die('Unable to open directory: ' . $file->{'dir'});
-			if ($DEBUG) {
-				print STDERR 'Refreshed folder: ' . $file->{'dir'} . "\n";
-			}
-			my @files = readdir($dh);
-			closedir($dh);
-			$folders{ $file->{'dir'} } = 1;
-		}
-	}
-
-	# Monitor files of all types
-	foreach my $file (values(%files)) {
-
-		# Record the previous status and clear the new one, unless the file is type EXTRA
-		if (!$file->{'attr'}->{'extra'}) {
-			$file->{'last'}  = $file->{'value'};
-			$file->{'value'} = 0;
-		}
-
-		# Skip NO_VALUE files -- their values are abstracted from other data
-		if ($file->{'attr'}->{'no_value'}) {
-			next;
-		}
-
-		# Track available/unavailable status in each cycle
-		{
-			my $wasAvailable = $file->{'attr'}->{'available'};
-			$file->{'attr'}->{'available'} = -r $file->{'path'} ? 1 : 0;
-			if ($wasAvailable != $file->{'attr'}->{'available'}) {
-
-				# Reset and extras for this file
-				if (exists($EXTRAS{ $file->{'name'} })) {
-					foreach my $extra (keys(%{ $EXTRAS{ $file->{'name'} } })) {
-						my $name = $file->{'name'} . '_' . $extra;
-						$files{$name}{'last'}  = $files{$name}{'value'};
-						$files{$name}{'value'} = 0;
-					}
-				}
-
-				# Determine which display device we have (if any)
-				if (exists($DISPLAY_DEVS{ $file->{'name'} })) {
-					$DISPLAY = $file->{'name'};
-					if ($DEBUG) {
-						print STDERR 'Selecting display device: ' . $DISPLAY . "\n";
-					}
-				}
-
-				if ($DEBUG) {
-					if ($file->{'attr'}->{'available'}) {
-						print STDERR 'Added file ' . $file->{'name'} . ': ' . $file->{'path'} . "\n";
-					} else {
-						print STDERR 'Dropped file ' . $file->{'name'} . ': ' . $file->{'path'} . "\n";
-					}
-				}
-			}
-		}
-
-		# Skip unavailable files
-		if (!$file->{'attr'}->{'available'}) {
-			next;
-		}
-
-		# Record the last update for MTIME files
-		if ($file->{'attr'}->{'mtime'}) {
-			my $mtime = mtime($file->{'path'});
-
-			# All EXISTS files have MTIME set, and mtime returns 0 if the file does not exist, so we can overload this check
-			if ($file->{'attr'}->{'exists'}) {
-				if ($mtime > 0) {
-					$file->{'update'} = $mtime;
-					$file->{'value'}  = 1;
-				} elsif ($file->{'last'}) {
-					$file->{'update'} = $now;
-				}
-			} else {
-				$file->{'update'} = $mtime;
-
-				# Set the value if the update is recent -- VALUE and STATUS will overwrite as needed
-				if ($mtime >= $now) {
-					$file->{'value'} = 1;
-				}
-			}
-
-			if ($DEBUG) {
-				print STDERR 'Mtime: ' . $file->{'name'} . ': ' . $file->{'value'} . ':' . $file->{'update'} . "\n";
-			}
-		}
-
-		# Grab the state from STATUS files (including VALUE files)
-		if ($file->{'attr'}->{'status'}) {
-
-			# Overwrite the mtime-based value
-			$file->{'value'} = 0;
-
-			my $text = '';
-			{
-				if (!open(my $fh, $file->{'path'})) {
-					warn('Unable to open ' . $file->{'path'} . "\n");
-				} else {
-					local $/;
-					$text = <$fh>;
-					close($fh);
-				}
-			}
-
-			# Parse the value for the file
-			if ($file->{'attr'}->{'value'}) {
-				if ($file->{'attr'}->{'line'}) {
-					($text) = $text =~ /^([^\n]+)\s*\n/;
-				} else {
-					$text =~ s/\s+$//;
-				}
-				$file->{'value'} = $text;
-			} elsif ($file->{'attr'}->{'playing'}) {
-				if ($text =~ /^playing:1/m) {
-					$file->{'value'} = 1;
-				}
-			} else {
-				if ($text =~ /1/) {
-					$file->{'value'} = 1;
-				}
-			}
-			if ($DEBUG) {
-				print STDERR 'Status: ' . $file->{'name'} . ': ' . $file->{'value'} . "\n";
-			}
-
-			# Handle extras, if any
-			if (exists($EXTRAS{ $file->{'name'} })) {
-				foreach my $extra (keys(%{ $EXTRAS{ $file->{'name'} } })) {
-
-					my $name = $file->{'name'} . '_' . $extra;
-					$files{$name}{'last'}   = $files{$name}{'value'};
-					$files{$name}{'value'}  = 0;
-					$files{$name}{'update'} = $file->{'update'};
-
-					if ($text =~ $EXTRAS{ $file->{'name'} }{$extra}) {
-						$files{$name}{'value'} = $1;
-					}
-
-					if ($DEBUG) {
-						print STDERR "\t" . $name . ': ' . $files{$name}{'value'} . "\n";
-					}
-				}
-			}
-		}
-	}
-
-	# Simulate the old GUI indicator
-	{
-		my $gui = 0;
-		if (exists($files{'PLAYING_WINDOW'}) && $files{'PLAYING_WINDOW'}->{'value'} ne 'Fullscreen video') {
-			$gui = 1;
-		}
-		$files{'GUI'}->{'value'} = $gui;
-	}
-	if (exists($files{'PLAYING'})) {
-		$files{'GUI'}->{'update'} = $files{'PLAYING'}->{'update'};
-	}
-
-	# Ignore the MOTION file if NO_MOTION is set
-	if (exists($files{'NO_MOTION'}) && $files{'NO_MOTION'}->{'value'}) {
-		if (exists($files{'MOTION'})) {
-			$files{'MOTION'}->{'value'}  = 0;
-			$files{'MOTION'}->{'update'} = 0;
-		}
-	}
-
-	# Set the global update timestamp, excluding files marked NOUPDATE
-	foreach my $file (values(%files)) {
-		if (   $file->{'update'} > $updateLast
-			&& !$file->{'attr'}->{'no_update'}
-			&& $file->{'update'} > $startTime)
-		{
-			$updateLast = $file->{'update'};
-		}
-	}
-	$timeSinceUpdate = $now - $updateLast;
-
-	# Calculate the PLEX state
-	if (exists($files{'FRONT_APP'})) {
-		if (   $files{'FRONT_APP'}->{'value'} =~ 'com.plexapp.plexhometheater'
-			|| $files{'FRONT_APP'}->{'value'} eq 'com.apple.ScreenSaver.Engine')
-		{
-			$files{'PLEX'}->{'value'} = 1;
-		}
-	}
-	if ($files{'PLEX'}->{'last'} != $files{'PLEX'}->{'value'}) {
-		$files{'PLEX'}->{'update'} = $now;
-	}
-
-	# Determine some intermediate state data
-	my $playing = 0;
-	if (exists($files{'PLAYING'}) && $files{'PLAYING'}->{'value'}) {
-		$playing = 1;
-	}
-	my $video = 0;
-	if (exists($files{'PLAYING_TYPE'}) && $files{'PLAYING_TYPE'}->{'value'} ne 'audio') {
-		$video = 1;
-	} elsif (!exists($files{'PLAYING_TYPE'})) {
-		$video = 1;
-	}
-
-	# Non-plex apps are always "playing" and "video"
-	if (exists($files{'PLEX'}) && !$files{'PLEX'}->{'value'}) {
-		$playing = 1;
-		$video   = 1;
-	}
-
-	# Calculate the new state
-	$stateLast = $state;
-	if (!defined($DISPLAY) || !exists($files{$DISPLAY})) {
-
-		# If there's no display the state is always PLAY or PAUSE, as indicated by the master
-		if ($playing) {
-			$state = 'PLAY';
-		} else {
-			$state = 'PAUSE';
-		}
-
-	} elsif ($files{$DISPLAY}->{'value'}) {
-
-		# If a display exists and is on:
-		# PLAY when video is active and playing
-		# PAUSE any other time (including when audio is active)
-		if ($playing && $video) {
-			$state = 'PLAY';
-		} else {
-			$state = 'PAUSE';
-		}
-
-	} else {
-
-		# If the display exists but is off, check the timeouts to determine MOTION vs. OFF
-		if ($timeSinceUpdate > $STATE_TIMEOUT) {
-			$state = 'OFF';
-		} elsif ($timeSinceUpdate < $STATE_TIMEOUT) {
-			$state = 'MOTION';
-		}
-	}
-	if ($state eq 'INIT') {
-		$state = 'OFF';
-	}
-
-	# Clear EXISTS_ON files when the main state is "OFF" (and before we append their status)
-	foreach my $file (values(%files)) {
-		if (   $file->{'attr'}->{'clear_off'}
-			&& $file->{'value'}
-			&& $state eq 'OFF')
-		{
-			unlink($file->{'path'});
-			$file->{'status'} = 0;
-			if ($DEBUG) {
-				print STDERR 'Clearing exists flag for: ' . $file->{'name'} . "\n";
-			}
-		}
-	}
-
-	# Clear EXISTS_TIMEOUT files when the main state is "OFF" and EXISTS_TIMEOUT has expired (and before we append their status)
-	foreach my $file (values(%files)) {
-		if (   $file->{'attr'}->{'clear_timeout'}
-			&& $file->{'value'}
-			&& $state eq 'OFF'
-			&& $timeSinceUpdate > $EXISTS_TIMEOUT)
-		{
-
-			unlink($file->{'path'});
-			$file->{'status'} = 0;
-			if ($DEBUG) {
-				print STDERR 'Clearing exists flag for: ' . $file->{'name'} . "\n";
-			}
-		}
-	}
-
-	# Calculate the new status
-	$statusLast = $status;
-	{
-		my @statTime = ();
-		foreach my $file (values(%files)) {
-			my $text = $file->{'value'};
-			$text =~ s/\s/ /g;
-			$text =~ s/\|/-/g;
-			push(@statTime, $file->{'name'} . '|' . $text . '|' . $file->{'update'});
-		}
-		$status = "\n" . join("\n", @statTime);
-	}
-
-	# Clear EXISTS_CLEAR files immediately (but after we append their status)
-	foreach my $file (values(%files)) {
-		if ($file->{'attr'}->{'clear'} && $file->{'value'}) {
-			unlink($file->{'path'});
-			$file->{'status'} = 0;
-			if ($DEBUG) {
-				print STDERR 'Clearing exists flag for: ' . $file->{'name'} . "\n";
-			}
-		}
-	}
-
-	# Update on a periodic basis, so we don't timeout
-	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
-		if ($DEBUG) {
-			print STDERR "Periodic update\n";
-		}
-		$update = 1;
-	}
-
-	# Update on a master state change
-	if (!$update && $stateLast ne $state) {
-		if ($DEBUG) {
-			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
-		}
-		$update = 1;
-	}
-
-	# Update on any mtime change
-	if (!$update) {
-		foreach my $file (values(%files)) {
-			if ($file->{'update'} > $pushLast) {
-				if ($DEBUG) {
-					print STDERR 'Mtime change: ' . $file->{'name'} . ' (' . $file->{'update'} . ")\n";
-				}
-				$update = 1;
-				last;
-			}
-		}
-	}
-
-	# Update on any value change
-	if (!$update) {
-		foreach my $file (values(%files)) {
-			if ($file->{'value'} ne $file->{'last'}) {
-				if ($DEBUG) {
-					print STDERR 'Value change: ' . $file->{'name'} . ' (' . $file->{'last'} . ' => ' . $file->{'value'} . ")\n";
-				}
-				$update = 1;
-				last;
-			}
-		}
-	}
-
-	# Push the update
-	if ($update) {
-
-		# Note the push
-		$pushLast = $now;
-
-		# Send notifications to all subscribers
-		my $msg = $state . $status;
-		if ($DEBUG) {
-			print STDERR '<STATE (' . length($msg) . ")>\n" . $msg . "\n<END STATE>\n\n";
-		}
-		foreach my $sub (@subscribers) {
-
-			# Drop subscribers that are not available
-			my $send = $sub->{'socket'}->send($msg);
-			if (!defined($send) || $send < 1) {
-				print STDERR 'Dropping bad socket from subscriber list: ' . $sub->{'path'} . "\n";
-
-				my @new_subscribers = ();
-				foreach my $new (@subscribers) {
-					if ($new->{'socket'} eq $sub->{'socket'}) {
-						next;
-					}
-					push(@new_subscribers, $new);
-				}
-				@subscribers = @new_subscribers;
-			}
-		}
-
-		# Save the state and value to disk
-		my ($fh, $tmp) = tempfile($DATA_DIR . 'STATE.XXXXXXXX', 'UNLINK' => 0);
-		print $fh $state . "\n";
-		close($fh);
-		rename($tmp, $DATA_DIR . 'STATE');
-	}
+    # Provide a method to force updates
+    my $update = 0;
+
+    # Avoid repeated calls to time()
+    my $now = time();
+
+    # Check for queued commands
+    my @ready_clients = $select->can_read($DELAY);
+    foreach my $fh (@ready_clients) {
+
+        # Grab the inbound text
+        my $path = undef();
+        $fh->recv( $path, $MAX_CMD_LEN );
+        $path =~ s/^\s+//;
+        $path =~ s/\s+$//;
+        $path =~ s/\W/_/g;
+        if ($DEBUG) {
+            print STDERR 'Got path: ' . $path . "\n";
+        }
+
+        # Open the new socket
+        my $sub = eval { DMX::clientSock($path); };
+        if ( !$sub ) {
+            print STDERR 'Unable to open socket: ' . $path . ": ${@}\n";
+            next;
+        }
+
+        # Add the socket to our subscriber list
+        my %tmp = (
+            'path'   => $path,
+            'socket' => $sub,
+        );
+        push( @subscribers, \%tmp );
+
+        # Send immediately when someone subscribes
+        if ( !$update ) {
+            if ($DEBUG) {
+                print STDERR "New subscriber\n";
+            }
+            $update = 1;
+        }
+    }
+
+    # Use opendir/readdir in each folder to ensure fresh results
+    my %folders = ();
+    foreach my $file ( values(%files) ) {
+        if ( !$folders{ $file->{'dir'} } ) {
+            my $dh = undef();
+            opendir( $dh, $file->{'dir'} )
+              or die( 'Unable to open directory: ' . $file->{'dir'} );
+            if ($DEBUG) {
+                print STDERR 'Refreshed folder: ' . $file->{'dir'} . "\n";
+            }
+            my @files = readdir($dh);
+            closedir($dh);
+            $folders{ $file->{'dir'} } = 1;
+        }
+    }
+
+    # Monitor files of all types
+    foreach my $file ( values(%files) ) {
+
+# Record the previous status and clear the new one, unless the file is type EXTRA
+        if ( !$file->{'attr'}->{'extra'} ) {
+            $file->{'last'}  = $file->{'value'};
+            $file->{'value'} = 0;
+        }
+
+        # Skip NO_VALUE files -- their values are abstracted from other data
+        if ( $file->{'attr'}->{'no_value'} ) {
+            next;
+        }
+
+        # Track available/unavailable status in each cycle
+        {
+            my $wasAvailable = $file->{'attr'}->{'available'};
+            $file->{'attr'}->{'available'} = -r $file->{'path'} ? 1 : 0;
+            if ( $wasAvailable != $file->{'attr'}->{'available'} ) {
+
+                # Reset and extras for this file
+                if ( exists( $EXTRAS{ $file->{'name'} } ) ) {
+                    foreach
+                      my $extra ( keys( %{ $EXTRAS{ $file->{'name'} } } ) )
+                    {
+                        my $name = $file->{'name'} . '_' . $extra;
+                        $files{$name}{'last'}  = $files{$name}{'value'};
+                        $files{$name}{'value'} = 0;
+                    }
+                }
+
+                # Determine which display device we have (if any)
+                if ( exists( $DISPLAY_DEVS{ $file->{'name'} } ) ) {
+                    $DISPLAY = $file->{'name'};
+                    if ($DEBUG) {
+                        print STDERR 'Selecting display device: ' . $DISPLAY
+                          . "\n";
+                    }
+                }
+
+                if ($DEBUG) {
+                    if ( $file->{'attr'}->{'available'} ) {
+                        print STDERR 'Added file '
+                          . $file->{'name'} . ': '
+                          . $file->{'path'} . "\n";
+                    }
+                    else {
+                        print STDERR 'Dropped file '
+                          . $file->{'name'} . ': '
+                          . $file->{'path'} . "\n";
+                    }
+                }
+            }
+        }
+
+        # Skip unavailable files
+        if ( !$file->{'attr'}->{'available'} ) {
+            next;
+        }
+
+        # Record the last update for MTIME files
+        if ( $file->{'attr'}->{'mtime'} ) {
+            my $mtime = mtime( $file->{'path'} );
+
+# All EXISTS files have MTIME set, and mtime returns 0 if the file does not exist, so we can overload this check
+            if ( $file->{'attr'}->{'exists'} ) {
+                if ( $mtime > 0 ) {
+                    $file->{'update'} = $mtime;
+                    $file->{'value'}  = 1;
+                }
+                elsif ( $file->{'last'} ) {
+                    $file->{'update'} = $now;
+                }
+            }
+            else {
+                $file->{'update'} = $mtime;
+
+# Set the value if the update is recent -- VALUE and STATUS will overwrite as needed
+                if ( $mtime >= $now ) {
+                    $file->{'value'} = 1;
+                }
+            }
+
+            if ($DEBUG) {
+                print STDERR 'Mtime: '
+                  . $file->{'name'} . ': '
+                  . $file->{'value'} . ':'
+                  . $file->{'update'} . "\n";
+            }
+        }
+
+        # Grab the state from STATUS files (including VALUE files)
+        if ( $file->{'attr'}->{'status'} ) {
+
+            # Overwrite the mtime-based value
+            $file->{'value'} = 0;
+
+            my $text = '';
+            {
+                if ( !open( my $fh, $file->{'path'} ) ) {
+                    warn( 'Unable to open ' . $file->{'path'} . "\n" );
+                }
+                else {
+                    local $/;
+                    $text = <$fh>;
+                    close($fh);
+                }
+            }
+
+            # Parse the value for the file
+            if ( $file->{'attr'}->{'value'} ) {
+                if ( $file->{'attr'}->{'line'} ) {
+                    ($text) = $text =~ /^([^\n]+)\s*\n/;
+                }
+                else {
+                    $text =~ s/\s+$//;
+                }
+                $file->{'value'} = $text;
+            }
+            elsif ( $file->{'attr'}->{'playing'} ) {
+                if ( $text =~ /^playing:1/m ) {
+                    $file->{'value'} = 1;
+                }
+            }
+            else {
+                if ( $text =~ /1/ ) {
+                    $file->{'value'} = 1;
+                }
+            }
+            if ($DEBUG) {
+                print STDERR 'Status: '
+                  . $file->{'name'} . ': '
+                  . $file->{'value'} . "\n";
+            }
+
+            # Handle extras, if any
+            if ( exists( $EXTRAS{ $file->{'name'} } ) ) {
+                foreach my $extra ( keys( %{ $EXTRAS{ $file->{'name'} } } ) ) {
+
+                    my $name = $file->{'name'} . '_' . $extra;
+                    $files{$name}{'last'}   = $files{$name}{'value'};
+                    $files{$name}{'value'}  = 0;
+                    $files{$name}{'update'} = $file->{'update'};
+
+                    if ( $text =~ $EXTRAS{ $file->{'name'} }{$extra} ) {
+                        $files{$name}{'value'} = $1;
+                    }
+
+                    if ($DEBUG) {
+                        print STDERR "\t"
+                          . $name . ': '
+                          . $files{$name}{'value'} . "\n";
+                    }
+                }
+            }
+        }
+    }
+
+    # Simulate the old GUI indicator
+    {
+        my $gui = 0;
+        if ( exists( $files{'PLAYING_WINDOW'} )
+            && $files{'PLAYING_WINDOW'}->{'value'} ne 'Fullscreen video' )
+        {
+            $gui = 1;
+        }
+        $files{'GUI'}->{'value'} = $gui;
+    }
+    if ( exists( $files{'PLAYING'} ) ) {
+        $files{'GUI'}->{'update'} = $files{'PLAYING'}->{'update'};
+    }
+
+    # Ignore the MOTION file if NO_MOTION is set
+    if ( exists( $files{'NO_MOTION'} ) && $files{'NO_MOTION'}->{'value'} ) {
+        if ( exists( $files{'MOTION'} ) ) {
+            $files{'MOTION'}->{'value'}  = 0;
+            $files{'MOTION'}->{'update'} = 0;
+        }
+    }
+
+    # Set the global update timestamp, excluding files marked NOUPDATE
+    foreach my $file ( values(%files) ) {
+        if (   $file->{'update'} > $updateLast
+            && !$file->{'attr'}->{'no_update'}
+            && $file->{'update'} > $startTime )
+        {
+            $updateLast = $file->{'update'};
+        }
+    }
+    $timeSinceUpdate = $now - $updateLast;
+
+    # Calculate the PLEX state
+    if ( exists( $files{'FRONT_APP'} ) ) {
+        if (   $files{'FRONT_APP'}->{'value'} =~ 'com.plexapp.plexhometheater'
+            || $files{'FRONT_APP'}->{'value'} eq
+            'com.apple.ScreenSaver.Engine' )
+        {
+            if ( !exists( $files{'GAME'} ) || !$files{'GAME'}->{'value'} ) {
+                $files{'PLEX'}->{'value'} = 1;
+            }
+        }
+    }
+    if ( $files{'PLEX'}->{'last'} != $files{'PLEX'}->{'value'} ) {
+        $files{'PLEX'}->{'update'} = $now;
+    }
+
+    # Determine some intermediate state data
+    my $playing = 0;
+    if ( exists( $files{'PLAYING'} ) && $files{'PLAYING'}->{'value'} ) {
+        $playing = 1;
+    }
+    my $video = 0;
+    if ( exists( $files{'PLAYING_TYPE'} )
+        && $files{'PLAYING_TYPE'}->{'value'} ne 'audio' )
+    {
+        $video = 1;
+    }
+    elsif ( !exists( $files{'PLAYING_TYPE'} ) ) {
+        $video = 1;
+    }
+
+    # Non-plex apps are always "playing" and "video"
+    if ( exists( $files{'PLEX'} ) && !$files{'PLEX'}->{'value'} ) {
+        $playing = 1;
+        $video   = 1;
+    }
+
+    # Calculate the new state
+    $stateLast = $state;
+    if ( !defined($DISPLAY) || !exists( $files{$DISPLAY} ) ) {
+
+# If there's no display the state is always PLAY or PAUSE, as indicated by the master
+        if ($playing) {
+            $state = 'PLAY';
+        }
+        else {
+            $state = 'PAUSE';
+        }
+
+    }
+    elsif ( $files{$DISPLAY}->{'value'} ) {
+
+        # If a display exists and is on:
+        # PLAY when video is active and playing
+        # PAUSE any other time (including when audio is active)
+        if ( $playing && $video ) {
+            $state = 'PLAY';
+        }
+        else {
+            $state = 'PAUSE';
+        }
+
+    }
+    else {
+
+# If the display exists but is off, check the timeouts to determine MOTION vs. OFF
+        if ( $timeSinceUpdate > $STATE_TIMEOUT ) {
+            $state = 'OFF';
+        }
+        elsif ( $timeSinceUpdate < $STATE_TIMEOUT ) {
+            $state = 'MOTION';
+        }
+    }
+    if ( $state eq 'INIT' ) {
+        $state = 'OFF';
+    }
+
+    # ALARM if LOCK exists and the master state is not OFF or any motion exists
+    {
+        my $alarm = 0;
+        if ( exists( $files{'LOCK'} ) && $files{'LOCK'}->{'value'} ) {
+            if (
+                   $state ne 'OFF'
+                || ( exists( $files{'MOTION'} ) && $files{'MOTION'}->{'value'} )
+                || ( exists( $files{'MOTION_STAIRS'} )
+                    && $files{'MOTION_STAIRS'}->{'value'} )
+                || ( exists( $files{'MOTION_GARAGE'} )
+                    && $files{'MOTION_GARAGE'}->{'value'} )
+              )
+            {
+                $alarm = 1;
+            }
+        }
+        if ( !exists( $files{'ALARM'}->{'value'} )
+            || $files{'ALARM'}->{'value'} != $alarm )
+        {
+            $files{'ALARM'}->{'update'} = $now;
+        }
+        $files{'ALARM'}->{'value'} = $alarm;
+    }
+
+# Clear EXISTS_ON files when the main state is "OFF" (and before we append their status)
+    foreach my $file ( values(%files) ) {
+        if (   $file->{'attr'}->{'clear_off'}
+            && $file->{'value'}
+            && $state eq 'OFF' )
+        {
+            unlink( $file->{'path'} );
+            $file->{'status'} = 0;
+            if ($DEBUG) {
+                print STDERR 'Clearing exists flag for: '
+                  . $file->{'name'} . "\n";
+            }
+        }
+    }
+
+# Clear EXISTS_TIMEOUT files when the main state is "OFF" and EXISTS_TIMEOUT has expired (and before we append their status)
+    foreach my $file ( values(%files) ) {
+        if (   $file->{'attr'}->{'clear_timeout'}
+            && $file->{'value'}
+            && $state eq 'OFF'
+            && $timeSinceUpdate > $EXISTS_TIMEOUT )
+        {
+
+            unlink( $file->{'path'} );
+            $file->{'status'} = 0;
+            if ($DEBUG) {
+                print STDERR 'Clearing exists flag for: '
+                  . $file->{'name'} . "\n";
+            }
+        }
+    }
+
+    # Calculate the new status
+    $statusLast = $status;
+    {
+        my @statTime = ();
+        foreach my $file ( values(%files) ) {
+            my $text = $file->{'value'};
+            $text =~ s/\s/ /g;
+            $text =~ s/\|/-/g;
+            push( @statTime,
+                $file->{'name'} . '|' . $text . '|' . $file->{'update'} );
+        }
+        $status = "\n" . join( "\n", @statTime );
+    }
+
+    # Clear EXISTS_CLEAR files immediately (but after we append their status)
+    foreach my $file ( values(%files) ) {
+        if ( $file->{'attr'}->{'clear'} && $file->{'value'} ) {
+            unlink( $file->{'path'} );
+            $file->{'status'} = 0;
+            if ($DEBUG) {
+                print STDERR 'Clearing exists flag for: '
+                  . $file->{'name'} . "\n";
+            }
+        }
+    }
+
+    # Update on a periodic basis, so we don't timeout
+    if ( !$update && $now - $pushLast > $PUSH_TIMEOUT ) {
+        if ($DEBUG) {
+            print STDERR "Periodic update\n";
+        }
+        $update = 1;
+    }
+
+    # Update on a master state change
+    if ( !$update && $stateLast ne $state ) {
+        if ($DEBUG) {
+            print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+        }
+        $update = 1;
+    }
+
+    # Update on any mtime change
+    if ( !$update ) {
+        foreach my $file ( values(%files) ) {
+            if ( $file->{'update'} > $pushLast ) {
+                if ($DEBUG) {
+                    print STDERR 'Mtime change: '
+                      . $file->{'name'} . ' ('
+                      . $file->{'update'} . ")\n";
+                }
+                $update = 1;
+                last;
+            }
+        }
+    }
+
+    # Update on any value change
+    if ( !$update ) {
+        foreach my $file ( values(%files) ) {
+            if ( $file->{'value'} ne $file->{'last'} ) {
+                if ($DEBUG) {
+                    print STDERR 'Value change: '
+                      . $file->{'name'} . ' ('
+                      . $file->{'last'} . ' => '
+                      . $file->{'value'} . ")\n";
+                }
+                $update = 1;
+                last;
+            }
+        }
+    }
+
+    # Push the update
+    if ($update) {
+
+        # Note the push
+        $pushLast = $now;
+
+        # Send notifications to all subscribers
+        my $msg = $state . $status;
+        if ($DEBUG) {
+            print STDERR '<STATE ('
+              . length($msg) . ")>\n"
+              . $msg
+              . "\n<END STATE>\n\n";
+        }
+        foreach my $sub (@subscribers) {
+
+            # Drop subscribers that are not available
+            if ( !eval { $sub->{'socket'}->send( $state . $status ) } ) {
+                print STDERR 'Dropping bad socket from subscriber list: '
+                  . $sub->{'path'} . "\n";
+
+                my @new_subscribers = ();
+                foreach my $new (@subscribers) {
+                    if ( $new->{'socket'} eq $sub->{'socket'} ) {
+                        next;
+                    }
+                    push( @new_subscribers, $new );
+                }
+                @subscribers = @new_subscribers;
+            }
+        }
+
+        # Save the state and value to disk
+        my ( $fh, $tmp ) =
+          tempfile( $DATA_DIR . 'STATE.XXXXXXXX', 'UNLINK' => 0 );
+        print $fh $state . "\n";
+        close($fh);
+        rename( $tmp, $DATA_DIR . 'STATE' );
+    }
 }
 
 sub mtime($) {
-	my ($file) = @_;
-	my $mtime = 0;
-	if (-r $file) {
-		(undef(), undef(), undef(), undef(), undef(), undef(), undef(), undef(), undef(), $mtime, undef(), undef(), undef()) = stat($file);
-	}
-	return $mtime;
+    my ($file) = @_;
+    my $mtime = 0;
+    if ( -r $file ) {
+        (
+            undef(), undef(), undef(), undef(), undef(), undef(), undef(),
+            undef(), undef(), $mtime,  undef(), undef(), undef()
+        ) = stat($file);
+    }
+    return $mtime;
 }

+ 249 - 0
dmx/tvPower.pl

@@ -0,0 +1,249 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# Config
+my $TIMEOUT     = 900;
+my $COUNTDOWN   = 119;
+my $OFF_DELAY   = 15;
+my $CMD_DELAY   = 10;
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'TV_POWER';
+my $PROJ_SOCK    = 'TV';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $DELAY        = $PULL_TIMEOUT / 2;
+
+# Prototypes
+sub sayShutdown($);
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+my $proj = DMX::clientSock($PROJ_SOCK);
+
+# State
+my $state        = 'OFF';
+my $stateLast    = $state;
+my %exists       = ();
+my %mtime        = ();
+my $pushLast     = 0;
+my $pullLast     = time();
+my $update       = 0;
+my $lastAnnounce = 0;
+my $lastUser     = time();
+my $shutdown     = 0;
+my $life         = 0;
+my $lastPlay     = 0;
+
+# Loop forever
+while (1) {
+
+	# State is calculated; use newState to gather data
+	my $newState = $state;
+
+	# Wait for state updates
+	my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+	# Avoid repeated calls to time()
+	my $now = time();
+
+	# Record only valid states
+	if (defined($cmdState)) {
+		$newState = $cmdState;
+		$pullLast = $now;
+	}
+
+	# Die if we don't see regular updates
+	if ($now - $pullLast > $PULL_TIMEOUT) {
+		die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
+	}
+
+	# Menu changes count as activity
+	if ($mtime{'GUI'} && $lastUser < $mtime{'GUI'}) {
+		$lastUser = $mtime{'GUI'};
+	}
+
+	# TV starts count as activity
+	if ($exists{'TV'} && $lastUser < $mtime{'TV'}) {
+		$lastUser = $mtime{'TV'};
+	} elsif ($newState eq 'ON') {
+		$lastUser = $now;
+	}
+
+	# Playing counts as activity
+	if ($exists{'PLAYING'} && $lastUser < $now) {
+		$lastUser = $now;
+	}
+
+	# Clear the shutdown timestamp if there is new user activity
+	if ($shutdown && (!$exists{'TV'} || $lastUser > $shutdown)) {
+		$shutdown = 0;
+	}
+
+	# Record the shutdown timestamp, if the TV is on
+	if ($newState eq 'SHUTDOWN' && $exists{'TV'}) {
+		if ($DEBUG) {
+			print STDERR "Recorded shutdown timestamp\n";
+		}
+		$shutdown = $now;
+		$lastUser = $now;
+	}
+
+	# Calculate the elapsed time
+	my $elapsed = $now - $lastUser;
+	if ($DEBUG) {
+		print STDERR 'Time since last user action: ' . $elapsed . "\n";
+	}
+
+	# Calculate the new state
+	$stateLast = $state;
+	if ($exists{'TV'}) {
+		if ($shutdown) {
+			$state = 'SHUTDOWN';
+			if ($elapsed > $OFF_DELAY) {
+				$state = 'OFF';
+			}
+		} else {
+			if ($elapsed > $TIMEOUT) {
+				$state = 'COUNTDOWN';
+				if ($elapsed > $TIMEOUT + $COUNTDOWN) {
+					$state = 'OFF';
+				}
+			} else {
+				$state = 'ON';
+			}
+		}
+	} else {
+		if ($newState eq 'ON') {
+			$state = 'ON';
+		}
+	}
+
+	# Force updates on a periodic basis
+	if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+
+		# Not for the TV
+		#if ($DEBUG) {
+		#	print STDERR "Forcing periodic update\n";
+		#}
+		#$update = 1;
+	}
+
+	# Force updates on any state change
+	if (!$update && $stateLast ne $state) {
+		if ($DEBUG) {
+			print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+		}
+		$update = 1;
+	}
+
+	# Force updates when there is a physical state mistmatch
+	if (!$update && $state eq 'OFF' && $exists{'TV'}) {
+		if ($DEBUG) {
+			print STDERR 'Physical state mismatch: ' . $state . ':' . $exists{'TV'} . "\n";
+		}
+		$update = 1;
+	}
+
+	# Announce a pending shutdown
+	if ($state eq 'COUNTDOWN' || $state eq 'SHUTDOWN') {
+		my $timeLeft = 0;
+		if ($state eq 'SHUTDOWN') {
+			$timeLeft = $OFF_DELAY - $elapsed;
+		} else {
+			$timeLeft = ($TIMEOUT + $COUNTDOWN) - $elapsed;
+		}
+		sayShutdown($timeLeft / 60);
+	}
+
+	# Only allow updates to "ON" or "OFF" -- the TV knows no other states
+	if ($update && ($state ne 'ON' && $state ne 'OFF')) {
+		if ($DEBUG) {
+			print STDERR 'Ignoring update state other than ON/OFF: ' . $state . "\n";
+		}
+		$update = 0;
+	}
+
+	# Only allow updates every few seconds -- the TV goes dumb during power state changes
+	if ($update && $now < $pushLast + $CMD_DELAY) {
+		if ($DEBUG) {
+			print STDERR 'Ignoring overrate update: ' . $state . "\n";
+		}
+		$update = 0;
+	}
+
+	# Update the TV
+	if ($update) {
+
+		# Extra debugging to record pushes
+		if ($DEBUG) {
+			print STDERR 'State: ' . $state . "\n";
+		}
+
+		# Send master power state
+		$proj->send($state)
+		  or die('Unable to write command to proj socket: ' . $state . ": ${!}\n");
+
+		# Annouce the state change, after the fact
+		DMX::say('TV ' . $state);
+
+		# No output file
+
+		# Update the push time
+		$pushLast = $now;
+
+		# Clear the lastAnnounce timer
+		$lastAnnounce = 0;
+
+		# Clear the update flag
+		$update = 0;
+	}
+}
+
+sub sayShutdown($) {
+	my ($minutesLeft) = @_;
+
+	# Only allow annoucements once per minute
+	my $now = time();
+	if ($now < $lastAnnounce + 60) {
+		return;
+	}
+	$lastAnnounce = $now;
+
+	# Determine the unit
+	my $unit     = 'minute';
+	my $timeLeft = $minutesLeft;
+	if ($minutesLeft < 1) {
+		$timeLeft = $minutesLeft * 60;
+		$unit     = 'second';
+	}
+
+	# Avoid saying "0" unless we *really* mean it
+	$timeLeft = ceil($timeLeft);
+
+	# Add an "s" as needed
+	my $plural = 's';
+	if ($timeLeft == 1) {
+		$plural = '';
+	}
+	$unit .= $plural;
+
+	# Speak
+	DMX::say('TV shutdown in about ' . $timeLeft . ' ' . $unit);
+}

+ 115 - 0
dmx/vr.pl

@@ -0,0 +1,115 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Local modules
+use Cwd qw(abs_path);
+use File::Basename qw(dirname);
+use lib dirname(abs_path($0));
+use DMX;
+
+# User config
+my %DIM = (
+    'OFF' => [
+        { 'channel' => 12, 'value' => 0, 'time' => 0 },
+        { 'channel' => 25, 'value' => 0, 'time' => 0 },
+        { 'channel' => 66, 'value' => 0, 'time' => 0 },
+    ],
+    'ON' => [
+        { 'channel' => 12, 'value' => 255, 'time' => 0 },
+        { 'channel' => 25, 'value' => 255, 'time' => 0 },
+        { 'channel' => 66, 'value' => 255, 'time' => 0 },
+    ],
+);
+
+# App config
+my $DATA_DIR     = DMX::dataDir();
+my $STATE_SOCK   = 'VR';
+my $OUTPUT_FILE  = $DATA_DIR . $STATE_SOCK;
+my $PUSH_TIMEOUT = 20;
+my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
+my $DELAY        = $PULL_TIMEOUT / 2;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+    $DEBUG = 1;
+}
+
+# State
+my $state       = 'OFF';
+my $stateLast   = $state;
+my $masterState = 'OFF';
+my %exists      = ();
+my %mtime       = ();
+my $pushLast    = 0;
+my $pullLast    = time();
+my $update      = 0;
+
+# Always force the heater into OFF at launch
+$state = 'OFF';
+DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+# Sockets
+DMX::stateSocket($STATE_SOCK);
+DMX::stateSubscribe($STATE_SOCK);
+
+# Loop forever
+while (1) {
+
+    # Wait for state updates
+    my $cmdState = DMX::readState($DELAY, \%exists, \%mtime, undef());
+
+    # Avoid repeated calls to time()
+    my $now = time();
+
+    # Record only valid states
+    if (defined($cmdState)) {
+        $masterState = $cmdState;
+        $pullLast    = $now;
+    }
+
+    # Die if we don't see regular updates
+    if ($now - $pullLast > $PULL_TIMEOUT) {
+        die(    'No update on state socket in past '
+              . $PULL_TIMEOUT
+              . " seconds. Exiting...\n");
+    }
+
+    # Calculate the new state
+    $stateLast = $state;
+    if ($exists{'GAME'}) {
+        $state = 'ON';
+    } else {
+        $state = 'OFF';
+    }
+
+    # Force updates on a periodic basis
+    if (!$update && $now - $pushLast > $PUSH_TIMEOUT) {
+        if ($DEBUG) {
+            print STDERR "Forcing periodic update\n";
+        }
+        $update = 1;
+    }
+
+    # Force updates on any state change
+    if (!$update && $stateLast ne $state) {
+        if ($DEBUG) {
+            print STDERR 'State change: ' . $stateLast . ' => ' . $state . "\n";
+        }
+        $update = 1;
+    }
+
+    # Update the DMX chain
+    if ($update) {
+
+        # Update
+        DMX::applyDataset($DIM{$state}, $state, $OUTPUT_FILE);
+
+        # Update the push time
+        $pushLast = $now;
+
+        # Clear the update flag
+        $update = 0;
+    }
+}

+ 20 - 0
motion/launchd/com.uberzach.motion.garage.plist

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>UserName</key>
+	<string>tv</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.motion.garage</string>
+	<key>KeepAlive</key>
+	<true/>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/motion/USBMicroX</string>
+		<string>150910134303</string>
+		<string>MOTION_GARAGE</string>
+	</array>
+</dict>
+</plist>

+ 1 - 9
motion/launchd/com.uberzach.motion.projector.plist

@@ -4,20 +4,12 @@
 <dict>
 	<key>UserName</key>
 	<string>tv</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>loki.uberzach.com</string>
-		<string>Loki.local</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
 	<string>com.uberzach.motion.projector</string>
 	<key>KeepAlive</key>
-	<dict>
-		<key>NetworkState</key>
-		<true/>
-	</dict>
+	<true/>
 	<key>ProgramArguments</key>
 	<array>
 		<string>/Users/tv/bin/video/motion/USBMicroX</string>

+ 1 - 9
motion/launchd/com.uberzach.motion.stairs.plist

@@ -4,20 +4,12 @@
 <dict>
 	<key>UserName</key>
 	<string>tv</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>loki.uberzach.com</string>
-		<string>Loki.local</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
 	<string>com.uberzach.motion.stairs</string>
 	<key>KeepAlive</key>
-	<dict>
-		<key>NetworkState</key>
-		<true/>
-	</dict>
+	<true/>
 	<key>ProgramArguments</key>
 	<array>
 		<string>/Users/tv/bin/video/motion/USBMicroX</string>

+ 20 - 0
motion/launchd/com.uberzach.motion.understairs.plist

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>UserName</key>
+	<string>tv</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.motion.understairs</string>
+	<key>KeepAlive</key>
+	<true/>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/motion/USBMicroX</string>
+		<string>140227170205</string>
+		<string>MOTION_STAIRS</string>
+	</array>
+</dict>
+</plist>

+ 20 - 0
motion/launchd/com.uberzach.motion.upstairs.plist

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>UserName</key>
+	<string>tv</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.motion.upstairs</string>
+	<key>KeepAlive</key>
+	<true/>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/motion/USBMicroX</string>
+		<string>140227170301</string>
+		<string>MOTION_STAIRS</string>
+	</array>
+</dict>
+</plist>

+ 33 - 5
pms/PMSCheck.sh

@@ -1,14 +1,31 @@
 #!/bin/bash
 
 # Config
-CURL_TIMEOUT=15
 RESTART_DELAY=600
-PMS_URL="http://localhost:32400/"
 UNWATCHED_SECTION="2"
 UNWATCHED_SLEEP=60
 UNWATCHED_RETRIES=5
 MIN_UNWATCHED_COUNT=10
+MAX_MEM=$(( 14 * 1024 * 1024 )) # GB in kB
 ADMIN_EMAIL="zach@kotlarek.com"
+IFS=''
+CURL_TIMEOUT=15
+CURL_OPTS=(--silent --connect-timeout 5 --max-time $CURL_TIMEOUT)
+
+# Construct URL components from the environment
+if [ -z "${PMS_URL}" ]; then
+	if [ -z "${PMS_HOST}" ]; then
+		PMS_HOST="localhost"
+	fi
+	if [ -z "${PMS_PORT}" ]; then
+		PMS_PORT=32400
+	fi
+	PMS_URL="http://${PMS_HOST}:${PMS_PORT}"
+fi
+if [ -z "${PMS_TOKEN}" ]; then
+	echo "No PMS_TOKEN provided" 1>&2
+fi
+CURL_OPTS+=(-H "X-Plex-Token: ${PMS_TOKEN}")
 
 # Heady allows 0 unwatched
 if hostname | grep -qi heady; then
@@ -35,7 +52,7 @@ while [ $LOOP -ne 0 ]; do
 
 	# Ask Plex for the top-level status page
 	if [ -z "${FAILED}" ]; then
-		PAGE="`curl --silent --max-time "${CURL_TIMEOUT}" "${PMS_URL}"`"
+		PAGE="`curl ${CURL_OPTS[@]} "${PMS_URL}/"`"
 		if [ -z "${PAGE}" ]; then
 			FAILED="HTTP timeout"
 		else
@@ -49,12 +66,12 @@ while [ $LOOP -ne 0 ]; do
 	# Ask Plex for a list of unwatched TV series
 	UNWATCHED_TIMEOUT=$(( $CURL_TIMEOUT ))
 	if [ -z "${FAILED}" ]; then
-		UNWATCHED_URL="${PMS_URL}library/sections/${UNWATCHED_SECTION}/all?unwatchedLeaves=1"
+		UNWATCHED_URL="${PMS_URL}/library/sections/${UNWATCHED_SECTION}/all?type=2&unwatched=1&sort=titleSort:asc&X-Plex-Container-Start=0&X-Plex-Container-Size=10000"
 		TRY=1
 		FAILED="Too few unwatched series"
 		while [ $TRY -le $UNWATCHED_RETRIES ] && [ -n "${FAILED}" ]; do
 			TRY=$(( $TRY + 1 ))
-			PAGE="`curl --silent --max-time "${UNWATCHED_TIMEOUT}" "${UNWATCHED_URL}"`"
+			PAGE="`curl ${CURL_OPTS[@]} --max-time "${UNWATCHED_TIMEOUT}" "${UNWATCHED_URL}"`"
 			COUNT="`echo "${PAGE}" | grep '</Directory>' | wc -l`"
 			if [ $COUNT -ge $MIN_UNWATCHED_COUNT ]; then
 				FAILED=""
@@ -66,6 +83,17 @@ while [ $LOOP -ne 0 ]; do
 		done
 	fi
 
+	# Check Plex's memory usage
+	if [ -z "${FAILED}" ]; then
+		MEM="`ps awx -o vsize,comm | awk '$0 ~ /\/Plex Media Server$/ {print $1}'`"
+		if [ -z "${MEM}" ]; then
+			FAILED="Unable to read memory use"
+		fi
+		if [ $MEM -gt $MAX_MEM ]; then
+			FAILED="Memory use too high: ${MEM}/${MAX_MEM} kB"
+		fi
+	fi
+
 	# If Plex has failed kill it
 	if [ -n "${FAILED}" ]; then
 		ERR_MSG="PMS is non-responsive (${FAILED}). Killing..."

+ 30 - 11
pms/PMSHelper.sh

@@ -1,16 +1,35 @@
 #!/bin/bash
 
-# Always re-trigger a mount (to fix links if the network changes)
-~/bin/video/mountMedia
-
-# Wait for the media share to be mounted
-if ! ~/bin/video/isMediaMounted; then
-	sleep 5
-	exit 1
+# Run interactively if requested
+INTERACTIVE=0
+if basename "${0}" | grep -iq interactive; then
+	INTERACTIVE=1
 fi
 
-# Kill previous instances
-~/bin/video/pms/killPMS.sh
+# This looks like it always loops
+# But if we exec at the end bash just goes away and the loop doesn't matter
+while [ 1 ]; do
+
+	# Always re-trigger a mount (to fix links if the network changes)
+	~/bin/video/mountMedia
+
+	# Wait for the media share to be mounted
+	if ! ~/bin/video/isMediaMounted; then
+		sleep 5
+		if [ $INTERACTIVE -gt 0 ]; then
+			continue
+		else
+			exit 1
+		fi
+	fi
+
+	# Kill previous instances
+	~/bin/video/pms/killPMS.sh
 
-# Exec
-exec '/Applications/Zach/Media/Plex Media Server.app/Contents/MacOS/Plex Media Server'
+	# Exec
+	EXEC=""
+	if [ $INTERACTIVE -le 0 ]; then
+		EXEC="exec"
+	fi
+	$EXEC '/Applications/Zach/Media/Plex Media Server.app/Contents/MacOS/Plex Media Server'
+done

+ 4 - 11
pms/com.uberzach.PMS.check.plist

@@ -4,23 +4,16 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Beddy.local</string>
-		<string>beddy.uberzach.com</string>
-		<string>Heady.local</string>
-		<string>heady.uberzach.com</string>
-		<string>Baldwin.local</string>
-		<string>baldwin.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
 	<string>com.uberzach.PMS.check</string>
 	<key>KeepAlive</key>
+	<true/>
+	<key>EnvironmentVariables</key>
 	<dict>
-		<key>NetworkState</key>
-		<true/>
+		<key>PMS_TOKEN</key>
+		<string>foo</string>
 	</dict>
 	<key>ProgramArguments</key>
 	<array>

+ 1 - 0
pms/pms-interactive

@@ -0,0 +1 @@
+PMSHelper.sh

+ 36 - 0
serial/findDevice.sh

@@ -0,0 +1,36 @@
+#!/bin/bash
+
+# CLI Parameters
+USB_LOCATION="${1}"
+if [ -z "${USB_LOCATION}" ]; then
+	echo "Usage: ${0} USB_LOCATION" 1>&2
+	exit 1
+fi
+
+# Find the serial adapter
+TTY_ID="`ioreg -c IOSerialBSDClient | \
+	grep -C 6 "USB-Serial Controller@${USB_LOCATION}" | \
+	grep IOSerialBSDClient | \
+	cut -d '<' -f 2 | \
+	awk '$1 == "class" {print $4}' | \
+	cut -d 'x' -f 2 | \
+	cut -d ',' -f 1`"
+if [ -z "${TTY_ID}" ]; then
+	echo "Unable to find TTY_ID for USB_LOCATION: ${USB_LOCATION}" 1>&2
+	exit 2
+fi
+
+# Find the related device file
+DEV="`ioreg -c IOSerialBSDClient | \
+	grep -C 12 "${TTY_ID}" | \
+	grep 'IODialinDevice' | \
+	cut -d '=' -f 2 | \
+	awk -F '"' '{print $2}'`"
+if [ -z "${DEV}" ]; then
+	echo "Unable to find DEV for TTY_ID: ${TTY_ID}" 1>&2
+	exit 2
+fi
+
+# Display and exit
+echo "${DEV}"
+exit 0

+ 1 - 0
serial/hdmi.pl

@@ -0,0 +1 @@
+serial.pl

+ 1 - 7
serial/launchd/com.uberzach.amplifier.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
@@ -24,7 +19,6 @@
 	<key>ProgramArguments</key>
 	<array>
 		<string>/Users/tv/bin/video/serial/amplifier.pl</string>
-		<string>5</string>
 	</array>
 	<!--
 		<key>EnvironmentVariables</key>
@@ -33,7 +27,7 @@
 			<string>1</string>
 		</dict>
 		<key>StandardErrorPath</key>
-		<string>/Users/tv/com.uberzach.amplifier_errlog.txt</string>
+		<string>/Users/tv/Library/Logs/com.uberzach.amplifier.log</string>
 	-->
 </dict>
 </plist>

+ 33 - 0
serial/launchd/com.uberzach.hdmi.plist

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.hdmi</string>
+	<key>KeepAlive</key>
+	<dict>
+		<key>PathState</key>
+		<dict>
+			<key>/dev/tty.usbserial-AL0096TO</key>
+			<true/>
+		</dict>
+	</dict>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/serial/hdmi.pl</string>
+	</array>
+	<!--
+		<key>EnvironmentVariables</key>
+		<dict>
+			<key>DEBUG</key>
+			<string>1</string>
+		</dict>
+		<key>StandardErrorPath</key>
+		<string>/Users/tv/Library/Logs/com.uberzach.hdmi.log</string>
+	-->
+</dict>
+</plist>

+ 10 - 6
serial/launchd/com.uberzach.projector.plist

@@ -4,11 +4,6 @@
 <dict>
 	<key>LimitLoadToSessionType</key>
 	<string>Aqua</string>
-	<key>LimitLoadToHosts</key>
-	<array>
-		<string>Loki.local</string>
-		<string>loki.uberzach.com</string>
-	</array>
 	<key>RunAtLoad</key>
 	<true/>
 	<key>Label</key>
@@ -18,7 +13,16 @@
 	<key>ProgramArguments</key>
 	<array>
 		<string>/Users/tv/bin/video/serial/projector.pl</string>
-		<string>5</string>
 	</array>
+	<!--
+		<key>EnvironmentVariables</key>
+		<dict>
+			<key>DEBUG</key>
+			<string>1</string>
+		</dict>
+	-->
+		<key>StandardErrorPath</key>
+		<string>/Users/tv/Library/Logs/com.uberzach.projector.log</string>
+	<!-- -->
 </dict>
 </plist>

+ 33 - 0
serial/launchd/com.uberzach.tv.plist

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>LimitLoadToHosts</key>
+	<array>
+		<string>Heady.local</string>
+		<string>heady.uberzach.com</string>
+	</array>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.tv</string>
+	<key>KeepAlive</key>
+	<true/>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/serial/tv.pl</string>
+		<string>2</string>
+	</array>
+	<!--
+		<key>EnvironmentVariables</key>
+		<dict>
+			<key>DEBUG</key>
+			<string>1</string>
+		</dict>
+		<key>StandardErrorPath</key>
+		<string>/Users/tv/Library/Logs/com.uberzach.tv.log</string>
+	-->
+</dict>
+</plist>

+ 184 - 63
serial/serial.pl

@@ -21,7 +21,7 @@ sub clearBuffer($);
 sub collectUntil($$);
 
 # Device parameters
-my ($DEV, $PORT, $BLUETOOTH, $CRLF, $DELIMITER, %CMDS, %STATUS_CMDS);
+my ($DEV, $PORT, $BLUETOOTH, $BAUD, $DBITS, $SBITS, $PARITY, $CRLF, $DELIMITER, %CMDS, %STATUS_CMDS);
 if (basename($0) =~ /PROJECTOR/i) {
 	$DEV       = 'Projector';
 	$PORT      = '/dev/tty.usbserial-A5006xbj';
@@ -55,7 +55,6 @@ if (basename($0) =~ /PROJECTOR/i) {
 	);
 } elsif (basename($0) =~ /AMPLIFIER/i) {
 	$DEV       = 'Amplifier';
-	$PORT      = '/dev/tty.usbserial-AM01C96Q';
 	$BLUETOOTH = 0;
 	$CRLF      = "\r";
 	$DELIMITER = "\r";
@@ -70,12 +69,11 @@ if (basename($0) =~ /PROJECTOR/i) {
 		'MUTE'         => 'MUON',
 		'UNMUTE'       => 'MUOFF',
 		'INPUT'        => 'SI?',
-		'TV'           => 'SITV',
-		'DVD'          => 'SIDVD',
+		'PLEX'         => 'SITV',
+		'GAME'         => 'SIDVD',
 		'MODE'         => 'MS?',
 		'SURROUND'     => 'MSDOLBY DIGITAL',
 		'STEREO'       => 'MS7CH STEREO',
-		'TV'           => 'SITV',
 		'INPUT'        => 'SI?',
 		'INPUT_MODE'   => 'SD?',
 		'INPUT_AUTO'   => 'SDAUTO',
@@ -83,37 +81,131 @@ if (basename($0) =~ /PROJECTOR/i) {
 		'INPUT_EXT'    => 'SDEXT.IN-1',
 
 	);
+
+	$PORT = `$ENV{'HOME'}/bin/video/serial/findDevice.sh 1d111200`;
+	$PORT =~ s/\s*$//;
+	if (!($PORT =~ /^\/dev\/tty\.usbserial/)) {
+		die("Unable to find serial device by USB location\n");
+	}
+
 	%STATUS_CMDS = (
-		'STATUS' => { 'MATCH'   => [ qr/^PW/, qr/$CMDS{'ON'}/ ] },
-		'MODE'   => { 'EVAL'    => [ qr/^MS/, 'if ($a =~ /STEREO/i) { $a = "STEREO" } elsif ($a =~ /MS(?:DOLBY|DTS)/i) { $a = "SURROUND" }' ] },
-		'VOL'    => { 'EVAL'    => [ qr/^MV/, '$a =~ s/^MV//; if (length($a) > 2) { $a =~ s/(\d\d)(\d)/$1.$2/ }' ] },
-		'INPUT'  => { 'REPLACE' => qr/^SI(.*)/ },
+		'STATUS' => { 'MATCH' => [ qr/^PW/, qr/$CMDS{'ON'}/ ] },
+		'MODE'   => { 'EVAL'  => [ qr/^MS/, 'if ($a =~ /STEREO/i) { $a = "STEREO" } elsif ($a =~ /MS(?:DOLBY|DTS)/i) { $a = "SURROUND" }' ] },
+		'VOL'    => { 'EVAL'  => [ qr/^MV/, '$a =~ s/^MV//; if (length($a) > 2) { $a =~ s/(\d\d)(\d)/$1.$2/ }' ] },
+		'INPUT'  => { 'EVAL'  => [ qr/^SI/, 'my $done = 0; foreach my $cmd (keys(%CMDS)) { if ($a eq $CMDS{$cmd}) { $a = $cmd; $done = 1; last; } } if (!$done) { $a =~ s/^SI/UNKNOWN-/; }' ] },
 	);
 } elsif (basename($0) =~ /TV/i) {
 	$DEV       = 'TV';
-	$PORT      = '/dev/tty.' . $DEV . '-DevB';
-	$BLUETOOTH = 1;
+	$PORT      = '/dev/tty.usbserial-A501JRBK';
+	$BLUETOOTH = 0;
+	$CRLF      = "\r";
+	$DELIMITER = "\r";
+	%CMDS      = (
+		'INIT'   => 'RSPW1   ',
+		'ON'     => 'POWR1   ',
+		'OFF'    => 'POWR0   ',
+		'STATUS' => 'POWR?   ',
+		'MUTE'   => 'MUTE1   ',
+		'UNMUTE' => 'MUTE2   ',
+		'INPUT'  => 'IAVD?   ',
+		'TV'     => 'ITVD0   ',
+		'PLEX'   => 'IAVD7   ',
+		'VOL'    => 'VOLM?   ',
+		'CH+'    => 'CHUP1   ',
+		'CH-'    => 'CHDW1   ',
+		'CC'     => 'CLCP1   ',
+		'VOL0'   => 'VOLM0   ',
+		'VOL1'   => 'VOLM1   ',
+		'VOL2'   => 'VOLM2   ',
+		'VOL3'   => 'VOLM3   ',
+		'VOL4'   => 'VOLM4   ',
+		'VOL5'   => 'VOLM5   ',
+		'VOL6'   => 'VOLM6   ',
+		'VOL7'   => 'VOLM7   ',
+		'VOL8'   => 'VOLM8   ',
+		'VOL9'   => 'VOLM9   ',
+		'VOL10'  => 'VOLM10  ',
+		'VOL11'  => 'VOLM11  ',
+		'VOL12'  => 'VOLM12  ',
+		'VOL13'  => 'VOLM13  ',
+		'VOL14'  => 'VOLM14  ',
+		'VOL15'  => 'VOLM15  ',
+		'VOL16'  => 'VOLM16  ',
+		'VOL17'  => 'VOLM17  ',
+		'VOL18'  => 'VOLM18  ',
+		'VOL19'  => 'VOLM19  ',
+		'VOL20'  => 'VOLM20  ',
+		'VOL21'  => 'VOLM21  ',
+		'VOL22'  => 'VOLM22  ',
+		'VOL23'  => 'VOLM23  ',
+		'VOL24'  => 'VOLM24  ',
+		'VOL25'  => 'VOLM25  ',
+		'VOL26'  => 'VOLM26  ',
+		'VOL27'  => 'VOLM27  ',
+		'VOL28'  => 'VOLM28  ',
+		'VOL29'  => 'VOLM29  ',
+		'VOL30'  => 'VOLM30  ',
+		'VOL31'  => 'VOLM31  ',
+		'VOL32'  => 'VOLM32  ',
+		'VOL33'  => 'VOLM33  ',
+		'VOL34'  => 'VOLM34  ',
+		'VOL35'  => 'VOLM35  ',
+		'VOL36'  => 'VOLM36  ',
+		'VOL37'  => 'VOLM37  ',
+		'VOL38'  => 'VOLM38  ',
+		'VOL39'  => 'VOLM39  ',
+		'VOL40'  => 'VOLM40  ',
+		'VOL41'  => 'VOLM41  ',
+		'VOL42'  => 'VOLM42  ',
+		'VOL43'  => 'VOLM43  ',
+		'VOL44'  => 'VOLM44  ',
+		'VOL45'  => 'VOLM45  ',
+		'VOL46'  => 'VOLM46  ',
+		'VOL47'  => 'VOLM47  ',
+		'VOL48'  => 'VOLM48  ',
+		'VOL49'  => 'VOLM49  ',
+		'VOL50'  => 'VOLM50  ',
+		'VOL51'  => 'VOLM51  ',
+		'VOL52'  => 'VOLM52  ',
+		'VOL53'  => 'VOLM53  ',
+		'VOL54'  => 'VOLM54  ',
+		'VOL55'  => 'VOLM55  ',
+		'VOL56'  => 'VOLM56  ',
+		'VOL57'  => 'VOLM57  ',
+		'VOL58'  => 'VOLM58  ',
+		'VOL59'  => 'VOLM59  ',
+		'VOL60'  => 'VOLM60  ',
+	);
+	%STATUS_CMDS = (
+		'STATUS' => { 'MATCH' => [ qr/\d/,       qr/1/ ] },
+		'VOL'    => { 'EVAL'  => [ qr/\d/,       '' ] },
+		'INPUT'  => { 'EVAL'  => [ qr/(\d|ERR)/, 'if ($a =~ /7/i) { $a = "PLEX" } elsif ($a =~ /\d/i) { $a = "OTHER" } else { $a = "TV" }' ] },
+	);
+} elsif (basename($0) =~ /HDMI/i) {
+	$DEV       = 'HDMI';
+	$BLUETOOTH = 0;
+	$BAUD      = 19200;
 	$CRLF      = "\r";
 	$DELIMITER = "\r";
 	%CMDS      = (
-		'INIT'      => 'RSPW1',
-		'ON'        => 'POWR1',
-		'OFF'       => 'POWR0',
-		'STATUS'    => 'POWR?',
-		'VOL+'      => 'MVUP',
-		'VOL-'      => 'MVDOWN',
-		'MUTE'      => 'MUTE1',
-		'UNMUTE'    => 'MUTE2',
-		'TV'        => 'IAVD0',
-		'PLEX'      => 'IAVD7',
-		'VOL_CHECK' => 'VOLM?',
-		'VOL6'      => 'VOLM6',
-		'VOL12'     => 'VOLM12',
-		'VOL24'     => 'VOLM24',
-		'VOL+'      => 'VOLM',
-		'VOL-'      => 'VOLM'
+		'INIT'    => '',
+		'PLEX'    => 'sw i01',
+		'GAME'    => 'sw i02',
+		'IN_3'    => 'sw i03',
+		'IN_4'    => 'sw i04',
+		'IN_NEXT' => 'sw +',
+		'IN_PREV' => 'sw -',
+		'POD_ON'  => 'pod on',
+		'POD_OFF' => 'pod off',
 	);
-	%STATUS_CMDS = ('STATUS' => { 'EQUAL' => '1' },);
+
+	$PORT = `$ENV{'HOME'}/bin/video/serial/findDevice.sh 1d111300`;
+	$PORT =~ s/\s*$//;
+	if (!($PORT =~ /^\/dev\/tty\.usbserial/)) {
+		die("Unable to find serial device by USB location\n");
+	}
+
+	%STATUS_CMDS = ();
 } else {
 	die("No device specified\n");
 }
@@ -122,7 +214,6 @@ if (basename($0) =~ /PROJECTOR/i) {
 my $DATA_DIR        = DMX::dataDir();
 my $CMD_FILE        = uc($DEV);
 my $BT_CHECK        = $ENV{'HOME'} . '/bin/btcheck';
-my $DELAY_STATUS    = 1;
 my $BYTE_TIMEOUT    = 50;
 my $SILENCE_TIMEOUT = $BYTE_TIMEOUT * 10;
 
@@ -136,7 +227,7 @@ if ($ENV{'DEBUG'}) {
 # Command-line arguments
 my ($DELAY) = @ARGV;
 if (!$DELAY) {
-	$DELAY = 15;
+	$DELAY = 1;
 }
 
 # Sanity check
@@ -150,7 +241,7 @@ if (!-r $PORT) {
 if ($BLUETOOTH) {
 	system($BT_CHECK, $DEV);
 	if ($? != 0) {
-		sleep($DELAY_STATUS);
+		sleep($DELAY);
 		die('Bluetooth device "' . $DEV . "\" not available\n");
 	}
 }
@@ -161,6 +252,18 @@ my $select = DMX::selectSock($CMD_FILE);
 # Port init
 my $port = new Device::SerialPort($PORT)
   or die('Unable to open serial connection: ' . $PORT . ": ${!}\n");
+if ($BAUD) {
+	$port->baudrate($BAUD);
+}
+if ($DBITS) {
+	$port->databits($DBITS);
+}
+if ($SBITS) {
+	$port->stopbits($SBITS);
+}
+if ($PARITY) {
+	$port->parity($PARITY);
+}
 $port->read_const_time($BYTE_TIMEOUT);
 
 # Init (clear any previous state)
@@ -170,11 +273,14 @@ sendQuery($port, $CMDS{'INIT'});
 my $lastStatus = 0;
 my %STATUS     = ();
 foreach my $cmd (keys(%STATUS_CMDS)) {
-	my %tmp = ('status' => 0, 'last' => 0);
+	my %tmp = ('status' => 0, 'last' => '');
 	$tmp{'path'} = $DATA_DIR . uc($DEV);
 	if ($cmd ne 'STATUS') {
 		$tmp{'path'} .= '_' . uc($cmd);
 	}
+	if (-r $tmp{'path'}) {
+		unlink($tmp{'path'});
+	}
 
 	$STATUS{$cmd} = \%tmp;
 }
@@ -183,8 +289,8 @@ foreach my $cmd (keys(%STATUS_CMDS)) {
 while (1) {
 
 	# Calculate our next timeout
-	# Hold on select() but not more than $DELAY_STATUS after our last update
-	my $timeout = ($lastStatus + $DELAY_STATUS) - time();
+	# Hold on select() but not more than $DELAY after our last update
+	my $timeout = ($lastStatus + $DELAY) - time();
 	if ($timeout < 0) {
 		$timeout = 0;
 	}
@@ -232,7 +338,7 @@ while (1) {
 	}
 
 	# Read periodic data, but not too frequently
-	if (time() > $lastStatus + $DELAY_STATUS) {
+	if (time() > $lastStatus + $DELAY) {
 
 		# Record the last status update time
 		$lastStatus = time();
@@ -251,30 +357,35 @@ while (1) {
 			}
 			my $result = sendQuery($port, $CMDS{$cmd});
 
-			# Process the result as requested
-			if ($result) {
-				if ($scmd->{'MATCH'}) {
-					if ($result =~ $scmd->{'MATCH'}[0]) {
-						if ($result =~ $scmd->{'MATCH'}[1]) {
-							$STATUS{$cmd}->{'status'} = 1;
-						} else {
-							$STATUS{$cmd}->{'status'} = 0;
-						}
-					}
-				} elsif ($scmd->{'REPLACE'}) {
-					if ($result =~ $scmd->{'REPLACE'}) {
-						$STATUS{$cmd}->{'status'} = $result;
-						$STATUS{$cmd}->{'status'} =~ s/$scmd->{'REPLACE'}/$1/;
-					}
-				} elsif ($scmd->{'EVAL'}) {
-					my $a = $result;
-					if ($result =~ $scmd->{'EVAL'}[0]) {
-						eval($scmd->{'EVAL'}[1]);
-						$STATUS{$cmd}->{'status'} = $a;
+			# On read error wait a short while for the device to recover
+			if (!defined($result) || $result eq '' || !($result =~ /[[:print:]]/)) {
+				print STDERR 'Read error. Delaying ' . (4 * $DELAY) . " seconds\n";
+				sleep(4 * $DELAY);
+				next;
+			}
+
+			# Process the (non-empty) result as requested
+			if ($scmd->{'MATCH'}) {
+				if ($result =~ $scmd->{'MATCH'}[0]) {
+					if ($result =~ $scmd->{'MATCH'}[1]) {
+						$STATUS{$cmd}->{'status'} = 1;
+					} else {
+						$STATUS{$cmd}->{'status'} = 0;
 					}
-				} else {
-					die('Invalid status match type "' . (keys(%{$scmd}))[0] . '" in command: ' . $cmd . "\n");
 				}
+			} elsif ($scmd->{'REPLACE'}) {
+				if ($result =~ $scmd->{'REPLACE'}) {
+					$STATUS{$cmd}->{'status'} = $result;
+					$STATUS{$cmd}->{'status'} =~ s/$scmd->{'REPLACE'}/$1/;
+				}
+			} elsif ($scmd->{'EVAL'}) {
+				my $a = $result;
+				if ($result =~ $scmd->{'EVAL'}[0]) {
+					eval($scmd->{'EVAL'}[1]);
+					$STATUS{$cmd}->{'status'} = $a;
+				}
+			} else {
+				die('Invalid status match type "' . (keys(%{$scmd}))[0] . '" in command: ' . $cmd . "\n");
 			}
 
 			# Ensure the data is clean
@@ -334,15 +445,14 @@ sub clearBuffer($) {
 }
 
 sub collectUntil($$) {
-	my ($port, $char) = @_;
-	if (length($char) != 1) {
-		die('Invalid collection delimiter: ' . $char . "\n");
-	}
+	my ($port, $delim) = @_;
+	my $char = substr($delim, 0, 1);
 
 	# This byte-by-byte reading is not efficient, but it's safe
 	# Allow reading forever as long as we don't exceed the silence timeout
 	my $count  = 0;
 	my $string = '';
+	my $end    = undef();
 	while ($count < $SILENCE_TIMEOUT / $BYTE_TIMEOUT) {
 		my $byte = $port->read(1);
 		if (length($byte)) {
@@ -353,8 +463,19 @@ sub collectUntil($$) {
 				print STDERR "\tRead: " . $byte . "\n";
 			}
 
-			if ($byte eq $char) {
-				last;
+			if (defined($end)) {
+				$end .= $byte;
+				if ($end eq $delim) {
+					last;
+				} elsif ($end ne substr($delim, 0, length($end))) {
+					undef($end);
+				}
+			} elsif ($byte eq $char) {
+				if (length($delim) == 1) {
+					last;
+				} else {
+					$end = $byte;
+				}
 			}
 		} else {
 			$count++;

+ 1 - 0
serial/tv.pl

@@ -0,0 +1 @@
+projector.pl

+ 6 - 1
speak/com.uberzach.speak.plist

@@ -6,9 +6,12 @@
 	<string>Aqua</string>
 	<key>LimitLoadToHosts</key>
 	<array>
-		<string>beddy.uberzach.com</string>
+		<string>Loki.local</string>
 		<string>loki.uberzach.com</string>
+		<string>Heady.local</string>
 		<string>heady.uberzach.com</string>
+		<string>Beddy.local</string>
+		<string>beddy.uberzach.com</string>
 	</array>
 	<key>RunAtLoad</key>
 	<true/>
@@ -19,6 +22,8 @@
 	<key>ProgramArguments</key>
 	<array>
 		<string>/Users/tv/bin/video/speak/speak</string>
+		<string></string>
+		<string>0.50</string>
 	</array>
 </dict>
 </plist>

+ 17 - 1
speak/speak.m

@@ -89,7 +89,8 @@ int main(int argc, const char *argv[]) {
 	NSString *str;
 	if (argc > 1) {
 		str = [NSString stringWithUTF8String: argv[1]];
-	} else {
+	}
+	if ([str length] < 1) {
 		NSString *tmpDir = NSTemporaryDirectory();
 		str = [NSString stringWithFormat:@"%@plexMonitor/SPEAK.socket", tmpDir];
 	}
@@ -97,6 +98,21 @@ int main(int argc, const char *argv[]) {
 	// Init the speech engine
 	synth = [[NSSpeechSynthesizer alloc] init];
 	[synth setDelegate: [SpeechDelegate new]];
+
+	// Allow changes to the overall speech volume
+	if (argc > 2) {
+		NSString *val = [NSString stringWithUTF8String: argv[2]];
+		NSNumberFormatter * f = [[NSNumberFormatter alloc] init];
+		[f setNumberStyle:NSNumberFormatterDecimalStyle];
+
+		NSNumber *volume = [f numberFromString: val];
+		if ([volume floatValue] == 0.25 || [volume floatValue] == 0.5 || \
+			[volume floatValue] == 0.75 || [volume floatValue] == 1.0) {
+				[synth setObject: volume forProperty: NSSpeechVolumeProperty error: nil];
+		} else {
+			NSLog(@"Invalid volume: %f", [volume floatValue]);
+		}
+	}
 	
 	// Open the socket
 	Socket *sock = [[Socket alloc] init];

+ 6 - 0
wol/wol-game.sh

@@ -0,0 +1,6 @@
+#!/bin/bash
+if [ -e ~/.bashrc ]; then
+	source ~/.bashrc
+fi
+
+exec wol.sh d8:cb:8a:9c:de:56 172.19.1.255

+ 24 - 0
wol/wol.sh

@@ -0,0 +1,24 @@
+#!/bin/bash
+
+# We need the target MAC and broadcast addresses
+ETHER="${1}"
+BCAST="${2}"
+if [ -z "${ETHER}" ] || [ -z "${BCAST}" ]; then
+	echo "Usage: ${0} MAC broadcast" 1>&2
+	exit 1
+fi
+
+# UDP needs a port
+PORT=7
+
+# Construct the packet
+RAW="`echo "${ETHER}" | sed 's/://g'`"
+PACKET="FFFFFFFFFFFF"
+i=0
+while [ $i -lt 16 ]; do
+	PACKET="${PACKET}${RAW}"
+	i=$(( $i + 1 ))
+done
+
+# Send with socat
+echo "${PACKET}" | xxd -r -p | socat - "UDP-DATAGRAM:${BCAST}:${PORT},broadcast"