ADDED   __init__.py
Index: __init__.py
==================================================================
--- /dev/null
+++ __init__.py
@@ -0,0 +1,599 @@
+#! /usr/bin/env python3
+
+import random
+import time
+import math
+
+class remote:
+	_remote_type_alias_map = {
+		'fut089': 'rgb+cct'
+	}
+	_remote_type_parameters_map = {
+		'rgbw': {
+			'channels': [9, 40, 71],
+			'syncword': [0x258B, 0x147A],
+			'features': [
+				'can_set_brightness',
+				'has_brightness',
+				'has_white',
+				'has_color'
+			],
+			'brightness_range': [0, 25],
+			'button_map': {
+				'slider':     0x00,
+				'on':         0x01,
+				'white':      0x11,
+				'off':        0x02,
+				'night':      0x12,
+				'zone_on:1':  0x03,
+				'zone_on:2':  0x05,
+				'zone_on:3':  0x07,
+				'zone_on:4':  0x09,
+				'zone_white:1': 0x13,
+				'zone_white:2': 0x15,
+				'zone_white:3': 0x17,
+				'zone_white:4': 0x19,
+				'zone_off:1': 0x04,
+				'zone_off:2': 0x06,
+				'zone_off:3': 0x08,
+				'zone_off:4': 0x0A,
+				'zone_night:1': 0x14,
+				'zone_night:2': 0x16,
+				'zone_night:3': 0x18,
+				'zone_night:4': 0x1A,
+				'speed_up':   0x0B,
+				'speed_down': 0x0C,
+				'change_color_mode': 0x0D,
+				'zone_set_brightness': 0x0E,
+				'set_color':  0x0F
+			}
+		},
+		'cct': {
+			'channels': [4, 39, 74],
+			'syncword': [0x55AA, 0x050A],
+			'brightness_range': [0, 9],
+			'temperature_output_range': [0, 9],
+			'temperature_input_range':  [3000, 9000],
+			'features': [
+				'has_max_brightness',
+				'has_brightness',
+				'has_temperature',
+				'is_white'
+			],
+			'button_map': {
+				'on':         0x05,
+				'off':        0x09,
+				'max':        0x15,
+				'night':      0x19,
+				'zone_on:1':  0x08,
+				'zone_on:2':  0x0D,
+				'zone_on:3':  0x07,
+				'zone_on:4':  0x02,
+				'zone_max:1': 0x18,
+				'zone_max:2': 0x1D,
+				'zone_max:3': 0x17,
+				'zone_max:4': 0x12,
+				'zone_off:1': 0x0B,
+				'zone_off:2': 0x03,
+				'zone_off:3': 0x0A,
+				'zone_off:4': 0x06,
+				'zone_night:1': 0x1B,
+				'zone_night:2': 0x13,
+				'zone_night:3': 0x1A,
+				'zone_night:4': 0x16,
+				'brightness_up':    0x0C,
+				'brightness_down':  0x04,
+				'temperature_up':   0x0E,
+				'temperature_down': 0x0F
+			}
+		},
+		'rgb+cct': {
+			'channels': [8, 39, 70],
+			'syncword': [0x1809, 0x7236]
+		},
+		'rgb': {
+			'channels': [3, 38, 73],
+			'syncword': [0xBCCD, 0x9AAB]
+		},
+		'fut020': {
+			'channels': [6, 41, 76],
+			'syncword': [0xAA55, 0x50A0]
+		}
+	}
+
+	def __init__(self, radio, remote_type, remote_id, message_id = None, config = None):
+		if not radio.initialize():
+			raise ValueError('Radio initialization failed')
+
+		# Pull in the config for this remote type
+		self._config = self._get_type_parameters(remote_type)
+
+		# Allow the user to specify some more parameters
+		if config is not None:
+			self._config.update(config)
+
+		# Store parameters
+		self._radio = radio
+		self._type = remote_type
+		self._id = remote_id
+
+		# Initialize the message ID for this remote
+		if message_id is None:
+			self._message_id = random.randint(0, 255)
+		else:
+			self._message_id = message_id
+
+		return None
+
+	def _scale_int(self, input_value, input_range_low, input_range_high, output_range_low, output_range_high):
+		input_range = input_range_high - input_range_low
+		output_range = output_range_high - output_range_low
+
+		input_value = input_value - input_range_low
+
+		output = input_value * (output_range / input_range)
+
+		output = output + output_range_low
+
+		output = int(output + 0.5)
+		return output
+
+	def _debug(self, message):
+		print("MILITE DEBUG: " + message)
+		return None
+
+	def _get_type_parameters(self, remote_type):
+		config = self._remote_type_parameters_map[remote_type]
+
+		# Supply default config values
+		if 'retries' not in config:
+			config['retries'] = 3
+		if 'delay' not in config:
+			config['delay'] = 0.1
+
+		setattr(self, '_compute_button_message', getattr(self, '_compute_button_message_' + remote_type))
+		setattr(self, '_parse_button_message', getattr(self, '_parse_button_message_' + remote_type))
+
+		return config
+
+	def _compute_button_and_zone_from_button_id(self, button_id):
+		button_info = {}
+		button_info['button'] = 'unknown=' + str(button_id)
+		for button_name, button_value in self._config['button_map'].items():
+			if button_value == button_id:
+				button_info['button'] = button_name
+				break
+
+		# If the button name has a zone, split it out into its own parameter
+		if button_info['button'].find(':') != -1:
+			button_name_zone = button_info['button'].split(':')
+			button_info['button'] = button_name_zone[0]
+			button_info['zone']   = int(button_name_zone[1])
+
+		return button_info
+
+	def _compute_button_message_cct(self, button_info):
+		remote_id = button_info['remote_id']
+		message_id = button_info['message_id']
+
+		# Header consists of magic (0x5A), follow by 16-bit remote ID
+		header = [0x5A, (remote_id >> 8) & 0xff, remote_id & 0xff]
+
+		# Determine zone, default to all
+		zone = button_info.get('zone', 0)
+
+		# Some buttons need to be converted to zones
+		button_name = button_info['button']
+		if button_name in ['zone_on', 'zone_off', 'zone_max', 'zone_night']:
+			button_name = button_name + ':' + str(zone)
+
+		# Look up the button
+		button_id = self._config['button_map'][button_name]
+
+		# Compute message body
+		body = [zone, button_id, message_id]
+
+		# Compute message trailer
+		## Include a CRC, for good measure ?
+		crc = 0
+		for byte in header + body:
+			crc = crc + byte
+		crc = crc & 0xff
+		trailer = [crc]
+
+		message = header + body + trailer
+
+		return message
+
+	def _parse_button_message_cct(self, button_message):
+		button_info = {}
+
+		# Verify the header -- if it is not valid, return None
+		if button_message[0] != 0x5A:
+			return None
+
+		# Parse out common parts of the message
+		button_info['remote_id'] = (button_message[1] << 8) | button_message[2]
+		button_info['zone'] = button_message[3]
+		button_info['message_id'] = button_message[5]
+
+		# Remove the all zone
+		if button_info['zone'] == 0:
+			del button_info['zone']
+
+		# Map the button ID to a button name
+		button_id = button_message[4]
+		button_info.update(self._compute_button_and_zone_from_button_id(button_id))
+
+		return button_info
+
+	def _compute_button_message_rgbw(self, button_info):
+		remote_id = button_info['remote_id']
+		message_id = button_info['message_id']
+
+		# Allow setting brightness for all zones
+		if button_info['button'] == 'set_brightness':
+			button_info['button'] = 'zone_set_brightness'
+			if 'zone' in button_info:
+				del button_info['zone']
+
+		# Header consists of magic (0xB0), follow by 16-bit remote ID
+		header = [0xB0, (remote_id >> 8) & 0xff, remote_id & 0xff]
+
+		# Default value for most buttons, since they do not need it
+		brightness = 0
+		color = 0
+
+		# Some buttons need to be converted to zones
+		button_name = button_info['button']
+		if button_name in ['zone_on', 'zone_off', 'zone_white', 'zone_night']:
+			button_name = button_name + ':' + str(button_info['zone'])
+
+		button_id = self._config['button_map'][button_name]
+
+		# Brightness and Color buttons should also set the appropriate
+		# parameters
+		if button_info['button'] == 'zone_set_brightness':
+			## Brightness is a range of [0..25] (26 steps)
+			## Shifted 3 bitsleft
+			brightness = button_info['brightness']
+			if brightness < 0:
+				brightness = 0
+			elif brightness > 25:
+				brightness = 25
+
+			brightness = 31 - ((brightness + 15) % 32)
+
+			brightness = brightness << 3
+
+			if 'zone' in button_info:
+				brightness = brightness | button_info['zone']
+
+		elif button_info['button'] == 'set_color':
+			color = button_info['color']
+
+		# Compute message
+		body = [color, brightness, button_id, message_id]
+
+		# Compute whole message
+		message = header + body
+
+		return message
+
+	def _parse_button_message_rgbw(self, button_message):
+		button_info = {}
+
+		# Verify the header -- if it is not valid, return None
+		if button_message[0] != 0xB0:
+			return None
+
+		# Parse out common parts of the message
+		button_info['remote_id'] = (button_message[1] << 8) | button_message[2]
+		button_info['color'] = button_message[3]
+		button_info['brightness'] = button_message[4]
+		button_info['message_id'] = button_message[6]
+
+		# Map the button ID to a button name
+		button_id = button_message[5]
+
+		button_info.update(self._compute_button_and_zone_from_button_id(button_id))
+
+		if button_info['button'] == 'zone_set_brightness':
+			brightness = button_info['brightness']
+			zone = brightness & 0b111
+			if zone != 0:
+				button_info['zone'] = zone
+			else:
+				button_info['button'] = 'set_brightness'
+
+			# Compute brightness value, there are 26 brightness steps, [16, 0][31, 23]
+			brightness = brightness >> 3
+			brightness = 31 - ((brightness + 15) % 32)
+			button_info['brightness'] = brightness
+
+		return button_info
+
+	def _get_next_message_id(self):
+		# Determine next message ID
+		self._message_id = (self._message_id + 1) & 0xff
+		return self._message_id
+
+	def _send_button(self, button_info):
+		# Configure radio parameters
+		self._radio.set_syncword(self._config['syncword'])
+
+		# Include the remote ID unless one was supplied
+		if 'remote_id' not in button_info:
+			button_info['remote_id'] = self._id
+
+			# Get the next message ID for this remote
+			if 'message_id' not in button_info:
+				message_id = self._get_next_message_id()
+				button_info['message_id'] = message_id
+			else:
+				self._message_id = button_info['message_id']
+
+		# Compute message
+		message = self._compute_button_message(button_info)
+
+		# Transmit
+		self._debug("Sending {}={} n={} times with a {}s delay".format(button_info, message, self._config['retries'], self._config['delay']))
+		self._radio.multi_transmit(message, self._config['channels'], self._config['retries'], self._config['delay'])
+
+		return True
+
+	def _set_brightness(self, brightness, zone = None):
+		if zone is None:
+			message = {'button': 'set_brightness'}
+		else:
+			message = {
+				'button': 'zone_set_brightness',
+				'zone': zone
+			}
+
+		message['brightness'] = brightness
+
+		return self._send_button(message)
+
+	def _step_value(self, target_value, target_range_min, target_range_max, button_prefix, zone):
+		# Step all the way to the nearest extreme before moving it to
+		# where it should be
+		target_range = target_range_max - target_range_min + 1
+		midpoint = (target_range / 2) + target_range_min
+
+		# Move to the "initial" value where we force the value
+		# to the extreme, then move it to its final value
+		initial_steps = target_range
+		if target_value < midpoint:
+			initial_direction = 'down'
+			final_direction = 'up'
+			initial_value = target_range_min
+		else:
+			initial_direction = 'up'
+			final_direction = 'down'
+			initial_value = target_range_max
+
+		# If this remote has a "max" feature, use that instead of stepping
+		use_max_button = False
+		if initial_value == target_range_max:
+			if 'has_max_{}'.format(button_prefix) in self._config['features']:
+				use_max_button = True
+
+		if use_max_button:
+			self._debug("[INITIAL] Going to max {}".format(button_prefix))
+			getattr(self, "max_{}".format(button_prefix))(zone)
+		else:
+			# Otherwise, step it
+			for step in range(initial_steps):
+				self._debug("[INITIAL] Stepping {} {}".format(button_prefix, initial_direction))
+				self._send_button({'button': "{}_{}".format(button_prefix, initial_direction)})
+
+		# Now that we have forced the value to the extreme, move in
+		# steps from that value to the desired value
+		if initial_value < target_value:
+			final_steps = target_value - initial_value
+		else:
+			final_steps = initial_value - target_value
+
+		for step in range(final_steps):
+			self._debug("[FINAL] Stepping {} {}".format(button_prefix, final_direction))
+			self._send_button({'button': "{}_{}".format(button_prefix, final_direction)})
+
+		return True
+
+	def _step_brightness(self, brightness, brightness_min, brightness_max, zone = None):
+		# Select the appropriate zone before sending the steps
+		# to ensure they reach the correct bulbs
+		self.on(zone)
+		time.sleep(0.1)
+		return self._step_value(brightness, brightness_min, brightness_max, 'brightness', zone)
+
+	def _step_temperature(self, temperature, temperature_min, temperature_max, zone = None):
+		# Select the appropriate zone before sending the steps
+		# to ensure they reach the correct bulbs
+		self.on(zone)
+		time.sleep(0.1)
+		return self._step_value(temperature, temperature_min, temperature_max, 'temperature', zone)
+
+	def _rgb_to_hue(self, r, g, b):
+		r = r / 255.0
+		g = g / 255.0
+		b = b / 255.0
+
+		cmax = max(r, max(g, b))
+		cmin = min(r, min(g, b))
+		diff = cmax - cmin
+
+		if cmax == cmin:
+			h = 0
+		elif cmax == r:
+			h = (60 * ((g - b) / diff) + 360) % 360
+		elif cmax == g:
+			h = (60 * ((b - r) / diff) + 120) % 360
+		elif cmax == b:
+			h = (60 * ((r - g) / diff) + 240) % 360
+
+		return h
+
+	def _rgb_to_color(self, rgb):
+		r = (rgb >> 16) & 0xff
+		g = (rgb >>  8) & 0xff
+		b =  rgb        & 0xff
+
+		if r == g and g == b:
+			return (r * -1) - 1
+
+		h = self._rgb_to_hue(r, g, b)
+
+		color = ((h / 360.0) * 255.0) + 26
+
+		color = int(color + 0.5)
+
+		self._debug("RGB = \x1b[38;2;%i;%i;%im%06x\x1b[0m; Hue = %s; Color = %i" % (r, g, b, rgb, str(h * 360), color))
+
+		return color
+
+	def rgb_to_color(self, rgb):
+		return self._rgb_to_color(rgb)
+
+	def raw_send_button(self, button_info):
+		return self._send_button(button_info)
+
+	def raw_read_button(self):
+		channel = self._config['channels'][0]
+		self._radio.set_syncword(self._config['syncword'])
+		data = self._radio.receive(channel = channel, wait = True, wait_time = 1)
+		message = self._parse_button_message(data)
+		return message
+
+	def set_brightness(self, brightness, zone = None):
+		if 'has_brightness' not in self._config['features']:
+			return False
+
+		if brightness < 0 or brightness > 255:
+			return False
+
+		self._debug("Setting brightness to {}".format(brightness))
+		if brightness == 0:
+			self._debug("Really setting to off")
+			return self.off(zone)
+
+		if brightness == 255:
+			if 'has_max_brightness' in self._config['features']:
+				return self.max_brightness(zone)
+
+		brightness_min = self._config['brightness_range'][0]
+		brightness_max = self._config['brightness_range'][1]
+
+		brightness = self._scale_int(brightness, 1, 255, self._config['brightness_range'][0], self._config['brightness_range'][1])
+
+		if 'can_set_brightness' in self._config['features']:
+			return self._set_brightness(brightness, zone)
+		else:
+			return self._step_brightness(brightness, brightness_min, brightness_max, zone)
+
+	def set_color(self, rgb, zone = None):
+		# Compute the color value from the RGB value
+		value = self._rgb_to_color(rgb)
+
+		# If the color selected is really a shade of grey, turn the
+		# bulbs white at that brightness
+		if value < 0:
+			brightness = (value + 1) * -1
+			self._debug("Brightness = {}".format(brightness))
+			if self.white(zone):
+				return self.set_brightness(brightness, zone)
+			else:
+				return False
+
+		# If the bulbs do not support color, nothing needs to be done
+		if 'has_color' not in self._config['features']:
+			return False
+
+		# Turn on the appropriate zone to select it
+		self.on(zone)
+		time.sleep(0.1)
+
+		# Press the button
+		return self._send_button({'button': 'set_color', 'color': value})
+
+	def set_temperature(self, kelvins, zone = None):
+		if 'has_temperature' not in self._config['features']:
+			return False
+
+		temperature_input_low = self._config['temperature_input_range'][0]
+		temperature_input_high = self._config['temperature_input_range'][1]
+		temperature_output_low = self._config['temperature_output_range'][0]
+		temperature_output_high = self._config['temperature_output_range'][1]
+
+		# Clamp the color temperature to something this remote supports
+		if kelvins < temperature_input_low:
+			kelvins = temperature_input_low
+		elif kelvins > temperature_input_high:
+			kelvins = temperature_input_high
+
+		temperature = self._scale_int(kelvins, temperature_input_low, temperature_input_high, temperature_output_low, temperature_output_high)
+
+		if 'can_set_temperature' in self._config['features']:
+			return self._set_temperature(temperature, zone)
+		else:
+			return self._step_temperature(temperature, temperature_output_low, temperature_output_high, zone)
+
+	def on(self, zone = None):
+		if zone is None:
+			message = {'button': 'on'}
+		else:
+			message = {
+				'button': 'zone_on',
+				'zone': zone
+			}
+		return self._send_button(message)
+
+	def off(self, zone = None):
+		if zone is None:
+			message = {'button': 'off'}
+		else:
+			message = {
+				'button': 'zone_off',
+				'zone': zone
+			}
+		return self._send_button(message)
+
+	def max_brightness(self, zone = None):
+		if 'has_max_brightness' not in self._config['features']:
+			return self.set_brightness(255, zone)
+			
+		if zone is None:
+			message = {'button': 'max'}
+		else:
+			message = {
+				'button': 'zone_max',
+				'zone': zone
+			}
+		return self._send_button(message)
+
+	def white(self, zone = None):
+		# If the bulbs are already white, nothing needs to be done
+		if 'is_white' in self._config['features']:
+			return True
+
+		# If the bulbs do not support white, nothing needs to be done
+		if 'has_white' not in self._config['features']:
+			return False
+
+		if zone is None:
+			message = {'button': 'white'}
+		else:
+			message = {
+				'button': 'zone_white',
+				'zone': zone
+			}
+		return self._send_button(message)
+
+	def pair(self, zone):
+		# XXX
+		return False
+
+	def unpair(self, zone):
+		# XXX
+		return False