From 72c147593235480cf5231b5a671922ff96f84f4f Mon Sep 17 00:00:00 2001 From: rudolfkoenig <> Date: Wed, 4 Jan 2017 21:31:55 +0000 Subject: [PATCH] 01_FHEMWEB.pm: first version of websocket support (Forum #59713) git-svn-id: https://svn.fhem.de/fhem/trunk@12957 2b470e98-0d58-463d-a4d8-8e2adae1ed80 --- fhem/FHEM/01_FHEMWEB.pm | 157 +++++++++++++++++++++++++-------------- fhem/www/pgm2/console.js | 86 +++++++++++++++------ fhem/www/pgm2/fhemweb.js | 61 ++++++++++----- 3 files changed, 208 insertions(+), 96 deletions(-) diff --git a/fhem/FHEM/01_FHEMWEB.pm b/fhem/FHEM/01_FHEMWEB.pm index af64b92a4..44647e6bc 100755 --- a/fhem/FHEM/01_FHEMWEB.pm +++ b/fhem/FHEM/01_FHEMWEB.pm @@ -11,6 +11,7 @@ use Time::HiRes qw(gettimeofday); ######################### # Forward declaration sub FW_IconURL($); +sub FW_addToWritebuffer($$@); sub FW_answerCall($); sub FW_dev2image($;$); sub FW_devState($$@); @@ -156,7 +157,7 @@ FHEMWEB_Initialize($) hiddengroup hiddenroom iconPath - longpoll:0,1 + longpoll:0,1,websocket longpollSVG:1,0 menuEntries mainInputLength @@ -376,7 +377,6 @@ FW_Read($$) } @FW_httpheader; delete($hash->{HDR}); - $FW_userAgent = $FW_httpheader{"User-Agent"}; my @origin = grep /Origin/, @FW_httpheader; $FW_headerlines = (AttrVal($FW_wname, "CORS", 0) ? (($#origin<0) ? "": "Access-Control-Allow-".$origin[0]."\r\n"). @@ -422,6 +422,25 @@ FW_Read($$) delete $hash->{CONTENT_LENGTH}; $hash->{LASTACCESS} = $now; + if( $method eq 'GET' && $FW_httpheader{Connection} =~ /Upgrade/i ) { + use Digest::SHA1 qw(sha1_base64); + TcpServer_WriteBlocking($FW_chash, + "HTTP/1.1 101 Switching Protocols\r\n" . + "Upgrade: websocket\r\n" . + "Connection: Upgrade\r\n" . + "Sec-WebSocket-Accept:". + sha1_base64($FW_httpheader{'Sec-WebSocket-Key'}. + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")."=\r\n" . + "\r\n" ); + $FW_chash->{websocket} = 1; + + my $me = $FW_chash; + my ($cmd, $cmddev) = FW_digestCgi($arg); + FW_initInform($me, 0) if($FW_inform); + return -1; + } + + $FW_userAgent = $FW_httpheader{"User-Agent"}; $arg = "" if(!defined($arg)); Log3 $FW_wname, 4, "$name $method $arg; BUFLEN:".length($hash->{BUF}); $FW_ME = "/" . AttrVal($FW_wname, "webname", "fhem"); @@ -482,7 +501,7 @@ FW_Read($$) ("Expires: ".FmtDateTimeRFC1123($now+900)."\r\n") : ""); Log3 $FW_wname, 4, "name: $arg / RL:$length / $FW_RETTYPE / $compressed / $expires"; - if( ! addToWritebuffer($hash, + if( ! FW_addToWritebuffer($hash, "HTTP/1.1 200 OK\r\n" . "Content-Length: $length\r\n" . $expires . $compressed . $FW_headerlines . @@ -496,6 +515,77 @@ FW_Read($$) } } +sub +FW_initInform($$) +{ + my ($me, $longpoll) = @_; + + if($FW_inform =~ /type=/) { + foreach my $kv (split(";", $FW_inform)) { + my ($key,$value) = split("=", $kv, 2); + $me->{inform}{$key} = $value; + } + + } else { # Compatibility mode + $me->{inform}{type} = ($FW_room ? "status" : "raw"); + $me->{inform}{filter} = ($FW_room ? $FW_room : ".*"); + } + my $filter = $me->{inform}{filter}; + $filter = "NAME=.*" if($filter eq "room=all"); + $filter = "room!=.+" if($filter eq "room=Unsorted"); + + my %h = map { $_ => 1 } devspec2array($filter); + $h{global} = 1 if( $me->{inform}{addglobal} ); + $h{"#FHEMWEB:$FW_wname"} = 1; + $me->{inform}{devices} = \%h; + %FW_visibleDeviceHash = FW_visibleDevices(); + + # NTFY_ORDER is larger than the normal order (50-) + $me->{NTFY_ORDER} = $FW_cname; # else notifyfn won't be called + %ntfyHash = (); + $me->{inform}{since} = time()-5 + if(!defined($me->{inform}{since}) || $me->{inform}{since} !~ m/^\d+$/); + + if($longpoll) { + my $sinceTimestamp = FmtDateTime($me->{inform}{since}); + TcpServer_WriteBlocking($me, + "HTTP/1.1 200 OK\r\n". + $FW_headerlines. + "Content-Type: application/octet-stream; charset=$FW_encoding\r\n\r\n". + FW_roomStatesForInform($me, $sinceTimestamp)); + } + + if($FW_id && $defs{$FW_wname}{asyncOutput}) { + my $data = $defs{$FW_wname}{asyncOutput}{$FW_id}; + if($data) { + FW_addToWritebuffer($me, $data."\n"); + delete $defs{$FW_wname}{asyncOutput}{$FW_id}; + } + } + if($me->{inform}{withLog}) { + $logInform{$me->{NAME}} = "FW_logInform"; + } else { + delete($logInform{$me->{NAME}}); + } +} + + +sub +FW_addToWritebuffer($$@) +{ + my ($hash, $txt, $callback, $nolimit) = @_; + + if( $hash->{websocket} ) { + my $len = length($txt); + if( $len < 126 ) { + $txt = chr(0x81) . chr(0xFF) . chr($len) . $txt; + } else { + $txt = chr(0x81) . chr(0x7E) . pack( 'n', $len ) . $txt; + } + } + return addToWritebuffer($hash, $txt, $callback, $nolimit); +} + sub FW_AsyncOutput($$) { @@ -524,7 +614,7 @@ FW_AsyncOutput($$) next if( $chash->{TYPE} ne 'FHEMWEB' ); next if( !$chash->{inform} ); next if( !$chash->{FW_ID} || $chash->{FW_ID} ne $hash->{FW_ID} ); - addToWritebuffer($chash, $data."\n"); + FW_addToWritebuffer($chash, $data."\n"); $found = 1; last; } @@ -670,51 +760,7 @@ FW_answerCall($) } if($FW_inform) { # Longpoll header - if($FW_inform =~ /type=/) { - foreach my $kv (split(";", $FW_inform)) { - my ($key,$value) = split("=", $kv, 2); - $me->{inform}{$key} = $value; - } - - } else { # Compatibility mode - $me->{inform}{type} = ($FW_room ? "status" : "raw"); - $me->{inform}{filter} = ($FW_room ? $FW_room : ".*"); - } - my $filter = $me->{inform}{filter}; - $filter = "NAME=.*" if($filter eq "room=all"); - $filter = "room!=.+" if($filter eq "room=Unsorted"); - - my %h = map { $_ => 1 } devspec2array($filter); - $h{global} = 1 if( $me->{inform}{addglobal} ); - $h{"#FHEMWEB:$FW_wname"} = 1; - $me->{inform}{devices} = \%h; - %FW_visibleDeviceHash = FW_visibleDevices(); - - # NTFY_ORDER is larger than the normal order (50-) - $me->{NTFY_ORDER} = $FW_cname; # else notifyfn won't be called - %ntfyHash = (); - $me->{inform}{since} = time()-5 - if(!defined($me->{inform}{since}) || $me->{inform}{since} !~ m/^\d+$/); - my $sinceTimestamp = FmtDateTime($me->{inform}{since}); - TcpServer_WriteBlocking($me, - "HTTP/1.1 200 OK\r\n". - $FW_headerlines. - "Content-Type: application/octet-stream; charset=$FW_encoding\r\n\r\n". - FW_roomStatesForInform($me, $sinceTimestamp)); - - if($FW_id && $defs{$FW_wname}{asyncOutput}) { - my $data = $defs{$FW_wname}{asyncOutput}{$FW_id}; - if($data) { - addToWritebuffer($me, $data."\n"); - delete $defs{$FW_wname}{asyncOutput}{$FW_id}; - } - } - if($me->{inform}{withLog}) { - $logInform{$me->{NAME}} = "FW_logInform"; - } else { - delete($logInform{$me->{NAME}}); - } - + FW_initInform($me, 1); return -1; } @@ -871,7 +917,7 @@ FW_answerCall($) my $csrf= ($FW_CSRF ? "fwcsrf='$defs{$FW_wname}{CSRFTOKEN}'" : ""); my $gen = 'generated="'.(time()-1).'"'; - my $lp = 'longpoll="'.AttrVal($FW_wname,"longpoll",1).'"'; + my $lp = 'longpoll="'.AttrVal($FW_wname,"longpoll",1).'"'; $FW_id = $FW_chash->{NR} if( !$FW_id ); FW_pO "\n"; @@ -2575,7 +2621,7 @@ FW_logInform($$) return; } $msg = FW_htmlEscape($msg); - if(!addToWritebuffer($ntfy, "
$msg
") ){ + if(!FW_addToWritebuffer($ntfy, "
$msg
") ){ TcpServer_Close($ntfy); delete $logInform{$me}; delete $defs{$me}; @@ -2596,7 +2642,7 @@ FW_Notify($$) my $vs = int(@structChangeHist) ? 'visible' : 'hidden'; my $data = FW_longpollInfo($h->{fmt}, "#FHEMWEB:$ntfy->{NAME}","\$('#saveCheck').css('visibility','$vs')",""); - addToWritebuffer($ntfy, $data."\n"); + FW_addToWritebuffer($ntfy, $data."\n"); if($dev->{CHANGED}) { $dn = $1 if($dev->{CHANGED}->[0] =~ m/^MODIFIED (.*)$/); @@ -2614,7 +2660,7 @@ FW_Notify($$) my $data = $3; return if( $2 && $ntfy->{PEER} !~ m/$2/ ); $data = FW_longpollInfo($h->{fmt}, "#FHEMWEB:$ntfy->{NAME}",$data,""); - addToWritebuffer($ntfy, $data."\n"); + FW_addToWritebuffer($ntfy, $data."\n"); return; } @@ -2687,7 +2733,8 @@ FW_Notify($$) } if(@data){ - if(!addToWritebuffer($ntfy, join("\n", map { s/\n/ /gm; $_ } @data)."\n") ){ + if(!FW_addToWritebuffer($ntfy, + join("\n", map { s/\n/ /gm; $_ } @data)."\n") ){ my $name = $ntfy->{NAME}; Log3 $name, 4, "Closing connection $name due to full buffer in FW_Notify"; TcpServer_Close($ntfy); @@ -2714,7 +2761,7 @@ FW_directNotify($@) # Notify without the event overhead (Forum #31293) !$ntfy->{inform}{devices}{$dev} || $ntfy->{inform}{type} ne "status"); next if($filter && $ntfy->{inform}{filter} !~ m/$filter/); - if(!addToWritebuffer($ntfy, + if(!FW_addToWritebuffer($ntfy, FW_longpollInfo($ntfy->{inform}{fmt}, @_)."\n")) { my $name = $ntfy->{NAME}; Log3 $name, 4, "Closing connection $name due to full buffer in FW_Notify"; diff --git a/fhem/www/pgm2/console.js b/fhem/www/pgm2/console.js index 8c450a14f..86f40ebde 100644 --- a/fhem/www/pgm2/console.js +++ b/fhem/www/pgm2/console.js @@ -8,24 +8,39 @@ var mustScroll = 1; log("Console is opening"); function -consUpdate() +consUpdate(evt) { - if(consConn.readyState == 4) { - FW_errmsg("Connection lost, trying a reconnect every 5 seconds."); - setTimeout(consFill, 5000); - return; // some problem connecting - } + var errstr = "Connection lost, trying a reconnect every 5 seconds."; + var new_content = ""; - if(consConn.readyState != 3) - return; - var len = consConn.responseText.length; - - if (consLastIndex == len) // No new data - return; - - var new_content = consConn.responseText.substring(consLastIndex, len); - consLastIndex = len; - + if(evt.target instanceof WebSocket) { + if(evt.type == 'close') { + FW_errmsg(errstr, 4900); + consConn.close(); + consConn = undefined; + setTimeout(consFill, 5000); + return; + } + new_content = evt.data; + consLastIndex = 0; + + } else { + if(consConn.readyState == 4) { + FW_errmsg(errstr, 4900); + setTimeout(consFill, 5000); + return; + } + + if(consConn.readyState != 3) + return; + + var len = consConn.responseText.length; + if (consLastIndex == len) // No new data + return; + + new_content = consConn.responseText.substring(consLastIndex, len); + consLastIndex = len; + } log("Console Rcvd: "+new_content); if(new_content.indexOf('<') != 0) new_content = new_content.replace(/ /g, " "); @@ -41,18 +56,41 @@ consFill() { FW_errmsg(""); - if(consConn) { - consConn.onreadystatechange = undefined; - consConn.abort(); + if(FW_pollConn) { + if($("body").attr("longpoll") == "websocket") { + FW_pollConn.close(); + } else { + FW_pollConn.abort(); + } + FW_pollConn = undefined; } - consConn = new XMLHttpRequest(); - var query = document.location.pathname+"?XHR=1"+ + + var query = "?XHR=1"+ "&inform=type=raw;withLog="+withLog+";filter="+consFilter+ "×tamp="+new Date().getTime(); query = addcsrf(query); - consConn.open("GET", query, true); - consConn.onreadystatechange = consUpdate; - consConn.send(null); + + if($("body").attr("longpoll") == "websocket") { + if(consConn) { + consConn.close(); + } + consConn = new WebSocket((location+query).replace(/^http/i, "ws")); + consConn.onclose = + consConn.onerror = + consConn.onmessage = consUpdate; + + } else { + if(consConn) { + consConn.onreadystatechange = undefined; + consConn.abort(); + } + consConn = new XMLHttpRequest(); + consConn.open("GET", location.pathname+query, true); + consConn.onreadystatechange = consUpdate; + consConn.send(null); + + } + consLastIndex = 0; if(oldFilter != consFilter) // only clear, when filter changes $("#console").html(""); diff --git a/fhem/www/pgm2/fhemweb.js b/fhem/www/pgm2/fhemweb.js index 13dbba405..f215498fd 100644 --- a/fhem/www/pgm2/fhemweb.js +++ b/fhem/www/pgm2/fhemweb.js @@ -670,24 +670,41 @@ var FW_longpollOffset = 0; var FW_leaving; function -FW_doUpdate() +FW_doUpdate(evt) { - if(FW_pollConn.readyState == 4 && !FW_leaving) { - if(FW_pollConn.status == "401") { - location.reload(); + var errstr = "Connection lost, trying a reconnect every 5 seconds."; + var input=""; + + if(evt.target instanceof WebSocket) { + if(evt.type == 'close') { + FW_errmsg(errstr, 4900); + FW_pollConn.close(); + FW_pollConn = undefined; + setTimeout(FW_longpoll, 5000); return; } - FW_errmsg("Connection lost, trying a reconnect every 5 seconds.", 4900); - setTimeout(FW_longpoll, 5000); - return; // some problem connecting + input = evt.data; + FW_longpollOffset = 0; + + } else { + if(FW_pollConn.readyState == 4 && !FW_leaving) { + if(FW_pollConn.status == "401") { + location.reload(); + return; + } + FW_errmsg(errstr, 4900); + setTimeout(FW_longpoll, 5000); + return; + } + + if(FW_pollConn.readyState != 3) + return; + + input = FW_pollConn.responseText; } - if(FW_pollConn.readyState != 3) - return; - - var input = FW_pollConn.responseText; var devs = new Array(); - if(input.length <= FW_longpollOffset) + if(!input || input.length <= FW_longpollOffset) return; FW_serverLastMsg = (new Date()).getTime()/1000; @@ -757,7 +774,6 @@ FW_longpoll() FW_pollConn.abort(); } - FW_pollConn = new XMLHttpRequest(); FW_leaving = 0; // Build the notify filter for the backend @@ -809,14 +825,25 @@ FW_longpoll() if(FW_serverGenerated) since = FW_serverLastMsg + (FW_serverGenerated-FW_serverFirstMsg); - var query = location.pathname+"?XHR=1"+ + var query = "?XHR=1"+ "&inform=type=status;filter="+filter+";since="+since+";fmt=JSON"+ '&fw_id='+$("body").attr('fw_id')+ "×tamp="+new Date().getTime(); query = addcsrf(query); - FW_pollConn.open("GET", query, true); - FW_pollConn.onreadystatechange = FW_doUpdate; - FW_pollConn.send(null); + + if($("body").attr("longpoll") == "websocket") { + FW_pollConn = new WebSocket((location+query).replace(/^http/i, "ws")); + FW_pollConn.onclose = + FW_pollConn.onerror = + FW_pollConn.onmessage = FW_doUpdate; + + } else { + FW_pollConn = new XMLHttpRequest(); + FW_pollConn.open("GET", location.pathname+query, true); + FW_pollConn.onreadystatechange = FW_doUpdate; + FW_pollConn.send(null); + + } log("Longpoll with filter "+filter); }