Simple event graphing using Linux tools and gnuplot

A more efficient way to get JSON records is by connecting to any of the several undocumented, unsupported websock sockets. Here's a little Perl script that does exactly that. You run it like this:

$ ./websocket.pl ws://hubitat.local/eventsocket >>$LOGFILE &

Since Perl does output buffering when writing to a non-terminal, you may have to wait a while to see any output in the output file. You can send the script a SIGUSR1 to flush output, or you can run it to the terminal and watch the events roll by.

This script understands the following undocumented HE websock socket record formats:

- ws://hubitat.local/eventsocket
- ws://hubitat.local/logsocket
- ws://hubitat.local/zwaveLogsocket
- ws://hubitat.local/zigbeeLogsocket
- ...most anything else...

It prepends the current date (GMT seconds since the epoch) to all records read. For eventsocket records beginning with source:, it also wraps the JSON with a content:{} outer layer to match what Maker API produces.

#!/usr/bin/perl -w
#  $0 ws://path...
# Listen on a websocket URL, e.g. ws://hubitat.local/eventsocket
# Touch up and then dump the records read to STDOUT.
#  - Add the current time in UTC seconds since epoch (for gnuplot)
#  - Wrap the JSON record with ("content": ... } to match Maker API JSON
# Traps SIGHUP SIGINT SIGTERM and flushes buffers before exit.
# Traps SIGUSR1 and flushes buffers and does NOT exit.
#
# Modeled after: Simple WebSocket test client using blocking I/O
#  Greg Kennedy 2019
# Originally from https://metacpan.org/pod/AnyEvent::WebSocket::Client
# Removed all reading of stdin; this version is listen only.
# -Ian! D. Allen - idallen@idallen.ca - www.idallen.com

use v5.014;
use warnings;
use JSON;

# Core IO::Socket::INET for creating sockets to the Internet
# Protocol handler for WebSocket HTTP protocol
# Time conversion to create UTC seconds since epoch
#
use IO::Socket::INET;
use Protocol::WebSocket::Client;
use Time::Local qw( timegm_nocheck );

die "Usage: $0 ws://URL\n" unless @ARGV == 1;

my $url = $ARGV[0];

# Protocol::WebSocket takes a full URL, but IO::Socket::* uses only a host
#  and port.  This regex section retrieves host/port from URL.
my ($proto, $host, $port, $path);
if ($url =~ m/^(?:(?<proto>ws|wss):\/\/)?(?<host>[^\/:]+)(?::(?<port>\d+))?(?<path>\/.*)?$/)
{
    $host = $+{host};
    $path = $+{path};

    if (defined $+{proto} && defined $+{port}) {
        $proto = $+{proto};
        $port = $+{port};
    } elsif (defined $+{port}) {
        $port = $+{port};
        if ($port == 443) { $proto = 'wss' } else { $proto = 'ws' }
    } elsif (defined $+{proto}) {
        $proto = $+{proto};
        if ($proto eq 'wss') { $port = 443 } else { $port = 80 }
    } else {
        $proto = 'ws';
        $port = 80;
    }
} else {
    die "$0: Failed to parse Host/Port from URL: $url\n";
}

warn "$0: Opening blocking INET socket to $proto://$host:$port\n"
   if -t STDERR;

# Create a basic TCP socket connected to the remote server.
my $tcp_socket = IO::Socket::INET->new(
    PeerAddr => $host,
    PeerPort => "$proto($port)",
    Proto => 'tcp',
    Blocking => 1
) or die "$0: Failed to connect to socket for host '$host' port '$port':\n$@\n";

# Create a websocket protocol handler.
#  This doesn't actually "do" anything with the socket:
#  It just encodes / decode WebSocket messages.  We have to send them ourselves.
warn "$0: Creating Protocol::WebSocket::Client handler\n"
    if -t STDERR;
my $client = Protocol::WebSocket::Client->new(url => $url);

# Set up the various methods for the WS Protocol handler
#  On Write: take the buffer (WebSocket packet) and send it on the socket.
$client->on(
    write => sub {
        my $client = shift;
        my ($buf) = @_;
        syswrite $tcp_socket, $buf;
    }
);

# On Connect: this is what happens after the handshake succeeds, and we
#  are "connected" to the service.
$client->on(
    connect => sub {
        my $client = shift;
        warn "$0: Successfully connected to $url\n"
            if -t STDERR;
    }
);

# On Error, print to console.  This can happen if the handshake
#  fails for whatever reason.
$client->on(
    error => sub {
        my $client = shift;
        my ($buf) = @_;

        warn "$0: Error on websocket: $buf\n";
        $tcp_socket->close;
        exit(1);
    }
);

# On Read: Called whenever a complete WebSocket "frame" successfully parsed.
$client->on(
    read => sub {
        my $client = shift;
        local ($_) = @_;
        # Something is wrong with the charset of these records.  Fix them:
        if ( s/([^\xc2])\xb0/$1\xc2\xb0/g ) {
	    # warn "DEBUG Updated Latin-1 in $_\n"
	    #     if -t STDERR;
        }
        if ( s/\\u00B0/\xc2\xb0/g ) {
	    # warn "DEBUG Updated odd escape in $_\n"
	    #     if -t STDERR;
        }
        # Add the current epoch "time" to the Hubitat JSON record.
        # Convert the time to UTC for gnuplot.
	# We accept /eventsocket, /logsocket, /zwaveLogsocket, /zigbeeLogsocket
        my $time = timegm_nocheck(localtime());
        # Wrap an /eventsocket record with ("content": ... } to match Maker API
        if ( s/^\s*\{\s*"source"\s*:/{"content":{"date":"$time","source":/ ) {
	    # This is /eventsocket format, make same as Maker API JSON format
            print $_, "}\n";
            return;
        }
	# Get here for all other JSON formats
	if ( s/^\s*\{\s*/{"date":"$time",/ ) {
            print $_, "\n";
	    return;
	}
        warn "$0: Unable to parse: $_\n";
    }
);

# Signals caught to allow exit with buffers flushing
$SIG{'HUP'} = $SIG{'INT'} = $SIG{'TERM'} = sub {
    # Sends correct close header
    $client->disconnect;
    die "$0: Caught signal @_ $!\n";
};
# SIGUSR1 used to flush buffers without exiting
$SIG{'USR1'} = sub {
    $| = 1;              # flush the output
    $| = 0;
};

# Now that we've set all that up, call connect on $client.
#  This causes the Protocol object to create a handshake and write it
#  (using the on_write method we specified - which includes sysread $tcp_socket)
warn "$0: Connecting to client\n"
    if -t STDERR;
$client->connect;
# Now, we go into a loop, calling sysread and passing results to client->read.
#  The client Protocol object knows what to do with the data, and will
#  call our hooks (on_connect, on_read, on_read, on_read...) accordingly.
while ($tcp_socket->connected) {
    # await response
    my $recv_data;
    my $bytes_read = sysread $tcp_socket, $recv_data, 16384;

    if (!defined $bytes_read) {
        next if $! =~ /interrupted/i;   # because of SIGUSR1 handler
        warn "$0: sysread on tcp_socket failed: $!\n";
        last;
    }
    if ($bytes_read == 0) {
        warn "$0: Connection terminated (EOF).\n";
        last;
    }
    # unpack response - this triggers any handler if a complete packet is read.
    $client->read($recv_data);
}
# Sends correct close header
$client->disconnect;