From ae19be15474bae2a288f674a33e30089d82c79b3 Mon Sep 17 00:00:00 2001 From: Pat Thoyts Date: Tue, 4 Jul 2023 15:56:58 +0100 Subject: [PATCH] Support uri argument and fix flake8 warnings Amended to expect the Monitor output tree. --- .gitignore | 1 + spectrumviewer.py | 28 ++++++----- tox.ini | 20 ++++++++ wdf-opc-monitor.py | 118 +++++++++++++++++++++++++++++++-------------- 4 files changed, 118 insertions(+), 49 deletions(-) create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index c18dd8d..de21d94 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ __pycache__/ +.tox/ diff --git a/spectrumviewer.py b/spectrumviewer.py index c8e18d9..6ae7569 100644 --- a/spectrumviewer.py +++ b/spectrumviewer.py @@ -1,24 +1,25 @@ 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.font = tkfont.nametofont('TkDefaultFont') 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='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) @@ -28,7 +29,7 @@ class SpectrumViewer(tk.Canvas): def adjust_axes(self): """Resize the axes space for the current font.""" - width = self.font.measure('0'*6) + width = self.font.measure('0' * 6) height = self.font.metrics()['linespace'] self.yaxis_space = width self.xaxis_space = height @@ -63,12 +64,13 @@ class SpectrumViewer(tk.Canvas): self.itemconfigure('YMax', text=f"{ymax:.0f}") if resized: y = height - self.xaxis_space - coords = [self.yaxis_space, y, width+self.yaxis_space, y] + 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] + 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('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]) + self.coords('Title', [width / 2, 10]) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1c71622 --- /dev/null +++ b/tox.ini @@ -0,0 +1,20 @@ +[tox] +envlist = py310, py38, lint +skip_missing_interpreters = True + +[testenv] +setenv = + PYTHONPATH = {toxinidir} +deps = + pytest +commands = + py.test + +[testenv:lint] +deps = + flake8 +commands = + flake8 + +[flake8] +max-line-length = 120 diff --git a/wdf-opc-monitor.py b/wdf-opc-monitor.py index 061c42c..a8e3a30 100755 --- a/wdf-opc-monitor.py +++ b/wdf-opc-monitor.py @@ -1,16 +1,22 @@ #!/usr/bin/env python3 import sys +import argparse 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 asyncua import Client, Node +from queue import Queue from spectrumviewer import SpectrumViewer +if sys.platform == 'win32': + # Fix for tiny menus on high dpi display + from ctypes import windll + windll.shcore.SetProcessDpiAwareness(1) + APP_TITLE = "OPC Monitor" logging.basicConfig(level=logging.WARNING) @@ -24,15 +30,17 @@ class SubscriptionHandler: 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 = str(node).split(';') id = id.split('.')[-1] + if id.startswith('s='): + id = id.split('=')[-1].lower() if id == 'xlist': if val[0] > val[1]: val.reverse() @@ -41,65 +49,78 @@ class SubscriptionHandler: self.reverse = False if id == 'ilist' and self.reverse: val.reverse() - if id in ('xlist', 'ilist', 'index'): + if id in ('xlist', 'ilist', 'index', 'time'): 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() + def __init__(self, app, uri): + self.uri = uri + self.thread = None + self.event = None self.app = app def start(self): - self.event.clear() - self.thread.start() - + if self.event: + self.event.clear() + if not self.thread: + self.thread = threading.Thread(target=self.thread_main, args=(app,)) + self.thread.daemon = True + self.thread.start() + def stop(self): - self.event.set() + if self.event: + self.event.set() - def thread_main(self, self2, app): - print(f"thread_main: {self} {self2} {app}") + def thread_main(self, app): self.handler = SubscriptionHandler(app) asyncio.run(self.main()) + self.thread = None + self.event = None async def main(self): - client = Client(url='opc.tcp://localhost:4840/') + if not self.event: + self.event = asyncio.Event() # ensure this gets attached to THIS event loop. + client = Client(url=self.uri) 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"]) + xlist_node = await client.nodes.objects.get_child([f"{idx}:Renishaw", f"{idx}:Spectrum", f"{idx}:XList"]) + ilist_node = await client.nodes.objects.get_child([f"{idx}:Renishaw", f"{idx}:Spectrum", f"{idx}:IList"]) + index_node = await client.nodes.objects.get_child([f"{idx}:Renishaw", f"{idx}:Spectrum", f"{idx}:Index"]) + time_node = await client.nodes.objects.get_child([f"{idx}:Renishaw", f"{idx}:Spectrum", f"{idx}:Time"]) - subscription = await client.create_subscription(500, self.handler) - nodes = [xlist_node, ilist_node, index_node] + subscription = await client.create_subscription(200, self.handler) + nodes = [xlist_node, ilist_node, index_node, time_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()]) + # Run until we get signalled to stop + wait_task = asyncio.Task(self.event.wait()) + await wait_task # 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): + def __init__(self, master, options, *args, **kwargs): super(App, self).__init__(master, *args, **kwargs) + self.options = options + self.index = None + self.timestamp = None master.wm_withdraw() master.wm_title(APP_TITLE) self.create_ui() - self.grid(sticky = "news") + 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.client = OpcClient(self, self.options.uri) self.after(100, self.connect) def on_destroy(self): @@ -109,27 +130,46 @@ class App(ttk.Frame): def create_ui(self): menu = tk.Menu(self) self.winfo_toplevel().configure(menu=menu) - filemenu = tk.Menu(menu) + filemenu = tk.Menu(menu, tearoff=False) 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) + filemenu.add_command(label="Disconnect", command=self.disconnect) + filemenu.add_separator() + filemenu.add_command(label='Exit', command=self.on_destroy) + self.plot = SpectrumViewer(self, background='white') + self.plot.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 disconnect(self): + self.client.stop() + def on_opc_data_received(self, ev): if not self.queue.empty(): - dtype,data = self.queue.get() + dtype, data = self.queue.get() if dtype == 'xlist': - self.canvas.xlist = data + self.plot.xlist = data elif dtype == 'ilist': - self.canvas.ilist = data - self.after_idle(self.canvas.replot) + self.plot.ilist = data + self.after_idle(self.plot.replot) + elif dtype == 'time': + self.timestamp = data + self.update_title() elif dtype == 'index': - self.canvas.title(f"Acquisition #{data}") + self.index = data + self.update_title() + + def update_title(self): + if self.timestamp and self.index: + ndx = self.index + t = f"{self.timestamp:%H:%M:%S %d/%m/%Y}" + self.index = None + self.timestamp = None + self.plot.title(f"Acquisition #{ndx} {t}") + def load_idle(root): """Load the IDLE shell""" @@ -141,10 +181,15 @@ def load_idle(root): except ModuleNotFoundError: pass + def main(args=None): global app, root + parser = argparse.ArgumentParser(description="Renishaw OPC spectrum server viewer") + parser.add_argument('-u', '--uri', type=str, default='opc.tcp://localhost:4840', + help='Specify the OPC server URI.') + options = parser.parse_args((args)) root = tk.Tk() - app = App(root) + app = App(root, options) root.after(200, load_idle, root) try: root.mainloop() @@ -152,5 +197,6 @@ def main(args=None): tkmessagebox.showerror('Error', e) return 0 + if __name__ == '__main__': sys.exit(main(sys.argv[1:])) -- 2.23.0