예제 #1
0
 def __init__(self,
              console,
              default_node,
              nodes):
     self.default_node = default_node
     self.nodes = nodes
     for node in nodes:
         if node != default_node:
             ClientAsync.aw(node.lock())
예제 #2
0
    def process_events(self, on_event_data=None, all_nodes=False):
        """Listen to events sent by the program running on the robot and process
        them until _exit is received.

        Argument:
            on_event_data -- func(node, event_name) called when new data is received
        """

        exit_received = None  # or exit code once received

        def on_event_received(node, event_name, event_data):
            if self.output_enabled:
                if event_name == "_exit":
                    nonlocal exit_received
                    exit_received = event_data[0]
                elif event_name == "_print":
                    print_id = event_data[0]
                    print_format, print_num_args = print_statements[print_id]
                    print_args = tuple(event_data[1 : 1 + print_num_args])
                    print_str = print_format % print_args
                    print(print_str)
                else:
                    if len(event_data) > 0:
                        if node.id_str not in self.event_data_dict:
                            self.event_data_dict[node.id_str] = {}
                        if event_name not in self.event_data_dict[node.id_str]:
                            self.event_data_dict[node.id_str][event_name] = []
                        self.event_data_dict[node.id_str][event_name].append(event_data)
                        if on_event_data is not None:
                            on_event_data(node, event_name)

        def wake():
            return exit_received is not None

        self.client.clear_event_received_listeners()
        self.client.add_event_received_listener(on_event_received)
        try:
            if all_nodes:
                for node in self.client.nodes:
                    ClientAsync.aw(node.watch(events=True))
            else:
                ClientAsync.aw(self.node.watch(events=True))
            ClientAsync.aw(self.client.sleep(wake=wake))
            self.stop_program(self.node, discard_output=True)
            if exit_received:
                print(f"Exit, status={exit_received}")
        finally:
            if all_nodes:
                for node in self.client.nodes:
                    ClientAsync.aw(node.watch(events=False))
            else:
                ClientAsync.aw(self.node.watch(events=False))
            self.client.clear_event_received_listeners()
예제 #3
0
        def sleep(t):
            """Wait for some time.

            Argument:
                t -- time to wait in seconds
            """

            # send and flush all variables which might have been changed
            self.send_variables(self.var_set)

            # wait
            ClientAsync.aw(self.client.sleep(t))

            # fetch all variables which might be used
            self.fetch_variables(self.var_got, node_flush=False)
예제 #4
0
async def start(zeroconf=None,
                tdm_addr=None,
                tdm_port=None,
                password=None,
                debug=0,
                **kwargs):
    """Start the connection with the Thymio and variable synchronization.

    Arguments:
        tdm_addr - TDM address as a string (default: localhost)
        tdm_port - TDM TCP port number
                   (default: standard or provided by zeroconf)
        password - TDM password (default: None, not necessary for local TDM)
        robot_id - robot node id (default: any)
        robot_name - robot name (default: any)
        zeroconf - True to find TDM with zeroconf (default: automatic)
    """

    client = ClientAsync(zeroconf=zeroconf,
                         tdm_addr=tdm_addr,
                         tdm_port=tdm_port,
                         password=password,
                         debug=debug)
    node = await client.wait_for_node(**kwargs)
    await node.lock()

    global _interactive_console
    _interactive_console = TDMConsole(local_var=get_ipython().user_ns)
    await _interactive_console.init(client, node)

    # configure ipython
    ip = get_ipython()

    ip.events.register("pre_run_cell", _pre_run_cell)
    ip.events.register("post_run_cell", _post_run_cell)
예제 #5
0
 def on_nodes_changed(nodes):
     nodes = [
         node for node in ClientAsync.filter_nodes(
             nodes, node_id=self.node_id, node_name=self.node_name)
         if node.status != self.client.NODE_STATUS_DISCONNECTED
     ]
     self.node = nodes[0] if len(nodes) > 0 else None
     if self.node is None:
         self.clear_variables()
         self.set_title()
         self.info_mode["text"] = ""
     else:
         self.info_mode["text"] = {
             self.client.NODE_STATUS_UNKNOWN: "No robot",
             self.client.NODE_STATUS_CONNECTED: "Robot connected",
             self.client.NODE_STATUS_AVAILABLE: "Observe",
             self.client.NODE_STATUS_BUSY: "Observe (robot busy)",
             self.client.NODE_STATUS_READY: "Control",
             self.client.NODE_STATUS_DISCONNECTED: "Robot disconnected",
         }[self.node.status]
         # new node, set it up by starting coroutine
         self.start_co = self.init_prog()
         # disable menu Control if busy
         if self.node.status in {
                 self.client.NODE_STATUS_AVAILABLE,
                 self.client.NODE_STATUS_READY
         }:
             self.robot_menu.entryconfig("Control", state="normal")
         else:
             self.robot_menu.entryconfig("Control", state="disabled")
예제 #6
0
    def connect(self):
        def on_nodes_changed(nodes):
            nodes = [
                node for node in ClientAsync.filter_nodes(
                    nodes, node_id=self.node_id, node_name=self.node_name)
                if node.status != self.client.NODE_STATUS_DISCONNECTED
            ]
            self.node = nodes[0] if len(nodes) > 0 else None
            if self.node is None:
                self.clear_variables()
                self.set_title()
                self.info_mode["text"] = ""
            else:
                self.info_mode["text"] = {
                    self.client.NODE_STATUS_UNKNOWN: "No robot",
                    self.client.NODE_STATUS_CONNECTED: "Robot connected",
                    self.client.NODE_STATUS_AVAILABLE: "Observe",
                    self.client.NODE_STATUS_BUSY: "Observe (robot busy)",
                    self.client.NODE_STATUS_READY: "Control",
                    self.client.NODE_STATUS_DISCONNECTED: "Robot disconnected",
                }[self.node.status]
                # new node, set it up by starting coroutine
                self.start_co = self.init_prog()
                # disable menu Control if busy
                if self.node.status in {
                        self.client.NODE_STATUS_AVAILABLE,
                        self.client.NODE_STATUS_READY
                }:
                    self.robot_menu.entryconfig("Control", state="normal")
                else:
                    self.robot_menu.entryconfig("Control", state="disabled")

        def on_variables_changed(node, variables):
            if self.edited_variable is None:
                for name in variables:
                    if variables[name] is not None:
                        self.add_variable(name, variables[name])

        self.client = ClientAsync(zeroconf=self.zeroconf,
                                  tdm_addr=self.tdm_addr,
                                  tdm_port=self.tdm_port,
                                  password=self.password,
                                  debug=self.debug)
        self.client.on_nodes_changed = on_nodes_changed
        self.client.add_variables_changed_listener(on_variables_changed)
        # schedule communication
        self.after(100, self.run)
예제 #7
0
async def list(zeroconf=None,
               tdm_addr=None,
               tdm_port=None,
               password=None,
               robot_id=None,
               robot_name=None,
               timeout=5):
    """Display a list of all the robots.

    Arguments:
        tdm_addr - TDM address as a string (default: as in start())
        tdm_port - TDM TCP port number (default: as in start())
        password - TDM password (default: None, not necessary for local TDM)
        robot_id - robot id to restrict the output (default: any)
        robot_name - robot name to restrict the output (default: any)
        timeout - time to obtain at least one node (default: 5s)
        zeroconf - True to find TDM with zeroconf (default: automatic)
    """

    with (ClientAsync(zeroconf=zeroconf,
                      tdm_addr=tdm_addr,
                      tdm_port=tdm_port,
                      password=password) if zeroconf is not None or tdm_addr
          is not None or tdm_port is not None or _interactive_console is None
          else _interactive_console.client) as client:

        for _ in range(1 if timeout < 0.1 else int(timeout / 0.1)):
            client.process_waiting_messages()
            if len(client.nodes) > 0:
                break
            await client.sleep(0.1)

        for node in client.filter_nodes(client.nodes,
                                        node_id=robot_id,
                                        node_name=robot_name):
            print(f"id:       {node.id_str}")
            if "group_id_str" in node.props and node.props[
                    "group_id_str"] is not None:
                print(f"group id: {node.props['group_id_str']}")
            if "name" in node.props:
                print(f"name:     {node.props['name']}")
            if "status" in node.props:
                status_str = {
                    ClientAsync.NODE_STATUS_UNKNOWN: "unknown",
                    ClientAsync.NODE_STATUS_CONNECTED: "connected",
                    ClientAsync.NODE_STATUS_AVAILABLE: "available",
                    ClientAsync.NODE_STATUS_BUSY: "busy",
                    ClientAsync.NODE_STATUS_READY: "ready",
                    ClientAsync.NODE_STATUS_DISCONNECTED: "disconnected",
                }[node.status]
                print(f"status:   {node.status} ({status_str})")
            if "capabilities" in node.props:
                print(f"cap:      {node.props['capabilities']}")
            if "fw_version" in node.props:
                print(f"firmware: {node.props['fw_version']}")
            print()
예제 #8
0
 def stop_program(self, node, discard_output=False):
     with self.lock_robots({node}) as nodes_l:
         output_enabled_orig = self.output_enabled
         self.output_enabled = not discard_output
         try:
             error = ClientAsync.aw(node.stop())
             if error is not None:
                 raise Exception(f"Error {error['error_code']}")
         finally:
             self.output_enabled = output_enabled_orig
예제 #9
0
def main(argv=None):
    tdm_addr = None
    tdm_port = None
    password = None
    robot_id = None
    robot_name = None

    if argv is not None:
        try:
            arguments, values = getopt.getopt(argv[1:], "", [
                "help",
                "password="******"robotid=",
                "robotname=",
                "tdmaddr=",
                "tdmport=",
            ])
        except getopt.error as err:
            print(str(err))
            sys.exit(1)
        for arg, val in arguments:
            if arg == "--help":
                help()
                sys.exit(0)
            elif arg == "--password":
                password = val
            elif arg == "--robotid":
                robot_id = val
            elif arg == "--robotname":
                robot_name = val
            elif arg == "--tdmaddr":
                tdm_addr = val
            elif arg == "--tdmport":
                tdm_port = int(val)

    with ClientAsync(tdm_addr=tdm_addr, tdm_port=tdm_port,
                     password=password) as client:

        async def co_init():
            with await client.lock(node_id=robot_id,
                                   node_name=robot_name) as node:
                interactive_console = TDMConsole(user_functions={
                    "get_client": lambda: client,
                    "get_node": lambda: node,
                })
                await interactive_console.init(client, node)
                interactive_console.interact()

        client.run_async_program(co_init)
예제 #10
0
 def run_node(node):
     """Compile, configure node, load and start program on a node.
     Return True if the node requires waiting.
     """
     print_statements[node] = []
     events = []
     if language == "python":
         # transpile from Python to Aseba
         transpiler = self.transpile(src, import_thymio)
         src_aseba = transpiler.get_output()
         print_statements[node] = transpiler.print_format_strings
         if len(print_statements[node]) > 0:
             events.append(("_print", 1 + transpiler.print_max_num_args))
         if transpiler.has_exit_event:
             events.append(("_exit", 1))
         for event_name in transpiler.events_in:
             events.append((event_name, transpiler.events_in[event_name]))
         for event_name in transpiler.events_out:
             events.append((event_name, transpiler.events_out[event_name]))
         if len(events) > 0:
             events = ClientAsync.aw(node.filter_out_vm_events(events))
         if len(events) > 0:
             ClientAsync.aw(node.register_events(events))
     elif language == "aseba":
         src_aseba = src
     else:
         raise Exception(f"Unsupported language {language}")
     error = ClientAsync.aw(node.compile(src_aseba))
     if error is not None:
         raise Exception(error["error_msg"])
     node.send_set_scratchpad(src_aseba)
     wait_for_node = wait
     if wait is None:
         # default: wait if there are events to receive
         wait_for_node = len(events) > 0
     if wait_for_node:
         ClientAsync.aw(node.watch(events=True, vm_state=True))
     error = ClientAsync.aw(node.run())
     if error is not None:
         raise Exception(f"Error {error['error_code']}")
     return wait_for_node
예제 #11
0
            sys.exit(0)
        elif arg == "--debug":
            debug = int(val)
        elif arg == "--password":
            password = val
        elif arg == "--robotid":
            robot_id = val
        elif arg == "--robotname":
            robot_name = val
        elif arg == "--tdmaddr":
            tdm_addr = val
        elif arg == "--tdmport":
            tdm_port = int(val)

    with ClientAsync(tdm_addr=tdm_addr,
                     tdm_port=tdm_port,
                     password=password,
                     debug=debug) as client:

        for _ in range(50):
            client.process_waiting_messages()
            if len(client.nodes) > 0:
                break
            sleep(0.1)

        for node in client.filter_nodes(client.nodes,
                                        node_id=robot_id,
                                        node_name=robot_name):
            print(f"id:       {node.id_str}")
            if "group_id_str" in node.props and node.props[
                    "group_id_str"] is not None:
                print(f"group id: {node.props['group_id_str']}")
예제 #12
0
#!/usr/bin/env python3

# This file is part of tdmclient.
# Copyright 2021 ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE,
# Miniature Mobile Robots group, Switzerland
# Author: Yves Piguet
#
# SPDX-License-Identifier: BSD-3-Clause

from tdmclient import ClientAsync

if __name__ == "__main__":

    with ClientAsync(debug=0) as client:

        thymio_program = """
leds.top = [0, 0, 32]
leds.bottom.left = [32, 0, 0]
leds.bottom.right = [0, 32, 0]
"""

        async def prog():
            await client.wait_for_status(client.NODE_STATUS_AVAILABLE)
            node = client.first_node()
            print(node.id_str)
            await node.lock_node()
            await client.wait_for_status(client.NODE_STATUS_READY)
            error = await node.compile(thymio_program)
            if error is not None:
                print(f"Compilation error: {error['error_msg']}")
            else:
예제 #13
0
# This file is part of tdmclient.
# Copyright 2021 ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE,
# Miniature Mobile Robots group, Switzerland
# Author: Yves Piguet
#
# SPDX-License-Identifier: BSD-3-Clause

from tdmclient import ClientAsync

if __name__ == "__main__":

    with ClientAsync() as client:

        async def prog():
            with await client.lock() as node:
                while True:
                    str = input("System sound id (0-8 or exit): ")
                    if str == "exit":
                        break
                    try:
                        i = int(str)
                        error = await node.compile(f"call sound.system({i})")
                        if error is not None:
                            print(f"Compilation error: {error['error_msg']}")
                        else:
                            await node.watch(events=True)
                            error = await node.run()
                            if error is not None:
                                print(f"Error {error['error_code']}")
                    except ValueError:
                        print("Unexpected value")
예제 #14
0
    def run_program(self, src,
                    nodes=None,
                    language="aseba", wait=False, import_thymio=True):
        if nodes is None:
            nodes = [self.node]

        running_nodes = set()

        # exit_received[node] = exit code once received
        exit_received = {}
        # print_statements[node][print_id] = (print_format, print_num_args)
        print_statements = {}

        def on_event_received(node, event_name, event_data):
            if self.output_enabled:
                if event_name == "_exit":
                    exit_received[node] = event_data[0]
                    if event_data[0]:
                        exit_str = f"Exit, status={event_data[0]}"
                        if len(nodes) > 1:
                            # multiple nodes: add prefix
                            exit_str = f"[R{nodes.index(node)}] " + exit_str
                        print(exit_str)
                    self.stop_program(node, discard_output=True)
                    running_nodes.remove(node)
                elif event_name == "_print":
                    print_id = event_data[0]
                    print_format, print_num_args = print_statements[node][print_id]
                    print_args = tuple(event_data[1 : 1 + print_num_args])
                    print_str = print_format % print_args
                    if len(nodes) > 1:
                        # multiple nodes: add prefix
                        print_str = f"[R{nodes.index(node)}] " + print_str
                    print(print_str)
                else:
                    if len(event_data) > 0:
                        if node.id_str not in self.event_data_dict:
                            self.event_data_dict[node.id_str] = {}
                        if event_name not in self.event_data_dict[node.id_str]:
                            self.event_data_dict[node.id_str][event_name] = []
                        self.event_data_dict[node.id_str][event_name].append(event_data)

        def on_vm_state_changed(node, state, line, error, error_msg):
            if error != ClientAsync.ERROR_NO_ERROR:
                exit_received[node] = f"vm error {error}"
            if error_msg:
                print(f"{error_msg} (line {line}{' in Aseba' if language != 'aseba' else ''})")

        def run_node(node):
            """Compile, configure node, load and start program on a node.
            Return True if the node requires waiting.
            """
            print_statements[node] = []
            events = []
            if language == "python":
                # transpile from Python to Aseba
                transpiler = self.transpile(src, import_thymio)
                src_aseba = transpiler.get_output()
                print_statements[node] = transpiler.print_format_strings
                if len(print_statements[node]) > 0:
                    events.append(("_print", 1 + transpiler.print_max_num_args))
                if transpiler.has_exit_event:
                    events.append(("_exit", 1))
                for event_name in transpiler.events_in:
                    events.append((event_name, transpiler.events_in[event_name]))
                for event_name in transpiler.events_out:
                    events.append((event_name, transpiler.events_out[event_name]))
                if len(events) > 0:
                    events = ClientAsync.aw(node.filter_out_vm_events(events))
                if len(events) > 0:
                    ClientAsync.aw(node.register_events(events))
            elif language == "aseba":
                src_aseba = src
            else:
                raise Exception(f"Unsupported language {language}")
            error = ClientAsync.aw(node.compile(src_aseba))
            if error is not None:
                raise Exception(error["error_msg"])
            node.send_set_scratchpad(src_aseba)
            wait_for_node = wait
            if wait is None:
                # default: wait if there are events to receive
                wait_for_node = len(events) > 0
            if wait_for_node:
                ClientAsync.aw(node.watch(events=True, vm_state=True))
            error = ClientAsync.aw(node.run())
            if error is not None:
                raise Exception(f"Error {error['error_code']}")
            return wait_for_node

        self.reset_sync_var()
        self.client.clear_event_received_listeners()
        self.client.add_event_received_listener(on_event_received)
        self.client.add_vm_state_changed_listener(on_vm_state_changed)
        wait_for_nodes = False
        with self.lock_robots(nodes) as nodes_l:
            # transpile, compile, load, set scratchpad, and run
            for node in nodes_l:
                wait_for_node = run_node(node)
                wait_for_nodes = wait_for_nodes or wait_for_node
                running_nodes.add(node)

        # wait until all nodes have exited
        if wait_for_nodes:
            try:
                def wake():
                    # True when all nodes have exited
                    return len(exit_received) >= len(nodes)
                ClientAsync.aw(self.client.sleep(wake=wake))
            finally:
                # stop nodes still running
                for node in running_nodes:
                    self.stop_program(node, discard_output=True)
                self.client.clear_event_received_listeners()
                self.client.clear_vm_state_changed_listener()
예제 #15
0
async def watch(timeout=-1,
                zeroconf=None,
                tdm_addr=None,
                tdm_port=None,
                password=None,
                robot_id=None,
                robot_name=None):
    """Display the robot variables with live updates until the timeout elapses
    or the execution is interrupted.

    Arguments:
        timeout -- amount of time until updates stop
        zeroconf -- True to use find TDM with zeroconf (default: automatic)
        password - TDM password (default: None, not necessary for local TDM)
        tdm_addr -- address of the tdm
        tdm_port -- port of the tdm
            (default: connection established by start(), or from zeroconf)
        robot_id ID -- robot specified by id (default: first robot)

        robot_name NAME -- robot specified by name (default: first robot)
    """

    import IPython.display

    def var_dict_to_md(variable_dict):
        md = "| Variable | Value |\n| --- | --- |\n"
        md += "\n".join(
            [f"| {name} | {variable_dict[name]} |" for name in variable_dict])
        return md

    async def watch_node(client, node):
        variable_dict = node.var

        def variables_changed_listener(node, variable_update_dict):
            nonlocal variable_dict
            variable_dict = dict(
                sorted({
                    **variable_dict,
                    **variable_update_dict
                }.items()))
            IPython.display.clear_output(wait=True)
            md = var_dict_to_md(variable_dict)
            IPython.display.display(IPython.display.Markdown(md))

        node.add_variables_changed_listener(variables_changed_listener)
        variables_changed_listener(node, node.var)
        try:
            await client.sleep()
        except:
            # avoid long exception message with stack trace
            print("Interrupted")
        finally:
            IPython.display.clear_output(wait=True)
            node.remove_variables_changed_listener(variables_changed_listener)

    if _interactive_console is not None:
        await watch_node(_interactive_console.client,
                         _interactive_console.node)
    else:
        with ClientAsync(zeroconf=zeroconf,
                         tdm_addr=tdm_addr,
                         tdm_port=tdm_port,
                         password=password) as client:
            await client.wait_for_status_set({
                ClientAsync.NODE_STATUS_AVAILABLE, ClientAsync.NODE_STATUS_BUSY
            })
            node = client.first_node(node_id=robot_id, node_name=robot_name)
            await node.watch(variables=True)
            await watch_node(client, node)
예제 #16
0
class VariableTableWindow(tk.Tk):
    def __init__(self,
                 zeroconf=None,
                 tdm_addr=None,
                 tdm_port=None,
                 password=None,
                 node_id=None,
                 node_name=None,
                 language=None,
                 debug=0):
        super(VariableTableWindow, self).__init__()
        self.geometry("800x600")

        self.program_path = None
        self.program_src = ""
        self.language = language or "aseba"
        self.zeroconf = zeroconf
        self.tdm_addr = tdm_addr
        self.tdm_port = tdm_port
        self.password = password
        self.node_id = node_id
        self.node_name = node_name
        self.debug = debug

        # menus
        accelerator_key = "Cmd" if sys.platform == "darwin" else "Ctrl"
        bind_key = "Command" if sys.platform == "darwin" else "Control"
        menubar = tk.Menu(self)
        self.config(menu=menubar)
        self.bind("<" + bind_key + "-q>", lambda event: self.quit())

        file_menu = tk.Menu(menubar, tearoff=False)
        file_menu.add_command(label="New",
                              command=self.new,
                              accelerator=accelerator_key + "-N")
        self.bind("<" + bind_key + "-n>", lambda event: self.new())
        file_menu.add_separator()
        file_menu.add_command(label="Open",
                              command=self.open,
                              accelerator=accelerator_key + "-O")
        self.bind("<" + bind_key + "-o>", lambda event: self.open())
        file_menu.add_command(label="Save",
                              command=lambda: self.save(self.program_path),
                              accelerator=accelerator_key + "-S")
        self.bind("<" + bind_key + "-s>",
                  lambda event: self.save(self.program_path))
        file_menu.add_command(label="Save As...",
                              command=lambda: self.save(None),
                              accelerator=accelerator_key + "-Shift-")
        self.bind("<" + bind_key + "-S>", lambda event: self.save(None))
        if sys.platform != "darwin":
            file_menu.add_separator()
            file_menu.add_command(label="Quit",
                                  command=self.quit,
                                  accelerator=accelerator_key + "-Q")
        menubar.add_cascade(label="File", menu=file_menu)

        def send_event_to_focused_widget(event_id):
            widget = self.focus_get()
            if widget is not None:
                widget.event_generate(event_id)

        edit_menu = tk.Menu(menubar, tearoff=False)
        edit_menu.add_command(
            label="Cut",
            command=lambda: send_event_to_focused_widget("<<Cut>>"))
        edit_menu.add_command(
            label="Copy",
            command=lambda: send_event_to_focused_widget("<<Copy>>"))
        edit_menu.add_command(
            label="Paste",
            command=lambda: send_event_to_focused_widget("<<Paste>>"))
        menubar.add_cascade(label="Edit", menu=edit_menu)

        view_menu = tk.Menu(menubar, tearoff=False)
        self.view_var = tk.IntVar()
        view_menu.add_radiobutton(label="Variables",
                                  variable=self.view_var,
                                  value=1,
                                  command=self.set_view_variables,
                                  accelerator=accelerator_key + "-Shift-V")
        self.bind("<" + bind_key + "-V>",
                  lambda event: self.set_view_variables())
        view_menu.add_radiobutton(label="Program",
                                  variable=self.view_var,
                                  value=2,
                                  command=self.set_view_program,
                                  accelerator=accelerator_key + "-Shift-P")
        self.bind("<" + bind_key + "-P>",
                  lambda event: self.set_view_program())
        menubar.add_cascade(label="View", menu=view_menu)
        self.view_var.set(1)

        self.robot_menu = tk.Menu(menubar, tearoff=False)
        lock_node_var = tk.BooleanVar()
        self.robot_menu.add_checkbutton(label="Control",
                                        variable=lock_node_var,
                                        accelerator=accelerator_key + "-L")
        self.bind("<" + bind_key + "-l>",
                  lambda event: lock_node_var.set(not lock_node_var.get()))
        lock_node_var.trace_add(
            "write",
            lambda var, index, mode: self.lock_node(lock_node_var.get()))
        self.robot_menu.add_separator()
        self.robot_menu.add_command(label="Run",
                                    command=self.run_program,
                                    state="disabled",
                                    accelerator=accelerator_key + "-R")
        self.bind("<" + bind_key + "-r>", lambda event: self.run_program())
        self.robot_menu.add_command(label="Stop",
                                    command=self.stop_program,
                                    state="disabled",
                                    accelerator="Escape")
        self.bind("<Escape>", lambda event: self.stop_program())
        self.robot_menu.add_separator()
        self.language_var = tk.IntVar()
        self.robot_menu.add_radiobutton(
            label="Aseba",
            variable=self.language_var,
            value=1,
            command=lambda: self.set_language("aseba"))
        self.robot_menu.add_radiobutton(
            label="Python",
            variable=self.language_var,
            value=2,
            command=lambda: self.set_language("py"))
        self.language_var.set(1)
        menubar.add_cascade(label="Robot", menu=self.robot_menu)

        # main layout: info at bottom (one line), scrollable main content above
        self.main_content = tk.Frame(self)
        self.main_content.pack(fill=tk.BOTH, expand=True)
        status_frame = tk.Frame(self, height=1)
        status_frame.pack(side=tk.BOTTOM, fill=tk.X)
        self.info_mode = tk.Label(status_frame,
                                  anchor="w",
                                  bg="#fff",
                                  fg="#666",
                                  width=16)  # char units
        self.info_mode.pack(side=tk.LEFT)
        self.info_error = tk.Label(status_frame,
                                   anchor="e",
                                   bg="#fff",
                                   fg="#666")
        self.info_error.pack(side=tk.LEFT, fill=tk.X, expand=True)

        # variables
        self.canvas = None
        self.scrollbar = None
        self.frame = None

        # program
        self.text_program = None

        # key=name, value={"widget": w, "value": v}
        self.variables = {}

        self.edited_variable = None

        self.client = None
        self.node = None
        self.locked = False

        self.start_co = None

        self.set_title()

    def set_title(self):
        name = self.node.props["name"] if self.node is not None else "No robot"
        if self.client is not None and self.client.tdm_addr is not None:
            name += f" (TDM: {self.client.tdm_addr}:{self.client.tdm_port})"
        if self.text_program is not None:
            name += " - "
            name += os.path.basename(
                self.program_path
            ) if self.program_path is not None else f"Untitled.{self.language}"
        self.title(name)

    def set_view_variables(self):
        self.view_var.set(1)
        self.remove_program_view()
        self.create_variable_view()
        self.set_title()

    def set_view_program(self):
        self.view_var.set(2)
        self.remove_variable_view()
        self.create_program_view()
        self.set_title()

    def set_language(self, language):
        self.language_var.set({
            "aseba": 1,
            "py": 2,
        }[language])
        self.language = language
        self.set_title()

    def new(self):
        self.remove_program_view()
        self.program_src = ""
        self.program_path = None
        self.set_view_program()

    def open(self):
        path = filedialog.askopenfilename(
            filetypes=[("All", ".aseba .py"), ("Aseba",
                                               ".aseba"), ("Python", ".py")])
        if path:
            with open(path, encoding="utf-8") as f:
                self.remove_program_view()
                self.program_src = f.read()
                self.program_path = path
                self.set_view_program()
                self.text_program.edit_modified(False)
                self.set_language("py" if os.path.splitext(path)[1] ==
                                  ".py" else "aseba")

    def save(self, path):
        if path is None:
            path = filedialog.asksaveasfilename(filetypes=[
                ("Aseba", ".aseba"),
            ],
                                                defaultextension="." +
                                                self.language)
        if path:
            with open(path, "wb") as f:
                self.program_src = self.text_program.get("1.0", "end")
                f.write(bytes(self.program_src, "utf-8"))
                self.text_program.edit_modified(False)
                self.program_path = path

    def run_src(self, src_aseba):
        async def run_a():
            error = await self.node.compile(src_aseba)
            if error is not None:
                self.error_msg = error["error_msg"]
                self.info_error["text"] = self.error_msg
            else:
                error = await self.node.run()
                if error is not None:
                    self.error_msg = f"Run error {error['error_code']}"
                    self.info_error["text"] = self.error_msg
                else:
                    self.error_msg = None
                    self.info_error["text"] = "OK"

        self.client.run_async_program(run_a)

    def run_program(self):
        if self.locked and self.text_program is not None:
            self.program_src = self.text_program.get("1.0", "end")
            if self.language == "py":
                try:
                    aseba_src = ATranspiler.simple_transpile(self.program_src)
                except Exception as e:
                    self.error_msg = str(e)
                    self.info_error["text"] = self.error_msg
                    return
            else:
                aseba_src = self.program_src
            self.run_src(aseba_src)

    def stop_program(self):
        async def stop_a():
            error = await self.node.stop()
            if error is not None:
                self.error_msg = f"Stop error {error['error_code']}"
                self.info_error["text"] = self.error_msg

        if self.locked:
            self.client.run_async_program(stop_a)

    async def init_prog(self):
        await self.client.wait_for_status_set(
            {
                self.client.NODE_STATUS_AVAILABLE,
                self.client.NODE_STATUS_BUSY, self.client.NODE_STATUS_READY
            },
            node_id=self.node_id,
            node_name=self.node_name)
        self.node = self.client.first_node(node_id=self.node_id,
                                           node_name=self.node_name)
        self.set_title()
        await self.node.watch(variables=True)

    def lock_node(self, locked):
        if locked:
            self.node.send_lock_node()
            self.robot_menu.entryconfig("Run", state="normal")
            self.robot_menu.entryconfig("Stop", state="normal")
        else:
            self.node.send_unlock_node()
            self.robot_menu.entryconfig("Run", state="disabled")
            self.robot_menu.entryconfig("Stop", state="disabled")
        self.locked = locked

    def remove_variable_view(self):
        if self.canvas is not None:
            self.canvas.destroy()
            self.canvas = None
            self.frame = None
            self.scrollbar.destroy()
            self.scrollbar = None

    def create_variable_row(self, name, value):
        f = tk.Frame(self.frame)
        f.pack(fill=tk.X, expand=True)
        title = name + (f"[{len(value)}]" if len(value) > 1 else "")
        l = tk.Label(f, text=title, anchor="w", width=25)
        l.pack(side=tk.LEFT)
        text = ", ".join([str(item) for item in value])
        v = tk.Label(f, text=text, anchor="w")
        v.pack(side=tk.LEFT, fill=tk.X, expand=True)
        v.bind("<Button-1>", lambda e: self.begin_editing(name))
        self.variables[name] = {
            "widget": f,
            "vwidget": v,
            "ewidget": None,
            "value": value,
            "text": text,
        }

    def create_variable_view(self):
        if self.frame is None:
            self.canvas = tk.Canvas(self.main_content)
            self.canvas.pack(side=tk.LEFT, expand=True, fill=tk.BOTH)
            self.scrollbar = tk.Scrollbar(self.main_content,
                                          orient="vertical",
                                          command=self.canvas.yview)
            self.scrollbar.pack(side=tk.RIGHT, fill="y")
            self.canvas.configure(yscrollcommand=self.scrollbar.set)
            self.canvas.bind(
                "<Configure>", lambda event: self.canvas.configure(
                    scrollregion=self.canvas.bbox("all")))
            self.canvas.bind(
                "<Enter>", lambda event: self.canvas.bind_all(
                    "<MouseWheel>", lambda event: self.canvas.yview_scroll(
                        -event.delta // 120, "units")))
            self.canvas.bind(
                "<Leave>",
                lambda event: self.canvas.unbind_all("<MouseWheel>"))
            self.frame = tk.Frame(self.canvas)
            self.frame.bind(
                "<Configure>", lambda event: self.canvas.configure(
                    scrollregion=self.canvas.bbox("all")))
            self.canvas.create_window((0, 0), window=self.frame, anchor="nw")
            for name in self.variables:
                self.create_variable_row(name, self.variables[name]["value"])

    def add_variable(self, name, value):
        if self.view_var.get() == 1 and value is not None:
            if name not in self.variables:
                self.create_variable_view()
                self.create_variable_row(name, value)
            else:
                text = ", ".join([str(item) for item in value])
                v = self.variables[name]
                v["text"] = text
                v["vwidget"]["text"] = text
        else:
            # just remember value
            self.variables[name] = {"value": value}

    def clear_variables(self):
        self.end_editing(cancel=True)
        for name in self.variables:
            self.variables[name]["widget"].destroy()
        self.variables = {}
        self.remove_variable_view()

    def begin_editing(self, name):
        if self.node.status != self.client.NODE_STATUS_READY or not self.end_editing(
                keep_editing_on_error=True):
            return
        self.edited_variable = name
        v = self.variables[name]
        entry = tk.Entry(v["vwidget"])
        v["ewidget"] = entry
        entry.insert(0, v["text"])
        entry.place(x=0, y=0, anchor="nw", relwidth=1, relheight=1)
        entry.bind("<Return>",
                   lambda e: self.end_editing(keep_editing_on_error=True))
        entry.bind("<Escape>", lambda e: self.end_editing(cancel=True))
        entry.focus_set()

    def end_editing(self, cancel=False, keep_editing_on_error=False):
        if self.edited_variable is not None:
            v = self.variables[self.edited_variable]
            text = v["ewidget"].get()
            if not cancel:
                try:
                    new_value = [int(s) for s in text.split(",")]
                    if len(new_value) != len(v["value"]):
                        raise Exception()
                    self.node.send_set_variables(
                        {self.edited_variable: new_value})
                except Exception as e:
                    print(type(e), e)
                    if keep_editing_on_error:
                        return False
            v["ewidget"].destroy()
            v["ewidget"] = None
            self.edited_variable = None
        return True

    def remove_program_view(self):
        if self.text_program is not None:
            self.program_src = self.text_program.get("1.0", "end")
            self.text_program.destroy()
            self.text_program = None
            self.scrollbar.destroy()
            self.scrollbar = None

    def create_program_view(self):
        if self.frame is None:
            self.scrollbar = tk.Scrollbar(self.main_content, orient="vertical")
            self.scrollbar.pack(side=tk.RIGHT, fill="y")
            self.text_program = tk.Text(self.main_content,
                                        yscrollcommand=self.scrollbar.set)
            self.text_program.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
            self.scrollbar.config(command=self.text_program.yview)
            if self.program_src.strip():
                self.text_program.insert("1.0", self.program_src)
            self.text_program.focus_set()

    def connect(self):
        def on_nodes_changed(nodes):
            nodes = [
                node for node in ClientAsync.filter_nodes(
                    nodes, node_id=self.node_id, node_name=self.node_name)
                if node.status != self.client.NODE_STATUS_DISCONNECTED
            ]
            self.node = nodes[0] if len(nodes) > 0 else None
            if self.node is None:
                self.clear_variables()
                self.set_title()
                self.info_mode["text"] = ""
            else:
                self.info_mode["text"] = {
                    self.client.NODE_STATUS_UNKNOWN: "No robot",
                    self.client.NODE_STATUS_CONNECTED: "Robot connected",
                    self.client.NODE_STATUS_AVAILABLE: "Observe",
                    self.client.NODE_STATUS_BUSY: "Observe (robot busy)",
                    self.client.NODE_STATUS_READY: "Control",
                    self.client.NODE_STATUS_DISCONNECTED: "Robot disconnected",
                }[self.node.status]
                # new node, set it up by starting coroutine
                self.start_co = self.init_prog()
                # disable menu Control if busy
                if self.node.status in {
                        self.client.NODE_STATUS_AVAILABLE,
                        self.client.NODE_STATUS_READY
                }:
                    self.robot_menu.entryconfig("Control", state="normal")
                else:
                    self.robot_menu.entryconfig("Control", state="disabled")

        def on_variables_changed(node, variables):
            if self.edited_variable is None:
                for name in variables:
                    if variables[name] is not None:
                        self.add_variable(name, variables[name])

        self.client = ClientAsync(zeroconf=self.zeroconf,
                                  tdm_addr=self.tdm_addr,
                                  tdm_port=self.tdm_port,
                                  password=self.password,
                                  debug=self.debug)
        self.client.on_nodes_changed = on_nodes_changed
        self.client.add_variables_changed_listener(on_variables_changed)
        # schedule communication
        self.after(100, self.run)

    def run(self):
        if self.start_co is not None:
            if not self.client.step_coroutine(self.start_co):
                # start_co is finished
                self.start_co = None
        else:
            self.client.process_waiting_messages()
        self.after(100, self.run)