Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
ffi-hdhomerun / lib / ffi-hdhomerun.rb
Size: Mime:
require 'ffi/hdhomerun'
require 'pry'

#
# Documentation can be found under HDHomeRun::Tuner
#
module HDHomeRun
  VERSION = "0.6"
  ERROR_STRING_UNKNOWN_VARIABLE = "ERROR: unknown getset variable"
  ERROR_REGEX_TUNER_LOCKED = /^ERROR: resource locked by /
  ERROR_STRING_TUNER_LOCK_EXPIRED = "ERROR: lock no longer held"
  MAX_TUNERS = 8

  class InvalidDeviceIdError < Exception; end
  class ConnectionError < Exception; end
  class InvalidTunerError < Exception; end
  class UnknownVariableError < Exception; end
  class TunerLockedError < Exception; end
  class LockExpiredError < Exception; end
  class UnsupportedVersion < Exception; end

  #
  # Discover tuners on the network.
  #
  # Warning: This function will return invalid data 
  #
  # Returns an array of hashes with the following fields:
  #   :id -- device id (string)
  #   :type -- device type (int)
  #   :ip_addr -- ip address (IPAddr)
  #   :tuner_count -- number of tuners on the device (int)
  #
  def self.discover
    results_ptr = FFI::MemoryPointer.new(FFI::HDHomeRun::DiscoverDevice, 64)
    count = FFI::HDHomeRun::discover_find_devices_custom(
              0,
              FFI::HDHomeRun::DEVICE_TYPE_TUNER,
              FFI::HDHomeRun::DEVICE_ID_WILDCARD,
              results_ptr,
              64)
    raise "error sending discover request" if count < 0

    # Loop through the results and construct an array of hashes
    # as our output.
    (0..count-1).map do |i|
      # construct our hash
      p = FFI::HDHomeRun::DiscoverDevice.new(results_ptr[i])
      out = { :id => "%08X" % p.device_id, 
              :type => p.device_type,
              :ip_addr => p.ip_addr }

      # Some versions of the library don't support tuner_count
      out.merge!({ :tuner_count => p.tuner_count }) if 
        p.respond_to? :tuner_count

      out
    end
  end

  # Return an array of all discoverable Tuners.
  def self.tuners
    raise UnsupportedVersion if ENV['FFI_HDHOMERUN_OLD_DEVICE_STRUCT']

    discover.map do |data|
      data[:tuner_count].times.map do |tuner|
        Tuner.new(data.merge(:tuner => tuner))
      end
    end.flatten
  end

  # HDHomeRun device wrapper.
  class Device
    include FFI::HDHomeRun

    attr_reader :id

    # Initialize new hdhomerun device
    #
    # Arguments:
    #   [:id] HDHomeRun id (default "FFFFFFFF")
    #
    # Create a device instance for any HDHomeRun device on the network:
    #   device = HDHomeRun::Device.new
    #
    # Create a device instance for a specific HDHomeRun device on the network:
    #   device = HDHomeRun::Device.new "FEEDFACE"
    #
    def initialize(p={})
      p = {:id => p} unless p.is_a? Hash
      p[:id] ||= "FFFFFFFF"
      p[:id] = "%08X" % p[:id] if p[:id].is_a? Integer

      @hd = create_from_str(p[:id].to_s, nil)
      raise InvalidDeviceIdError, "Invalid device id: %s" % p[:id] \
        if @hd.null?
      @hd = FFI::HDHomeRun::Device.new(@hd)
      @id = get_device_id_requested(@hd)
      raise InvalidDeviceIdError, "Invalid device id: %08X" % @id \
        unless validate_device_id(@id)
      raise ConnectionError, "Unable to connect to device" \
        unless get_model_str(@hd)
    end

    #
    # Get a device variable; equivalent to:
    #   hdhomerun_config <id> get <key>
    #
    def get(key)
      value = FFI::MemoryPointer.new(:pointer, 1)
      error = FFI::MemoryPointer.new(:pointer, 1)

      if get_var(@hd, key, value, error) < 0
        raise ConnectionError, 
          "communication error sending request to hdhomerun device"
      end
      value = value.read_pointer
      error = error.read_pointer

      # Return the value if we didn't get an error.
      return value.read_string if error.null?

      # Throw a useful exception if this get was for an unknown variable.
      error = error.read_string
      raise UnknownVariableError, "unknown variable '%s'" % key if
        error == ERROR_STRING_UNKNOWN_VARIABLE

      # otherwise, it's a generic runtime error.
      raise RuntimeError, error
    end

    #
    # Set a device variable; equivalent to:
    #   hdhomerun_config <id> set <key> <value>
    #
    def set(key, value)
      error = FFI::MemoryPointer.new(:pointer, 1)

      if set_var(@hd, key, value.to_s, nil, error) < 0
        raise ConnectionError, 
          "communication error sending request to hdhomerun device"
      end
      error = error.read_pointer

      return true if error.null?

      # Throw a useful exception if this get was for an unknown variable.
      error = error.read_string
      raise UnknownVariableError, "unknown variable '%s'" % key \
        if error == ERROR_STRING_UNKNOWN_VARIABLE

      # Check for locking exceptions
      raise TunerLockedError, error if error =~ ERROR_REGEX_TUNER_LOCKED
      raise LockExpiredError, error \
        if error == ERROR_STRING_TUNER_LOCK_EXPIRED

      # otherwise, it's a generic runtime error.
      raise RuntimeError, error
    end

    # Get the number of tuners on this device.
    def tuner_count
      @tuner_count ||= begin
        MAX_TUNERS.times do |tuner|
          begin
            get "/tuner%d/debug" % tuner
          rescue UnknownVariableError
            break tuner
          end
        end or raise RuntimeError, "Unable to detect tuner count"
      end
    end

    # Get an array of tuners for this device.
    def tuners
      @tuners ||= tuner_count.times.map do |tuner|
        Tuner.new(:id => @id, :tuner => tuner)
      end
    end

    def id
      "%08X" % @id
    end

    def to_s
      "<%s:0x%08x @id=%s>" % [ self.class, object_id, id ]
    end
  end

  # HDHomeRun tuner wrapper.
  #
  # Basic usage:
  #
  #   # Get a tuner instance for the first tuner on the network.
  #   tuner = HDHomeRun::Tuner.new(:id => "FFFFFFFF", :tuner => 0)
  #
  #   # Set the channel and program
  #   tuner.channel = 20
  #   tuner.program = 3
  #
  #   # Open a file to write the video stream to
  #   File.open("capture.ts", "w") do |output|
  #     puts "Capturing (^C to stop):"
  #
  #     # Read the video stream from the tuner
  #     tuner.capture do |video_data|
  # 
  #       # write the video data to the output file
  #       output.write(video_data)
  #
  #       # Display some sort of feedback to show we're capturing.
  #       STDOUT.write(video_data.size > 0 ? "." : "?")
  #       STDOUT.flush
  #     end
  #   end
  #   
  class Tuner < Device
    extend Forwardable
    include FFI::HDHomeRun

    attr_reader :hd, :tuner

    # Initialize new hdhomerun tuner
    #
    # Arguments:
    # [:id] HDHomeRun id (default: "FFFFFFFF")
    # [:tuner] Tuner number (default: "/tuner0")
    # [:key] lockkey for tuner, see lock (default: nil)
    #
    # Create a tuner instance for the first tuner on any HDHomeRun box
    # on the network:
    #   tuner => HDHomeRun::Tuner.new
    #
    # Create a tuner instance for the second tuner on the HDHomeRun box
    # with id FE92EBD0
    #   tuner = HDHomeRun::Tuner.new(:id => "FE92EBD0", :tuner => 1)
    #
    def initialize(p={})
      super(p)
      @tuner = p[:tuner] || 0
      @tuner = "/tuner%d" % @tuner if @tuner.is_a? Integer
      self.key = p[:key] if p[:key]

      # Verify the format of the tuner argument
      raise InvalidTunerError, "invalid tuner: %s" % @tuner \
        if set_tuner_from_str(@hd, @tuner) <= 0

      # Final check to see if the tuner exists on our device.
      begin
        get_tuner("status")
      rescue UnknownVariableError => e
        raise InvalidTunerError, "invalid tuner: %s, %s" % [@tuner, e]
      end
    end

    # Dynamically define the following getter and setter methods
    %w{channel channelmap filter program target}.each do |name|
      define_method name do
        get_tuner(name)
      end

      define_method "#{name}=" do |value|
        set_tuner(name, value)
      end
    end
    
    # Dynamically define these as simple getter methods
    %w{status streaminfo debug}.each do |name|
      define_method name do
        get_tuner(name)
      end
    end
	
    # Do a channel scan, like +hdhomerun_config+ +scan+.
    # 
    # Returns:
    # * array FFI::HDHomeRun::Result if not given a block
    # * +nil+ if given a block
    def scan
  		lock
  
      begin
        set_tuner_target(@hd, 'none')
        
        ptr = FFI::MemoryPointer.new(:pointer, 1)
        get_tuner_channelmap(@hd, ptr)
        
        str_ptr = ptr.read_pointer
        
        channel_map = str_ptr.null? ? nil : str_ptr.read_string()
        
        scan_group = get_channelmap_scan_group(channel_map)
        
        channelscan_init(@hd, scan_group)
        
        results = Array.new
        
        while true
          result = Result.new
        
          break if channelscan_advance(@hd, result.pointer) <= 0
          
          rc = channelscan_detect(@hd, result.pointer)
          break if rc < 0
          next if rc == 0
          
          if block_given?
            yield result
          else
            results << result
          end
        end
      # ensure the tuner gets unlocked if we run in to an exception
      rescue Exception
        unlock
        
        raise
      end
      
  		unlock if locked?
      
      return block_given? ? nil : results
    end
    
    ##
    # Get the tuner status.
    # 
    # Returns: FFI::HDHomeRun::Status
    def status
      status = Status.new
      
      get_tuner_status(@hd, nil, status)
      
      status
    end
    
    ##
    # Get the tuner status.
    # 
    # Returns: FFI::HDHomeRun::ChannelEntry
    def info
      channel_list_ptr = channel_list_create("us-bcast")
      
      channel_entry_ptr = channel_list_first(channel_list_ptr)
      
      ChannelEntry.new channel_entry_ptr
    end
    
    ##
    # Gets the program streams for the current channel.
    # 
    # Returns: an array of strings
    def programs
      pstreaminfo_ptr = FFI::MemoryPointer.new(:pointer, 1)
      
      get_tuner_streaminfo(@hd, pstreaminfo_ptr)
      
      pstreaminfo_str = pstreaminfo_ptr.read_pointer
      
      if pstreaminfo_str.null?
        return nil
      else
        programs = pstreaminfo_str.read_string.split "\n"
        
        # remove tsid
        programs.shift
        
        return programs
      end
    end
    
    ##
    # Get the current program stream.
    def program
      program_ptr = FFI::MemoryPointer.new(:pointer, 1)
      
      get_tuner_program(@hd, program_ptr)
      
      program_str = program_ptr.read_pointer
      
      program_str.null? ? nil : program_str.read_string()
    end
    
    ##
    # Set the current program stream.
    def program=(program)
      set_tuner_program(@hd, program.to_s)
    end

    # Get the capture statistics for the tuner.
    #
    # Returns: FFI::HDHomeRun::Stats
    def stats
      ptr = FFI::MemoryPointer.new Stats
      out = Stats.new ptr;
      get_video_stats(@hd, out)
      out
    end

    # Capture video data from the tuner.  
    #
    # As data is received from the tuner, that data will be yielded to the
    # block provided.  If no data has been received from the tuner in over
    # a second, capture will yield a string of length zero to the block.
    #
    # If the block returns [false], the tuner will stop capturing and return.
    #
    # Capture from the tuner, but abort if we see no data:
    #   tuner.capture do |buf|
    #     break false if buf.empty?
    #   end
    #
    def capture(p={}, &block)
      raise ArgumentError, "no block" unless block_given?
      raise RuntimeError, "unable to start stream" \
        if stream_start(@hd) <= 0
    
      max_delay = (p[:delay] || 0.064).to_f
      last_yield = 0

      begin
        len_ptr = FFI::MemoryPointer.new :ulong

        while true do
          start = Time.now.to_f
          data  = stream_recv(@hd, VIDEO_DATA_BUFFER_SIZE_1S, len_ptr)

          if !data.null? or start - last_yield > 1.0
            last_yield = start

            len = len_ptr.read_ulong
            buf = data.read_string(len) if len > 0
            buf ||= ""
            yield(buf) or break
          end

          delay = max_delay - (Time.now.to_f - start)
          sleep delay if delay > 0
        end
      ensure
        stream_stop(@hd)
      end
    end

    # Lock the tuner.  Optionally a key can be provided to allow
    # locking across non-persistent connections.
    #
    # [:k:] optional key for non-persistent connections
    #
    # Examples:
    #
    # Locking with a persistent connection:
    #
    #   t = Tuner.new(:id => 0xFEFEFEFE, :tuner => 0)
    #   t.lock            # => true
    #   t.channel = 3     # => 3
    #   # Create a second instance of the tuner and try to change
    #   # the channel.
    #   t2 = Tuner.new(:id => 0xFEFEFEFE, :tuner => 0)
    #   t2.channel = 4    # => TunerLockedError exception
    #   # first tuner instance releases lock
    #   t.unlock          # => true
    #   # Now second tuner can make changes.
    #   t2.channel = 4    # => 4
    #
    # Locking across non-persistent connections:
    #
    #   # Grab a tuner and lock it.
    #   t = Tuner.new(:id => 0xFEFEFEFE, :tuner => 0)
    #   t.lock 0xFFFF     # => true
    #   t.channel = 3     # => 3
    #   t = nil           # => nil
    #
    #   # Create a new instance referencing the same tuner, cannot
    #   # set tuner variables without setting the key first.
    #   t2 = Tuner.new(:id => 0xFEFEFEFE, :tuner => 0)
    #   t2.channel = 4    # => TunerLockedError exception
    #   t2.key = 0xFFFF   # => true
    #   t2.channel = 4    # => 4
    #   t2 = nil          # => nil
    #
    #   # tuner key can be passed in instansiation arguments.
    #   t3 = Tuner.new(:id => 0xFEFEFEFE, :tuner => 0, :key => 0xFFFF)
    #   t3.channel = 5    # => 5
    #
    #   # Invalid keys (or expired keys) result in a LockExpiredError
    #   t3.key = 0x1234   # => true
    #   t3.channel = 6    # => LockExpiredError exception
    #   t3 = nil          # => nil
    #
    def lock(k = nil)
      # If no key was provided, generate a random key for this
      # session.
      k ||= rand(0xFFFFFFFF)
      set_tuner("lockkey", k)
      self.key = k
      true
    end

    # Unlock the tuner.  Requires you to already have the correct
    # key set or use the [:force:] argument.
    #
    # Example:
    #   t = Tuner.new(:id => 0xFEFEFEFE, :tuner => 0)
    #   t2 = Tuner.new(:id => 0xFEFEFEFE, :tuner => 0)
    #
    #   # Lock the tuner and try to unlock it without the key
    #   t.lock            # => true
    #   t2.unlock         # => TunerLockedError exception
    #   t.locked?         # => true
    #
    #   # force the tuner to unlock
    #   t2.unlock :force  # => true
    #   t.locked?         # => false
    #
    def unlock(force=false)
      # FIXME uncommenting the following line causes the tests to core
      # dump.
      # return true unless locked?
      set_tuner("lockkey", force != false ? "force" : "none")
      self.key = 0

      # Strange behavior here, setting the lockkey to "none" without
      # the correct lockkey does not generate an error.  Instead the
      # new value is ignored by the tuner.  So we have to check here
      # to see if the tuner is still locked here to tell if our unlock
      # was successful.
      raise TunerLockedError if locked?
      true
    end

    # Specify the key to use when communicating with this tuner.
    def key=(key)
      lockkey_use_value(@hd, key.to_i)
    end

    # check to see if the tuner is locked
    def locked?
      get_tuner("lockkey") != "none"
    end

    # Return the ip address that holds the lock on the tuner
    def lock_owner
      get_tuner("lockkey")
    end

    def to_s
      "<%s:0x%08x @id=%s, @tuner=%s>" % 
        [ self.class, object_id, id, @tuner ]
    end

    private

    def get(key)
      value = FFI::MemoryPointer.new(:pointer, 1)
      error = FFI::MemoryPointer.new(:pointer, 1)

      if get_var(@hd, key, value, error) < 0
        raise RuntimeError, 
          "communication error sending request to hdhomerun device"
      end
      value = value.read_pointer
      error = error.read_pointer

      raise RuntimeError, error.read_string unless error.null?
      value.read_string
    end

    def set(key, value)
      error = FFI::MemoryPointer.new(:pointer, 1)

      if set_var(@hd, key, value.to_s, nil, error) < 0
        raise RuntimeError, 
          "communication error sending request to hdhomerun device"
      end
      error = error.read_pointer

      raise RuntimeError, error.read_string unless error.null?
      true
    end

    def get_tuner(key)
      get("%s/%s" % [@tuner, key.to_s])
    end

    def set_tuner(key, value)
      set("%s/%s" % [@tuner, key.to_s], value)
    end
  end
end