[Ground-station] COBS Decoder: Human vs OpenAI GPT
Mark Whittington
markwhi at gmail.com
Thu Dec 8 21:22:52 PST 2022
Hi Michelle,
For some reason I didn't see this reply until now, but I have submitted the
blog post to HaD. Hope your travels are going well!
Best,
Mark
On Mon, Dec 5, 2022 at 5:33 AM Michelle Thompson <
mountain.michelle at gmail.com> wrote:
> Thank you, Mark.
>
> It’s posted to the ORI website as a blog entry, but there are no plans to
> publish it elsewhere. We do not mind if it’s forwarded to Hack-a-Day.
>
> I’ll be on travel through the 10th, so would you please take the lead on a
> submission?
>
> -Michelle
>
> On Sun, Dec 4, 2022 at 22:43 Mark Whittington <markwhi at gmail.com> wrote:
>
>> Michelle and Paul:
>>
>> This was a fascinating read! I have seen a lot of coverage of
>> AI-generated code (mostly GitHub's Copilot) but this is the first test case
>> that I've seen involving an HDL. Do you plan to publish this work somewhere
>> other than this list? Would you mind if I forward it to the editors at
>> Hack-a-Day, or would you consider doing so yourselves?
>>
>> Thank you both for the write-up and to anyone else involved!
>>
>> On Sat, Dec 3, 2022, 1:31 PM Michelle Thompson via Ground-Station
>> <ground-station at lists.openresearch.institute> wrote:
>>
>>> Greetings all,
>>>
>>> You know that ORI has been active in a variety of regulatory work for
>>> several years now. One of those areas has been in AI/ML and the effect
>>> on communications. We served on the US FCC TAC in 2022, presented on
>>> the subject, and are working towards an AI/ML Handbook for Amateur
>>> Radio.
>>>
>>> You may have seen news about OpenAI GPT, or ChatGPT.
>>>
>>> There's a lot of people testing this chatbot out for reverse
>>> engineering and in writing software. There's been some successes in
>>> cases where the model would have had access to code or documentation
>>> about protocols, observations, or technical behavior in order to
>>> train, and requests match up well with the models.
>>>
>>> For example, a friend asked the chatbot to create a Python script to
>>> access the webcam on their computer, and they shared a still image
>>> from the resulting feed. Another example from another friend resulted
>>> in better reverse engineering results than they had gotten with either
>>> IDA or Ghidra over many weeks.
>>>
>>> So, how does ChatGPT handle VHDL?
>>>
>>> We have something specific to compare against, since we just completed
>>> a COBS decoder in VHDL for our open source HEO/GEO transponder work
>>> for Haifuraiya.
>>>
>>> Let's start off simple. Here's the first dialogue with the chatbot,
>>> and the results.
>>>
>>> -=-=-=-=-=-=-=-=-=-=-=-=-=-
>>>
>>> Abraxas3d: Design a COBS decoder in VHDL
>>>
>>> Sure, here's an example of a COBS decoder in VHDL:
>>>
>>>
>>>
>>> library IEEE;
>>> use IEEE.STD_LOGIC_1164.ALL;
>>>
>>> entity COBS_decoder is
>>> Port (
>>> input_data : in std_logic_vector;
>>> decoded_data : out std_logic_vector
>>> );
>>> end COBS_decoder;
>>>
>>> architecture Behavioral of COBS_decoder is
>>> -- Declare a variable to keep track of the current index
>>> -- in the input and output data streams
>>> variable current_index : integer := 1;
>>>
>>> -- Declare a variable to store the length of the
>>> -- run of zeros in the input data
>>> variable zero_run_length : integer := 0;
>>> begin
>>> -- Iterate over the input data
>>> for i in input_data'range loop
>>> -- If the current input value is a zero, increment
>>> -- the zero run length variable
>>> if input_data(i) = '0' then
>>> zero_run_length := zero_run_length + 1;
>>> -- Otherwise, the current input value is not a zero,
>>> -- so we've reached the end of the zero run
>>> else
>>> -- Set the decoded value at the current index to
>>> -- the length of the zero run
>>> decoded_data(current_index) :=
>>> conv_std_logic_vector(zero_run_length, 8);
>>>
>>> -- Increment the current index and reset the zero
>>> -- run length for the next iteration
>>> current_index := current_index + 1;
>>> zero_run_length := 0;
>>> end if;
>>> end loop;
>>> end Behavioral;
>>>
>>> This code decodes the input data using the COBS (Consistent Overhead
>>> Byte Stuffing) algorithm, which involves replacing runs of zeros in
>>> the input data with a single byte that indicates the length of the
>>> zero run. For example, the input '101001000' would be decoded as
>>> '103101'.
>>>
>>> This decoder iterates over the input data and maintains a variable
>>> zero_run_length that is incremented for each zero in the input data.
>>> When a non-zero value is encountered, the length of the zero run is
>>> written to the output data and the zero_run_length variable is reset.
>>>
>>> I hope this helps! Let me know if you have any other questions."
>>>
>>> -=-=-=-=-=-=-=-=-=-=-=-=-=-
>>>
>>> It knew what COBS meant. It produced code, with comments, and a brief
>>> "design document", with examples.
>>>
>>> Our open source HEO transponder project uses AXI-based blocks in the
>>> FPGA. So, let's add AXI to the request.
>>>
>>> -=-=-=-=-=-=-=-=-=-=-=-=-=-
>>>
>>> Abraxas3d: Design a COBS decoder with AXI interface in VHDL
>>>
>>> Sure, here's an example of a COBS decoder with an AXI interface in VHDL:
>>>
>>> library IEEE;
>>> use IEEE.STD_LOGIC_1164.ALL;
>>> use IEEE.NUMERIC_STD.ALL;
>>>
>>> -- AXI interface definitions
>>> library unisim;
>>> use unisim.vcomponents.all;
>>>
>>> entity COBS_decoder is
>>> Port (
>>> AXI_IN : in AXI4Stream;
>>> AXI_OUT : out AXI4Stream
>>> );
>>> end COBS_decoder;
>>>
>>> architecture Behavioral of COBS_decoder is
>>> -- Declare a variable to keep track of the current index
>>> -- in the input and output data streams
>>> variable current_index : integer := 1;
>>>
>>> -- Declare a variable to store the length of the
>>> -- run of zeros in the input data
>>> variable zero_run_length : integer := 0;
>>> begin
>>> -- Iterate over the input data
>>> for i in AXI_IN.data'range loop
>>> -- If the current input value is a zero, increment
>>> -- the zero run length variable
>>> if AXI_IN.data(i) = '0' then
>>> zero_run_length := zero_run_length + 1;
>>> -- Otherwise, the current input value is not a zero,
>>> -- so we've reached the end of the zero run
>>> else
>>> -- Set the decoded value at the current index to
>>> -- the length of the zero run
>>> AXI_OUT.data(current_index) :=
>>> conv_std_logic_vector(zero_run_length, 8);
>>>
>>> -- Increment the current index and reset the zero
>>> -- run length for the next iteration
>>> current_index := current_index + 1;
>>> zero_run_length := 0;
>>> end if;
>>> end loop;
>>>
>>> -- Set the valid and last flags in the output AXI stream
>>> AXI_OUT.valid <= '1';
>>> AXI_OUT.last <= '1';
>>> end Behavioral;
>>>
>>> This code is similar to the previous example, but it uses an AXI
>>> (Advanced eXtensible Interface) interface for the input and output
>>> data. The AXI interface is a standard interface used in many FPGA
>>> designs, and it provides a way to stream data into and out of the
>>> decoder.
>>>
>>> In this code, the input data is read from the AXI_IN.data port, and
>>> the decoded output is written to the AXI_OUT.data port. The valid and
>>> last flags are also set in the output AXI stream to indicate that the
>>> data is valid and that it is the last data in the stream.
>>>
>>> I hope this helps! Let me know if you have any other questions.
>>>
>>> -=-=-=-=-=-=-=-=-=-=-=-=-=-
>>>
>>> It knows about the previous request and updated the design.
>>>
>>> Here's a review of the COBS implementation from Paul Williamson KB5MU.
>>>
>>> "I don’t know what this is, but it isn’t a COBS decoder with AXI
>>> interfaces.
>>>
>>> It does seem at first glance to be some sort of thing with AXI
>>> interfaces, but it does not implement an AXI interface at all. It just
>>> relies on a (possibly imaginary) library type called AXI4Stream that
>>> somehow magically does AXI interfaces, in a way that’s both
>>> implausibly smart and unexplicably dumb. I think the classic phrase
>>> “not even wrong” applies here.
>>>
>>> The AXI4Stream object apparently has a member .data that is an array
>>> of bytes, which might make sense if the entire AXI transfer is being
>>> treated as a single entity here. The code iterates through the input
>>> .data using the ’range attribute, as if the AXI4Stream object has a
>>> defined fixed transfer length.
>>>
>>> Which it can’t have, if it’s a general purpose AXI stream implementation.
>>>
>>> So, if this was ever anything real, it must have been a special
>>> purpose AXI implementation with a fixed transfer length. Assuming that
>>> the AXI4Stream .data actually works that way, the entity does
>>> do some sort of transformation on the input data to create the output
>>> data.
>>>
>>> We asked for that to be a COBS decoder, but the function that it
>>> actually implements bears only the slightest resemblance to a COBS
>>> decoder. What it actually does is output a stream of words that are
>>> the lengths of the runs of zeroes in the input data. Nonzero data does
>>> not get passed through at all.
>>>
>>> Instead, a run of nonzero bytes gets translated into a run of zeroes,
>>> as if two adjacent nonzero bytes meant nothing but a zero-length run
>>> of zeroes.
>>>
>>> Here’s an example of what this code seems to do:
>>> Input .data: 1 2 3 4 0 0 0 0 1 2 3 4
>>> Output .data: 0 0 0 0 4 0 0 0
>>>
>>> That’s certainly not a COBS decoder, or even anything useful at all.
>>>
>>> Note that the output is variable length, but never longer than the input.
>>> We had to assume that the AXI4Stream object had a fixed .data size to
>>> get this far, but now it has to have a variable data size for the
>>> output.
>>>
>>> Supposing we had successfully suspended disbelief up to here, we now
>>> examine the final two lines of code in the Behavioral architecture. It
>>> sets the .valid and .last members of the AXI_OUT port to ‘1’.
>>>
>>> That makes absolutely no sense if it’s supposed to encapsulate an
>>> entire AXI stream transfer. Those signals only make sense if we’re
>>> treating one word at a time, as you’d expect to see in a normal,
>>> general-purpose AXI stream object.
>>>
>>> The extremely verbose commenting style in this code suggests that the
>>> original source material was from an introductory tutorial. Maybe the
>>> AI has been tuned to favor heavily commented code, and ends up picking
>>> up partially completed junk from the middle of tutorials?
>>> Anyhow, this is not an example of automated programming in response to
>>> a high-level query. This is gibberish that succeeds only in having a
>>> superficial resemblance to the code requested."
>>>
>>> So, does this work?
>>>
>>> Let's try it out in the lab!
>>>
>>> The first thing we notice is that there is an obvious syntax error
>>> with a missing semicolon. Aside from that, there is no AXI4Stream port
>>> type. It's not recognized. Vivado appears to be disappointed in this
>>> file, and wants little to do with this code. This is where we should
>>> stop, but I spent some time looking to see if the ports were at least
>>> close to being declared correctly.
>>>
>>> The model appears to have taken some custom or maybe enumerated type
>>> from somewhere, and come up with a custom AXI4Stream type. This is not
>>> recognized by the unisim library right off the bat, and that's the
>>> sort of thing that automated code writing needs to get right.
>>>
>>> Let's look at the code written by humans from ORI.
>>>
>>> -=-=-=-=-=-=-=-=-=-=-=-=-=-
>>>
>>>
>>> ----------------------------------------------------------------------------------
>>> -- Company: Open Research Institute, Inc.
>>> -- Engineer: Skunkwrx, Abraxas3d
>>> --
>>> -- Design Name: COBS protocol decoder
>>> -- Module Name: decoder - Behavioral
>>> -- Project Name: Phase 4 "Haifuraiya"
>>> -- Target Devices: 7000 Zynq
>>> -- Tool Versions: 2021.1
>>> -- Description: COBS protocol decoder.
>>> --
>>> https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing
>>> --
>>> -- Dependencies:
>>> --
>>> -- Additional Comments: This work is Open Source and licensed using
>>> CERN OHL v2.0
>>> --
>>>
>>> ----------------------------------------------------------------------------------
>>>
>>>
>>> library IEEE;
>>> use IEEE.STD_LOGIC_1164.ALL;
>>>
>>> use IEEE.NUMERIC_STD.ALL;
>>>
>>> --Entity Declaration
>>> entity decoder is
>>> Port ( rst : in STD_LOGIC;
>>> clk : in STD_LOGIC;
>>> s_tdata : in STD_LOGIC_VECTOR (7 downto 0);
>>> s_tlast : in STD_LOGIC;
>>> s_tvalid : in STD_LOGIC;
>>> s_tready : out STD_LOGIC;
>>> m_tdata : out STD_LOGIC_VECTOR (7 downto 0);
>>> m_tlast : out STD_LOGIC;
>>> m_tvalid : out STD_LOGIC;
>>> m_tready : in STD_LOGIC);
>>> end decoder;
>>>
>>> --Architecture
>>> architecture Behavioral of decoder is
>>>
>>> -- internal copy of s_tdata
>>> signal input_data : STD_LOGIC_VECTOR (7 downto 0);
>>>
>>> -- s_tdata delayed by one clk where s_tvalid is high
>>> signal input_data_d : STD_LOGIC_VECTOR (7 downto 0);
>>>
>>> -- internal version of s_tlast
>>> -- not used for anything
>>> -- We create our own m_tlast from the COBS framing instead
>>> signal s_tlast_i : STD_LOGIC;
>>>
>>> -- internal version of input handshaking signal s_tvalid
>>> signal s_tvalid_i : STD_LOGIC;
>>>
>>> -- s_tvalid delayed by one clk
>>> -- thus, a version of s_tvalid aligned with input_data_d
>>> signal s_tvalid_i_d : STD_LOGIC;
>>>
>>> -- s_tvalid delayed by two clks
>>> -- thus, a version of s_tvalid aligned with output_data
>>> signal s_tvalid_i_d_d : STD_LOGIC;
>>>
>>> -- countdown of bytes in this sequence
>>> -- loads from input_data_d when it represents the frame count
>>> -- as determined by signal counter_load
>>> -- contains the count while m_tdata contains the implied 0 at
>>> -- the end of a sequence, or the next count that replaced it
>>> -- counts down thereafter, to contain 1 when the last non-zero
>>> -- byte of the sequence is on m_tdata
>>> -- allowed to count down to 0, but not beyond
>>> signal count : STD_LOGIC_VECTOR (7 downto 0);
>>>
>>> -- enable to load count from input_data_d on this clk edge
>>> -- two cases detected:
>>> -- * first valid non-zero byte after a frame separator
>>> -- * first valid byte after count is exhausted
>>> -- allowed to be high for multiple cycles
>>> signal counter_load : STD_LOGIC;
>>>
>>> -- counter_load delayed by one clk where s_tvalid is high
>>> -- used to identify the first valid data byte of any sequence,
>>> -- for purposes of computing m_tvalid (via pre_tvalid)
>>> signal counter_load_d : STD_LOGIC;
>>>
>>> -- detection of a valid frame separator (zero) byte in input_data_d
>>> signal frame_sep : STD_LOGIC;
>>>
>>> -- frame_sep delayed by one clk where s_tvalid is high
>>> -- used to compute counter_load
>>> -- used to compute rising edge of pre_tvalid
>>> signal frame_sep_d : STD_LOGIC;
>>>
>>> -- frame_sep_d delayed by an additional clk (not depending on
>>> s_tvalid)
>>> -- used to find the first non-zero byte of the new frame
>>> signal frame_sep_d_d : STD_LOGIC;
>>>
>>> -- move the frame_sep signal that occurred during m_tready low
>>> -- out to the first cycle when m_tready is high again
>>> signal use_saved_frame_sep : STD_LOGIC;
>>>
>>> -- flag to remember that the frame count for this sequence was 255,
>>> -- to handle the special case that such a sequence does not have
>>> -- an implied zero byte at the end.
>>> -- set when loading count with 255
>>> -- cleared when the counter is reloaded with anything else
>>> signal case_255 : STD_LOGIC;
>>>
>>> -- internal version of m_tdata output
>>> signal output_data : STD_LOGIC_VECTOR (7 downto 0);
>>>
>>> -- internal version of m_tlast output
>>> -- high when the last byte of a frame is valid on m_tdata
>>> signal m_tlast_i : STD_LOGIC;
>>>
>>> -- delayed versions of m_tlast
>>> signal m_tlast_i_d : STD_LOGIC;
>>> signal m_tlast_i_d_d : STD_LOGIC;
>>>
>>> -- intermediate result for m_tvalid.
>>> -- high across all data bytes of each sequence on m_tdata
>>> -- does not go low for bytes on m_tdata corresponding to
>>> -- bytes invalidated by s_tvalid.
>>> signal pre_tvalid : STD_LOGIC;
>>>
>>> -- internal version of m_tvalid output.
>>> -- pre_tvalid with periods of low s_tvalid_d_d punched out
>>> signal m_tvalid_i : STD_LOGIC;
>>>
>>> -- internal version of m_tready input
>>> -- also the internal version of s_tready output
>>> -- passes through m_tready to s_tready with no clk delays
>>> signal m_tready_i : STD_LOGIC;
>>>
>>> -- constant byte value 0xFF, for comparison purposes
>>> signal all_ones : STD_LOGIC_VECTOR(input_data'range) :=
>>> (others => '1');
>>>
>>> -- constant byte value 0x00, for comparison purposes
>>> signal all_zeros : STD_LOGIC_VECTOR(input_data'range) :=
>>> (others => '0');
>>>
>>> begin
>>>
>>> -- asynchronous assignments
>>>
>>> frame_sep <= '1' when input_data_d = all_zeros and s_tvalid_i_d = '1'
>>> else '0';
>>>
>>> m_tlast_i <= '1' when ((frame_sep = '1' and m_tvalid_i = '1' and
>>> m_tready = '1'))
>>> else '0';
>>>
>>> counter_load <= '1' when (input_data_d /= all_zeros and
>>> frame_sep_d = '1' and s_tvalid_i_d = '1') -- start of frame
>>> or (to_integer(unsigned(count)) = 1 and
>>> s_tvalid_i_d = '1') -- start of next sequence in frame
>>> else '0';
>>>
>>> m_tvalid_i <= '1' when ((pre_tvalid = '1' and s_tvalid_i_d_d = '1'
>>> -- usual case, if input_data was valid
>>> and not (to_integer(unsigned(count)) = 1 and
>>> s_tvalid_i_d = '0')) -- defer last byte; might be m_tlast
>>> or (pre_tvalid = '1' and
>>> to_integer(unsigned(count)) = 1
>>> and s_tvalid_i_d = '1' and s_tvalid_i_d_d =
>>> '0')) -- pick up that deferred last byte
>>> else '0';
>>>
>>>
>>> s_tready <= m_tready_i;
>>> m_tdata <= output_data;
>>> input_data <= s_tdata;
>>> s_tvalid_i <= s_tvalid;
>>> m_tready_i <= m_tready;
>>> m_tvalid <= m_tvalid_i;
>>> m_tlast <= m_tlast_i;
>>>
>>> -- processes
>>>
>>>
>>>
>>> set_case_255 : process (rst, clk)
>>> begin
>>> if rst = '1' then
>>> case_255 <= '0';
>>> elsif rising_edge(clk) and m_tready_i = '1' then
>>> if counter_load = '1' and input_data_d = all_ones then
>>> case_255 <= '1';
>>> elsif counter_load = '1' and input_data_d /= all_ones then
>>> case_255 <= '0';
>>> end if;
>>> end if;
>>> end process set_case_255;
>>>
>>>
>>>
>>> delay_s_tvalid : process (rst, clk)
>>> begin
>>> if rst = '1' then
>>> s_tvalid_i_d <= '0';
>>> s_tvalid_i_d_d <= '0';
>>> elsif rising_edge(clk) and m_tready_i = '1' then
>>> s_tvalid_i_d <= s_tvalid_i;
>>> s_tvalid_i_d_d <= s_tvalid_i_d;
>>> end if;
>>> end process delay_s_tvalid;
>>>
>>>
>>>
>>> create_pre_tvalid : process (rst, clk)
>>> begin
>>> if rst = '1' then
>>> counter_load_d <= '0';
>>> pre_tvalid <= '0';
>>> elsif rising_edge(clk) and m_tready_i = '1' then
>>> if s_tvalid_i_d = '1' then
>>> counter_load_d <= counter_load;
>>> if (frame_sep_d_d = '1' and frame_sep_d = '0')
>>> -- normal last byte of frame
>>> or (counter_load_d = '1' and frame_sep_d = '0')
>>> -- normal first byte of a sequence
>>> then
>>> pre_tvalid <= '1';
>>> end if;
>>> end if;
>>> if frame_sep = '1'
>>> then
>>> pre_tvalid <= '0';
>>> end if;
>>> if counter_load = '1' and case_255 = '1' then
>>> pre_tvalid <= '0';
>>> end if;
>>> end if;
>>> end process create_pre_tvalid;
>>>
>>>
>>>
>>> delay_m_tlast_i : process (rst, clk)
>>> begin
>>> if rst = '1' then
>>> m_tlast_i_d <= '0';
>>> m_tlast_i_d_d <= '0';
>>> elsif rising_edge(clk) and m_tready_i = '1' then
>>> m_tlast_i_d <= m_tlast_i;
>>> m_tlast_i_d_d <= m_tlast_i_d;
>>> end if;
>>> end process delay_m_tlast_i;
>>>
>>>
>>>
>>> set_counter : process (rst,clk)
>>> begin
>>> if rst = '1' then
>>> count <= (others => '0');
>>> frame_sep_d <= '0';
>>> frame_sep_d_d <= '0';
>>> elsif rising_edge(clk) and m_tready_i = '1' then
>>> frame_sep_d_d <= frame_sep_d;
>>> if s_tvalid_i_d = '1' then
>>> frame_sep_d <= frame_sep;
>>> if counter_load = '1' then
>>> count <= input_data_d;
>>> elsif count /= all_zeros
>>> then
>>> count <= STD_LOGIC_VECTOR(unsigned(count) - 1);
>>> end if;
>>> end if;
>>> end if;
>>> end process set_counter;
>>>
>>>
>>>
>>> create_output : process (rst, clk)
>>> begin
>>> if rst = '1' then
>>> output_data <= (others => '0');
>>> elsif rising_edge(clk) and m_tready_i = '1' then
>>> if counter_load = '1'
>>> then
>>> output_data <= all_zeros;
>>> elsif s_tvalid_i_d = '1' then
>>> output_data <= input_data_d;
>>> end if;
>>> end if;
>>> end process create_output;
>>>
>>>
>>>
>>> selective_delay_of_input_data : process (rst,clk)
>>> begin
>>> if rst = '1' then
>>> input_data_d <= all_zeros;
>>> elsif rising_edge(clk) and m_tready_i = '1' then
>>> if s_tvalid_i = '1' then
>>> input_data_d <= input_data;
>>> end if;
>>> end if;
>>> end process selective_delay_of_input_data;
>>>
>>>
>>> end Behavioral;
>>>
>>> -=-=-=-=-=-=-=-=-=-=-=-=-=-
>>>
>>> This codebase doesn't include just the source code, but also a
>>> testbench file, a design document, timing diagrams, a python notebook
>>> that creates test vectors, and python code that compares results
>>> produced by the testbench that uses the test vectors. None of that is
>>> produced by ChatGPT.
>>>
>>> There is code in some languages that can be written by AI at this point.
>>>
>>> This is a situation similar to algorithmic musical composition.
>>> Algorithms can come up with ideas, sketches, or traverse a search
>>> space more quickly than a human. They can help a composer, but do not
>>> replace one. Algorithms can produce some sparkling gems of ideas.
>>> These are fresh cuts and views. This is mixed in with a lot of music
>>> that is just plain wrong.
>>>
>>> If your programming job happens to be in a well-defined area that is
>>> easily automated, then you have something to worry about today. All
>>> jobs are a target for automation. Yes, all of them. As AI/ML becomes
>>> more capable, it will automate even things like testbench writing (HDL
>>> Coder already does something like this) and support code (like a
>>> Python notebook that creates a solid set of test vectors for a
>>> testbench) and design documents. All of us should be worrying about
>>> some number of tomorrows in the future.
>>>
>>> That day is not yet "today" with Chat GPT, when it comes to writing VHDL.
>>>
>> --
> -Michelle Thompson
>
> "Potestatem obscuri lateris nescis."
>
>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.openresearch.institute/pipermail/ground-station-openresearch.institute/attachments/20221209/80900ca5/attachment-0001.htm>
More information about the Ground-Station
mailing list