"""
A python module for GridLAB-D data (JSON) data exchange over UDP and TCP
Classes:
MasterXchg
SlaveXchg
Implemented Methods:
Notes:
-- Should work with Python 2.6+ and 3.x. Tested with 2.7.2 and 3.3
-- Currently only supports IPv4
-- The SLAVE will handle connections from any number of masters, BUT since the
code is only single threaded each processing (including delay) are
blocking. Hence, the SLAVE may miss packets if it is busy processing
(or waiting to send) a different reply.
@author: Bryan Palmintier, NREL 2013
"""
# Enable use of Python 3.0 print statement in older (2.x) versions, ignored in
# newer (3.x) versions
# Must occur at top of file
from __future__ import print_function
__author__ = "Bryan Palmintier"
__copyright__ = "Copyright (c) 2013 Bryan Palmintier"
__license__ = """
@copyright: Copyright (c) 2013, Bryan Palmintier
@license: BSD 3-clause --
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1) Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2) Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3) Neither the name of the National Renewable Energy Lab nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."""
## ===== History ====
# [Current] version date time who Comments
# ------- ---------- ----- ---------- -------------
__version__, __date__ = "0.4.1b","2013-04-29" #22:20 BryanP BUGFIX: receive timeout when connect not ready. Move receive to _BaseXchg
# [Older, in reverse order]
# version date time who Comments
# ------- ---------- ----- ---------- ---------------------------------------
# 0.4.0b 2013-04-28 22:20 BryanP RENAMED to raw_xchg & methods to Xchg* and Errors to RawXchg*Error
# 0.3.1a 2013-04-27 02:00 BryanP Throw exception when can't send. Added codes to Exceptions, Add "Raw" prefix to verbose msg
# 0.3.0a 2013-04-25 16:23 BryanP Use Exceptions for error handling. Renamed Link* to Xchg*
# 0.2.0a 2013-04-25 03:30 BryanP Works! Added Master TCP timeouts
# 0.1.0a 2013-04-24 21:43 BryanP Extracted from json_test.py v1.6.1a
#Standard Library imports
import socket
import timeit
import sys
import time
#===============================================================================
# Module level constants
#===============================================================================
UDP = socket.SOCK_DGRAM
TCP = socket.SOCK_STREAM
HEAD_VER = '0'
MSG_TYPE = 'JSON'
MSG_VER = '1.0'
#===============================================================================
# Error (Exception) Class definitions
#===============================================================================
[docs]class RawXchgError(IOError):
"""Base class for exceptions in the raw_xchg module"""
code = 1000
[docs]class RawXchgTimeoutError(RawXchgError):
"""Timeout waiting for reply"""
code = RawXchgError.code + 1
[docs]class RawXchgSetupError(RawXchgError):
"""Problems setting up the communication (e.g. opening the socket)"""
code = RawXchgError.code + 2
[docs]class RawXchgNoDataError(RawXchgError):
"""No data read"""
code = RawXchgError.code + 4
[docs]class RawXchgCantSendError(RawXchgError):
"""Unable to send packet"""
code = RawXchgError.code + 5
#===============================================================================
# _BaseXchg (module internal)
#===============================================================================
class _BaseXchg(object):
""" Base class for UDP/TCP link subclasses"""
#socket and settings
_sock = None
PROTOCOL = None
HOST_ADDR = None
PORT = None
TIMEOUT = None
BUF_SIZE = 4906
response_code_out = None #override in subclass
#Options
opt_verbose = True
opt_header = True
ignore_head_err = False
ignore_no_data_err = True
#Track link statistics
num_err = 0
num_tx = 0
num_rx = 0
def __init__(self, host_addr, port, protocol,
opt_verbose=True, opt_header=True, timeout=None,
buf_size=4906):
"""Constructor"""
self.HOST_ADDR = host_addr
self.PORT = port
self.PROTOCOL = protocol
self.opt_verbose = opt_verbose
self.opt_header = opt_header
self.TIMEOUT = max(timeout,0)
self.BUF_SIZE = buf_size
def isReady(self):
"""Returns True if we have assigned the socket. Does not check socket connectivity"""
return self._sock is not None
#-------------------
# wrapPacket
#-------------------
# From: https://sourceforge.net/apps/mediawiki/gridlab-d/index.php?title=JSON_link_protocol
#
# Bytes Contents
# --------- --------------------------------------------------------------------------------
# 00-01 Header version (currently "0", space padded)
# 02-05 Offset to message from byte 00 (currently always 32, max is 999, space padded)
# 06-13 Size of message (varies, max is 9999999, left justified, space padded), count does not include string termination character
# 14-19 Type of message (currently "JSON", left justified, space padded)
# 20-23 Version of type (currently "1.0", left justified, space padded)
# 24-25 Connection status (0=closed, 1-9=keep-alive timeout, space padded)
# 26-29 Response code (0 for requests, must be 200 for responses, left justified, space padded)
# 30-31 Reserved (space padded)
# 32- Message content (current JSON 1.0 format)
#
# Byte Number 111111111122222222223333333333444444444455555555556666666666
# 0123456789 123456789 123456789 123456789 123456789 123456789 123456789
# Master Ex: '0 32 27 JSON 1.0 1 0 {"method":"example","id":1}'
# Slave Ex: '0 32 27 JSON 1.0 1 200 {"result":"example","id":1}'
# Message Length 111111111122222222223333333333444444444455555555556666666666
# 123456789 123456789 123456789 123456789 123456789 123456789 123456789
def wrapPacket(self, msg, keep_alive=9):
""" Add GridLAB UDP/TCP header and return complete data packet to send"""
msg_size = len(msg)
#Build potentially variable portion of header
# 0123456- 234-892123 456- 9312
head_str = "%-7d %5s %3s %d %-3s "%(msg_size, MSG_TYPE, MSG_VER, keep_alive, self.response_code_out)
#Measure current size and add 5bytes for the header ver & msg offset
head_len = len(head_str) + 6
assert head_len==32, "outgoing packet header length should be 32 not %d"%head_len
#Return the complete Packet String
return "%1s %-3d %s%s"%(HEAD_VER, head_len, head_str, msg)
#-------------------
# unwrapPacket
#-------------------
def unwrapPacket(self, packet_str):
""" Remove GridLAB UDP/TCP header, check validity and return Packet JSON content"""
body = None
reopen_conn = False
if len(packet_str) == 0:
raise RawXchgNoDataError("No Packet Data")
if packet_str[0] != HEAD_VER:
raise RawXchgHeaderError("Header: unsupported version must be %s not %s"%(HEAD_VER, packet_str[0]))
try:
#Extract header size (data offset) and split header and body
head_len = int(packet_str[2:5])
head = packet_str[0:head_len]
body = packet_str[head_len:]
#Attempt to extract whitespace delimited header
_head_ver, _offset, msg_size, msg_type, msg_ver, conn_status, response = head.split()
except:
raise RawXchgHeaderError('Header: Invalid Format-must have 7 whitespace delimited fields')
#Verify/Parse header contents
# Note: already checked head_ver & offset is redundant b/c we used the same
# same data to split head and body. (At least assuming reasonable space
# delimiting
if int(msg_size) != len(body):
raise RawXchgHeaderError('Header: message length mismatch (%s vs %d actual)'%(msg_size, len(body)))
if msg_type != MSG_TYPE:
raise RawXchgHeaderError('Header: Unsupported message type (%s). Must be %s'%(msg_type, MSG_TYPE))
if msg_ver != MSG_VER:
raise RawXchgHeaderError('Header: Unsupported message version (%s). Must be %s'%(msg_ver, MSG_VER))
if response != self.response_code_in:
raise RawXchgHeaderError('Header: Invalid request (%s), expected %s'%(response, self.response_code_in))
#Extract connection re-open flag
if int(conn_status) <= 0:
reopen_conn = True
return body, reopen_conn
def close(self):
"""gracefully close the current socket"""
try:
self._sock.shutdown(socket.SHUT_RDWR)
except socket.error:
pass
try:
self._sock.close()
except socket.error:
pass
self._sock = None
def receive(self):
"""Receive data and process. May need to be proceeded by some additional setup"""
if self._sock is None:
self.setupXchg()
msg_in = None
#Listen for data with timeout
end_time = timeit.default_timer() + self.TIMEOUT
not_run_once = True
while timeit.default_timer() < end_time or not_run_once:
not_run_once = False
try:
msg_in, self._remote_addr = self._sock.recvfrom(self.BUF_SIZE)
except socket.error:
time.sleep(self.TIMEOUT/1000)
continue
if msg_in:
break
if not msg_in:
raise RawXchgTimeoutError('Receive timeout')
msg_in=msg_in.decode('utf-8')
if self.opt_verbose:
print(" Raw RX: %s"%(msg_in))
if self.opt_header:
try:
msg_in, reopen_conn = self.unwrapPacket(msg_in)
except RawXchgHeaderError:
err = sys.exc_info()[1] #Use instead of "as err" For compatibility with python < 2.6
if self.ignore_head_err:
print('WARNING: %s'%err)
msg_in = None
else:
raise err
except RawXchgNoDataError:
err = sys.exc_info()[1] #Use instead of "as err" For compatibility with python < 2.6
if self.ignore_no_data_err:
print('WARNING: %s'%err)
msg_in = None
else:
raise err
else:
if reopen_conn:
self.close()
return msg_in
#===============================================================================
# MasterXchg
#===============================================================================
[docs]class MasterXchg(_BaseXchg):
""" Implements a low-level GridLAB-D (JSON) UDP/TCP Master/client link"""
response_code_out = '0'
response_code_in = '200'
[docs] def setupXchg(self):
if not self.isReady():
self._sock = socket.socket(family=socket.AF_INET, type=self.PROTOCOL)
try:
self._sock.connect((self.HOST_ADDR, self.PORT))
except Exception:
print("ERROR: Can't Connect to Slave (Are you using the right packet type?)")
self._sock=None
return
self._sock.settimeout(self.TIMEOUT)
[docs] def send(self, msg_out):
"""Wrap message with a header and send out"""
if not self.isReady():
self.setupXchg()
if self.opt_header:
msg_out = self.wrapPacket(msg_out)
try:
self._sock.sendall(msg_out.encode('utf-8'))
except IOError:
if self.opt_verbose:
print("Unable to Send Packet")
self.close()
raise RawXchgCantSendError("Unable to Send Packet")
if self.opt_verbose:
print(" Raw TX: %s"%(msg_out))
#===============================================================================
# SlaveXchg
#===============================================================================
[docs]class SlaveXchg(_BaseXchg):
""" Implements a low_level GridLAB-D (JSON) UDP/TCP Slave/server link"""
response_code_out = '200'
response_code_in = '0'
_reopen_conn = False
_remote_addr = None
_connected = False
_conn = None
[docs] def close(self):
"""Expanded close to handle our state flags"""
if self.PROTOCOL == UDP:
_BaseXchg.close(self)
else:
self._reopen_conn = False
self._connected = False
if self._conn is not None:
try:
self._conn.shutdown(socket.SHUT_RDWR)
except socket.error:
pass
try:
self._conn.close()
except IOError:
pass
self._conn = None
[docs] def setupXchg(self):
"""Setup (Internet) socket for Slave."""
if not self.isReady():
self._sock = socket.socket(socket.AF_INET, self.PROTOCOL)
try:
self._sock.bind((self.HOST_ADDR, self.PORT))
except OSError:
self.close()
if self._sock is None:
raise RawXchgSetupError('could not bind to socket')
if self.PROTOCOL == TCP:
self._sock.listen(1)
self._conn, self._remote_addr = self._sock.accept() #Updates our socket for connection
self._connected = True
self._conn.settimeout(self.TIMEOUT)
print('Connected by', self._remote_addr)
self._sock.settimeout(self.TIMEOUT)
[docs] def receive(self):
"""(Slave) wait to receive a packet, decode the header, and return data"""
if self._sock is None:
self.setupXchg()
if self.PROTOCOL == TCP:
self.setupXchg()
return _BaseXchg.receive(self)
[docs] def send(self, msg_out):
"""Wrap message with a header and send out"""
if self.opt_header:
msg_out = self.wrapPacket(msg_out)
if self.PROTOCOL == TCP:
self._conn.send(msg_out.encode('utf-8'))
else:
self._sock.sendto(msg_out.encode('utf-8'), self._remote_addr)
if self.opt_verbose:
print(" Raw TX: %s"%(msg_out))
if self._reopen_conn:
self.close()