From 68e87c72140ac6c9aeccc376881988883cdeb139 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 5 Jun 2025 14:30:53 +0200 Subject: [PATCH 01/19] first draft Copies UserInput plug for now And some UI blocks implemented (but not sent) --- tofupilot/openhtf/__init__.py | 1 + tofupilot/openhtf/operator_ui.py | 248 +++++++++++++++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 tofupilot/openhtf/operator_ui.py diff --git a/tofupilot/openhtf/__init__.py b/tofupilot/openhtf/__init__.py index 6611486..6fe0fbf 100644 --- a/tofupilot/openhtf/__init__.py +++ b/tofupilot/openhtf/__init__.py @@ -7,3 +7,4 @@ from .upload import upload from .tofupilot import TofuPilot +from .operator_ui import OperatorUi diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py new file mode 100644 index 0000000..9cfae07 --- /dev/null +++ b/tofupilot/openhtf/operator_ui.py @@ -0,0 +1,248 @@ +from openhtf.core.base_plugs import FrontendAwareBasePlug + +import functools +import logging +import os +import platform +import select +import sys +import threading +from typing import Any, Callable, Dict, Optional, Text, Union +import uuid +from dataclasses import dataclass +from abc import ABC, abstractmethod +from typing import Union, Tuple + +import attr + +@attr.s(slots=True, frozen=True) +class Prompt(object): + id = attr.ib(type=Text) + message = attr.ib(type=Text) + text_input = attr.ib(type=bool) + image_url = attr.ib(type=Optional[Text], default=None) + +class OperatorUiPlug(FrontendAwareBasePlug): + """Get user input from inside test phases. + + Attributes: + last_response: None, or a pair of (prompt_id, response) indicating the last + user response that was received by the plug. + """ + + def __init__(self): + super(OperatorUiPlug, self).__init__() + self.last_response: Optional[tuple[str, str]] = None + self._prompt: Optional[Prompt] = None + #self._console_prompt: Optional[ConsolePrompt] = None + self._response: Optional[Text] = None + self._cond = threading.Condition(threading.RLock()) + + def _asdict(self) -> Optional[Dict[Text, Any]]: + """Return a dictionary representation of the current prompt.""" + with self._cond: + if self._prompt is None: + return None + return { + 'id': self._prompt.id, + 'message': self._prompt.message, + 'text-input': self._prompt.text_input, + 'image-url': self._prompt.image_url + } + + def tearDown(self) -> None: + self.remove_prompt() + + def remove_prompt(self) -> None: + """Remove the prompt.""" + with self._cond: + self._prompt = None + #if self._console_prompt: + # self._console_prompt.stop() + # self._console_prompt = None + self.notify_update() + + def prompt(self, + message: Text, + text_input: bool = False, + timeout_s: Union[int, float, None] = None, + cli_color: Text = '', + image_url: Optional[Text] = None) -> Text: + """Display a prompt and wait for a response. + + Args: + message: A string to be presented to the user. + text_input: A boolean indicating whether the user must respond with text. + timeout_s: Seconds to wait before raising a PromptUnansweredError. + cli_color: An ANSI color code, or the empty string. + image_url: Optional image URL to display or None. + + Returns: + A string response, or the empty string if text_input was False. + + Raises: + MultiplePromptsError: There was already an existing prompt. + PromptUnansweredError: Timed out waiting for the user to respond. + """ + self.start_prompt(message, text_input, cli_color, image_url) + return self.wait_for_prompt(timeout_s) + + def start_prompt(self, + message: Text, + text_input: bool = False, + cli_color: Text = '', + image_url: Optional[Text] = None) -> Text: + """Display a prompt. + + Args: + message: A string to be presented to the user. + text_input: A boolean indicating whether the user must respond with text. + cli_color: An ANSI color code, or the empty string. + image_url: Optional image URL to display or None. + + Raises: + MultiplePromptsError: There was already an existing prompt. + + Returns: + A string uniquely identifying the prompt. + """ + with self._cond: + #if self._prompt: + # raise MultiplePromptsError( + # 'Multiple concurrent prompts are not supported.') + prompt_id = uuid.uuid4().hex + + self._response = None + self._prompt = Prompt( + id=prompt_id, + message=message, + text_input=text_input, + image_url=image_url) + #if sys.stdin.isatty(): + #self._console_prompt = ConsolePrompt(message, functools.partial(self.respond, prompt_id), cli_color) + #self._console_prompt.start() + + self.notify_update() + return prompt_id + + def wait_for_prompt(self, timeout_s: Union[int, float, None] = None) -> Text: + """Wait for the user to respond to the current prompt. + + Args: + timeout_s: Seconds to wait before raising a PromptUnansweredError. + + Returns: + A string response, or the empty string if text_input was False. + + Raises: + PromptUnansweredError: Timed out waiting for the user to respond. + """ + with self._cond: + if self._prompt: + if timeout_s is None: + self._cond.wait(3600 * 24 * 365) + else: + self._cond.wait(timeout_s) + #if self._response is None: + # raise PromptUnansweredError + return self._response + + def respond(self, prompt_id: Text, response: Text) -> None: + """Respond to the prompt with the given ID. + + If there is no active prompt or the given ID doesn't match the active + prompt, do nothing. + + Args: + prompt_id: A string uniquely identifying the prompt. + response: A string response to the given prompt. + """ + #_LOG.debug('Responding to prompt (%s): "%s"', prompt_id, response) + with self._cond: + if not (self._prompt and self._prompt.id == prompt_id): + return + self._response = response + self.last_response = (prompt_id, response) + self.remove_prompt() + self._cond.notifyAll() + + # TODO: Reimplement this with new framework + """ + def prompt_for_test_start( + message: Text = 'Enter a DUT ID in order to start the test.', + timeout_s: Union[int, float, None] = 60 * 60 * 24, + validator: Callable[[Text], Text] = lambda sn: sn, + cli_color: Text = '') -> openhtf.PhaseDescriptor: + ""Returns an OpenHTF phase for use as a prompt-based start trigger. + + Args: + message: The message to display to the user. + timeout_s: Seconds to wait before raising a PromptUnansweredError. + validator: Function used to validate or modify the serial number. + cli_color: An ANSI color code, or the empty string. + "" + + @openhtf.PhaseOptions(timeout_s=timeout_s) + @plugs.plug(prompts=UserInput) + def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: + ""Test start trigger that prompts the user for a DUT ID."" + dut_id = prompts.prompt( + message, text_input=True, timeout_s=timeout_s, cli_color=cli_color) + test.test_record.dut_id = validator(dut_id) + + return trigger_phase + """ + +@dataclass +class Element(ABC): + @abstractmethod + def as_dict(self): + pass + +@dataclass +class Text(Element): + s: str + + def as_dict(self): + return { "class": "Text", "s": self.s} + +@dataclass +class TextInput(Element): + placeholder: Optional[str] + + def as_dict(self): + return { "class": "TextInput", "s": self.placeholder} + +@dataclass +class Select(Element): + choices: Tuple[str, ...] + + def as_dict(self): + return { "class": "Select", "choices": self.choices} + +@dataclass +class TopDown(Element): + children: Tuple['Element', ...] + + def as_dict(self): + print(self.children) + children_dicts = tuple(map(lambda c: c.as_dict(), self.children)) + return { "class": "TopDown", "options": children_dicts} + +class OperatorUi: + plug = OperatorUiPlug + + def text(s: str) -> Text: + "Text to be displayed to the user, python `str` can also be used" + return Text(s) + + def text_input(placeholder: str = None) -> TextInput: + "A place for the user to input text" + return TextInput(placeholder) + + def select(*choices: str) -> Select: + return Select(choices) + + def top_down(*children: Union[str, Element]) -> TopDown: + elements: tuple[Element] = tuple(map(lambda c: Text(c) if isinstance(c, str) else c, children)) + return TopDown(elements) From 8942ce1b68f9e8b48d7731a0b0cfcac4a0859550 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 5 Jun 2025 14:32:49 +0200 Subject: [PATCH 02/19] move to top --- tofupilot/openhtf/operator_ui.py | 72 ++++++++++++++++---------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index 9cfae07..0758a86 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -15,6 +15,42 @@ import attr +@dataclass +class Element(ABC): + @abstractmethod + def as_dict(self): + pass + +@dataclass +class Text(Element): + s: str + + def as_dict(self): + return { "class": "Text", "s": self.s} + +@dataclass +class TextInput(Element): + placeholder: Optional[str] + + def as_dict(self): + return { "class": "TextInput", "s": self.placeholder} + +@dataclass +class Select(Element): + choices: Tuple[str, ...] + + def as_dict(self): + return { "class": "Select", "choices": self.choices} + +@dataclass +class TopDown(Element): + children: Tuple['Element', ...] + + def as_dict(self): + print(self.children) + children_dicts = tuple(map(lambda c: c.as_dict(), self.children)) + return { "class": "TopDown", "options": children_dicts} + @attr.s(slots=True, frozen=True) class Prompt(object): id = attr.ib(type=Text) @@ -193,42 +229,6 @@ def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: return trigger_phase """ -@dataclass -class Element(ABC): - @abstractmethod - def as_dict(self): - pass - -@dataclass -class Text(Element): - s: str - - def as_dict(self): - return { "class": "Text", "s": self.s} - -@dataclass -class TextInput(Element): - placeholder: Optional[str] - - def as_dict(self): - return { "class": "TextInput", "s": self.placeholder} - -@dataclass -class Select(Element): - choices: Tuple[str, ...] - - def as_dict(self): - return { "class": "Select", "choices": self.choices} - -@dataclass -class TopDown(Element): - children: Tuple['Element', ...] - - def as_dict(self): - print(self.children) - children_dicts = tuple(map(lambda c: c.as_dict(), self.children)) - return { "class": "TopDown", "options": children_dicts} - class OperatorUi: plug = OperatorUiPlug From 2dff97e57e6f880312236d5add2bf5eeb7b2899e Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 5 Jun 2025 14:42:23 +0200 Subject: [PATCH 03/19] Remove Text import --- tofupilot/openhtf/operator_ui.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index 0758a86..8a59212 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -7,7 +7,7 @@ import select import sys import threading -from typing import Any, Callable, Dict, Optional, Text, Union +from typing import Any, Callable, Dict, Optional, Union import uuid from dataclasses import dataclass from abc import ABC, abstractmethod @@ -71,10 +71,10 @@ def __init__(self): self.last_response: Optional[tuple[str, str]] = None self._prompt: Optional[Prompt] = None #self._console_prompt: Optional[ConsolePrompt] = None - self._response: Optional[Text] = None + self._response: Optional[str] = None self._cond = threading.Condition(threading.RLock()) - def _asdict(self) -> Optional[Dict[Text, Any]]: + def _asdict(self) -> Optional[Dict[str, Any]]: """Return a dictionary representation of the current prompt.""" with self._cond: if self._prompt is None: @@ -161,7 +161,7 @@ def start_prompt(self, self.notify_update() return prompt_id - def wait_for_prompt(self, timeout_s: Union[int, float, None] = None) -> Text: + def wait_for_prompt(self, timeout_s: Union[int, float, None] = None) -> str: """Wait for the user to respond to the current prompt. Args: @@ -183,7 +183,7 @@ def wait_for_prompt(self, timeout_s: Union[int, float, None] = None) -> Text: # raise PromptUnansweredError return self._response - def respond(self, prompt_id: Text, response: Text) -> None: + def respond(self, prompt_id: str, response: str) -> None: """Respond to the prompt with the given ID. If there is no active prompt or the given ID doesn't match the active @@ -205,10 +205,10 @@ def respond(self, prompt_id: Text, response: Text) -> None: # TODO: Reimplement this with new framework """ def prompt_for_test_start( - message: Text = 'Enter a DUT ID in order to start the test.', + message: str = 'Enter a DUT ID in order to start the test.', timeout_s: Union[int, float, None] = 60 * 60 * 24, - validator: Callable[[Text], Text] = lambda sn: sn, - cli_color: Text = '') -> openhtf.PhaseDescriptor: + validator: Callable[[str], str] = lambda sn: sn, + cli_color: str = '') -> openhtf.PhaseDescriptor: ""Returns an OpenHTF phase for use as a prompt-based start trigger. Args: From 1d03de77d1e4acc4fa0b37aafe1d852e2d626a65 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 5 Jun 2025 15:07:41 +0200 Subject: [PATCH 04/19] Make Element immutable --- tofupilot/openhtf/operator_ui.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index 8a59212..2ac8474 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -15,34 +15,34 @@ import attr -@dataclass +@dataclass(frozen=True) class Element(ABC): @abstractmethod def as_dict(self): pass -@dataclass +@dataclass(frozen=True) class Text(Element): s: str def as_dict(self): return { "class": "Text", "s": self.s} -@dataclass +@dataclass(frozen=True) class TextInput(Element): placeholder: Optional[str] def as_dict(self): return { "class": "TextInput", "s": self.placeholder} -@dataclass +@dataclass(frozen=True) class Select(Element): choices: Tuple[str, ...] def as_dict(self): return { "class": "Select", "choices": self.choices} -@dataclass +@dataclass(frozen=True) class TopDown(Element): children: Tuple['Element', ...] From 1680a11837bca2bb1e3397b16cb64302e977562a Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 6 Jun 2025 11:17:19 +0200 Subject: [PATCH 05/19] First working version --- tofupilot/openhtf/operator_ui.py | 191 ++++++++++++------------------- 1 file changed, 71 insertions(+), 120 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index 2ac8474..dd5cdbc 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -1,11 +1,7 @@ +import openhtf +from openhtf import plugs from openhtf.core.base_plugs import FrontendAwareBasePlug -import functools -import logging -import os -import platform -import select -import sys import threading from typing import Any, Callable, Dict, Optional, Union import uuid @@ -13,50 +9,51 @@ from abc import ABC, abstractmethod from typing import Union, Tuple -import attr - @dataclass(frozen=True) class Element(ABC): @abstractmethod - def as_dict(self): + def _asdict(self): pass @dataclass(frozen=True) class Text(Element): s: str - def as_dict(self): + def _asdict(self): return { "class": "Text", "s": self.s} @dataclass(frozen=True) class TextInput(Element): placeholder: Optional[str] - def as_dict(self): + def _asdict(self): return { "class": "TextInput", "s": self.placeholder} @dataclass(frozen=True) class Select(Element): choices: Tuple[str, ...] - def as_dict(self): + def _asdict(self): return { "class": "Select", "choices": self.choices} @dataclass(frozen=True) class TopDown(Element): children: Tuple['Element', ...] - def as_dict(self): - print(self.children) - children_dicts = tuple(map(lambda c: c.as_dict(), self.children)) - return { "class": "TopDown", "options": children_dicts} + def _asdict(self): + children_dicts = tuple(map(lambda c: c._asdict(), self.children)) + return { "class": "TopDown", "children": children_dicts} + +@dataclass(frozen=True) +class Prompt: + id: str + element: Element -@attr.s(slots=True, frozen=True) -class Prompt(object): - id = attr.ib(type=Text) - message = attr.ib(type=Text) - text_input = attr.ib(type=bool) - image_url = attr.ib(type=Optional[Text], default=None) + def _asdict(self) -> Dict[str, Any]: + return { + 'id': self.id, + 'element': self.element._asdict(), + } class OperatorUiPlug(FrontendAwareBasePlug): """Get user input from inside test phases. @@ -68,6 +65,7 @@ class OperatorUiPlug(FrontendAwareBasePlug): def __init__(self): super(OperatorUiPlug, self).__init__() + # TODO: Remove last_response self.last_response: Optional[tuple[str, str]] = None self._prompt: Optional[Prompt] = None #self._console_prompt: Optional[ConsolePrompt] = None @@ -79,12 +77,7 @@ def _asdict(self) -> Optional[Dict[str, Any]]: with self._cond: if self._prompt is None: return None - return { - 'id': self._prompt.id, - 'message': self._prompt.message, - 'text-input': self._prompt.text_input, - 'image-url': self._prompt.image_url - } + return self._prompt._asdict() def tearDown(self) -> None: self.remove_prompt() @@ -98,50 +91,11 @@ def remove_prompt(self) -> None: # self._console_prompt = None self.notify_update() - def prompt(self, - message: Text, - text_input: bool = False, - timeout_s: Union[int, float, None] = None, - cli_color: Text = '', - image_url: Optional[Text] = None) -> Text: - """Display a prompt and wait for a response. - - Args: - message: A string to be presented to the user. - text_input: A boolean indicating whether the user must respond with text. - timeout_s: Seconds to wait before raising a PromptUnansweredError. - cli_color: An ANSI color code, or the empty string. - image_url: Optional image URL to display or None. - - Returns: - A string response, or the empty string if text_input was False. - - Raises: - MultiplePromptsError: There was already an existing prompt. - PromptUnansweredError: Timed out waiting for the user to respond. - """ - self.start_prompt(message, text_input, cli_color, image_url) + def prompt(self, element: Element, *, timeout_s: Union[int, float, None] = None) -> str: + self.start_prompt(element) return self.wait_for_prompt(timeout_s) - def start_prompt(self, - message: Text, - text_input: bool = False, - cli_color: Text = '', - image_url: Optional[Text] = None) -> Text: - """Display a prompt. - - Args: - message: A string to be presented to the user. - text_input: A boolean indicating whether the user must respond with text. - cli_color: An ANSI color code, or the empty string. - image_url: Optional image URL to display or None. - - Raises: - MultiplePromptsError: There was already an existing prompt. - - Returns: - A string uniquely identifying the prompt. - """ + def start_prompt(self, element: Element) -> str: with self._cond: #if self._prompt: # raise MultiplePromptsError( @@ -150,35 +104,18 @@ def start_prompt(self, self._response = None self._prompt = Prompt( - id=prompt_id, - message=message, - text_input=text_input, - image_url=image_url) - #if sys.stdin.isatty(): - #self._console_prompt = ConsolePrompt(message, functools.partial(self.respond, prompt_id), cli_color) - #self._console_prompt.start() + prompt_id, + element + ) self.notify_update() return prompt_id def wait_for_prompt(self, timeout_s: Union[int, float, None] = None) -> str: - """Wait for the user to respond to the current prompt. - - Args: - timeout_s: Seconds to wait before raising a PromptUnansweredError. - - Returns: - A string response, or the empty string if text_input was False. - - Raises: - PromptUnansweredError: Timed out waiting for the user to respond. - """ with self._cond: if self._prompt: - if timeout_s is None: - self._cond.wait(3600 * 24 * 365) - else: - self._cond.wait(timeout_s) + # if timeout_s is none, wait forever + self._cond.wait(timeout_s) #if self._response is None: # raise PromptUnansweredError return self._response @@ -191,7 +128,7 @@ def respond(self, prompt_id: str, response: str) -> None: Args: prompt_id: A string uniquely identifying the prompt. - response: A string response to the given prompt. + response: The response to the given prompt. """ #_LOG.debug('Responding to prompt (%s): "%s"', prompt_id, response) with self._cond: @@ -203,31 +140,6 @@ def respond(self, prompt_id: str, response: str) -> None: self._cond.notifyAll() # TODO: Reimplement this with new framework - """ - def prompt_for_test_start( - message: str = 'Enter a DUT ID in order to start the test.', - timeout_s: Union[int, float, None] = 60 * 60 * 24, - validator: Callable[[str], str] = lambda sn: sn, - cli_color: str = '') -> openhtf.PhaseDescriptor: - ""Returns an OpenHTF phase for use as a prompt-based start trigger. - - Args: - message: The message to display to the user. - timeout_s: Seconds to wait before raising a PromptUnansweredError. - validator: Function used to validate or modify the serial number. - cli_color: An ANSI color code, or the empty string. - "" - - @openhtf.PhaseOptions(timeout_s=timeout_s) - @plugs.plug(prompts=UserInput) - def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: - ""Test start trigger that prompts the user for a DUT ID."" - dut_id = prompts.prompt( - message, text_input=True, timeout_s=timeout_s, cli_color=cli_color) - test.test_record.dut_id = validator(dut_id) - - return trigger_phase - """ class OperatorUi: plug = OperatorUiPlug @@ -243,6 +155,45 @@ def text_input(placeholder: str = None) -> TextInput: def select(*choices: str) -> Select: return Select(choices) - def top_down(*children: Union[str, Element]) -> TopDown: - elements: tuple[Element] = tuple(map(lambda c: Text(c) if isinstance(c, str) else c, children)) + def top_down(*children: Union[str, Element, None]) -> TopDown: + + # Remove `None`s and convert `str` to `Text` elements + elements: tuple[Element] = tuple( + map( + lambda c: Text(c) if isinstance(c, str) else c, + filter(lambda c: c != None, children) + ) + ) return TopDown(elements) + +def prompt_for_test_start( + message: str = 'Enter a DUT ID in order to start the test.', + timeout_s: Union[int, float, None] = 60 * 60 * 24, + validator: Callable[[str], str] = lambda sn: sn, + ) -> openhtf.PhaseDescriptor: + """Returns an OpenHTF phase for use as a prompt-based start trigger. + + Drop-in replacement for openhtf.plugs.user_input.prompt_for_test_start. + (If you were using cli_color, remove that parameter) + + Args: + message: The message to display to the user. + timeout_s: Seconds to wait before raising a PromptUnansweredError. + validator: Function used to validate or modify the serial number. + """ + ui = OperatorUi + + @openhtf.PhaseOptions(timeout_s=timeout_s) + @plugs.plug(ui_plug=OperatorUi.plug) + def trigger_phase(test: openhtf.TestApi, ui_plug: OperatorUi.plug) -> None: + """Test start trigger that prompts the user for a DUT ID.""" + dut_id = ui_plug.prompt( + ui.top_down( + message, + ui.text_input(), + ), + timeout_s=timeout_s, + ) + test.test_record.dut_id = validator(dut_id) + + return trigger_phase From 7761c961221b564710e148717030231731d03c5c Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 6 Jun 2025 11:43:37 +0200 Subject: [PATCH 06/19] Remove individual _asdict methods --- tofupilot/openhtf/operator_ui.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index dd5cdbc..3bb22c5 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -5,37 +5,31 @@ import threading from typing import Any, Callable, Dict, Optional, Union import uuid -from dataclasses import dataclass +from dataclasses import dataclass, asdict from abc import ABC, abstractmethod from typing import Union, Tuple @dataclass(frozen=True) class Element(ABC): - @abstractmethod + def _asdict(self): - pass + return { + "class": self.__class__.__name__, + **asdict(self), + } @dataclass(frozen=True) class Text(Element): s: str - def _asdict(self): - return { "class": "Text", "s": self.s} - @dataclass(frozen=True) class TextInput(Element): placeholder: Optional[str] - def _asdict(self): - return { "class": "TextInput", "s": self.placeholder} - @dataclass(frozen=True) class Select(Element): choices: Tuple[str, ...] - def _asdict(self): - return { "class": "Select", "choices": self.choices} - @dataclass(frozen=True) class TopDown(Element): children: Tuple['Element', ...] From 36a65d28872b10f312f8dd020cefe444b07f1df3 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 6 Jun 2025 17:39:23 +0200 Subject: [PATCH 07/19] Add support for images --- tofupilot/openhtf/operator_ui.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index 3bb22c5..117ebcf 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -18,10 +18,18 @@ def _asdict(self): **asdict(self), } +# Output + @dataclass(frozen=True) class Text(Element): s: str +@dataclass(frozen=True) +class Base64Image(Element): + data: str + +# Input + @dataclass(frozen=True) class TextInput(Element): placeholder: Optional[str] @@ -30,7 +38,9 @@ class TextInput(Element): class Select(Element): choices: Tuple[str, ...] -@dataclass(frozen=True) +# Layout + +@dataclass(frozen=True) class TopDown(Element): children: Tuple['Element', ...] @@ -138,10 +148,20 @@ def respond(self, prompt_id: str, response: str) -> None: class OperatorUi: plug = OperatorUiPlug + # Outputs + def text(s: str) -> Text: "Text to be displayed to the user, python `str` can also be used" return Text(s) + def image(*, path: str) -> Base64Image: + "Image to be displayed to the user" + with open(path, "rb") as file: + # Encode the file to base 64 (b64encode), then convert to a string (decode) + return Base64Image(base64.b64encode(file.read()).decode()) + + # Inputs + def text_input(placeholder: str = None) -> TextInput: "A place for the user to input text" return TextInput(placeholder) @@ -149,6 +169,8 @@ def text_input(placeholder: str = None) -> TextInput: def select(*choices: str) -> Select: return Select(choices) + # Layout + def top_down(*children: Union[str, Element, None]) -> TopDown: # Remove `None`s and convert `str` to `Text` elements From 504f7c1688e784d4183087f8eef903fe231ddb30 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 6 Jun 2025 17:40:05 +0200 Subject: [PATCH 08/19] Add support for multiple inputs --- tofupilot/openhtf/operator_ui.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index 117ebcf..c6edf50 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -33,10 +33,12 @@ class Base64Image(Element): @dataclass(frozen=True) class TextInput(Element): placeholder: Optional[str] + id: Union[str, None] = None @dataclass(frozen=True) class Select(Element): choices: Tuple[str, ...] + id: Union[str, None] = None # Layout @@ -162,12 +164,12 @@ def image(*, path: str) -> Base64Image: # Inputs - def text_input(placeholder: str = None) -> TextInput: + def text_input(placeholder: str = None, *, id: Optional[str] = None) -> TextInput: "A place for the user to input text" - return TextInput(placeholder) + return TextInput(placeholder, id) - def select(*choices: str) -> Select: - return Select(choices) + def select(*choices: str, id: Optional[str] = None) -> Select: + return Select(choices, id) # Layout From ce55a670be5f0835060f9b8d3123c35c78f40717 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 6 Jun 2025 17:40:36 +0200 Subject: [PATCH 09/19] fixup! Add support for images --- tofupilot/openhtf/operator_ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index c6edf50..4ff642d 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -3,8 +3,9 @@ from openhtf.core.base_plugs import FrontendAwareBasePlug import threading -from typing import Any, Callable, Dict, Optional, Union +import base64 import uuid +from typing import Any, Callable, Dict, Optional, Union from dataclasses import dataclass, asdict from abc import ABC, abstractmethod from typing import Union, Tuple From 9568a17bde187b9326335adaadd8335b64c76c99 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Tue, 10 Jun 2025 11:10:58 +0200 Subject: [PATCH 10/19] Fix multiple inputs not being supported --- tofupilot/openhtf/operator_ui.py | 48 ++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index 4ff642d..ccec109 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -5,11 +5,21 @@ import threading import base64 import uuid +import json from typing import Any, Callable, Dict, Optional, Union from dataclasses import dataclass, asdict from abc import ABC, abstractmethod from typing import Union, Tuple +def _validate_id(id: Optional[str]) -> str: + if id == '': + raise ValueError("id cannot be the empty string") + + if id == None: + return '' + else: + return id + @dataclass(frozen=True) class Element(ABC): @@ -32,14 +42,23 @@ class Base64Image(Element): # Input @dataclass(frozen=True) -class TextInput(Element): +class FormInput(Element): + """ + Abstract, should not be directy instantiated! + + Parent class of all form-only inputs + + Provides an identifier to be used to retrieve the value + """ + id: str + +@dataclass(frozen=True) +class TextInput(FormInput): placeholder: Optional[str] - id: Union[str, None] = None @dataclass(frozen=True) -class Select(Element): +class Select(FormInput): choices: Tuple[str, ...] - id: Union[str, None] = None # Layout @@ -141,13 +160,20 @@ def respond(self, prompt_id: str, response: str) -> None: with self._cond: if not (self._prompt and self._prompt.id == prompt_id): return - self._response = response + parsed = json.loads(response) + + # Shortcut: If the user defined only one input with no id, + # return that single value instead of the dict + no_id = '' + if len(parsed) == 1 and no_id in parsed.keys(): + self._response = parsed[no_id] + else: + self._response = parsed + self.last_response = (prompt_id, response) self.remove_prompt() self._cond.notifyAll() - # TODO: Reimplement this with new framework - class OperatorUi: plug = OperatorUiPlug @@ -155,22 +181,22 @@ class OperatorUi: def text(s: str) -> Text: "Text to be displayed to the user, python `str` can also be used" - return Text(s) + return Text(s=s) def image(*, path: str) -> Base64Image: "Image to be displayed to the user" with open(path, "rb") as file: # Encode the file to base 64 (b64encode), then convert to a string (decode) - return Base64Image(base64.b64encode(file.read()).decode()) + return Base64Image(data=base64.b64encode(file.read()).decode()) # Inputs def text_input(placeholder: str = None, *, id: Optional[str] = None) -> TextInput: "A place for the user to input text" - return TextInput(placeholder, id) + return TextInput(placeholder=placeholder, id=_validate_id(id)) def select(*choices: str, id: Optional[str] = None) -> Select: - return Select(choices, id) + return Select(choices=choices, id=_validate_id(id)) # Layout From a8b574d0ad78f6a7328b7b71766cbe6f573df06c Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Tue, 17 Jun 2025 14:59:34 +0200 Subject: [PATCH 11/19] Refactor TopDown into Flex --- tofupilot/openhtf/operator_ui.py | 35 ++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/openhtf/operator_ui.py index ccec109..3ab2541 100644 --- a/tofupilot/openhtf/operator_ui.py +++ b/tofupilot/openhtf/operator_ui.py @@ -6,7 +6,7 @@ import base64 import uuid import json -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Callable, Dict, Optional, Union, Literal from dataclasses import dataclass, asdict from abc import ABC, abstractmethod from typing import Union, Tuple @@ -63,12 +63,26 @@ class Select(FormInput): # Layout @dataclass(frozen=True) -class TopDown(Element): +class Flex(Element): children: Tuple['Element', ...] + direction: Literal['top_down', 'bottom_up', 'left_to_right', 'right_to_left'] def _asdict(self): children_dicts = tuple(map(lambda c: c._asdict(), self.children)) - return { "class": "TopDown", "children": children_dicts} + return { "class": "Flex", "direction": self.direction, "children": children_dicts} + +def _parse_children(children: Tuple[Union[str, Element, None]]) -> Tuple[Element]: + """ + Remove `None`s and convert `str` to `Text` elements + """ + + elements: tuple[Element] = tuple( + map( + lambda c: Text(c) if isinstance(c, str) else c, + filter(lambda c: c != None, children) + ) + ) + return elements @dataclass(frozen=True) class Prompt: @@ -200,16 +214,11 @@ def select(*choices: str, id: Optional[str] = None) -> Select: # Layout - def top_down(*children: Union[str, Element, None]) -> TopDown: - - # Remove `None`s and convert `str` to `Text` elements - elements: tuple[Element] = tuple( - map( - lambda c: Text(c) if isinstance(c, str) else c, - filter(lambda c: c != None, children) - ) - ) - return TopDown(elements) + def top_down(*children: Union[str, Element, None]) -> Flex: + return Flex(_parse_children(children), 'top_down') + + def left_right(*children: Union[str, Element, None]) -> Flex: + return Flex(_parse_children(children), 'left_right') def prompt_for_test_start( message: str = 'Enter a DUT ID in order to start the test.', From e07606034e9e8959bc4b9ad3e618d296a6e3fa8d Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 09:22:50 +0200 Subject: [PATCH 12/19] Move operator_ui to root --- tofupilot/openhtf/__init__.py | 1 - tofupilot/{openhtf => }/operator_ui.py | 0 2 files changed, 1 deletion(-) rename tofupilot/{openhtf => }/operator_ui.py (100%) diff --git a/tofupilot/openhtf/__init__.py b/tofupilot/openhtf/__init__.py index 6fe0fbf..6611486 100644 --- a/tofupilot/openhtf/__init__.py +++ b/tofupilot/openhtf/__init__.py @@ -7,4 +7,3 @@ from .upload import upload from .tofupilot import TofuPilot -from .operator_ui import OperatorUi diff --git a/tofupilot/openhtf/operator_ui.py b/tofupilot/operator_ui.py similarity index 100% rename from tofupilot/openhtf/operator_ui.py rename to tofupilot/operator_ui.py From 00ca5f0ff1d63523c689cd9156e67c6f414506d5 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 09:34:15 +0200 Subject: [PATCH 13/19] Move methods to root --- tofupilot/operator_ui.py | 70 +++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/tofupilot/operator_ui.py b/tofupilot/operator_ui.py index 3ab2541..87a463b 100644 --- a/tofupilot/operator_ui.py +++ b/tofupilot/operator_ui.py @@ -95,6 +95,36 @@ def _asdict(self) -> Dict[str, Any]: 'element': self.element._asdict(), } +# Outputs + +def text(s: str) -> Text: + "Text to be displayed to the user, python `str` can also be used" + return Text(s=s) + +def image(*, path: str) -> Base64Image: + "Image to be displayed to the user" + with open(path, "rb") as file: + # Encode the file to base 64 (b64encode), then convert to a string (decode) + return Base64Image(data=base64.b64encode(file.read()).decode()) + +# Inputs + +def text_input(placeholder: str = None, *, id: Optional[str] = None) -> TextInput: + "A place for the user to input text" + return TextInput(placeholder=placeholder, id=_validate_id(id)) + +def select(*choices: str, id: Optional[str] = None) -> Select: + return Select(choices=choices, id=_validate_id(id)) + +# Layout + +def top_down(*children: Union[str, Element, None]) -> Flex: + return Flex(_parse_children(children), 'top_down') + +def left_right(*children: Union[str, Element, None]) -> Flex: + return Flex(_parse_children(children), 'left_right') + + class OperatorUiPlug(FrontendAwareBasePlug): """Get user input from inside test phases. @@ -188,38 +218,7 @@ def respond(self, prompt_id: str, response: str) -> None: self.remove_prompt() self._cond.notifyAll() -class OperatorUi: - plug = OperatorUiPlug - - # Outputs - - def text(s: str) -> Text: - "Text to be displayed to the user, python `str` can also be used" - return Text(s=s) - - def image(*, path: str) -> Base64Image: - "Image to be displayed to the user" - with open(path, "rb") as file: - # Encode the file to base 64 (b64encode), then convert to a string (decode) - return Base64Image(data=base64.b64encode(file.read()).decode()) - - # Inputs - def text_input(placeholder: str = None, *, id: Optional[str] = None) -> TextInput: - "A place for the user to input text" - return TextInput(placeholder=placeholder, id=_validate_id(id)) - - def select(*choices: str, id: Optional[str] = None) -> Select: - return Select(choices=choices, id=_validate_id(id)) - - # Layout - - def top_down(*children: Union[str, Element, None]) -> Flex: - return Flex(_parse_children(children), 'top_down') - - def left_right(*children: Union[str, Element, None]) -> Flex: - return Flex(_parse_children(children), 'left_right') - def prompt_for_test_start( message: str = 'Enter a DUT ID in order to start the test.', timeout_s: Union[int, float, None] = 60 * 60 * 24, @@ -235,16 +234,15 @@ def prompt_for_test_start( timeout_s: Seconds to wait before raising a PromptUnansweredError. validator: Function used to validate or modify the serial number. """ - ui = OperatorUi @openhtf.PhaseOptions(timeout_s=timeout_s) - @plugs.plug(ui_plug=OperatorUi.plug) - def trigger_phase(test: openhtf.TestApi, ui_plug: OperatorUi.plug) -> None: + @plugs.plug(ui_plug=OperatorUiPlug) + def trigger_phase(test: openhtf.TestApi, ui_plug: OperatorUiPlug) -> None: """Test start trigger that prompts the user for a DUT ID.""" dut_id = ui_plug.prompt( - ui.top_down( + top_down( message, - ui.text_input(), + text_input(), ), timeout_s=timeout_s, ) From 97c866372d65377ece8316de35c0602c98a952ad Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 09:39:52 +0200 Subject: [PATCH 14/19] Handle unanswered prompt --- tofupilot/operator_ui.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tofupilot/operator_ui.py b/tofupilot/operator_ui.py index 87a463b..6bbea9b 100644 --- a/tofupilot/operator_ui.py +++ b/tofupilot/operator_ui.py @@ -1,6 +1,7 @@ import openhtf from openhtf import plugs from openhtf.core.base_plugs import FrontendAwareBasePlug +from openhtf.plugs.user_input import PromptUnansweredError import threading import base64 @@ -187,7 +188,7 @@ def wait_for_prompt(self, timeout_s: Union[int, float, None] = None) -> str: # if timeout_s is none, wait forever self._cond.wait(timeout_s) #if self._response is None: - # raise PromptUnansweredError + raise PromptUnansweredError return self._response def respond(self, prompt_id: str, response: str) -> None: From 4b2b4b345f43daa56562dab089f89f66fe2627f3 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 09:40:19 +0200 Subject: [PATCH 15/19] Fix typing issues --- tofupilot/operator_ui.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tofupilot/operator_ui.py b/tofupilot/operator_ui.py index 6bbea9b..1a60ce1 100644 --- a/tofupilot/operator_ui.py +++ b/tofupilot/operator_ui.py @@ -72,18 +72,17 @@ def _asdict(self): children_dicts = tuple(map(lambda c: c._asdict(), self.children)) return { "class": "Flex", "direction": self.direction, "children": children_dicts} -def _parse_children(children: Tuple[Union[str, Element, None]]) -> Tuple[Element]: +def _parse_children(children: Tuple[Union[str, Element, None], ...]) -> Tuple[Element, ...]: """ Remove `None`s and convert `str` to `Text` elements """ - elements: tuple[Element] = tuple( + return tuple( map( lambda c: Text(c) if isinstance(c, str) else c, filter(lambda c: c != None, children) ) ) - return elements @dataclass(frozen=True) class Prompt: @@ -110,7 +109,7 @@ def image(*, path: str) -> Base64Image: # Inputs -def text_input(placeholder: str = None, *, id: Optional[str] = None) -> TextInput: +def text_input(placeholder: Union[str, None] = None, *, id: Optional[str] = None) -> TextInput: "A place for the user to input text" return TextInput(placeholder=placeholder, id=_validate_id(id)) @@ -123,7 +122,7 @@ def top_down(*children: Union[str, Element, None]) -> Flex: return Flex(_parse_children(children), 'top_down') def left_right(*children: Union[str, Element, None]) -> Flex: - return Flex(_parse_children(children), 'left_right') + return Flex(_parse_children(children), 'left_to_right') class OperatorUiPlug(FrontendAwareBasePlug): @@ -187,7 +186,7 @@ def wait_for_prompt(self, timeout_s: Union[int, float, None] = None) -> str: if self._prompt: # if timeout_s is none, wait forever self._cond.wait(timeout_s) - #if self._response is None: + if self._response is None: raise PromptUnansweredError return self._response From 161b7401a21b4810665796fcf451d696e4e0cb82 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 10:44:31 +0200 Subject: [PATCH 16/19] Improve refresh frequency --- tofupilot/openhtf/tofupilot.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index c6b310d..5859d01 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -56,22 +56,18 @@ class SimpleStationWatcher(threading.Thread): def __init__(self, send_update_callback): super().__init__(daemon=True) self.send_update = send_update_callback - self.last_phase = None + self.previous_state = None self.stop_event = threading.Event() def run(self): while not self.stop_event.is_set(): _, test_state = _get_executing_test() if test_state is not None: - current_phase = ( - test_state.running_phase_state.name - if test_state.running_phase_state - else None - ) - if current_phase != self.last_phase: - test_state_dict, _ = _to_dict_with_event(test_state) + # TODO: Add a hash to result of _to_dict_with_event to speed up comparaison + test_state_dict, _ = _to_dict_with_event(test_state) + if test_state_dict != self.previous_state: self.send_update(test_state_dict) - self.last_phase = current_phase + self.previous_state = test_state_dict sleep(0.1) # Wait for 100 milliseconds def stop(self): From 56ca27dde4ddd61d579692170904c81f8890a9ed Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 10:44:47 +0200 Subject: [PATCH 17/19] add openhtf requirement --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index f1a1740..153adc7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ pytest paho-mqtt build twine +openhtf certifi>=2023.7.22 From 1a475270daf811a88884b6c633be7807c79d649c Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 10:51:35 +0200 Subject: [PATCH 18/19] Add dynamic element --- tofupilot/operator_ui.py | 72 ++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/tofupilot/operator_ui.py b/tofupilot/operator_ui.py index 1a60ce1..402ccd8 100644 --- a/tofupilot/operator_ui.py +++ b/tofupilot/operator_ui.py @@ -7,6 +7,7 @@ import base64 import uuid import json +import time from typing import Any, Callable, Dict, Optional, Union, Literal from dataclasses import dataclass, asdict from abc import ABC, abstractmethod @@ -23,6 +24,15 @@ def _validate_id(id: Optional[str]) -> str: @dataclass(frozen=True) class Element(ABC): + @abstractmethod + def _as_static(self) -> "StaticElement": + raise NotImplementedError(f"{self.__class__.__name__} does not implement `_as_sendable`") + +@dataclass(frozen=True) +class StaticElement(Element): + + def _as_static(self): + return self def _asdict(self): return { @@ -33,17 +43,17 @@ def _asdict(self): # Output @dataclass(frozen=True) -class Text(Element): +class Text(StaticElement): s: str @dataclass(frozen=True) -class Base64Image(Element): +class Base64Image(StaticElement): data: str # Input @dataclass(frozen=True) -class FormInput(Element): +class FormInput(StaticElement): """ Abstract, should not be directy instantiated! @@ -63,15 +73,37 @@ class Select(FormInput): # Layout +## Static + @dataclass(frozen=True) -class Flex(Element): - children: Tuple['Element', ...] +class StaticFlex(StaticElement): + children: Tuple['StaticElement', ...] direction: Literal['top_down', 'bottom_up', 'left_to_right', 'right_to_left'] def _asdict(self): children_dicts = tuple(map(lambda c: c._asdict(), self.children)) return { "class": "Flex", "direction": self.direction, "children": children_dicts} +## Potentially dynamic + +@dataclass(frozen=True) +class Flex(Element): + children: Tuple['Element', ...] + direction: Literal['top_down', 'bottom_up', 'left_to_right', 'right_to_left'] + + def _as_static(self): + static_children = tuple(map(lambda c: c._as_static(), self.children)) + return StaticFlex(children=static_children, direction=self.direction) + +# Dynamic + +@dataclass(frozen=True) +class Dynamic(Element): + child: Callable[[], Element] + + def _as_static(self): + return self.child()._as_static() + def _parse_children(children: Tuple[Union[str, Element, None], ...]) -> Tuple[Element, ...]: """ Remove `None`s and convert `str` to `Text` elements @@ -84,15 +116,23 @@ def _parse_children(children: Tuple[Union[str, Element, None], ...]) -> Tuple[El ) ) -@dataclass(frozen=True) +@dataclass() class Prompt: id: str element: Element + update_period: float + _previous_element: Optional[StaticElement] = None + _previous_element_expiry_time: float = 0 def _asdict(self) -> Dict[str, Any]: + current_time = time.time() + if self._previous_element is None or current_time > self._previous_element_expiry_time: + self._previous_element = self.element._as_static() + self._previous_element_expiry_time = current_time + self.update_period + return { 'id': self.id, - 'element': self.element._asdict(), + 'element': self._previous_element._asdict(), } # Outputs @@ -119,11 +159,15 @@ def select(*choices: str, id: Optional[str] = None) -> Select: # Layout def top_down(*children: Union[str, Element, None]) -> Flex: - return Flex(_parse_children(children), 'top_down') + return Flex(children=_parse_children(children), direction='top_down') -def left_right(*children: Union[str, Element, None]) -> Flex: - return Flex(_parse_children(children), 'left_to_right') - +def left_to_right(*children: Union[str, Element, None]) -> Flex: + return Flex(children=_parse_children(children), direction='left_to_right') + +# Dynamic + +def dynamic(child: Callable[[], Element]) -> Dynamic: + return Dynamic(child=child) class OperatorUiPlug(FrontendAwareBasePlug): """Get user input from inside test phases. @@ -143,7 +187,7 @@ def __init__(self): self._cond = threading.Condition(threading.RLock()) def _asdict(self) -> Optional[Dict[str, Any]]: - """Return a dictionary representation of the current prompt.""" + """Return a dictionary representation of the current state.""" with self._cond: if self._prompt is None: return None @@ -175,8 +219,10 @@ def start_prompt(self, element: Element) -> str: self._response = None self._prompt = Prompt( prompt_id, - element + element, + update_period=1, ) + self._previous_element = None self.notify_update() return prompt_id From 1a2a457deb6a7093de8b5791e2b5eb11177d0ab1 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 18 Jun 2025 10:52:18 +0200 Subject: [PATCH 19/19] Refactor deprecated notify_all --- tofupilot/operator_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tofupilot/operator_ui.py b/tofupilot/operator_ui.py index 402ccd8..638c3a2 100644 --- a/tofupilot/operator_ui.py +++ b/tofupilot/operator_ui.py @@ -262,7 +262,7 @@ def respond(self, prompt_id: str, response: str) -> None: self.last_response = (prompt_id, response) self.remove_prompt() - self._cond.notifyAll() + self._cond.notify_all() def prompt_for_test_start(