# = blinkenlights.rb - Controlling the keyboard LEDs from Ruby
#
# == Author
#
# Florian Frank mailto:flori@ping.de
#
# == License
#
# This is free software; you can redistribute it and/or modify it under the
# terms of the GNU General Public License Version 2 as published by the Free
# Software Foundation: www.gnu.org/copyleft/gpl.html
#
# == Download
#
# The latest version of <b>blinkenlights</b> can be found at
#
# * http://rubyforge.org/frs/?group_id=1021
#
# The homepage of this library is located at
#
# * http://blinkenlights.rubyforge.org
#
# == Description
#
# This Ruby library is named after an old joke, see
#   http://catb.org/~esr/jargon/html/B/blinkenlights.html
#
# It enables you to control the LEDs on your keyboard to signal digital numbers
# between 0 and 7, events like received/transmitted network packets, or just
# let them blink in visually pleasing patterns.
# 
# == Examples
#
# The block form opens the TTY, that controls the LEDs, and closes/resets
# the LEDs  it after the block has been processed:
#  require 'blinkenlights'
#  BlinkenLights.open do |bl|
#    bl.off
#    bl.circle
#  end
#
# It's also possible to manually close the object:
#  require 'blinkenlights'
#  bl = BlinkenLights.new
#  bl.off
#  100.times { bl.random }
#  bl.close
#
# There's also two short examples examples/netblinker.rb and
# examples/loadbar.rb in the distribution directory of this library, that show
# how to let the lights blink if network packets are received/transmitted on
# your host or how high the cpu load average is.
class BlinkenLights

  # Module to hold the BlinkenLights constants.
  module Constants
    # The default tty. It happens to be the one, I run X on. ;)
    DEF_TTY   = '/dev/tty8'

    # DEF_DELAY is the default standard delay in seconds, that is slept everytime
    # the LED state is changed. If it is too small your keyboard may become
    # confused about its LEDs' status.
    DEF_DELAY = 0.1

    # Scroll Lock LED (from /usr/include/linux/kd.h)
    LED_SCR   = 0x01

    # Num Lock LED (from /usr/include/linux/kd.h)
    LED_NUM   = 0x02

    # Caps Lock LED (from /usr/include/linux/kd.h)
    LED_CAP   = 0x04

    # Return current LED state (from /usr/include/linux/kd.h)
    KDGETLED  = 0x4B31

    # Set LED state [lights, not flags] (from /usr/include/linux/kd.h)
    KDSETLED  = 0x4B32

    # In order from left to right. This setting may have to be tweaked, if your
    # keyboard has some unusual LED positions.
    LEDS        = [ :LED_NUM, :LED_CAP, :LED_SCR ]

    # Values for LEDs from left to right
    LEDS_VALUES = LEDS.map { |c| self.const_get(c) }

    # In order from lowest to highest
    DIGITAL     = [ :LED_SCR, :LED_NUM, :LED_CAP ]

    # The left LED
    LED_LEFT    = 4

    # The middle LED
    LED_MIDDLE  = 2

    # The right LED
    LED_RIGHT   = 1

    # None of the LEDs
    LED_NONE    = 0

    # All of the LEDs
    LED_ALL     = 7
  end
  include Constants

  # Creates a BlinkenLights instance for _tty_, a full pathname like
  # '/dev/tty8' to control the LEDs. _delay_ is the standard delay in seconds,
  # that is slept everytime the LED state is changed. If _delay_ is too small
  # your keyboard may become confused about its LEDs' status.
  def initialize(tty = DEF_TTY, delay = DEF_DELAY)
    @tty      = File.new(tty, File::RDWR)
    @delay    = delay
    @old_leds = get
  end

  # The standard delay of this BlinkenLights instance.
  attr_accessor :delay

  # Creates a BlinkenLights instance and yields to it. After the block returns
  # the BlinkenLights#close method is called.
  def self.open(tty = DEF_TTY, delay = DEF_DELAY)
    obj = new(tty, delay)
    yield obj
  ensure
    obj.close if obj
  end

  # Close the open console tty after resetting LEDs to the original state.
  def close
    reset
    @tty.close
    self
  end

  # Resets the LED state to the starting state (when the BlinkenLights object
  # was created).
  def reset
    set @old_leds
    self
  end
  
  # Switch off all LEDs.
  def off
    set LED_NONE
    self
  end
  
  # Switch on all LEDs.
  def on
    set LED_ALL
    self
  end

  # First switches all LEDs on, then off. Sleep for _delay_ seconds after
  # switching them on.
  def flash(delay = 0.0)
    on
    sleep delay
    off
  end

  # Set LEDs to _number_ in binary digital mode.
  def digital=(number)
    number  %= 8
    setting  = 0
    0.upto(2) do |i|
      if number[i] == 1
        setting |= 1 << DIGITAL.index(LEDS[2 - i])
      end
    end
    set setting
  end
  
  # Return the state of the LEDs expressed in binary digital mode.
  def digital
    setting = get
    result  = 0
    2.downto(0) do |i|
      if setting[i] == 1
        result |= 1 << (2 - LEDS_VALUES.index(1 << i))
      end
    end
    result
  end

  # Blink all the LEDs from the left to the right. Sleep for _delay_ seconds in
  # between.
  def left_to_right(delay = 0.0)
    for i in [ LED_LEFT, LED_MIDDLE, LED_RIGHT ]
      self.digital = i
      sleep delay
    end
    self
  end

  # Blink all the LEDs from the right to the left. Sleep for _delay_ seconds in
  # between.
  def right_to_left(delay = 0.0)
    for i in [ LED_RIGHT, LED_MIDDLE, LED_LEFT ]
      self.digital = i
      sleep delay
    end
    self
  end

  # Blink all the LEDs from the left to the right, and then from the right to
  # the left. Sleep for _delay_ seconds in between.
  def circle(delay = 0.0)
    left_to_right(delay)
    right_to_left(delay)
    self
  end

  # Blink all the LEDs from the right to the left, and then from the left to
  # the right. Sleep for _delay_ seconds in between.
  def reverse_circle(delay = 0.0)
    right_to_left(delay)
    left_to_right(delay)
    self
  end

  # Switch some of the LEDs on by random. Then sleep for _delay_ seconds.
  def random(delay = 0.0)
    self.digital = rand(LED_ALL + 1)
    sleep delay
    self
  end

  # Converge, that is, first blink the outer LEDs, then blink the inner LED.
  # Sleep for _delay_ seconds in between.
  def converge(delay = 0.0)
    for i in [ LED_LEFT|LED_RIGHT, LED_MIDDLE ]
      self.digital = i
      sleep delay
    end
    self
  end

  # Diverge, that is, first blink the inner LED, then blink the outer LEDs.
  # Sleep for _delay_ seconds in between.
  def diverge(delay = 0.0)
    for i in [ LED_MIDDLE, LED_LEFT|LED_RIGHT ]
      self.digital = i
      sleep delay
    end
    self
  end

  # Return the state of the Scroll Lock LED: true for switched on, false for
  # off.
  def scr
    (get & LED_SCR) != LED_NONE
  end

  # Switch the Scroll Lock LED on, if _toggle_ is true, off, otherwise.
  def scr=(toggle)
    old = get
    if toggle
      set old | LED_SCR
    else
      set old & ~LED_SCR
    end
  end

  # Switch the Scroll Lock LED on, if it was off before. Switch the Scroll Lock
  # LED off, if it was on before. 
  def toggle_scr(delay = 0.0)
    self.scr = !scr
    sleep delay
    self
  end

  # Return the state of the Caps Lock LED: true for switched on, false for off.
  def cap
    (get & LED_CAP) != LED_NONE
  end

  # Switch the Caps Lock LED on, if _toggle_ is true, off, otherwise.
  def cap=(toggle)
    old = get
    if toggle
      set old | LED_CAP
    else
      set old & ~LED_CAP
    end
  end

  # Switch the Caps Lock LED on, if it was off before. Switch the Caps Lock
  # LED off, if it was on before. 
  def toggle_cap(delay = 0.0)
    self.cap = !cap
    sleep delay
    self
  end

  # Return the state of the Num Lock LED: true for switched on, false for off.
  def num
    (get & LED_NUM) != LED_NONE
  end

  # Switch the Num Lock LED on, if _toggle_ is true, off, otherwise.
  def num=(toggle)
    old = get
    if toggle
      set old | LED_NUM
    else
      set old & ~LED_NUM
    end
  end

  # Switch the Num Lock LED on, if it was off before. Switch the Num Lock LED
  # off, if it was on before. 
  def toggle_num(delay = 0.0)
    self.num = !num
    self
  end

  # Return the state of the left LED: true for switched on, false for
  # off.
  def left
    (digital & LED_LEFT) != LED_NONE
  end

  # Switch the left LED on, if _toggle_ is true, off, otherwise.
  def left=(toggle)
    old = digital
    if toggle
      self.digital = old | LED_LEFT
    else
      self.digital = old & ~LED_LEFT
    end
  end

  # Switch the left LED on, if it was off before. Switch the left
  # LED off, if it was on before. 
  def toggle_left(delay = 0.0)
    self.left = !left
    sleep delay
    self
  end

  # Return the state of the middle LED: true for switched on, false for off.
  def middle
    (digital & LED_MIDDLE) != LED_NONE
  end

  # Switch the middle LED on, if _toggle_ is true, off, otherwise.
  def middle=(toggle)
    old = digital
    if toggle
      self.digital = old | LED_MIDDLE
    else
      self.digital = old & ~LED_MIDDLE
    end
  end

  # Switch the middle LED on, if it was off before. Switch the middle LED off,
  # if it was on before. 
  def toggle_middle(delay = 0.0)
    self.middle = !middle
    sleep delay
    self
  end

  # Return the state of the right LED: true for switched on, false for off.
  def right
    (digital & LED_RIGHT) != LED_NONE
  end

  # Switch the right LED on, if _toggle_ is true, off, otherwise.
  def right=(toggle)
    old = digital
    if toggle
      self.digital = old | LED_RIGHT
    else
      self.digital = old & ~LED_RIGHT
    end
  end

  # Switch the right LED on, if it was off before. Switch the right off, if it
  # was on before. 
  def toggle_right(delay = 0.0)
    self.right = !right
    self
  end

  # Set the state of the LEDs to integer _number_. (Quite low level)
  def set(number)
    @tty.ioctl(KDSETLED, number)
    sleep @delay
    number
  end

  # Return the state of the LEDs as an integer _number_. (Quite low level)
  def get
    char = [0].pack('C')
    @tty.ioctl(KDGETLED, char)
    char.unpack('C')[0]
  end

  # Return a string representation of this BlinkenLights instance, showing
  # some interesting data.
  def to_s
    if @tty.closed?
      "#<#{self.class}: closed>"
    else
      "#<#{self.class}: delay=#{@delay}s, tty=#{@tty.path}," +
      " LEDs=#{'%03b' % self.digital}>"
    end
  end

  alias inspect to_s
end
