Birds Clock - A modern cuckoo clock with SoundBridge

Introduction

birdclock.png

I saw a nice clock at my local café. It's a modern version of the cuckoo clock where a different bird sound is played each hour.

http://www.kookoo.eu/

But I thought that this could be done much better by using my SoundBridge media players located in my house. I would get much better sound quality and would be able to choose the birds myself.

So, I went on and made a ruby script to make it work as a even more modern version of a cuckoo clock.

Target was to use my preferred DLNA server called, minidlna, which is really simple to configure and run. MiniDLNA as well as the ruby script is run on my local OpenBSD server as all my things are.

I wanted to be able to show what kind of bird I was playing during the playback so the final script combines telnet access to the SoundBridge both through the shell interface and the RCP interface.

What doesn't work

Browse filter doesn't allow the song to played. Player just says Unable to Play Bofink.

SetBrowseFilterAlbum Bird Sounds
ListSongs
PlayIndex 0

SetBrowseFilterAlbum Bird Sounds
ListSongs
QueueAndPlay 0

SearchSongs Bofink
QueueAndPlay 0

Working session via container browsing

The preferred way to access the DLNA server seems to be through container browsing. The following session works. dream is the hostname of the SoundBridge.

$ telnet dream 5555
Trying 192.168.0.200...
Connected to dream.lounge.se.
Escape character is '^]'.
roku: ready
GetPowerState
GetPowerState: standby
SetPowerState on yes
SetPowerState: OK
GetConnectedServer
GetConnectedServer: OK
GetActiveServerInfo
GetActiveServerInfo: Type: upnp
GetActiveServerInfo: Name: OpenNAS DLNA Server
GetActiveServerInfo: OK

ListServers
ListServers: ListResultSize 2
ListServers: OpenNAS DLNA Server
ListServers: Internet Radio
ListServers: ListResultEnd
ServerConnect 0
ServerConnect: ConnectionFailedAlreadyConnected

GetCurrentContainerPath
GetCurrentContainerPath: /Album/Bird Sounds/

ContainerExit
ContainerExit: OK
ContainerExit
ContainerExit: OK
GetCurrentContainerPath
GetCurrentContainerPath: /

ListContainerContents
ListContainerContents: TransactionInitiated
ListContainerContents: ListResultSize 6
ListContainerContents: Album
ListContainerContents: All Music
ListContainerContents: Artist
ListContainerContents: Folders
ListContainerContents: Genre
ListContainerContents: Playlists
ListContainerContents: ListResultEnd
ListContainerContents: TransactionComplete
ContainerEnter 0
ContainerEnter: OK

ListContainerContents
ListContainerContents: TransactionInitiated
ListContainerContents: ListResultSize 62
ListContainerContents: 9 Lives to Wonder
ListContainerContents: A Hundred Days Off
ListContainerContents: All The King's Men
ListContainerContents: Amethyst Rock Star
ListContainerContents: Animositisomina
ListContainerContents: B-Sides Collection
ListContainerContents: Badmotorfinger
ListContainerContents: Beatles Cover - Rare!
ListContainerContents: Beaucoup Fish
ListContainerContents: Belief
ListContainerContents: Big Hit
ListContainerContents: Bird Sounds
ListContainerContents: Bites
...
ListContainerContents: Youth Novels
ListContainerContents: ListResultEnd
ListContainerContents: TransactionComplete
ContainerEnter 11
ContainerEnter: OK
ListContainerContents
ListContainerContents: TransactionInitiated
ListContainerContents: ListResultSize 13
ListContainerContents: Sädesärla
ListContainerContents: Rödhake
ListContainerContents: Näktergal
ListContainerContents: Koltrast
ListContainerContents: Grönsångare
ListContainerContents: Gransångare
ListContainerContents: Lövsångare
ListContainerContents: Blåmes
ListContainerContents: Talgoxe
ListContainerContents: Trädkrypare
ListContainerContents: Bofink
ListContainerContents: Grönfink
ListContainerContents: Kattuggla
ListContainerContents: ListResultEnd
ListContainerContents: TransactionComplete
QueueAndPlayOne 0     
QueueAndPlayOne: OK
GetTransportState
GetTransportState: Play
GetTransportState
GetTransportState: Stop
SetPowerState standby yes   
SetPowerState: OK
exit

The final birdclock.rb script

So this script was what I finally came up with. There were a lot of issues because the telnet API isn't perfect, but this script works fine. I also access a shell API over telnet to be able to print the name of the bird and current time before the song is played.

The script should be fairly straight forward to understand if you know ruby.

You need to adapt the the SERVER,PATH,ITEMS variables and the connect function to suit your DLNA server and media content. Also adapt DAYS if you want a different language.

I run this script from cron utility.

# start birdclock every daily hour
0	8-22	*	*	*	/root/bin/birdclock.rb auto >/dev/null

Have fun with your scripted SoundBridge!


#!/usr/local/bin/ruby

# - #!/usr/bin/env ruby

require 'net/telnet'

# Other communicaations:
# Web UI: http://soundbridge.local./SoundBridgeStatus.html
# Shell: telnet dream 4444

# Other sounds: Car sounds, gingles, TV shows, Cities, African animals ...

# Run from cron:
# 0     8-22     *     *     *         /root/bin/birdclock.sh

SERVER = "OpenNAS DLNA Server"
PATH = [ "Album", "Bird Sounds" ]
ITEMS = [ "Sädesärla","Rödhake","Näktergal","Koltrast","Grönsångare","Gransångare",
          "Lövsångare","Blåmes","Talgoxe","Trädkrypare","Bofink","Grönfink","Kattuggla" ]
DAYS = [ "Måndag","Tisdag","Onsdag","Torsdag","Fredag","Lördag","Söndag" ]

def connect
  @sb = Net::Telnet::new( "Host" => "dream", "Port" => 5555, "Timeout" => 10 )
  @sh = Net::Telnet::new( "Host" => "dream", "Port" => 4444, "Timeout" => 10 )
end

def quit_session(code = 0, leave_on = false)
  turn_off unless leave_on
  call("exit", /SoundBridge> /, @sh)
  @sh.close
  @sb.close
  exit code
end

def log(m)
  puts m
end

# Helper which makes a telnet call but yield block as complete lines instead of sub string
# TODO: The complete output is stored in a variable which isn't very memory efficient
def call(cmd, match, on = @sb)
  lines = ''
  on.cmd( "String" => cmd, "Match" => match ) { |c| lines += c }
  if block_given?
    lines.split(/\n/).each { |l| 
      yield l unless l =~ /^$/
    }
  end
end

def get_power_state
  ans = ''
  call("GetPowerState", /GetPowerState: (standby|on)\n/) { |l|
    log(l)
    l =~ /(standby|on)/
    ans = $& unless $&.nil?  
  }
  return ans
end

def turn_on
  call("SetPowerState on yes", /SetPowerState: (OK|ParameterError)\n/)
end

def turn_off
  call("SetPowerState standby yes", /SetPowerState: (OK|ParameterError)\n/)
end

def get_active_server
  ans = nil
  conn = false
  # This sometimes return "GenericError" (i.e. no connected server) even if there is one
  call("GetConnectedServer", /GetConnectedServer: (OK|GenericError)\n/) { |l|
    log(l)
    conn = true if l =~ /OK/
  }
  if conn
    call("GetActiveServerInfo", /GetActiveServerInfo: (OK|ErrorDisconnected)\n/) { |l|
      log(l)
      if l =~ /GetActiveServerInfo: Name: /
        ans = $'.strip if $'
      end
    }
  end
  return ans
end

def get_servers
  ans = []
  call("ListServers", /ListServers: ListResultEnd\n/) { |l|
    unless l =~ /ListResultSize|ListResultEnd/ 
      log(l)
      l =~ /ListServers: /
      ans << $'.strip if $'
    end
  }
  return ans
end

def connect_server(n)
  ans = false
  errors = "ParameterError|ConnectionFailedAlreadyConnected|ResourceAllocationError"
  errors += "|ConnectionFailedNoContact|ConnectionFailedUnknown|GenericError"
  call("ServerConnect #{n.to_s}", /ServerConnect: (TransactionComplete|#{errors})\n/) { |l|
    log(l)
    ans = true if l =~ /ServerConnect: (Connected|ConnectionFailedAlreadyConnected)/
  }
  return ans
end

def disconnect_server
  ans = false
  errors = "ErrorDisconnected|ResourceAllocationError|GenericError"
  call("ServerDisconnect", /ServerDisconnect: (TransactionComplete|#{errors})\n/) { |l|
    log(l)
    ans = true if l =~ /ServerDisconnect: Disconnected/
  }    
  return ans
end

def get_container_path
  ans = nil
  call("GetCurrentContainerPath", /GetCurrentContainerPath: .+\n/) { |l|
    log(l)
    l =~ /GetCurrentContainerPath: /
  }
  ans = $'.strip if $'
  return ans
end

def container_list
  ans = []
  errors = "ErrorDisconnected|GenericError"
  call("ListContainerContents", /ListContainerContents: (TransactionComplete|#{errors})\n/) { |l|
    log(l)
    unless l =~ /ListResultSize|ListResultEnd|TransactionInitiated|TransactionComplete/ 
      l =~ /ListContainerContents: /
      ans << $'.strip if $'
    end
  }
  return ans
end

def container_exit
  ans = false
  call("ContainerExit", /ContainerExit: (OK|ParameterError)\n/) { |l|
    log(l)
    ans = true if l =~ /OK/
  }
  ans
end

def container_enter(n)
  ans = false
  call("ContainerEnter #{n.to_s}", /ContainerEnter: (OK|ParameterError)\n/) { |l|
    log(l)
    ans = true if l =~ /OK/
  }
  return ans
end

def container_play(n)
  call("QueueAndPlayOne #{n.to_s}", /QueueAndPlayOne: .+\n/)
end

def get_transport_state
  ans = ''
  call("GetTransportState", /GetTransportState: .+\n/) { |l|
    log(l)
    l =~ /GetTransportState: /
    ans = $' unless $&.nil?  
  }
  return ans
end

# Shell utilities

def text_init
  call("sketch -c encoding utf8", /SoundBridge> /, @sh)
end

def text_clear
  call("sketch -c clear", /SoundBridge> /, @sh)
end

def text(x, y, message)
  call("sketch -c text #{x} #{y} \"#{message}\"", /SoundBridge> /, @sh)
end

def text_quit
  call("sketch -c quit", /SoundBridge> /, @sh)
end

# Main session

if ARGV[0] && ARGV[0] =~ /auto/
  t = Time.now
  hour = (t.min < 30) ? t.hour : t.hour + 1
  @item = ITEMS[hour%12]
elsif ARGV[0] && ARGV[0].to_i >= 0 && ARGV[0].to_i <= 12
  @item = ITEMS[ARGV[0].to_i]
else
  puts "Play bird sound with index 0 to 12 OR select automatically based on closest hour"
  puts "Usage: ./birdclock.rb auto"
  puts "       ./birdclock.rb n"
  exit 1
end

t = Time.now
t0 = "#{@item}"
t1 = DAYS[t.strftime("%w").to_i] + t.strftime(" %H:%M")
puts t0
puts t1

connect

unless get_power_state =~ /on/
  turn_on
  sleep 3
else
  if get_transport_state =~ /Play/
    puts "Don't interrupt ongoing playback"; quit_session(0, true)
  end
end

active_server = get_active_server
unless active_server && active_server == SERVER
  disconnect_server
  sleep 2
  servers = get_servers
  n = servers.index(SERVER)
  if n
    connect_server(n)
    sleep 2
  else
    puts "Failed to connect to server #{SERVER}"; quit_session 1
  end
end

path = get_container_path
until (path == "/")
  container_exit
  path = get_container_path
end

PATH.each { |c|
  items = container_list
  n = items.index(c)
  if n
    container_enter(n)
  else
    puts "#{c} container not found"; quit_session 1
  end
}

text_init
text_clear
text((39-t0.size)/2, 0, t0)
text((39-t1.size)/2, 1, t1)
sleep 10
text_quit

items = container_list
n = items.index(@item)
if n
  container_play(n)
else
  puts "#{@item} item not found"; quit_session 1
end

while get_transport_state =~ /Play/ do
  sleep 5
end

quit_session

References