#!/usr/bin/perl
package main;
########################################################################
# Set debug to 1 to enable debug output and suppress sleeping as well
# as calls to the actual devices. Look for additional print
# statements throughout the code that are commented out if you
# want even more output at stragically important places!
########################################################################
our $debug = 0;
our $RECORDING_DEVICE;
init();
my $henrys_channel_recording_changer =
sub {
my $what = shift;
my $where = ("@ARGV" =~ m/directv/) ? 'living' : 'office';
#print "In $where do $what\n";
print `/home/henry/tv/chan $where $what`;
};
$RECORDING_DEVICE->run_state
( $henrys_channel_recording_changer );
$RECORDING_DEVICE->run_channel
( $henrys_channel_recording_changer );
########################################################################
########################################################################
#
# No user servicable parts below this section
#
########################################################################
########################################################################
=head1 NAME control_tv_recording
=head1 SYNOPSIS
control_tv_recording [filenames]
default for filenames is shows.txt
use -d option to turn on debugging
use -w option to get a weekly summary of the amount of
recording tape needed for this week
Use the output of the tv_check program in the XMLTV package to run a
satellite receiver and vcr to record your favorite programs. Minimal
user intervention is required.
=head1 DESCRIPTION
I hate commercials. I also hate being required to be at a specific place
at a specific time in order to watch one of my favorite TV programs, so I
record everything and watch it later. Recently I discovered the excellent
XMLTV package at L. This program
uses the ouput of the F program as input to control whatever
devices you have to record what you want to watch. You will need to write
at least two (hopefully small) programs that change the channel for you and
control the recording device. Then you should edit the output of
F to only include the lines of the tv programs you want to watch.
Feed that file to control_tv_recording in an xterm window, (or use nohup)
and be sure to remember to change tapes as needed.
Additional wrinkles:
I extended the syntax that you can feed to this program just a little.
If you replace the mmdd field that tv_check outputs with the keyword
B then this event will be set to go off at the same time every
week. Also, if in the program description you put the words B then only the channel will change, the recorder will not be
issued the I and I commands. I use this to set up my
weekly recording schedule, and then check out the tv_check output to
see if there are any differences and just include those as an extra
file on the input line. Here are some examples of input that would be
acceptable to this program:
Sat 0914 2205/60 Channel 386 ABCB The Practice "Target Practice"
Record on September 14 of this year at 10:05PM for 1 hour on channel 386.
Note that the I field is ignored in this case.
Wed weekly 1800/60 Channel 654 WSBK Enterprise "Shockwave" 2/2
Record every Wednesday from 6-7PM on channel 654
Mon weekly 1300/60 Channel 387 All my Children channel only
Tue weekly 1300/60 Channel 387 All my Children channel only
Wed weekly 1300/60 Channel 387 All my Children channel only
Thu weekly 1300/60 Channel 387 All my Children channel only
Fri weekly 1300/60 Channel 387 All my Children channel only
Change the channel to 387 every weekday at 1PM. Nothing will happen
at 2PM. The program does not try to I the previous channel.
My setup:
I have a starchoice receiver in my office and a directv receiver in the
living room. Both have a Phillips VCR next to them. I'm using an
B device, which can be programmed with IR commands from
your existing remotes. It receives X-10 commands and plays back the
associated IR command. I'm also using B devices with translates
RS-232 input into X-10 output. The system is contolled by an old laptop that
runs 24 hours a day. The B and B devices are available
from L for $120 and $33 (September 2002 prices)
respectively.
At this point, let me point you to L, which has
some great software for controlling X-10 devices. You will need to write
the programs that send the change channel and start/stop recording commands
to your devices. In my case, both of these programs are called I and
take as parameters the keywords I or I, as well as a
I to change the channel or the words I to control
the vcr. Once you have written these programs, pass a reference to them to
the methods C and C as shown above.
=head1 REQUIRES
Class::Date
Class::MethodMaker
Getopt::Std;
=head1 AUTHOR
Henry Laxen
http://www.maztravel.com/perl
=head1 SEE ALSO
http://sourceforge.net/projects/xmltv/
http://membled.com/work/apps/xmltv/
http://www.misterhouse.com/
=head1 PACKAGES
=cut
use strict;
package Event;
use Class::Date qw(:errors date now );
=head2
B encapsulates the notion of an tv program event, which
has a start and stop time, a channel number, and perhaps a program
description.
=cut
use Class::MethodMaker
new_hash_init => 'new',
get_set => [ qw( start end duration channel program)];
=item overlap($event1,$event2) or $event1->overlap($event2)
Return true if the two events overlap. Events are considered as
intervals which are closed on the left, and open on the right. In
math notation this is [0,1). Thus the intervals [0,1) and [1,2)
do B overlap.
=cut
sub overlap {
my ($first,$second) = $_[0]->start < $_[1]->start ?
@_[0,1] : @_[1,0];
return ! ($first->end <= $second->start);
}
=item $event->inside($date)
Return true if the C<$date> argument is inside the start, end
interval of the event.
=cut
sub inside {
my ($self,$date) = @_;
return $self->start <= $date && $date < $self->end;
}
=item $event->as_text
Return a string that represent the event in human readable form.
=cut
sub as_text {
my $self = shift;
return sprintf("%s - %s on %s: %s\n ",
$self->start,$self->end,$self->channel,$self->program);
}
=item next_event($array_of_events_reference,$time)
The C must be sorted in start time order. Does a
binary search on the array and returns the next event to consider.
If the $time is inside the event interval, then that event is
returned, otherwise the event previous to that time is returned.
=cut
sub next_event {
my ($events,$time) = @_;
return 0 if $time <= $events->[0]->start;
return $#$events if $time >= $events->[$#$events]->start;
return if $time >= $events->[$#$events]->end;
my ($start,$end) = (0,$#$events);
my $midpoint;
while (1) {
last if $start > $end;
$midpoint = int(($start + $end) / 2);
last
if $events->[$midpoint]->start <= $time &&
$time < $events->[$midpoint+1]->start;
if ($events->[$midpoint]->start <= $time) {
$start = $midpoint + 1;
# print " up $start ";
} else {
$end = $midpoint - 1;
# print " down $end "
}
}
die "Binary search failed\n" if $start > $end ;
return $midpoint;
}
=item $event->sleep_for($time);
Returns the number of seconds to sleep for either the
beginning or end of the the current event, depending on the
time passed as an argument. Issues a warning if the event
should have already occurred.
=cut
sub sleep_for {
my ($event,$time) = @_;
my $sleeptime;
if ($time < $event->start) {
$sleeptime = $event->start - $time;
} elsif ($time <= $event->end ) {
$sleeptime = $event->end - $time;
} else {
warn "Can't sleep: ",$event->end, " is after ", $time, "\n";
$sleeptime = 0;
}
return $sleeptime;
}
=item calculate_tape_needed($array_of_events_reference,$time,$interval,$span)
Calculate the sum of the durations of the events that fall into an
as specified interval (usually 1 day) and for a specified span
of time (usually 1 day or 1 week) and return the result as an array.
=cut
sub calculate_tape_needed {
my ($events,$time,$interval,$span) = @_;
$time = now unless defined $time;
$interval = '1D' unless defined $interval;
$span = $interval unless defined $span;
my $next_event_index = next_event($events,$time);
my @totals;
my $start_time = $time;
while ($start_time < $time+$span ) {
my $end_time = $start_time + $interval;
my $total = 0;
for (my $i = $next_event_index; $i<=$#$events; $i++) {
$total += $events->[$i]->duration
if $events->[$i]->start >= $start_time &&
$events->[$i]->start < $end_time &&
$events->[$i]->program !~ /channel only/;
last if $events->[$i]->start > $end_time;
}
$start_time = $end_time;
push @totals,$total;
}
return \@totals;
}
package Schedule;
=head2
B reads and parses the file produced by
tv_check and creates an array of events. Should you wish to
parse a file with a different format, you may only need to
modify the regular expression that describes each line of the
file. The current regex looks like this:
^\s* # maybe leading whitespace
(\w+)\s+ # the day of the week
(weekly | \d{4})\s+ # either weekly or mmdd
(..) # the hour
(..)/ # the minute
(\d+)\s+ # the duration in minutes
(?:Channel\s+)? # maybe the word Channel
(\d+)\s+ # the channel number
(.*)$ # the program description
=cut
use Class::Date qw(:errors date now );
my $schedule_re = qr {
^\s* # maybe leading whitespace
(\w+)\s+ # the day of the week
(weekly | \d{4})\s+ # either weekly or mmdd
(..) # the hour
(..)/ # the minute
(\d+)\s+ # the duration in minutes
(?:Channel\s+)? # maybe the word Channel
(\d+)\s+ # the channel number
(.*)$ # the program description
}x;
my ($weekday, $month_day, $hour, $minute, $duration, $channel, $program);
my ($month,$day);
my @order = (0,1,2,3,4,5,6);
my %weekdays;
@weekdays{ qw ( sun mon tue wed thu fri sat ) } = (0..6); #)
=item schedule_changed(@array_of_file_names)
Compare the modification times of each of the files in the array
to their previous modification times, and return true if any
of them changed. Also sets the package global variable
C<$Schedule::changed> to that value.
=cut
my %modification_times;
our $changed;
sub schedule_changed {
$changed = 0;
foreach my $file (@_) {
$changed = 1 if ! exists $modification_times{$file} ||
-C $file != $modification_times{$file};
$modification_times{$file} = -C $file;
}
return $changed;
}
=item read_schedule
Reads all of the files in @ARGV and returns an array of events
sorted in event starting time order.
=cut
my @events;
sub read_schedule {
return \@events unless schedule_changed(@ARGV);
@events = ();
my @argv = @ARGV;
my $year = (localtime(time))[5] + 1900;
while (<>) {
chomp;
next unless $_;
next if m/^#/;
my @fields = m/$schedule_re/;
($weekday, $month_day, $hour, $minute, $duration, $channel, $program) =
@fields[@order];
if ($month_day eq 'weekly') {
############################################################################
#
# This section is a little tricky. We need to compute the next
# occurance of a weekly event. So we need to see if the current
# time is after the end of this event, and if so, add 7 days to it.
# Now unless today happens to be the weekday in question, $difference
# will be non-zero, and hence $then = $now + "$difference D" will
# be in the future. However if $difference is 0, then we add the
# duration of the event to $then, and add another 7 days if it
# already happened. This should even handle year boundarys!
#
###########################################################################
my $now = now;
my $weekday_now = $now->_wday;
my $difference = $weekdays{lc($weekday)} - $weekday_now;
$difference += 7 if $difference < 0;
my $then = $now + "$difference D";
$then = date [ $then->year, $then->month, $then->day,
$hour,$minute,0];
$then += $duration * 60;
$then += '7 D' if $then < $now;
($year,$month,$day) = ($then->year, $then->month, $then->day);
} else {
$month = substr($month_day,0,2);
$day = substr($month_day,2,2);
}
$program =~ s/\s+/ /;
#print "$month,$day,$hour,$minute,$duration,$channel,$program\n";
my $start = date [$year,$month,$day,$hour,$minute,0];
$duration = new Class::Date::Rel{ min => $duration};
my $end = $start + $duration;
my $event = new Event(
start => $start,
end => $end,
duration => $duration,
channel => $channel,
program => $program);
push @events, $event;
}
@events = sort { $a->start <=> $b->start } @events;
for (my $i = 0; $i < @events - 1; $i++) {
my $j = $i+1;
print "Events $i and $j overlap\n" ,
$events[$i]->as_text,$events[$j]->as_text,
'-' x 72,"\n"
if Event::overlap($events[$i],$events[$j]);
}
#map {print $_->start,'--',$_->end,$_->program,"\n"} @events;
@ARGV = @argv;
return \@events;
}
package TV_Device;
=head2
B is the interface to the hardware, ie, the actual
program receiver (cable, satellite) and recording device (VCR, DVD-RW) and
this software.
=cut
use Class::MethodMaker
new_with_init => 'new',
get_set => [ qw( run_state run_channel debug )];
sub init {
my $self = shift;
$self->{state} = undef;
$self->{channel} = undef;
$self->run_state(sub { die "vcr run state undefined\n"});
$self->run_channel(sub { die "vcr run channel undefined\n"});
$self->debug($::debug) if defined $::debug;
}
=item $device->valid_states(@array_of_valid_commands)
Set up or return a reference to a hash of valid commands that
can change the state of the device
=cut
sub valid_states {
my $self = shift;
return $self->{valid_states} unless @_;
my %valid;
@valid{@_} = ();
$self->{valid_states} = \%valid;
}
=item $device->state($new_state)
Changes the state of the device to the C<$new_state>, or returns
the current state if called without an argument. The state is only
changed if it is different from the current state. Sleeps for 1 second
after the state change (X-10 devices like this)
=cut
sub state {
my ($self,$new_state) = @_;
if (@_ == 2) {
warn "Invalid state $new_state\n"
unless exists $self->valid_states->{$new_state};
if (!defined $self->state || $new_state ne $self->state) {
$self->{state} = $new_state;
$self->debug ? print "Changing state to $new_state\n" :
&{$self->run_state}($new_state);
sleep(1);
}
} else {
return $self->{state};
}
}
=item $device->channel($new_channel)
Changes the channel of the device to the C<$new_channel>, or returns
the current channel if called without an argument. Issues a warning if
the channel is not numeric. The channel is changed unconditionally.
=cut
sub channel {
my ($self,$new_channel) = @_;
if (@_ == 2) {
warn "Invalid channel $new_channel\n" unless $new_channel =~ /^\d+$/;
$self->{channel} = $new_channel;
$self->debug ? print "Changing channel to $new_channel\n" :
&{$self->run_channel}($new_channel);
sleep(1);
} else {
return $self->{channel};
}
}
package Timer;
=head2
B isn't really needed, but is very useful for
debugging. One problem with testing this program is that you don't
really want to wait for events to occur. By using the Timer package
we can simulate the actual passage of time without waiting for all
the C calls to finish.
=cut
use Class::Date qw(:errors date ); #w
use Class::MethodMaker
new_with_init => 'new',
get_set => [ qw( time debug )];
sub init {
my $self = shift;
$self->time(CORE::time);
$self->debug($::debug) if defined $::debug;
}
=item $timer->sleep($amout)
Go to sleep for the specified amount of time. Return the current
time after waking up.
=cut
sub sleep {
my ($self,$amount) = @_;
CORE::sleep($amount) unless $self->debug;
print "Sleeping for $amount\n" if $self->debug;
$self->time($self->time + $amount);
return $self->now;
}
=item $timer->now
Returns the current time as a Class::Date object.
=cut
sub now {
my $self = shift;
return date $self->time if $self->debug;
$self->time(CORE::time);
return date $self->time;
}
package main;
=head2
B does the job of reading the schedule,
calculating the next event to process, and issue the device
commands to drive the hardware. There are two parameters that
you can conceivable set to change the behaviour. These are:
$MIN_GAP_TIME = 3; # in seconds
Set this to the I that determines when an event
starts and stops. If your machine happens to be heavily loaded
when a sleep is scheduled to wake up, the actual time we woke
up may not be the exact time of the event. We thus test if the
current time is within C<$MIN_GAP_TIME> of the start or stop times
of the event to determine if this is the event we need to process.
$MAX_SLEEP_TIME = 60 * 10; # in seconds
Every time this program wakes up, it rereads the Schedule file
to see if anything changed. Thus to make changes to your schedule,
all you need to do is edit that file. The problem is that sometimes
the next program you want to record is days away. Setting the
C<$MAX_SLEEP_TIME> to a non-zero value means the program will wake up
at least that often, and reread the Schedule file for any changes. Setting
it to zero disables this feature.
=cut
use Class::Date qw(:errors date now );
use Getopt::Std;
our ($opt_d,$opt_w);
sub init {
getopt('');
$debug = 1 if defined $opt_d;
$RECORDING_DEVICE = new TV_Device();
$RECORDING_DEVICE->valid_states(qw(stop record));
push @ARGV,'shows.txt' unless @ARGV;
$|++;
}
=item as_hms($seconds)
Return a string in the hh:mm:ss format
based on the number of seconds
=cut
sub as_hms {
my $secs = shift;
my $hour = int($secs/(60*60));
my $min = int(($secs - $hour*(60*60))/60);
my $sec = $secs % 60;
return sprintf ("%02u:%02u:%02u",$hour,$min,$sec)
}
if ($opt_w) {
# print out how much tape will be needed this week
# make it easy to know when to change tapes
my $events = Schedule::read_schedule();
my $time = now;
my $interval = '1D';
my $tape =Event::calculate_tape_needed($events,$time,$interval,'7D');
print "Tape required for this week is\n";
foreach my $amount (@$tape) {
printf (" %-10s %s\n", $time->wdayname,as_hms($amount))
if $amount;
$time += $interval;
}
exit(0);
}
my $MIN_GAP_TIME = 3; # in seconds
my $MAX_SLEEP_TIME = 60 * 10; # in seconds set to 0 for long naps
my $this_day = -1;
my $TIME = new Timer();
my $previous_event;
while ( 1 ) {
my $events = Schedule::read_schedule(); # Reread schedule files
my $now = $TIME->now;
# Once a day, or when the schedule changes, tell the user how much
# tape should be in the machine.
print "Tape required for next 24 hours is: ",
as_hms(Event::calculate_tape_needed($events,$now,'1D','1D')->[0]),"\n"
if $Schedule::changed || $this_day != $now->day;
$this_day = $now->day;
# print "Now is $now\n";
my $next = Event::next_event($events,$now); # get the next one
last unless defined $next; # no more, leave loop
$next++ if $events->[$next]->end < $now; # That one is over, get next
my $event = $events->[$next];
my $next_start = $event->start - $now;
my $next_end = $event->end - $now;
my $channel_only = # Are we only changing the channel?
($event->program =~ /channel only/i);
if (abs($next_start) < $MIN_GAP_TIME) { # This event is starting
my $message = $channel_only ?
"Changing Channels" : "Recording";
print "$now $message: ",$event->program,"\n";
$RECORDING_DEVICE->channel($event->channel); # Change the channel twice
$RECORDING_DEVICE->state('record') # because the X-10 system isn't
unless $channel_only; # that reliable!
$RECORDING_DEVICE->channel($event->channel);
} elsif (abs($next_end) < $MIN_GAP_TIME) { # This one is ending
print "$now Stop\n" unless $channel_only ; # So stop the VCR
$RECORDING_DEVICE->state('stop')
unless $channel_only;
$next++; # and move on to the next event
}
$event = $events->[$next];
last if $next > $#$events; # We might be done now
$now = $TIME->now; # Otherwise figure out how
my $sleep_time = $events->[$next]->sleep_for($now); # long to sleep for
if ($event->as_text ne $previous_event) {
print "Next event is ", $event->as_text; # Let user know about
print ("Sleeping for ",as_hms($sleep_time),"\n");
$previous_event = $event->as_text;
}
print '.'; # let user know you're still alive
if ($MAX_SLEEP_TIME) {
$sleep_time = $MAX_SLEEP_TIME if $sleep_time > $MAX_SLEEP_TIME;
}
$TIME->sleep($sleep_time);
}
__END__