new tower/sector UI, mapping features, and network monitoring, #37802
[freeside.git] / FS / FS / tower_sector.pm
1 package FS::tower_sector;
2 use base qw( FS::Record );
3
4 use Class::Load qw(load_class);
5 use File::Path qw(make_path);
6 use Data::Dumper;
7 use Cpanel::JSON::XS;
8
9 use strict;
10
11 =head1 NAME
12
13 FS::tower_sector - Object methods for tower_sector records
14
15 =head1 SYNOPSIS
16
17   use FS::tower_sector;
18
19   $record = new FS::tower_sector \%hash;
20   $record = new FS::tower_sector { 'column' => 'value' };
21
22   $error = $record->insert;
23
24   $error = $new_record->replace($old_record);
25
26   $error = $record->delete;
27
28   $error = $record->check;
29
30 =head1 DESCRIPTION
31
32 An FS::tower_sector object represents a tower sector.  FS::tower_sector
33 inherits from FS::Record.  The following fields are currently supported:
34
35 =over 4
36
37 =item sectornum
38
39 primary key
40
41 =item towernum
42
43 towernum
44
45 =item sectorname
46
47 sectorname
48
49 =item ip_addr
50
51 ip_addr
52
53 =item height
54
55 The height of this antenna on the tower, measured from ground level. This
56 plus the tower's altitude should equal the height of the antenna above sea
57 level.
58
59 =item freq_mhz
60
61 The band center frequency in MHz.
62
63 =item direction
64
65 The antenna beam direction in degrees from north.
66
67 =item width
68
69 The -3dB horizontal beamwidth in degrees.
70
71 =item downtilt
72
73 The antenna beam elevation in degrees below horizontal.
74
75 =item v_width
76
77 The -3dB vertical beamwidth in degrees.
78
79 =item db_high
80
81 The signal loss margin to treat as "high quality".
82
83 =item db_low
84
85 The signal loss margin to treat as "low quality".
86
87 =item image 
88
89 The coverage map, as a PNG.
90
91 =item west, east, south, north
92
93 The coordinate boundaries of the coverage map.
94
95 =back
96
97 =head1 METHODS
98
99 =over 4
100
101 =item new HASHREF
102
103 Creates a new sector.  To add the sector to the database, see L<"insert">.
104
105 Note that this stores the hash reference, not a distinct copy of the hash it
106 points to.  You can ask the object for a copy with the I<hash> method.
107
108 =cut
109
110 sub table { 'tower_sector'; }
111
112 =item insert
113
114 Adds this record to the database.  If there is an error, returns the error,
115 otherwise returns false.
116
117 =cut
118
119 sub insert {
120   my $self = shift;
121   my $error = $self->SUPER::insert;
122   return $error if $error;
123
124   if (scalar($self->need_fields_for_coverage) == 0) {
125     $self->queue_generate_coverage;
126   }
127 }
128
129 sub replace {
130   my $self = shift;
131   my $old = shift || $self->replace_old;
132   my $regen_coverage = 0;
133   if ( !$self->get('no_regen') ) {
134     foreach (qw(height freq_mhz direction width downtilt
135                 v_width db_high db_low))
136     {
137       $regen_coverage = 1 if ($self->get($_) ne $old->get($_));
138     }
139   }
140
141   my $error = $self->SUPER::replace($old);
142   return $error if $error;
143
144   if ($regen_coverage) {
145     $self->queue_generate_coverage;
146   }
147 }
148
149 =item delete
150
151 Delete this record from the database.
152
153 =cut
154
155 sub delete {
156   my $self = shift;
157
158   #not the most efficient, not not awful, and its not like deleting a sector
159   # with customers is a common operation
160   return "Can't delete a sector with customers" if $self->svc_broadband;
161
162   $self->SUPER::delete;
163 }
164
165 =item check
166
167 Checks all fields to make sure this is a valid sector.  If there is
168 an error, returns the error, otherwise returns false.  Called by the insert
169 and replace methods.
170
171 =cut
172
173 sub check {
174   my $self = shift;
175
176   my $error = 
177     $self->ut_numbern('sectornum')
178     || $self->ut_number('towernum', 'tower', 'towernum')
179     || $self->ut_text('sectorname')
180     || $self->ut_textn('ip_addr')
181     || $self->ut_floatn('height')
182     || $self->ut_numbern('freq_mhz')
183     || $self->ut_numbern('direction')
184     || $self->ut_numbern('width')
185     || $self->ut_numbern('v_width')
186     || $self->ut_numbern('downtilt')
187     || $self->ut_floatn('sector_range')
188     || $self->ut_numbern('db_high')
189     || $self->ut_numbern('db_low')
190     || $self->ut_anything('image')
191     || $self->ut_sfloatn('west')
192     || $self->ut_sfloatn('east')
193     || $self->ut_sfloatn('south')
194     || $self->ut_sfloatn('north')
195   ;
196   return $error if $error;
197
198   $self->SUPER::check;
199 }
200
201 =item tower
202
203 Returns the tower for this sector, as an FS::tower object (see L<FS::tower>).
204
205 =item description
206
207 Returns a description for this sector including tower name.
208
209 =cut
210
211 sub description {
212   my $self = shift;
213   if ( $self->sectorname eq '_default' ) {
214     $self->tower->towername
215   }
216   else {
217     $self->tower->towername. ' sector '. $self->sectorname
218   }
219 }
220
221 =item svc_broadband
222
223 Returns the services on this tower sector.
224
225 =item need_fields_for_coverage
226
227 Returns a list of required fields for the coverage map that aren't yet filled.
228
229 =cut
230
231 sub need_fields_for_coverage {
232   my $self = shift;
233   my $tower = $self->tower;
234   my %fields = (
235     height    => 'Height',
236     freq_mhz  => 'Frequency',
237     direction => 'Direction',
238     downtilt  => 'Downtilt',
239     width     => 'Horiz. width',
240     v_width   => 'Vert. width',
241     db_high   => 'High quality',
242     latitude  => 'Latitude',
243     longitude => 'Longitude',
244   );
245   my @need;
246   foreach (keys %fields) {
247     if ($self->get($_) eq '' and $tower->get($_) eq '') {
248       push @need, $fields{$_};
249     }
250   }
251   @need;
252 }
253
254 =item queue_generate_coverage
255
256 Starts a job to recalculate the coverage map.
257
258 =cut
259
260 sub queue_generate_coverage {
261   my $self = shift;
262   my $need_fields = join(',', $self->need_fields_for_coverage);
263   return "Sector needs fields $need_fields" if $need_fields;
264   $self->set('no_regen', 1); # avoid recursion
265   if ( length($self->image) > 0 ) {
266     foreach (qw(image west south east north)) {
267       $self->set($_, '');
268     }
269     my $error = $self->replace;
270     return $error if $error;
271   }
272   my $job = FS::queue->new({
273       job => 'FS::tower_sector::process_generate_coverage',
274   });
275   $job->insert('_JOB', { sectornum => $self->sectornum});
276 }
277
278 =back
279
280 =head1 SUBROUTINES
281
282 =over 4
283
284 =item process_generate_coverage JOB, PARAMS
285
286 Queueable routine to fetch the sector coverage map from the tower mapping
287 server and store it. Highly experimental. Requires L<Map::Splat> to be
288 installed.
289
290 PARAMS must include 'sectornum'.
291
292 =cut
293
294 sub process_generate_coverage {
295   my $job = shift;
296   my $param = shift;
297   $job->update_statustext('0,generating map') if $job;
298   my $sectornum = $param->{sectornum};
299   my $sector = FS::tower_sector->by_key($sectornum)
300     or die "sector $sectornum does not exist";
301   $sector->set('no_regen', 1); # avoid recursion
302   my $tower = $sector->tower;
303
304   load_class('Map::Splat');
305
306   # since this is still experimental, put it somewhere we can find later
307   my $workdir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/" .
308                 "generate_coverage/sector$sectornum-". time;
309   make_path($workdir);
310   my $splat = Map::Splat->new(
311     lon         => $tower->longitude,
312     lat         => $tower->latitude,
313     height      => ($sector->height || $tower->height || 0),
314     freq        => $sector->freq_mhz,
315     azimuth     => $sector->direction,
316     h_width     => $sector->width,
317     tilt        => $sector->downtilt,
318     v_width     => $sector->v_width,
319     db_levels   => [ $sector->db_low, $sector->db_high ],
320     dir         => $workdir,
321     #simplify    => 0.0004, # remove stairstepping in SRTM3 data?
322   );
323   $splat->calculate;
324
325   my $box = $splat->box;
326   foreach (qw(west east south north)) {
327     $sector->set($_, $box->{$_});
328   }
329   $sector->set('image', $splat->png);
330   my $error = $sector->replace;
331   die $error if $error;
332
333   foreach ($sector->sector_coverage) {
334     $error = $_->delete;
335     die $error if $error;
336   }
337   # XXX undecided whether Map::Splat should even do this operation
338   # or how to store it
339   # or anything else
340   $DB::single = 1;
341   my $data = decode_json( $splat->polygonize_json );
342   for my $feature (@{ $data->{features} }) {
343     my $db = $feature->{properties}{level};
344     my $coverage = FS::sector_coverage->new({
345       sectornum => $sectornum,
346       db_loss   => $db,
347       geometry  => encode_json($feature->{geometry})
348     });
349     $error = $coverage->insert;
350   }
351
352   die $error if $error;
353 }
354
355 =head1 BUGS
356
357 =head1 SEE ALSO
358
359 L<FS::tower>, L<FS::Record>, schema.html from the base documentation.
360
361 =cut
362
363 1;
364