Birds Clock - A modern cuckoo clock with SoundBridge
Introduction
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.
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
birdclock.rb
script
The final 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
- Roku Control Protocol for the SoundBridge
- Net:Telnet Ruby
- Soundbridge Information Display
- What to expect from the Ruby expect library?
- Process spawning patterns