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