Initial version
authorPat Thoyts <pat.thoyts@gmail.com>
Sun, 25 Jun 2023 21:49:04 +0000 (22:49 +0100)
committerPat Thoyts <pat.thoyts@gmail.com>
Sun, 25 Jun 2023 21:49:04 +0000 (22:49 +0100)
.gitignore [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
spectrumviewer.py [new file with mode: 0644]
wdf-opc-monitor.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..c18dd8d
--- /dev/null
@@ -0,0 +1 @@
+__pycache__/
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..6ab4424
--- /dev/null
@@ -0,0 +1 @@
+asyncua
diff --git a/spectrumviewer.py b/spectrumviewer.py
new file mode 100644 (file)
index 0000000..c8e18d9
--- /dev/null
@@ -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('<Configure>', 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 (executable)
index 0000000..061c42c
--- /dev/null
@@ -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('<<OpcDataReceived>>')
+
+
+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('<<OpcDataReceived>>', 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("<Control-F2>", lambda ev: pyshell.main())
+        root.bind("<Control-i>", 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:]))