Homepage GitHub

Yukon design megathread

(Theodoros Ntakouris) #61

I was also thinking that it would make sense to work on a tiny parser of the type text, that maps (where it is possible – mostly enums, text and primitive number types – not arrays) data type characteristics to form data validation fields.

(That is, strictly on the Yukon side, not intended for public usage)

Checking array inputs for user-input mistakes would be a bit more tricky, as it would require parsing textfields by hand and working out each element, but it’s not that hard to do.

(Theodoros Ntakouris) #62

Another quick question: Is the only place where we need to have different display methods on a type’s value, the array case?

A simple array check can be done, so that the user can select viewtype, from bitstring, array format, image, or text.

On the image side of things, would it be appropriate to assume that it is an image by the large size of the array? I don’t know what heuristic to use for the other ones. Maybe by leveraging pydsdl we can have a _displayhint_ attribute on that – one of [text, array, bitstring, image].

(Pavel Kirienko) #63

Yes, name.major.minor is the way to go.

The name could be useful but it’s not strictly necessary. Remember, the name can’t be changed by the user, it is set once by the vendor, so one system may have dozens of nodes under the same name.

I would make this generic. Say, an integer value could be viewed as decimal, binary, or hexadecimal. More display methods are likely to come up in the future.

Would it not be wise to postpone this until much later, when we have an actual MVP that can connect to other nodes and be generally useful?

(Theodoros Ntakouris) #64

Let’s postpone the advanced display features for later. I updated the version display.

name[id] seems like a better choice for displaying the node name.

How are we going to represent non-primitive and array values on the GRV table?
Two things come to mind: either ... (dots) with a hover-over popup that shows the full details, or a simple traversal of the type tree, showing a truncated text of the first few primitives and arrays found, with that hover-over feature too.

How’s that for a register UI edit?

(client side data validations are wip)

(Pavel Kirienko) #65

A register value may be one of the few predefined types, all of which are arrays of primitives (except Empty, which contains nothing):

Therefore, my proposal is to display them as follows:

  • uavcan.primitive.Empty - special placeholder like “(none)”.
  • uavcan.primitive.String - as text.
  • uavcan.primitive.Bit - or .
  • uavcan.primitive.Unstructured, uavcan.primitive.array.Integer*, uavcan.primitive.array.Natural*, and uavcan.primitive.array.Real* - raw values (more clever representations to come, as discussed above).

(Theodoros Ntakouris) #66

I can see (from the dsdl definition) that these are the only types that the registers support. That implicitly signifies that there is only one indentation level on 384.Access.0.1.uavcan . Shall I refactor the current generic TypeValue component not to use recursion, assuming that we are only going to ever preview register values? (Im my mind: No, that needs to be generic, in a uniform way, showing some kind of preview without the need of knowing type info, just by recursively traversing the type tree, until you meet a constant).

Input is different though. Would there be a need on any other place inside Yukon, where a user expects to edit (on a web-form), a data type that is different than uavcan.register.Value ? If there is no need to support a more generic form generation mechanism, the UX could be improved and the SLoC drastically reduced. (Provided that the scope of the types of the form generation is only 1-nested level deep and said types are only the ones you mention on the table above).

Is the user expected to ever want to change a register’s Value to none ? Maybe we can treat an empty input field as none. Can none be used if the type is an array or any primitive? (I guess it signifies null or undefined ?)

Base64 encoded string representation too.

(Pavel Kirienko) #67

We will need generic representations for the subscriber, publisher, and service caller tools, so they are useful. It makes sense to implement special case representations for registers because the register model is a higher-level model compared to the types.

Yes, the publisher and service calling tools, which we will need later. For register view, as I wrote somewhere earlier, it should be sufficient to parse JSON string representations of the values. For example, [123, 456.789] could be used to initialize a register of type uavcan.primitive.array.Real32 containing two elements: 123.0 and 456.789. If the register was, say, of type uavcan.primitive.String, the value would be interpreted as a string "[123, 456.789]".

The type of a register can’t be changed, as written in the doc comments for the register interface. This means that the node decides what the register type is and it stays constant while the node is running. The empty value is used to signify either:

  • when writing a register: the value need not be changed;
  • when reading a register: such register does not exist (this actually means that we won’t need (none), which I should have realized when writing my previous post).

(Theodoros Ntakouris) #68

But here:

Except the uavcan.register.* types, the other type is represented as it’s format primitive value definition per the dsdl section of the spec. Some last note to end the conversation per the form-generation UX:

The ‘primitive’ types that the user is going to be able to edit will be in name: {type: (optional: saturated/whatever datatype(optional array)} format? The other option would be to map everything under uavcan.primitive.* into their corresponding form model & validation rules. I think the latter is a bad idea because everything under these types is reduced to the first format at the leaf level.

Special treatment of the register types is ensured, because of the pre-existing structure of the data type definitions.

Now that primitives (and arrays) are no part of the problem, let’s discuss about unions:

We can treat unions as a one-of-each validation rule, but inferring the type from the raw data user inputs is much weak typing. Maybe, instead of unions, the backend needs to provide specific type information for each required input field, where possible, in order to avoid that mess. Ideas and suggestion are as always, welcome.
Other option would be to show a drop-down for selection. I guess I’m prototyping multiple UIs on the subset of features we decide that could make it to the final version. I’ll start prototyping on the drop-down as well if you don’t have any suggestion: that would mean that on unions, an array of all the possible union types would need to be provided. That could get chaotic with unions that are made of composite types (that’s possible, right).

Every day I’m learning more about UAVCAN and it keeps getting more interesting! You guys have already done so much work!

(Pavel Kirienko) #69

I suspect the reason for the miscommunication above is that we are concurrently discussing two different issues: internal data modeling and UI design. To make sure that we are on the same page, I’d like to reiterate how I see things applied to the Global Register View. As far as the data model is concerned, I don’t think any special treatment is warranted. This means that the JSON representation we’ve discussed above should probably be used for transferring register-related over REST. The GUI, on the other hand, need not be generic, because we don’t want to burden the user with the overhead of dealing with unions and stuff; so here I suggest we tightly optimize the UI design specifically for registers. Practically this means that when a user wants to edit a register that contains a vector of (123, 456, -9) of type uavcan.primitive.array.Integer32, they see a neat little edit widget containing only this (metadata like type, name, flags, etc. not shown here):

[123, 456, -9]

There is no point informing the user that the values shown above are actually contained in a field named value, which is a part of a type which in turn is a field named integer32 contained inside the union type uavcan.register.Value. It’s irrelevant if the user just wants to have some registers edited. On the other hand, if the user asked the GUI tool to invoke the service uavcan.register.Access manually, they’d get the full blown value structure in the response, with unions and stuff.

Speaking about unions, this is how they will be modeled on the back end (that is, in pyuavcan); the example below is generated from uavcan.register.Value:

# Generated at:  2019-04-16 17:01:28.164445 UTC
# Is deprecated: no
# Fixed port ID: None
# Full name:     uavcan.register.Value
# Version:       0.1

import numpy as _np_
from typing import Optional as _Optional_, List as _List_, Union as _Union_
from pyuavcan.dsdl import CompositeObject as _CompositeObject_
from pydsdl import UnionType as _UnionType_
import uavcan.primitive
import uavcan.primitive.array

# noinspection PyUnresolvedReferences, PyPep8, PyPep8Naming, SpellCheckingInspection
class Value_0_1(_CompositeObject_):
    def __init__(self, *,
                 empty:        _Optional_[uavcan.primitive.Empty_1_0] = None,
                 string:       _Optional_[uavcan.primitive.String_1_0] = None,
                 unstructured: _Optional_[uavcan.primitive.Unstructured_1_0] = None,
                 bit:          _Optional_[uavcan.primitive.array.Bit_1_0] = None,
                 integer64:    _Optional_[uavcan.primitive.array.Integer64_1_0] = None,
                 integer32:    _Optional_[uavcan.primitive.array.Integer32_1_0] = None,
                 integer16:    _Optional_[uavcan.primitive.array.Integer16_1_0] = None,
                 integer8:     _Optional_[uavcan.primitive.array.Integer8_1_0] = None,
                 natural64:    _Optional_[uavcan.primitive.array.Natural64_1_0] = None,
                 natural32:    _Optional_[uavcan.primitive.array.Natural32_1_0] = None,
                 natural16:    _Optional_[uavcan.primitive.array.Natural16_1_0] = None,
                 natural8:     _Optional_[uavcan.primitive.array.Natural8_1_0] = None,
                 real64:       _Optional_[uavcan.primitive.array.Real64_1_0] = None,
                 real32:       _Optional_[uavcan.primitive.array.Real32_1_0] = None,
                 real16:       _Optional_[uavcan.primitive.array.Real16_1_0] = None):
        If no parameters are provided, the first field will be default-initialized and selected.
        If one parameter is provided, it will be used to initialize and select the field under the same name.
        If more than one parameter is provided, a ValueError will be raised.
        self._empty:        _Optional_[uavcan.primitive.Empty_1_0] = None
        self._string:       _Optional_[uavcan.primitive.String_1_0] = None
        self._unstructured: _Optional_[uavcan.primitive.Unstructured_1_0] = None
        self._bit:          _Optional_[uavcan.primitive.array.Bit_1_0] = None
        self._integer64:    _Optional_[uavcan.primitive.array.Integer64_1_0] = None
        self._integer32:    _Optional_[uavcan.primitive.array.Integer32_1_0] = None
        self._integer16:    _Optional_[uavcan.primitive.array.Integer16_1_0] = None
        self._integer8:     _Optional_[uavcan.primitive.array.Integer8_1_0] = None
        self._natural64:    _Optional_[uavcan.primitive.array.Natural64_1_0] = None
        self._natural32:    _Optional_[uavcan.primitive.array.Natural32_1_0] = None
        self._natural16:    _Optional_[uavcan.primitive.array.Natural16_1_0] = None
        self._natural8:     _Optional_[uavcan.primitive.array.Natural8_1_0] = None
        self._real64:       _Optional_[uavcan.primitive.array.Real64_1_0] = None
        self._real32:       _Optional_[uavcan.primitive.array.Real32_1_0] = None
        self._real16:       _Optional_[uavcan.primitive.array.Real16_1_0] = None
        _init_cnt_: int = 0
        if empty is not None:
            _init_cnt_ += 1
            self.empty = empty
        if string is not None:
            _init_cnt_ += 1
            self.string = string
        if unstructured is not None:
            _init_cnt_ += 1
            self.unstructured = unstructured
        if bit is not None:
            _init_cnt_ += 1
            self.bit = bit
        if integer64 is not None:
            _init_cnt_ += 1
            self.integer64 = integer64
        if integer32 is not None:
            _init_cnt_ += 1
            self.integer32 = integer32
        if integer16 is not None:
            _init_cnt_ += 1
            self.integer16 = integer16
        if integer8 is not None:
            _init_cnt_ += 1
            self.integer8 = integer8
        if natural64 is not None:
            _init_cnt_ += 1
            self.natural64 = natural64
        if natural32 is not None:
            _init_cnt_ += 1
            self.natural32 = natural32
        if natural16 is not None:
            _init_cnt_ += 1
            self.natural16 = natural16
        if natural8 is not None:
            _init_cnt_ += 1
            self.natural8 = natural8
        if real64 is not None:
            _init_cnt_ += 1
            self.real64 = real64
        if real32 is not None:
            _init_cnt_ += 1
            self.real32 = real32
        if real16 is not None:
            _init_cnt_ += 1
            self.real16 = real16
        if _init_cnt_ == 0:
            self.empty = uavcan.primitive.Empty_1_0()  # Default initialization
        elif _init_cnt_ == 1:
            pass  # A value is already assigned, nothing to do
            raise ValueError(f'Union cannot hold values of more than one field')

    def empty(self) -> _Optional_[uavcan.primitive.Empty_1_0]:
        """uavcan.primitive.Empty.1.0 empty"""
        return self._empty

    def empty(self, x: uavcan.primitive.Empty_1_0) -> None:
        if isinstance(x, uavcan.primitive.Empty_1_0):
            self._empty = x
            raise ValueError(f'empty: expected uavcan.primitive.Empty_1_0 got {type(x).__name__}')

    def string(self) -> _Optional_[uavcan.primitive.String_1_0]:
        """uavcan.primitive.String.1.0 string"""
        return self._string

    def string(self, x: uavcan.primitive.String_1_0) -> None:
        if isinstance(x, uavcan.primitive.String_1_0):
            self._string = x
            raise ValueError(f'string: expected uavcan.primitive.String_1_0 got {type(x).__name__}')

    def unstructured(self) -> _Optional_[uavcan.primitive.Unstructured_1_0]:
        """uavcan.primitive.Unstructured.1.0 unstructured"""
        return self._unstructured

    def unstructured(self, x: uavcan.primitive.Unstructured_1_0) -> None:
        if isinstance(x, uavcan.primitive.Unstructured_1_0):
            self._unstructured = x
            raise ValueError(f'unstructured: expected uavcan.primitive.Unstructured_1_0 got {type(x).__name__}')

    def bit(self) -> _Optional_[uavcan.primitive.array.Bit_1_0]:
        """uavcan.primitive.array.Bit.1.0 bit"""
        return self._bit

    def bit(self, x: uavcan.primitive.array.Bit_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Bit_1_0):
            self._bit = x
            raise ValueError(f'bit: expected uavcan.primitive.array.Bit_1_0 got {type(x).__name__}')

    def integer64(self) -> _Optional_[uavcan.primitive.array.Integer64_1_0]:
        """uavcan.primitive.array.Integer64.1.0 integer64"""
        return self._integer64

    def integer64(self, x: uavcan.primitive.array.Integer64_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Integer64_1_0):
            self._integer64 = x
            raise ValueError(f'integer64: expected uavcan.primitive.array.Integer64_1_0 got {type(x).__name__}')

    def integer32(self) -> _Optional_[uavcan.primitive.array.Integer32_1_0]:
        """uavcan.primitive.array.Integer32.1.0 integer32"""
        return self._integer32

    def integer32(self, x: uavcan.primitive.array.Integer32_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Integer32_1_0):
            self._integer32 = x
            raise ValueError(f'integer32: expected uavcan.primitive.array.Integer32_1_0 got {type(x).__name__}')

    def integer16(self) -> _Optional_[uavcan.primitive.array.Integer16_1_0]:
        """uavcan.primitive.array.Integer16.1.0 integer16"""
        return self._integer16

    def integer16(self, x: uavcan.primitive.array.Integer16_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Integer16_1_0):
            self._integer16 = x
            raise ValueError(f'integer16: expected uavcan.primitive.array.Integer16_1_0 got {type(x).__name__}')

    def integer8(self) -> _Optional_[uavcan.primitive.array.Integer8_1_0]:
        """uavcan.primitive.array.Integer8.1.0 integer8"""
        return self._integer8

    def integer8(self, x: uavcan.primitive.array.Integer8_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Integer8_1_0):
            self._integer8 = x
            raise ValueError(f'integer8: expected uavcan.primitive.array.Integer8_1_0 got {type(x).__name__}')

    def natural64(self) -> _Optional_[uavcan.primitive.array.Natural64_1_0]:
        """uavcan.primitive.array.Natural64.1.0 natural64"""
        return self._natural64

    def natural64(self, x: uavcan.primitive.array.Natural64_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Natural64_1_0):
            self._natural64 = x
            raise ValueError(f'natural64: expected uavcan.primitive.array.Natural64_1_0 got {type(x).__name__}')

    def natural32(self) -> _Optional_[uavcan.primitive.array.Natural32_1_0]:
        """uavcan.primitive.array.Natural32.1.0 natural32"""
        return self._natural32

    def natural32(self, x: uavcan.primitive.array.Natural32_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Natural32_1_0):
            self._natural32 = x
            raise ValueError(f'natural32: expected uavcan.primitive.array.Natural32_1_0 got {type(x).__name__}')

    def natural16(self) -> _Optional_[uavcan.primitive.array.Natural16_1_0]:
        """uavcan.primitive.array.Natural16.1.0 natural16"""
        return self._natural16

    def natural16(self, x: uavcan.primitive.array.Natural16_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Natural16_1_0):
            self._natural16 = x
            raise ValueError(f'natural16: expected uavcan.primitive.array.Natural16_1_0 got {type(x).__name__}')

    def natural8(self) -> _Optional_[uavcan.primitive.array.Natural8_1_0]:
        """uavcan.primitive.array.Natural8.1.0 natural8"""
        return self._natural8

    def natural8(self, x: uavcan.primitive.array.Natural8_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Natural8_1_0):
            self._natural8 = x
            raise ValueError(f'natural8: expected uavcan.primitive.array.Natural8_1_0 got {type(x).__name__}')

    def real64(self) -> _Optional_[uavcan.primitive.array.Real64_1_0]:
        """uavcan.primitive.array.Real64.1.0 real64"""
        return self._real64

    def real64(self, x: uavcan.primitive.array.Real64_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Real64_1_0):
            self._real64 = x
            raise ValueError(f'real64: expected uavcan.primitive.array.Real64_1_0 got {type(x).__name__}')

    def real32(self) -> _Optional_[uavcan.primitive.array.Real32_1_0]:
        """uavcan.primitive.array.Real32.1.0 real32"""
        return self._real32

    def real32(self, x: uavcan.primitive.array.Real32_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Real32_1_0):
            self._real32 = x
            raise ValueError(f'real32: expected uavcan.primitive.array.Real32_1_0 got {type(x).__name__}')

    def real16(self) -> _Optional_[uavcan.primitive.array.Real16_1_0]:
        """uavcan.primitive.array.Real16.1.0 real16"""
        return self._real16

    def real16(self, x: uavcan.primitive.array.Real16_1_0) -> None:
        if isinstance(x, uavcan.primitive.array.Real16_1_0):
            self._real16 = x
            raise ValueError(f'real16: expected uavcan.primitive.array.Real16_1_0 got {type(x).__name__}')

    def _reset_(self) -> None:
        self._empty = None
        self._string = None
        self._unstructured = None
        self._bit = None
        self._integer64 = None
        self._integer32 = None
        self._integer16 = None
        self._integer8 = None
        self._natural64 = None
        self._natural32 = None
        self._natural16 = None
        self._natural8 = None
        self._real64 = None
        self._real32 = None
        self._real16 = None

    _TYPE_: _UnionType_ = _CompositeObject_._restore_constant_(
    assert isinstance(_TYPE_, _UnionType_)

The actual internal implementation may be changed, but the API is unlikely to.

(Theodoros Ntakouris) #70

Updated swagger docs with the previously discussed type information.

Also added /types/{type} endpoint, used for type information queries.

Regarding node updating: does uploading the base64 encoded bytes of target image work? Server would just easily deserialise and send over wire.

Next feature for discussion would be the can bus monitor: How did you decide what kind of colouring to use for each? I guess we are sticking with the previous UI, the table is usable. Since we’ve got a new type UI do I use that for the value view on the selected row or do you prefer the flattened-text one? (Tabs for composite type values indendation maybe? - copy-pasteability could aid debugging). Also, what’s the plotter window going to do (bottom right on old gui_tool) ? Is that omitted because we have a plotter tool now?

(Pavel Kirienko) #71

I suppose so.

I just defined color as function of the value of the cell. Like this: https://github.com/UAVCAN/gui_tool/blob/0bb0ab49e2f4372a0581a157ca019449e20af1bb/uavcan_gui_tool/widgets/__init__.py#L608-L617, or like this:

def colorize_can_id(frame):
    if not frame.extended:
    mask = 0b11111
    priority = (frame.id >> 24) & mask
    col = QColor()
    col.setRgb(0xFF, 0xFF - (mask - priority) * 6, 0xFF)
    return col

def colorize_transfer_id(e):
    if len(e[1].data) < 1:

    # Making a rather haphazard hash using transfer ID and a part of CAN ID
    x = (e[1].data[-1] & 0b11111) | (((e[1].id >> 16) & 0b1111) << 5)
    red = ((x >> 6) & 0b111) * 25
    green = ((x >> 3) & 0b111) * 25
    blue = (x & 0b111) * 25

    col = QColor()
    col.setRgb(0xFF - red, 0xFF - green, 0xFF - blue)
    return col

I like your new UI. The old UI relied on text out of the lack of better options. Your UI would be more configurable, e.g., the user could (eventually) select the preferred representation per field (radix, or a progress bar instead of number; low-pass filtering, etc.)

I am not sure what are you referring to when you say “bottom right on old gui_tool”.

(Theodoros Ntakouris) #72

Selectability/Copyability is what concerns me. Maybe we include a copy to JSON or some other format on each value (or each subtree of the value, I don’t know)

(Pavel Kirienko) #73

Yep, we could have a tiny button which copies the entire subtree as JSON or YAML when clicked.

If possible, it’s best to keep this tiny plot in the new version, too – it’s quite useful. It displays the current bus load in frames per second (we could also display bytes per second); this information cannot be easily obtained in the plotter tool because it does not really originate from the bus, it is estimated locally.

(Theodoros Ntakouris) #74


As , messages per second?

Bytes per second can be ontop too (perhaps with a can 2.0 bps limit as the max value to scale?)

(Pavel Kirienko) #75

No, frames per second. A message can be split over multiple frames.

There will be different communication protocols so it shouldn’t be dependent on CAN 2.0. In the old GUI tool, the graph rescales itself automatically, it’s probably best to keep this behavior unchanged.

(Theodoros Ntakouris) #76

Do you want to keep the data hex and data ascii ? (I guess so).

The last remaining things to chat about design and ux considerations would be the dynamic node allocation widget, message logging and subscribing widget.

I guess it makes sense to just keep a dictionary of node ids <=> uids. Do you want to support reassigning uids as well? (Is that even supported?).

The message logging part could be part of the can bus monitor, by just applying filters like the designed ones on the plotter. I’m not sure if you prefer this to be a different component.

One extra idea would be a message ‘emitter/debugger’ to post messages (periodic, from csv or whatever), after evaluating some javascript. We can have some defaults and also support vendors too. For example, a sawtooth wave curve to test motors or…

(Pavel Kirienko) #77

Sure. Keep in mind that if we decided to support Ethernet (I’m pretty sure we’re heading this way), the maximum frame size may be up to 9000 bytes.

The allocation table could be just a plain array of UIDs, where the index matches the node ID. Relevant specs:

Reassignment of UID is not formally defined, so it’s safe to leave it out. The user must be able to wipe the allocation table though if needed. Ideally it should be possible to store the allocation table in a file (persistent) or in memory (forgotten after a restart).

I understand that by message logging you mean the subscription tool (rather than frame logging). I think it should be a different component because we can’t know the ratio between the desired message rate and the total frame rate on the bus. If it’s small, the tool will have to sift through copious amounts of data on the bus in real time just to cherry pick the few messages the user cares about. It won’t scale well. Instead, I suggest to implement it based on regular subscription logic, allowing PyUAVCAN to deal with the real-time filtering part (it has access to the interface configuration, so it can employ hardware acceptance filters as necessary).

As in the case of frame logging, we shouldn’t stream anything in real time:

(Theodoros Ntakouris) #78

That’s only for display purposes? Are we going to add an action endpoint to upload pre-existing allocation table?

Displayed values should be truncated? (hover over to preview full hex and ascii representation is possible, but a bit unnecessary in my opinion since you have the click-to-view-data-tree feature)

Ok. What query parameters are going to be used? Pagination, time constraints, ?

(Theodoros Ntakouris) #79

Can pyuavcan dynamically reconfigure it’s id on runtime? I’m asking because I’m considering to add a /restart and /reconfigure endpoint for the backend, in order to change params like log directory location, bus id, log rotation times, whatever comes up.

(Pavel Kirienko) #80

I think it’s best to postpone that until much later. Not sure if it’s that useful.

You can’t apply a data tree view to a single frame, because a frame is not guaranteed to contain an entire serialized object. The purpose of the bus monitor is to provide a very, very low-level view of the data, like a hex editor.

The message view that appears in the bottom-left corner of the old gooey tool does not necessarily apply to the currently selected frame only. The old tool detects which frame is selected, then walks up and down the list of frames to reassemble the transfer that the selected frame belongs to, then deserializes it and displays the result. So it is important to keep in mind that the bottom-left view is more complex than just a decoded single-frame payload.

I think the most common use case would be where the request is constrained by a time boundary (either upper or lower) and the maximum number of items to return. Like select * from rows where timestamp >= x order by timestamp limit 1000.

If the user is scrolling the view towards the bottom and reaches the end of the locally available data, the frontend would look at the last loaded entry, pick its timestamp (let it be X), and request the next chunk where timestamp >= X and the max number of returned elements is 1000 (surely any decent computer can chew 1000 items at a time?). The frontend should also drop some frames from the opposite end of the view if it exceeds some sensible threshold (say, ~5k? assuming 9000 bytes per frame in the worst case, that would be 43 MB of locally stored data).

If the user is scrolling the view in the opposite direction, the logic would be pretty much the same except that we’ll be looking at the top entry’s timestamp instead of the bottom one and the boundary will be specified as (timestamp <= X).

If the user desires the view to auto-scroll in (quasi) real time, the frontend would just poll the backend every second or so, simply requesting the latest available entries.

Have you thought about storage yet? Context:

We will need to define a simple and extensible log dump format. It probably makes sense to use log files in that format as the underlying storage for the bus monitor & frame logger as well, so that the backend would store received frames in a rotating collection of files in that format (say, we could keep only the last few files, a couple of gibibytes each, removing the oldest ones automatically unless configured otherwise); when the front-end requests a particular slice, the backend would just open the matching file and return the matching frames from there. If the frames are ordered by timestamp (there is no reason for them not to be, although the backend should be prepared to properly handle short-term out-of-order frames received from the underlying driver, this is easy to do by keeping a short (say, 1k frames) buffer in memory before committing it into the file), the backend can navigate around the file in O(log n) using simple binary search. If the user desires to store a dump locally for a later study, it would just pick the boundary (say, timestamp between X and Y, but no more than Z frames; or just N last frames) and the backend would return the matching slice.

I particularly like the idea of defining a very minimalistic headerless file format which simply contains a sequence of frames, each with its own dedicated header and CRC. This would allow the user to easily manipulate log files by truncating or concatenating them naively, no special tools needed.

That said, I don’t have any particularly strong feelings about this approach. If you prefer having a proper database for temporary log storage, go for it. In this case we could still generate a log file in our to-be-defined minimalistic format upon request, dynamically.

I should sit down someday soon and seriously think about that format.

That would require us to instantiate a new node (destroying the old one), otherwise yes. UAVCAN is a very static thing (because it is designed for robust embedded systems), it does not define dynamic reconfigurations.