From c74ea09972d0af30d717230255b490b1c6566d13 Mon Sep 17 00:00:00 2001 From: Pat Thoyts Date: Sun, 25 Jun 2023 22:49:04 +0100 Subject: [PATCH 1/1] Initial version --- .gitignore | 1 + requirements.txt | 1 + spectrumviewer.py | 74 +++++++++++++++++++++ wdf-opc-monitor.py | 156 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 spectrumviewer.py create mode 100755 wdf-opc-monitor.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ab4424 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +asyncua diff --git a/spectrumviewer.py b/spectrumviewer.py new file mode 100644 index 0000000..c8e18d9 --- /dev/null +++ b/spectrumviewer.py @@ -0,0 +1,74 @@ +import tkinter as tk +import tkinter.font as tkfont + +class SpectrumViewer(tk.Canvas): + def __init__(self, master, *args, **kwargs): + self.xlist = [] + self.ilist = [] + self.font = tkfont.nametofont('TkDefaultFont', root=master) + self.yaxis_space = 40 + self.xaxis_space = 10 + super(SpectrumViewer, self).__init__(master, *args, **kwargs) + self.create_line((0, 0, 0, 0), tag="Spectrum", fill='red', width=1) + self.create_line((0, 0, 0, 0), tag='XAxis', fill='black', width=1) + self.create_line((0, 0, 0, 0), tag='YAxis', fill='black', width=1) + self.create_text((0,0), tag='XMin', anchor=tk.NW, font=self.font) + self.create_text((0,0), tag='XMax', anchor=tk.NE, font=self.font) + self.create_text((0,0), tag='YMin', anchor=tk.SE, font=self.font) + self.create_text((0,0), tag='YMax', anchor=tk.NE, font=self.font) + self.create_text((0, 0), tag='Title', anchor=tk.CENTER, font=self.font) + self.bind('', self.on_resize) + + def title(self, text): + self.itemconfigure('Title', text=text) + + def on_resize(self, event): + """Replot on window resize events""" + self.replot(True) + + def adjust_axes(self): + """Resize the axes space for the current font.""" + width = self.font.measure('0'*6) + height = self.font.metrics()['linespace'] + self.yaxis_space = width + self.xaxis_space = height + + def replot(self, resized=False): + """ + Update the canvas graph lines from the cached data lists. + The lines are scaled to match the canvas size as the window may + be resized by the user. + """ + self.adjust_axes() + width = self.winfo_width() - self.yaxis_space + height = self.winfo_height() - self.xaxis_space + if self.ilist and self.xlist: + ymin = min(self.ilist) + ymax = max(self.ilist) + yrange = ymax - ymin + npoints = len(self.xlist) + coords = [] + for n in range(0, npoints): + x = (width * n) / npoints + self.yaxis_space + y = height - self.xaxis_space + if yrange != 0: + yval = ((self.ilist[n] - ymin) * height) / yrange + y -= yval + coords.append(x) + coords.append(y) + self.coords('Spectrum', *coords) + self.itemconfigure('XMin', text=f"{self.xlist[0]:.0f}") + self.itemconfigure('XMax', text=f"{self.xlist[-1]:.0f}") + self.itemconfigure('YMin', text=f"{ymin:.0f}") + self.itemconfigure('YMax', text=f"{ymax:.0f}") + if resized: + y = height - self.xaxis_space + coords = [self.yaxis_space, y, width+self.yaxis_space, y] + self.coords('XAxis', coords) + coords = [self.yaxis_space, 0, self.yaxis_space, height-self.xaxis_space] + self.coords('YAxis', coords) + self.coords('XMin', [self.yaxis_space, height-self.xaxis_space]) + self.coords('XMax', [width+self.yaxis_space, height-self.xaxis_space]) + self.coords('YMin', [self.yaxis_space, height-self.xaxis_space]) + self.coords('YMax', [self.yaxis_space, 0]) + self.coords('Title', [width/2, 10]) diff --git a/wdf-opc-monitor.py b/wdf-opc-monitor.py new file mode 100755 index 0000000..061c42c --- /dev/null +++ b/wdf-opc-monitor.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 + +import sys +import tkinter as tk +import tkinter.ttk as ttk +import tkinter.messagebox as tkmessagebox +import asyncio +import threading +import logging +from asyncua import Client, Node, ua +from queue import Queue, Empty +from spectrumviewer import SpectrumViewer + +APP_TITLE = "OPC Monitor" + +logging.basicConfig(level=logging.WARNING) +_logger = logging.getLogger('asyncua') + + +class SubscriptionHandler: + """ + The SubscriptionHandler is used to handle the data that is received for the subscription. + """ + def __init__(self, app): + self.app = app + self.reverse = False + + def datachange_notification(self, node: Node, val, data): + """ + Callback for asyncua Subscription. + This method will be called when the Client received a data change message from the Server. + """ + _logger.info('datachange_notification %r', node) + _,id = str(node).split(';') + id = id.split('.')[-1] + if id == 'xlist': + if val[0] > val[1]: + val.reverse() + self.reverse = True + else: + self.reverse = False + if id == 'ilist' and self.reverse: + val.reverse() + if id in ('xlist', 'ilist', 'index'): + self.app.queue.put((id, val)) + self.app.event_generate('<>') + + +class OpcClient: + def __init__(self, app): + self.thread = threading.Thread(target=self.thread_main, args=(self, app)) + self.thread.daemon = True + self.event = asyncio.Event() + self.app = app + + def start(self): + self.event.clear() + self.thread.start() + + def stop(self): + self.event.set() + + def thread_main(self, self2, app): + print(f"thread_main: {self} {self2} {app}") + self.handler = SubscriptionHandler(app) + asyncio.run(self.main()) + + async def main(self): + client = Client(url='opc.tcp://localhost:4840/') + async with client: + idx = await client.get_namespace_index(uri="urn:renishaw.spd") + xlist_node = await client.nodes.objects.get_child([f"{idx}:Renishaw", f"{idx}:SPD", f"{idx}:Spectrum", f"{idx}:xlist"]) + ilist_node = await client.nodes.objects.get_child([f"{idx}:Renishaw", f"{idx}:SPD", f"{idx}:Spectrum", f"{idx}:ilist"]) + index_node = await client.nodes.objects.get_child([f"{idx}:Renishaw", f"{idx}:SPD", f"{idx}:Spectrum", f"{idx}:index"]) + + subscription = await client.create_subscription(500, self.handler) + nodes = [xlist_node, ilist_node, index_node] + # We subscribe to data changes for two nodes (variables). + await subscription.subscribe_data_change(nodes) + # We let the subscription run for ten seconds + await asyncio.wait([self.event.wait()]) + # We delete the subscription (this un-subscribes from the data changes of the two variables). + # This is optional since closing the connection will also delete all subscriptions. + await subscription.delete() + # After one second we exit the Client context manager - this will close the connection. + await asyncio.sleep(1) + + +class App(ttk.Frame): + def __init__(self, master, *args, **kwargs): + super(App, self).__init__(master, *args, **kwargs) + master.wm_withdraw() + master.wm_title(APP_TITLE) + self.create_ui() + self.grid(sticky = "news") + self.bind('<>', self.on_opc_data_received) + master.wm_protocol("WM_DELETE_WINDOW", self.on_destroy) + master.grid_rowconfigure(0, weight=1) + master.grid_columnconfigure(0, weight=1) + master.wm_deiconify() + self.queue = Queue() + self.client = OpcClient(self) + self.after(100, self.connect) + + def on_destroy(self): + self.client.stop() + self.master.destroy() + + def create_ui(self): + menu = tk.Menu(self) + self.winfo_toplevel().configure(menu=menu) + filemenu = tk.Menu(menu) + menu.add_cascade(label="File", menu=filemenu) + filemenu.add_command(label="Connect", command=self.connect) + self.canvas = canvas = SpectrumViewer(self, background='white') #tk.Canvas(self, background="white") + canvas.grid(row=0, column=0, sticky=tk.NSEW) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + def connect(self): + self.client.start() + + def on_opc_data_received(self, ev): + if not self.queue.empty(): + dtype,data = self.queue.get() + if dtype == 'xlist': + self.canvas.xlist = data + elif dtype == 'ilist': + self.canvas.ilist = data + self.after_idle(self.canvas.replot) + elif dtype == 'index': + self.canvas.title(f"Acquisition #{data}") + +def load_idle(root): + """Load the IDLE shell""" + try: + import idlelib.pyshell as pyshell + sys.argv = [sys.argv[0], "-n"] + root.bind("", lambda ev: pyshell.main()) + root.bind("", lambda ev: pyshell.main()) + except ModuleNotFoundError: + pass + +def main(args=None): + global app, root + root = tk.Tk() + app = App(root) + root.after(200, load_idle, root) + try: + root.mainloop() + except Exception as e: + tkmessagebox.showerror('Error', e) + return 0 + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) -- 2.23.0