9 use Mojo::IOLoop::Stream;
10 use Mojo::Transaction::WebSocket;
11 #use Mojo::JSON qw(decode_json encode_json);
15 use Math::Round qw(nearest);
17 use Data::Random qw(rand_chars);
20 use constant pi => 3.14159265358979;
22 my $devname = "/dev/davis";
23 my $datafn = ".loop_data";
26 my $poll_interval = 2.5;
27 my $rain_mult = 0.2; # 0.1 or 0.2 mm or 0.01 inches
35 my $ser; # the serial port Mojo::IOLoop::Stream
39 our $json = JSON->new->canonical(1);
40 our $WS = {}; # websocket connections
44 our $loop_count; # how many LOOPs we have done, used as start indicator
47 0x0, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7,
48 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef,
49 0x1231, 0x210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6,
50 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de,
51 0x2462, 0x3443, 0x420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485,
52 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d,
53 0x3653, 0x2672, 0x1611, 0x630, 0x76d7, 0x66f6, 0x5695, 0x46b4,
54 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc,
55 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x840, 0x1861, 0x2802, 0x3823,
56 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b,
57 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0xa50, 0x3a33, 0x2a12,
58 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a,
59 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0xc60, 0x1c41,
60 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49,
61 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0xe70,
62 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78,
63 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f,
64 0x1080, 0xa1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067,
65 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e,
66 0x2b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256,
67 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d,
68 0x34e2, 0x24c3, 0x14a0, 0x481, 0x7466, 0x6447, 0x5424, 0x4405,
69 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c,
70 0x26d3, 0x36f2, 0x691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634,
71 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab,
72 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x8e1, 0x3882, 0x28a3,
73 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a,
74 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0xaf1, 0x1ad0, 0x2ab3, 0x3a92,
75 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9,
76 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0xcc1,
77 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8,
78 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0xed1, 0x1ef0
83 $bar_trend{-60} = "Falling Rapidly";
84 $bar_trend{196} = "Falling Rapidly";
85 $bar_trend{-20} = "Falling Slowly";
86 $bar_trend{236} = "Falling Slowly";
87 $bar_trend{0} = "Steady";
88 $bar_trend{20} = "Rising Slowly";
89 $bar_trend{60} = "Rising Rapidly";
93 $SIG{TERM} = $SIG{INT} = sub {++$ending; Mojo::IOLoop->stop;};
97 # WebSocket weather service
98 websocket '/weather' => sub {
104 app->log->debug('WebSocket opened.');
105 dbg 'WebSocket opened' if isdbg 'chan';
108 # send historical data
109 $c->send($ld->{lasthour_h}) if exists $ld->{lasthour_h};
110 $c->send($ld->{lastmin_h}) if exists $ld->{lastmin_h};
113 $c->inactivity_timeout(3615);
119 dbg "websocket: text $msg" if isdbg 'chan';
123 dbg "websocket: json $msg" if isdbg 'chan';
128 $c->on(finish => sub {
129 my ($c, $code, $reason) = @_;
130 app->log->debug("WebSocket closed with status $code.");
131 dbg 'webwocket closed with status $code' if isdbg 'chan';
136 get '/' => {template => 'index'};
146 dbg "*** starting $0";
151 our $dlog = SMGLog->new("day");
152 dbg "before next tick";
153 Mojo::IOLoop->next_tick(sub { loop() });
154 dbg "before app start";
156 dbg "after app start";
159 $dataf->close if $dataf;
161 # move all the files along one
162 cycle_loop_data_files();
170 ##################################################################################
174 dbg "last_min: " . scalar gmtime($ld->{last_min});
175 dbg "last_hour: " . scalar gmtime($ld->{last_hour});
177 $did = Mojo::IOLoop->recurring(1 => sub {$dlog->flushall});
188 $d =~ s/([\%\x00-\x1f\x7f-\xff])/sprintf("%%%02X", ord($1))/eg;
189 dbg "read added '$d' buf lth=" . length $buf if isdbg 'raw';
190 if ($state eq 'waitnl' && $buf =~ /[\cJ\cM]+/) {
191 dbg "Got \\n" if isdbg 'state';
192 Mojo::IOLoop->remove($tid) if $tid;
196 $ser->write("LPS 1 1\n");
197 chgstate("waitloop");
198 } elsif ($state eq "waitloop") {
199 if ($buf =~ /\x06/) {
200 dbg "Got ACK 0x06" if isdbg 'state';
201 chgstate('waitlooprec');
204 } elsif ($state eq 'waitlooprec') {
205 if (length $buf >= 99) {
206 dbg "got loop record" if isdbg 'chan';
217 dbg "start_loop writing $nlcount \\n" if isdbg 'state';
219 Mojo::IOLoop->remove($tid) if $tid;
221 $tid = Mojo::IOLoop->recurring(0.6 => sub {
222 if (++$nlcount > 10) {
223 dbg "\\n count > 10, closing connection" if isdbg 'chan';
227 dbg "writing $nlcount \\n" if isdbg 'state';
235 dbg "state '$state' -> '$_[0]'" if isdbg 'state';
242 dbg "do reopen on '$name' ending $ending";
244 $ser = do_open($name);
248 Mojo::IOLoop->start unless Mojo::IOLoop->is_running;
259 my $ob = Serial->new($name, 19200) || die "$name $!\n";
260 dbg "streaming $name fileno(" . fileno($ob) . ")" if isdbg 'chan';
262 my $ser = Mojo::IOLoop::Stream->new($ob);
263 $ser->on(error=>sub {dbg "serial $_[1]"; do_reopen($name) unless $ending});
264 $ser->on(close=>sub {dbg "serial closing"; do_reopen($name) unless $ending});
265 $ser->on(timeout=>sub {dbg "serial timeout";});
266 $ser->on(read=>sub {on_read(@_)});
269 Mojo::IOLoop->remove($tid) if $tid;
271 Mojo::IOLoop->remove($rid) if $rid;
273 $rid = Mojo::IOLoop->recurring($poll_interval => sub {
274 start_loop() if !$state;
288 my $loo = substr $blk,0,3;
289 unless ( $loo eq 'LOO') {
290 dbg "Block invalid loo -> $loo" if isdbg 'chan'; return;
298 my $crc_calc = CRC_CCITT($blk);
303 $tmp = unpack("s", substr $blk,7,2) / 1000;
304 $h{Pressure} = nearest(1, in2mb($tmp));
306 $tmp = unpack("s", substr $blk,9,2) / 10;
307 $h{Temp_In} = nearest(0.1, f2c($tmp));
309 $temp = nearest(0.1, f2c(unpack("s", substr $blk,12,2) / 10));
310 $h{Temp_Out} = $temp;
311 if ($temp > 75 || $temp < -75) {
312 dbg "LOOP Temperature out of range ($temp), record ignored";
316 $tmp = unpack("C", substr $blk,14,1);
317 $h{Wind} = nearest(0.1, mph2mps($tmp));
318 $h{Dir} = unpack("s", substr $blk,16,2)+0;
320 my $wind = {w => $h{Wind}, d => $h{Dir}};
321 $wind = 0 if $wind == 255;
322 push @{$ld->{wind_min}}, $wind;
324 $tmp = int(unpack("C", substr $blk,33,1)+0);
326 dbg "LOOP Outside Humidity out of range ($tmp), record ignored";
329 $h{Humidity_Out} = $tmp;
330 $tmp = int(unpack("C", substr $blk,11,1)+0);
332 dbg "LOOP Inside Humidity out of range ($tmp), record ignored";
335 $h{Humidity_In} = $tmp;
338 $tmp = unpack("C", substr $blk,43,1)+0;
339 $h{UV} = $tmp unless $tmp >= 255;
340 $tmp = unpack("s", substr $blk,44,2)+0; # watt/m**2
341 $h{Solar} = $tmp unless $tmp >= 32767;
343 # $h{Rain_Rate} = nearest(0.1,unpack("s", substr $blk,41,2) * $rain_mult);
344 $rain = $h{Rain_Day} = nearest(0.1, unpack("s", substr $blk,50,2) * $rain_mult);
345 my $delta_rain = $h{Rain} = nearest(0.1, ($rain >= $ld->{last_rain} ? $rain - $ld->{last_rain} : $rain)) if $loop_count;
346 $ld->{last_rain} = $rain;
348 # what sort of packet is it?
349 my $sort = unpack("C", substr $blk,4,1);
353 $tmp = unpack("C", substr $blk,18,2);
354 # $h{Wind_Avg_10} = nearest(0.1,mph2mps($tmp/10));
355 $tmp = unpack("C", substr $blk,20,2);
356 # $h{Wind_Avg_2} = nearest(0.1,mph2mps($tmp/10));
357 $tmp = unpack("C", substr $blk,22,2);
358 # $h{Wind_Gust_10} = nearest(0.1,mph2mps($tmp/10));
360 # $h{Dir_Avg_10} = unpack("C", substr $blk,24,2)+0;
361 $tmp = unpack("C", substr $blk,30,2);
362 $h{Dew_Point} = nearest(0.1, f2c($tmp));
367 $tmp = unpack("C", substr $blk,15,1);
368 # $h{Wind_Avg_10} = nearest(0.1,mph2mps($tmp));
369 $h{Dew_Point} = nearest(0.1, dew_point($h{Temp_Out}, $h{Humidity_Out}));
370 $h{Rain_Month} = nearest(0.1, unpack("s", substr $blk,52,2) * $rain_mult);
371 $h{Rain_Year} = nearest(0.1, unpack("s", substr $blk,54,2) * $rain_mult);
376 my $dayno = int($ts/86400);
377 if ($dayno > $ld->{last_day}) {
378 $ld->{Temp_Out_Max} = $ld->{Temp_Out_Min} = $temp;
379 $ld->{Temp_Out_Max_T} = $ld->{Temp_Out_Min_T} = clocktime($ts, 0);
380 $ld->{last_day} = $dayno;
382 cycle_loop_data_files();
384 if ($temp > $ld->{Temp_Out_Max}) {
385 $ld->{Temp_Out_Max} = $temp;
386 $ld->{Temp_Out_Max_T} = clocktime($ts, 0);
389 if ($temp < $ld->{Temp_Out_Min}) {
390 $ld->{Temp_Out_Min} = $temp;
391 $ld->{Temp_Out_Min_T} = clocktime($ts, 0);
395 if ($ts >= $ld->{last_hour} + 1800) {
396 $h{Pressure_Trend} = unpack("C", substr $blk,3,1);
397 $h{Pressure_Trend_txt} = $bar_trend{$h{Pressure_Trend}};
398 $h{Batt_TX_OK} = (unpack("C", substr $blk,86,1)+0) ^ 1;
399 $h{Batt_Console} = nearest(0.01, unpack("s", substr $blk,87,2) * 0.005859375);
400 $h{Forecast_Icon} = unpack("C", substr $blk,89,1);
401 $h{Forecast_Rule} = unpack("C", substr $blk,90,1);
402 $h{Sunrise} = sprintf( "%04d", unpack("S", substr $blk,91,2) );
403 $h{Sunrise} =~ s/(\d{2})(\d{2})/$1:$2/;
404 $h{Sunset} = sprintf( "%04d", unpack("S", substr $blk,93,2) );
405 $h{Sunset} =~ s/(\d{2})(\d{2})/$1:$2/;
406 $h{Temp_Out_Max} = $ld->{Temp_Out_Max};
407 $h{Temp_Out_Min} = $ld->{Temp_Out_Min};
408 $h{Temp_Out_Max_T} = $ld->{Temp_Out_Max_T};
409 $h{Temp_Out_Min_T} = $ld->{Temp_Out_Min_T};
412 if ($loop_count) { # i.e not the first
413 my $a = wind_average(scalar @{$ld->{wind_hour}} ? @{$ld->{wind_hour}} : {w => $h{Wind}, d => $h{Dir}});
415 $h{Wind_1h} = nearest(0.1, $a->{w});
416 $h{Dir_1h} = nearest(0.1, $a->{d});
418 $a = wind_average(@{$ld->{wind_min}});
419 $h{Wind_1m} = nearest(0.1, $a->{w});
420 $h{Dir_1m} = nearest(1, $a->{d});
422 ($h{Rain_1m}, $h{Rain_1h}, $h{Rain_24h}) = calc_rain($rain);
424 $ld->{last_rain_min} = $ld->{last_rain_hour} = $rain;
427 $s = genstr($ts, 'h', \%h);
428 $ld->{lasthour_h} = $s;
430 $ld->{last_hour} = int($ts/1800)*1800;
431 $ld->{last_min} = int($ts/60)*60;
432 @{$ld->{wind_hour}} = ();
433 @{$ld->{wind_min}} = ();
435 output_str($s, 1) if $s;
438 } elsif ($ts >= $ld->{last_min} + 60) {
439 my $a = wind_average(@{$ld->{wind_min}});
442 push @{$ld->{wind_hour}}, $a;
444 if ($loop_count) { # i.e not the first
447 $h{Wind_1m} = nearest(0.1, $a->{w});
448 $h{Dir_1m} = nearest(1, $a->{d});
449 ($h{Rain_1m}, $h{Rain_1h}, $h{Rain_24h}) = calc_rain($rain);
451 $ld->{last_rain_min} = $rain;
453 $h{Temp_Out_Max} = $ld->{Temp_Out_Max};
454 $h{Temp_Out_Min} = $ld->{Temp_Out_Min};
455 $h{Temp_Out_Max_T} = $ld->{Temp_Out_Max_T};
456 $h{Temp_Out_Min_T} = $ld->{Temp_Out_Min_T};
459 $s = genstr($ts, 'm', \%h);
460 $ld->{lastmin_h} = $s;
462 $ld->{last_min} = int($ts/60)*60;
463 @{$ld->{wind_min}} = ();
465 output_str($s, 1) if $s;
469 my $o = gen_hash_diff($ld->{last_h}, \%h);
471 $s = genstr($ts, 'r', $o);
474 dbg "loop rec not changed" if isdbg 'chan';
476 output_str($s, 0) if $s;
481 dbg "CRC check failed for LOOP data!";
492 my $j = $json->encode($h);
493 my $tm = clocktime($ts, 1);
494 return qq|{"tm":"$tm","t":$ts,"$let":$j}|;
501 my ($sec,$min,$hr) = (gmtime $ts)[0,1,2];
504 $s = sprintf "%02d:%02d:%02d", $hr, $min, $sec;
506 $s = sprintf "%02d:%02d", $hr, $min;
518 $dlog->writenow($s) if $logit;
519 foreach my $ws (keys $WS) {
536 while (my ($k, $v) = each %$now) {
537 if ($last->{$k} ne $now->{$k}) {
542 return $count ? \%o : undef;
550 # Using the simplified approximation for dew point
551 # Accurate to 1 degree C for humidities > 50 %
552 # http://en.wikipedia.org/wiki/Dew_point
554 my $dewpoint = $temp - ((100 - $rh) / 5);
556 # this is the more complete one (which doesn't work)
560 #my $ytrh = log(($rh/100) + ($b * $temp) / ($c + $temp));
561 #my $dewpoint = ($c * $ytrh) / ($b - $ytrh);
568 # Expects packed data...
569 my $data_str = shift @_;
572 my @lst = split //, $data_str;
573 foreach my $data (@lst) {
574 my $data = unpack("c",$data);
577 my $index = $crc >> 8 ^ $data;
578 my $lhs = $crc_table[$index];
579 #print "lhs=$lhs, crc=$crc\n";
580 my $rhs = ($crc << 8) & 0xFFFF;
591 return ($_[0] - 32) * 5/9;
596 return $_[0] * 0.44704;
601 return $_[0] * 33.8637526;
606 my ($sindir, $cosdir, $wind);
611 $sindir += sin(d2r($r->{d})) * $r->{w};
612 $cosdir += cos(d2r($r->{d})) * $r->{w};
616 my $avhdg = r2d(atan2($sindir, $cosdir));
617 $avhdg += 360 if $avhdg < 0;
618 return {w => nearest(0.1,$wind / $count), d => nearest(0.1,$avhdg)};
625 return ($n / pi) * 180;
632 return ($n / 180) * pi;
639 $ld->{rain24} ||= [];
641 my $Rain_1h = nearest(0.1, $rain >= $ld->{last_rain_hour} ? $rain - $ld->{last_rain_hour} : $rain); # this is the rate for this hour, so far
642 my $rm = nearest(0.1, $rain >= $ld->{last_rain_min} ? $rain - $ld->{last_rain_min} : $rain);
643 my $Rain_1m = nearest(0.1, $rm);
644 push @{$ld->{rain24}}, $Rain_1m;
645 $ld->{rain_24} += $rm;
646 while (@{$ld->{rain24}} > 24*60) {
647 $ld->{rain_24} -= shift @{$ld->{rain24}};
649 my $Rain_24h = nearest(0.1, $ld->{rain_24});
650 return ($Rain_1m, $Rain_1h, $Rain_24h);
656 $dataf = IO::File->new("+>> $datafn") or die "cannot open $datafn $!";
657 $dataf->autoflush(1);
663 dbg "read loop data: $s" if isdbg 'json';
664 $ld = $json->decode($s) if length $s;
666 # sort out rain stats
668 if ($ld->{rain24} && ($c = @{$ld->{rain24}}) < 24*60) {
669 my $diff = 24*60 - $c;
670 unshift @{$ld->{rain24}}, 0 for 0 .. $diff;
675 $rain += $_ for @{$ld->{rain24}};
678 $ld->{rain_24} = nearest(0.1, $rain);
686 $dataf = IO::File->new("+>> $datafn") or die "cannot open $datafn $!";
687 $dataf->autoflush(1);
693 my $s = $json->encode($ld);
694 dbg "write loop data: $s" if isdbg 'json';
698 sub cycle_loop_data_files
700 $dataf->close if $dataf;
702 rename "$datafn.oooo", "$datafn.ooooo";
703 rename "$datafn.ooo", "$datafn.oooo";
704 rename "$datafn.oo", "$datafn.ooo";
705 rename "$datafn.o", "$datafn.oo";
706 copy $datafn, "$datafn.o";
712 % my $url = url_for 'weather';
716 <title>DWeather</title>
717 <meta charset="utf-8">
718 <meta http-equiv="X-UA-Compatible" content="IE=edge">
719 <meta name="viewport" content="width=device-width, initial-scale=1">
721 <!-- Latest compiled and minified CSS -->
722 <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
724 <!-- Optional theme -->
725 <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap-theme.min.css">
729 <center><h1>High View Weather</h1></center>
734 function process(key,value) {
735 var d = document.getElementById(key);
741 function traverse(o) {
745 if (o[i] !== null && typeof(o[i])=="object") {
751 window.onload = function() {
753 ws = new WebSocket('<%= $url->to_abs %>');
755 if (typeof(ws) === 'object') {
756 ws.onmessage = function (event) {
757 var js = JSON.parse(event.data);
758 if (js !== null && typeof(js) === 'object') {
762 ws.onopen = function (event) {
763 document.getElementById("wsconnect").innerHTML = 'ws connected to: <%= $url->to_abs %>';
764 ws.send('WebSocket support works!');
766 ws.onclose = function(event) {
767 document.getElementById("wsconnect").innerHTML = 'ws disconnected, refresh to restart';
770 document.body.innerHTML += 'Webserver only works with Websocket aware browsers';
776 <div id="start-template">
778 <table border=1 width=80% align="center">
780 <th>Time:<td><span id="tm"> </span>
781 <th>Sunrise:<td><span id="Sunrise"> </span>
782 <th>Sunset:<td><span id="Sunset"> </span>
783 <th>Console Volts:<td><span id="Batt_Console"> </span>
784 <th>TX Battery OK:<td><span id="Batt_TX_OK"> </span>
787 <th>Pressure:<td><span id="Pressure"> </span>
788 <th>Trend:<td><span id="Pressure_Trend_txt"> </span>
791 <th>Temperature in:<td> <span id="Temp_In"> </span>
792 <th>Humidity:<td> <span id="Humidity_In"> </span>
795 <th>Temperature out:<td> <span id="Temp_Out"> </span>
796 <th>Min:<td> <span id="Temp_Out_Min"> </span> @ <span id="Temp_Out_Min_T"> </span>
797 <th>Max:<td> <span id="Temp_Out_Max"> </span> @ <span id="Temp_Out_Max_T"> </span>
798 <th>Humidity:<td> <span id="Humidity_Out"> </span>
799 <th>Dew Point:<td> <span id="Dew_Point"> </span>
802 <th>Wind Direction:<td> <span id="Dir"> </span>
803 <th>Minute Avg:<td> <span id="Dir_1m"> </span>
804 <th>Speed:<td> <span id="Wind"> </span>
805 <th>Minute Avg:<td> <span id="Wind_1m"> </span>
808 <th>Rain 30mins:<td> <span id="Rain_1h"> </span>
809 <th>Day:<td> <span id="Rain_Day"> </span>
810 <th>24hrs:<td> <span id="Rain_24h"> </span>
811 <th>Month:<td> <span id="Rain_Month"> </span>
812 <th>Year:<td> <span id="Rain_Year"> </span>
815 <div id="wsconnect" align="center"> </div>
818 <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
819 <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
820 <!-- Latest compiled and minified JavaScript -->
821 <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>