208 lines
8.4 KiB
Python
208 lines
8.4 KiB
Python
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
# ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
|
|
# ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
|
|
# ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
|
|
# ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
|
|
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
|
# ┃ Copyright (c) 2017, the Perspective Authors. ┃
|
|
# ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
|
|
# ┃ This file is part of the Perspective library, distributed under the terms ┃
|
|
# ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
|
|
# ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
|
|
|
import logging
|
|
import tornado.websocket
|
|
import tornado.web
|
|
import tornado.ioloop
|
|
from datetime import date, datetime
|
|
import perspective
|
|
import perspective.handlers.tornado
|
|
import json
|
|
import taosws
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
logger = logging.getLogger('main')
|
|
|
|
|
|
# =============================================================================
|
|
# TDengine connection parameters
|
|
# =============================================================================
|
|
TAOS_HOST = "localhost" # TDengine server host
|
|
TAOS_PORT = 6041 # TDengine server port
|
|
TAOS_USER = "root" # TDengine username
|
|
TAOS_PASSWORD = "taosdata" # TDengine password
|
|
|
|
TAOS_DATABASE = "power" # TDengine database name
|
|
TAOS_TABLENAME = "meters" # TDengine table name
|
|
|
|
# =============================================================================
|
|
# Perspective server parameters
|
|
# =============================================================================
|
|
PERSPECTIVE_TABLE_NAME = "meters_values" # name of the Perspective table
|
|
PERSPECTIVE_REFRESH_RATE = 250 # refresh rate in milliseconds
|
|
|
|
|
|
class CustomJSONEncoder(json.JSONEncoder):
|
|
"""
|
|
Custom JSON encoder that serializes datetime and date objects
|
|
"""
|
|
def default(self, obj):
|
|
if isinstance(obj, datetime):
|
|
return obj.isoformat()
|
|
elif isinstance(obj, date):
|
|
return obj.isoformat()
|
|
return super().default(obj)
|
|
|
|
|
|
json.JSONEncoder.default = CustomJSONEncoder().default
|
|
|
|
|
|
def convert_ts(ts) -> datetime:
|
|
"""
|
|
Convert a timestamp string to a datetime object
|
|
"""
|
|
for fmt in ('%Y-%m-%d %H:%M:%S.%f %z', '%Y-%m-%d %H:%M:%S %z'):
|
|
try:
|
|
return datetime.strptime(ts, fmt)
|
|
except ValueError:
|
|
continue
|
|
raise ValueError(f"Time data '{ts}' does not match any format")
|
|
|
|
|
|
def create_tdengine_connection(
|
|
host: str = TAOS_HOST,
|
|
port: int = TAOS_PORT,
|
|
user: str = TAOS_USER,
|
|
password: str = TAOS_PASSWORD,
|
|
) -> taosws.Connection:
|
|
try:
|
|
# connect to the tdengine server
|
|
conn = taosws.connect(
|
|
user=user,
|
|
password=password,
|
|
host=host,
|
|
port=port,
|
|
)
|
|
# switch to the right database
|
|
conn.execute(f"USE {TAOS_DATABASE}")
|
|
# connection successful
|
|
logger.info(f"Connected to tdengine successfully: {host}:{port}")
|
|
return conn
|
|
except Exception as err:
|
|
logger.error(f"Failed to connect to tdengine: {host}:{port} -- ErrMessage: {err}")
|
|
raise err
|
|
|
|
|
|
def read_tdengine(
|
|
conn: taosws.Connection,
|
|
) -> list[dict]:
|
|
try:
|
|
# query the database
|
|
sql = f"""
|
|
SELECT `ts`, location, groupid, current, voltage, phase
|
|
FROM {TAOS_TABLENAME}
|
|
WHERE `ts` >= NOW() - 12h
|
|
ORDER BY `ts` DESC
|
|
LIMIT 1000
|
|
"""
|
|
logger.debug(f"Executing query: {sql}")
|
|
res = conn.query(sql)
|
|
data = [
|
|
{
|
|
"timestamp": convert_ts(row[0]),
|
|
"location": row[1],
|
|
"groupid": row[2],
|
|
"current": row[3],
|
|
"voltage": row[4],
|
|
"phase": row[5],
|
|
}
|
|
for row in res
|
|
]
|
|
logger.info(f"select result: {data}")
|
|
return data
|
|
except Exception as err:
|
|
logger.error(f"Failed to query tdengine: {err}")
|
|
raise err
|
|
|
|
|
|
// ANCHOR: perspective_server
|
|
def perspective_thread(perspective_server: perspective.Server, tdengine_conn: taosws.Connection):
|
|
"""
|
|
Create a new Perspective table and update it with new data every 50ms
|
|
"""
|
|
# create a new Perspective table
|
|
client = perspective_server.new_local_client()
|
|
schema = {
|
|
"timestamp": datetime,
|
|
"location": str,
|
|
"groupid": int,
|
|
"current": float,
|
|
"voltage": int,
|
|
"phase": float,
|
|
}
|
|
# define the table schema
|
|
table = client.table(
|
|
schema,
|
|
limit=1000, # maximum number of rows in the table
|
|
name=PERSPECTIVE_TABLE_NAME, # table name. Use this with perspective-viewer on the client side
|
|
)
|
|
logger.info("Created new Perspective table")
|
|
|
|
# update with new data
|
|
def updater():
|
|
data = read_tdengine(tdengine_conn)
|
|
table.update(data)
|
|
logger.debug(f"Updated Perspective table: {len(data)} rows")
|
|
|
|
logger.info(f"Starting tornado ioloop update loop every {PERSPECTIVE_REFRESH_RATE} milliseconds")
|
|
# start the periodic callback to update the table data
|
|
callback = tornado.ioloop.PeriodicCallback(callback=updater, callback_time=PERSPECTIVE_REFRESH_RATE)
|
|
callback.start()
|
|
|
|
// ANCHOR_END: perspective_server
|
|
|
|
def make_app(perspective_server):
|
|
"""
|
|
Create a new Tornado application with a websocket handler that
|
|
serves a Perspective table. PerspectiveTornadoHandler handles
|
|
the websocket connection and streams the Perspective table changes
|
|
to the client.
|
|
"""
|
|
return tornado.web.Application([
|
|
(
|
|
r"/websocket", # websocket endpoint. Use this URL to configure the websocket client OR Prospective Server adapter
|
|
perspective.handlers.tornado.PerspectiveTornadoHandler, # PerspectiveTornadoHandler handles perspective table updates <-> websocket client
|
|
{"perspective_server": perspective_server}, # pass the perspective server to the handler
|
|
),
|
|
])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logger.info("TDEngine <-> Perspective Demo")
|
|
|
|
# create a new Perspective server
|
|
logger.info("Creating new Perspective server")
|
|
perspective_server = perspective.Server()
|
|
# create the tdengine connection
|
|
logger.info("Creating new TDEngine connection")
|
|
tdengine_conn = create_tdengine_connection()
|
|
|
|
# setup and start the Tornado app
|
|
logger.info("Creating Tornado server")
|
|
app = make_app(perspective_server)
|
|
app.listen(8085, address='0.0.0.0')
|
|
logger.info("Listening on http://localhost:8080")
|
|
|
|
try:
|
|
# start the io loop
|
|
logger.info("Starting ioloop to update Perspective table data via tornado websocket...")
|
|
loop = tornado.ioloop.IOLoop.current()
|
|
loop.call_later(0, perspective_thread, perspective_server, tdengine_conn)
|
|
loop.start()
|
|
except KeyboardInterrupt:
|
|
logger.warning("Keyboard interrupt detected. Shutting down tornado server...")
|
|
loop.stop()
|
|
loop.close()
|
|
logging.info("Shut down")
|