Skip to content

Commit b72de03

Browse files
committed
Add preliminary CDI variable editor (only default, no read/write. Add CDIVar and OpenLCBAction).
1 parent c5edb84 commit b72de03

6 files changed

Lines changed: 269 additions & 4 deletions

File tree

examples/examples_gui.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,8 +459,15 @@ def _gui(self, parent):
459459
state=tk.DISABLED, # enabled on connect success callback
460460
)
461461
self.cdi_refresh_button.grid(row=self.cdi_row, column=1)
462+
self.cdi_row += 1
462463

464+
self.cdiSettingFrame = ttk.Frame(self.cdi_tab)
465+
self.cdiSettingFrame.grid(row=self.cdi_row+1, column=1)
466+
# NOTE: ^ See self.cdi_form.setSettingsContainer in setupNetwork
467+
# ^ +1 to row so it is across from _treeview
468+
# below invisible status label in second row on left
463469
self.cdi_row += 1
470+
464471
self.network = None
465472
self.cdi_form = None # type: CDIForm|None
466473
# ^ CDIForm or other XMLDataProcessor subclass
@@ -494,6 +501,7 @@ def _gui(self, parent):
494501
def setupNetwork(self):
495502
self.network = OpenLCBNetwork(self.getValue('localNodeID'))
496503
self.cdi_form = CDIForm(self.network.canLink, self.cdi_tab)
504+
self.cdi_form.setSettingsContainer(self.cdiSettingFrame)
497505
self.cdi_form.setStatusCallback(self.setStatus)
498506
# ^ formerly OpenLCBNetwork() subclass
499507
# ^ CDIForm has ttk.Treeview etc.

examples/tkexamples/cdiform.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
import tkinter as tk
1616
import warnings
1717

18-
from tkinter import ttk
18+
from tkinter import EventType, ttk
1919

2020
from collections import deque
2121
from logging import getLogger
2222
from typing import Any, Callable, Dict, Union
2323
from xml.etree import ElementTree as ET
2424

25+
from openlcb.cdivar import CLASSNAME_TYPES, CDIVar
26+
2527

2628
if __name__ == "__main__":
2729
logger = getLogger(__file__)
@@ -75,10 +77,19 @@ def __init__(self, *args, **kwargs):
7577
if hasattr(self.parent, 'root'):
7678
self.root = self.parent.root
7779
self._container = self # where to put visible widgets
78-
self._treeview = None # type: ttk.Treeview
80+
self._treeview = None # type: ttk.Treeview|None
81+
self._treeMemos = {} # type: Dict[str, CDIMemo]
82+
7983
self._gui(self._container)
8084
self.cursorCol = 0
8185

86+
self.cdiSettingWidgets = [] # type: list[tk.Widget]
87+
self.cdiSettingRow = 0
88+
self.cdiSettingFrame = None # type: Union[ttk.Frame, tk.Frame, None]
89+
90+
def setSettingsContainer(self, container: Union[ttk.Frame, tk.Frame]):
91+
self.cdiSettingFrame = container
92+
8293
def _gui(self, container: tk.Widget):
8394
if self._top_widgets:
8495
raise RuntimeError("gui can only be called once unless reset")
@@ -92,10 +103,94 @@ def _gui(self, container: tk.Widget):
92103
self._top_widgets.append(self._overview)
93104
self._treeview = ttk.Treeview(container)
94105
self._treeview.grid(sticky=tk.NSEW, row=len(self._top_widgets))
106+
self._treeview.bind("<<TreeviewSelect>>", self.onTreeSelect)
95107
self.rowconfigure(len(self._top_widgets), weight=1) # weight=1: expand
96108
self._top_widgets.append(self._treeview)
97109
self._current_iid = 0 # id of Treeview element
98110

111+
def onTreeSelect(self, event: tk.Event):
112+
# print(f"event={event}")
113+
# print(f"dir(event)={dir(event)}")
114+
# print(f"event.__dict__={event.__dict__}")
115+
# ^ {'serial': 794, 'num': '??', 'height': '??', 'keycode':
116+
# '??', 'state': 0, 'time': 0, 'width': '??', 'x': 0, 'y': 0,
117+
# 'char': '??', 'send_event': False, 'keysym': '??',
118+
# 'keysym_num': '??', 'type': <EventType.VirtualEvent: '35'>,
119+
# 'widget': <tkinter.ttk.Treeview object
120+
# .!mainform.!notebook.!frame.!cdiform.!treeview>, 'x_root':
121+
# 0, 'y_root': 0, 'delta': 0}
122+
for iid in event.widget.selection():
123+
item = event.widget.item(iid) # type: dict
124+
# ^ such as {'text': 'Track Output', 'image': '',
125+
# 'values': [CDIMemo], 'open': 0, 'tags': ''}
126+
cm = self._treeMemos[iid]
127+
# print(f"type(item)={type(item)}")
128+
# raise NotImplementedError(item)
129+
# print(f"cm={cm}")
130+
self.clearSettingWidgets()
131+
if cm.tag not in CLASSNAME_TYPES:
132+
# Non-value (such as segment or group)
133+
# So there is nothing to do.
134+
return
135+
136+
name = cm.getChildContent("name")
137+
if name is None:
138+
# self.setStatus("Selected element has no name.")
139+
break
140+
141+
nameLabel = ttk.Label(self.cdiSettingFrame, text=name)
142+
nameLabel.tip = cm.getChildContent("description")
143+
nameLabel.grid(column=0, row=self.cdiSettingRow)
144+
self.cdiSettingWidgets.append(nameLabel)
145+
# self.cdiSettingRow += 1
146+
cdivar = cm.toCDIVar()
147+
tkvar = None
148+
v_widget = None
149+
if cdivar.max:
150+
if cdivar.className == "int":
151+
tkvar = tk.IntVar(self.root)
152+
elif cdivar.className == "float":
153+
tkvar = tk.DoubleVar(self.root)
154+
else:
155+
raise TypeError("Device should not specify max for {}"
156+
.format(cdivar.className))
157+
158+
v_widget = ttk.LabeledScale(self.cdiSettingFrame, variable=tkvar)
159+
# ^ widget.scale is ttk.Scale, widget.label is ttk.Label
160+
v_widget.scale.cdivar = cdivar
161+
v_widget.scale.tip = nameLabel.tip
162+
else:
163+
tkvar = tk.StringVar(self.root)
164+
v_widget = ttk.Entry(self.cdiSettingFrame, textvariable=tkvar)
165+
v_widget.grid(column=1, row=self.cdiSettingRow)
166+
self.cdiSettingRow += 1
167+
self.cdiSettingWidgets.append(v_widget)
168+
if cdivar.default is not None:
169+
tkvar.set(cdivar.default)
170+
v_widget.cdivar = cdivar
171+
v_widget.tip = nameLabel.tip
172+
173+
174+
address_str = ""
175+
if address_str is not None:
176+
address_str = str(cm.address)
177+
a_widget = ttk.Label(self.cdiSettingFrame, text="(Address:")
178+
a_widget.grid(column=0, row=self.cdiSettingRow, sticky=tk.W)
179+
av_widget = ttk.Label(self.cdiSettingFrame, text=address_str + ")")
180+
av_widget.grid(column=1, row=self.cdiSettingRow)
181+
self.cdiSettingWidgets.append(a_widget)
182+
self.cdiSettingWidgets.append(av_widget)
183+
self.cdiSettingRow += 1
184+
185+
break
186+
187+
def clearSettingWidgets(self):
188+
for widget in self.cdiSettingWidgets:
189+
widget.grid_forget()
190+
widget.destroy()
191+
del self.cdiSettingWidgets[:]
192+
self.cdiSettingRow = 0
193+
99194
def clear(self):
100195
while self._top_widgets:
101196
widget = self._top_widgets.pop()
@@ -337,6 +432,7 @@ def _onPushScope(self, cm: CDIMemo):
337432
iid=self._current_iid,
338433
text=content,
339434
)
435+
self._treeMemos[new_branch] = cm
340436
# values=(), image=None
341437
# self._tag_stack[-1].iid = new_branch
342438
# NOTE: ^ _tag_stack is unreliable due to race condition!
@@ -358,6 +454,7 @@ def _onPushScope(self, cm: CDIMemo):
358454
iid=self._current_iid,
359455
text=content,
360456
)
457+
self._treeMemos[new_branch] = cm
361458
# values=(), image=None
362459
# self._tag_stack[-1].iid = new_branch
363460
# NOTE: ^ _tag_stack is unreliable due to race condition!

openlcb/cdimemo.py

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
from collections import OrderedDict
2+
import json
3+
import math
14
import xml.etree.ElementTree
5+
# import xml.etree.ElementTree as ET
26

37
from typing import List, Optional, Union
48

9+
from openlcb.cdivar import FLOAT_MAXIMUMS, NUM_TYPES, CDIVar
510
from openlcb.message import Message
611

712

13+
def element_ordered(el: xml.etree.ElementTree.Element):
14+
od = OrderedDict()
15+
od['tag'] = el.tag
16+
od['attrib'] = el.attrib
17+
return od
18+
19+
820
class CDIMemo:
921
"""Store parsing state info as a tree (This is a tree node)
1022
@@ -48,13 +60,41 @@ def __init__(self, tag: Union[str, None] = None,
4860
self.message: Union[Message, None] = None # type: Message|None
4961
self.iid = None # type: str|None
5062
self.address = None # type: int|None
63+
self.cdivar = None # type: CDIVar|None
5164
self.children = [] # type: List[CDIMemo]
5265

5366
def getTag(self):
5467
if self.element is None:
5568
return self.tag # May have been set manually (stray end tag)
5669
return self.element.tag
5770

71+
def getChildContentN(self, tag, className) -> Union[int, float, None]:
72+
for child in self.children:
73+
if child.tag == tag:
74+
if child.content is None:
75+
return None
76+
if className == "int":
77+
if not child.content.strip():
78+
return None
79+
return int(child.content.strip())
80+
elif className == "float":
81+
if not child.content.strip():
82+
return None
83+
return float(child.content.strip())
84+
else:
85+
raise NotImplementedError(
86+
"className {} is not implemented in getChildContent"
87+
.format(className))
88+
return None
89+
90+
def getChildContent(self, tag) -> Union[str, None]:
91+
for child in self.children:
92+
if child.tag == tag:
93+
if child.content is None:
94+
return None
95+
return child.content.strip()
96+
return None
97+
5898
def copy(self):
5999
cm = CDIMemo()
60100
for k, v in self.__dict__.items():
@@ -91,5 +131,72 @@ def get(self, key):
91131
def __repr__(self):
92132
return repr(self.__dict__)
93133

134+
@staticmethod
135+
def to_dict(cm):
136+
d = OrderedDict()
137+
for k, v in cm.__dict__.items():
138+
# if k == 'children':
139+
# continue
140+
if k == 'parent':
141+
continue
142+
if isinstance(v, xml.etree.ElementTree.Element):
143+
d[k] = element_ordered(v)
144+
continue
145+
d[k] = v
146+
return d
147+
94148
def __str__(self):
95-
return str(self.__dict__)
149+
return json.dumps(CDIMemo.to_dict(self), default=CDIMemo.to_dict)
150+
151+
def toCDIVar(self):
152+
"""Create a CDIVar from descriptors (child elements of self).
153+
See LCC "Configuration Description Information" Standard.
154+
"""
155+
result = CDIVar(self.tag)
156+
assert (self.tag is not None) and (self.tag.strip())
157+
result.className = self.tag.lower()
158+
if self.element:
159+
result.floatFormat = self.element.attrib.get('floatFormat')
160+
this_t = NUM_TYPES.get(self.tag) if self.tag else None
161+
if this_t is not None:
162+
result.min = self.getChildContentN("min", result.className)
163+
result.max = self.getChildContentN("max", result.className)
164+
result.default = self.getChildContentN("default", result.className)
165+
result.size = self.getSize()
166+
167+
if result.className == "int":
168+
if result.min is None:
169+
result.min = 0
170+
elif result.min < 0:
171+
result.signed = True
172+
# if self.size is not None:
173+
if result.size not in [1, 2, 4, 8]:
174+
raise AttributeError(
175+
f"expected 1,2,4,8 for int size, got {result.size}"
176+
f" in children={json.dumps(self.children, sort_keys=True, indent=2,
177+
default=CDIMemo.to_dict)}")
178+
if result.max is None:
179+
if result.signed:
180+
result.max = math.pow(2, result.size * 8 - 1) - 1
181+
else:
182+
result.max = math.pow(2, result.size * 8)
183+
elif result.className == "float":
184+
result.signed = True # float is always signed in CDI
185+
if result.min is None:
186+
result.min = 0.0
187+
# if self.size is not None:
188+
if result.size not in [2, 4, 8]:
189+
raise AttributeError(
190+
f"expected 2,4,8 for float size, got {result.size}")
191+
if result.max is None:
192+
assert isinstance(result.size, int)
193+
result.max = FLOAT_MAXIMUMS[result.size * 8]
194+
return result
195+
196+
def getSize(self):
197+
if self.element is None:
198+
return None
199+
size = self.element.attrib.get('size')
200+
if size is None:
201+
return None
202+
return int(size)

openlcb/cdivar.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
2+
import json
3+
import math
4+
5+
from openlcb import emit_cast
6+
from typing import Any, Type
7+
8+
from openlcb.eventid import EventID
9+
from openlcb.openlcbaction import OpenLCBAction
10+
11+
12+
NUM_TYPES = {'int': int, 'float': float} # type: dict[str, Type]
13+
# Assumes "IEEE" in LCC CDI Standard means IEEE 754-2008:
14+
FLOAT_MAXIMUMS = {16: 65504.0, 32: 3.40e38, 64: 1.80e308} # type: dict[int, float] # noqa: E501
15+
CLASSNAME_TYPES = {'int': int, 'float': float, 'string': str,
16+
'blob': bytearray, 'eventid': EventID,
17+
'action': OpenLCBAction}
18+
19+
20+
class CDIVar:
21+
"""
22+
Attributes:
23+
floatFormat (str): Optional printf-style format
24+
(for className == "float").
25+
signed (bool): Whether the value is signed (False unless min is
26+
negative). Defaults to True.
27+
See LCC "Configuration Description Information" Standard.
28+
value (Any): The value read from the device (type should be
29+
from CLASSNAME_TYPES values).
30+
"""
31+
TYPED_KEYS = ['min', 'max', 'default']
32+
33+
def __init__(self, className):
34+
assert isinstance(className, str), \
35+
f"Expected {CLASSNAME_TYPES.keys()} got {emit_cast(className)}"
36+
assert className, f"Expected {CLASSNAME_TYPES.keys()} got {className}"
37+
assert className in CLASSNAME_TYPES, \
38+
f"Expected {CLASSNAME_TYPES.keys()} got {className}"
39+
self.className = className # type: str
40+
self.signed = False # type: bool
41+
self.value = None # type: Any
42+
self.min = None # type: int|float|None
43+
self.max = None # type: int|float|None
44+
self.default = None # type: int|float|None
45+
self.size = None # type: int|None
46+
self.floatFormat = None # type: str|None

openlcb/openlcbaction.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class OpenLCBAction:
2+
"""LCC Action
3+
See LCC "Configuration Description Information" Standard.
4+
"""
5+
pass
6+
# TODO: Finish this.

python-openlcb.code-workspace

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"canphysicallayersimulation",
4141
"cdiform",
4242
"cdimemo",
43+
"cdivar",
4344
"columnspan",
4445
"controlframe",
4546
"datagram",
@@ -65,7 +66,6 @@
6566
"MDNS",
6667
"mdnsconventions",
6768
"memoryservice",
68-
"xmldataprocessor",
6969
"metas",
7070
"MSGLEN",
7171
"nodeid",
@@ -104,6 +104,7 @@
104104
"usbmodem",
105105
"WASI",
106106
"winnative",
107+
"xmldataprocessor",
107108
"xscrollcommand",
108109
"zeroconf"
109110
]

0 commit comments

Comments
 (0)