Repository: PandABlocks/PandABlocks-client

Issue:
Command changes
- Rename `GetBlockNumbers` to  `GetBlockInfo` and make it return `Dict[str, BlockInfo]` where `BlockInfo` has `number` and `description`
- Rename `FieldType` to `FieldInfo` and add `description` and `labels`, rename `GetFields` to `GetFieldInfo` and make it do the `*DESC` and `*ENUM` commands that https://github.com/dls-controls/pymalcolm/blob/b6b5671cce204ed88bb61f9af899e45190591ab9/malcolm/modules/pandablocks/pandablocksclient.py#L206 does

Discussion:

Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7843a1a..f2f15d4 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,6 +2,7 @@
     "editor.defaultFormatter": "ms-python.python",
     "python.linting.pylintEnabled": false,
     "python.linting.flake8Enabled": true,
+    "python.linting.mypyEnabled": true,
     "python.linting.enabled": true,
     "python.testing.pytestArgs": [],
     "python.testing.unittestEnabled": false,
diff --git a/Pipfile b/Pipfile
index 1a36d92..3d56942 100644
--- a/Pipfile
+++ b/Pipfile
@@ -5,13 +5,14 @@ verify_ssl = true
 
 [dev-packages]
 # Pinning black stops us having to allow pre-releases globally
-black = "==19.10b0"
+black = "==21.7b0"
 
 # Pins to make lockfile usable on multiple Python versions and platforms
 mypy = "*"
 atomicwrites = "*"
 typing-extensions = "*"
 importlib-metadata = "*"
+typed-ast = "*"
 
 # Test and docs dependencies
 pytest-cov = "*"
@@ -26,6 +27,8 @@ sphinx-rtd-theme = "*"
 [packages]
 # All other package requirements from setup.cfg
 pandablocks = {editable = true, extras = ["hdf5"], path = "."}
+# Pins to make lockfile usable on multiple Python versions and platforms
+numpy = "*"
 
 [scripts]
 tests = "python -m pytest"
diff --git a/Pipfile.lock b/Pipfile.lock
index db65319..8e1ac62 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "774f165c59bde4fe89dc0bf353c2664eeafb258a97b9653869ffc14f3d76692b"
+            "sha256": "213e1133ca30cc822e88acf2b1366fdddecaea796a428d7fd6f1906684d8abf0"
         },
         "pipfile-spec": 6,
         "requires": {},
@@ -139,6 +139,7 @@
                 "sha256:e9459f40244bb02b2f14f6af0cd0732791d72232bbb0dc4bab57ef88e75f6935",
                 "sha256:edb1f041a9146dcf02cd7df7187db46ab524b9af2515f392f337c7cbbf5b52cd"
             ],
+            "index": "pypi",
             "version": "==1.20.2"
         },
         "pandablocks": {
@@ -233,10 +234,10 @@
         },
         "attrs": {
             "hashes": [
-                "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6",
-                "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"
+                "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1",
+                "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"
             ],
-            "version": "==20.3.0"
+            "version": "==21.2.0"
         },
         "babel": {
             "hashes": [
@@ -247,11 +248,11 @@
         },
         "black": {
             "hashes": [
-                "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
-                "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
+                "sha256:1c7aa6ada8ee864db745b22790a32f94b2795c253a75d6d9b5e439ff10d23116",
+                "sha256:c8373c6491de9362e39271630b65b964607bc5c79c83783547d76c839b3aa219"
             ],
             "index": "pypi",
-            "version": "==19.10b0"
+            "version": "==21.7b0"
         },
         "certifi": {
             "hashes": [
@@ -744,6 +745,14 @@
             ],
             "version": "==0.10.2"
         },
+        "tomli": {
+            "hashes": [
+                "sha256:056f0376bf5a6b182c513f9582c1e5b0487265eb6c48842b69aa9ca1cd5f640a",
+                "sha256:d60e681734099207a6add7a10326bc2ddd1fdc36c1b0f547d00ef73ac63739c2"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==1.2.0"
+        },
         "typed-ast": {
             "hashes": [
                 "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace",
@@ -777,7 +786,7 @@
                 "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f",
                 "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"
             ],
-            "markers": "python_version < '3.8'",
+            "index": "pypi",
             "version": "==1.4.3"
         },
         "typing-extensions": {
diff --git a/docs/explanations/sans-io.rst b/docs/explanations/sans-io.rst
index 3ccde52..246c844 100644
--- a/docs/explanations/sans-io.rst
+++ b/docs/explanations/sans-io.rst
@@ -30,8 +30,8 @@ corresponding `ControlConnection` and `DataConnection` objects:
     bytes that should be send back. The :meth:`~ControlConnection.responses`
     method returns an iterator of ``(command, response)`` tuples that have now
     completed. The response type will depend on the command. For instance `Get`
-    returns `bytes` or a `list` of `bytes` of the field value, and `GetFields`
-    returns a `dict` mapping `str` field name to `FieldType`.
+    returns `bytes` or a `list` of `bytes` of the field value, and `GetFieldInfo`
+    returns a `dict` mapping `str` field name to `FieldInfo`.
 
 .. autoclass:: DataConnection
     :noindex:
diff --git a/docs/how-to/introspect-panda.rst b/docs/how-to/introspect-panda.rst
new file mode 100644
index 0000000..44d65c1
--- /dev/null
+++ b/docs/how-to/introspect-panda.rst
@@ -0,0 +1,22 @@
+How to introspect a PandA
+===========================
+
+Using a combination of `commands <pandablocks.commands>` it is straightforward to query the PandA
+to list all blocks, and all fields inside each block, that exist. 
+
+Call the following script, with the address of the PandA as the first and only command line argument:
+
+
+.. literalinclude:: ../../examples/introspect_panda.py
+
+This script can be found in ``docs/examples/introspect_panda.py``.
+
+By examining the `BlockInfo` structure returned from `GetBlockInfo` for each Block the number
+and description may be acquired for every block.
+
+By examining the `FieldInfo` structure (which is fully printed in this example) the ``type``, 
+``sub-type``, ``description`` and ``label`` may all be found for every field. 
+
+Lastly the complete list of every ``BITS`` field in the ``PCAP`` block are gathered and
+printed. See the documentation in the `Field Types <https://pandablocks-server.readthedocs.io/en/latest/fields.html?#field-types>`_
+section of the PandA Server documentation.
diff --git a/docs/index.rst b/docs/index.rst
index b158015..2bd70d1 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -35,6 +35,7 @@ Practical step-by-step guides for the more experienced user.
 
     how-to/library-hdf
     how-to/poll-changes
+    how-to/introspect-panda
 
 .. rst-class:: columns
 
diff --git a/docs/reference/api.rst b/docs/reference/api.rst
index b93dacb..d804c0b 100644
--- a/docs/reference/api.rst
+++ b/docs/reference/api.rst
@@ -23,7 +23,7 @@ from code:
     There is a `Command` subclass for every sort of command that can be sent to
     the `ControlConnection` of a PandA. Many common actions can be accomplished
     with a simple `Get` or `Put`, but some convenience commands like
-    `GetBlockNumbers`, `GetFields`, etc. are provided that parse output into
+    `GetBlockInfo`, `GetFieldInfo`, etc. are provided that parse output into
     specific classes.
 
 
@@ -82,4 +82,3 @@ from code:
     gives multi-CPU benefits without hitting the limit of the GIL.
 
     .. seealso:: `library-hdf`, `performance`
-
diff --git a/examples/introspect_panda.py b/examples/introspect_panda.py
new file mode 100644
index 0000000..29b2a9e
--- /dev/null
+++ b/examples/introspect_panda.py
@@ -0,0 +1,27 @@
+import asyncio
+import pprint
+import sys
+
+from pandablocks.asyncio import AsyncioClient
+from pandablocks.commands import GetBlockInfo, GetFieldInfo, GetPcapBitsLabels
+
+
+async def introspect():
+    # Create a client and connect the control and data ports
+    async with AsyncioClient(sys.argv[1]) as client:
+
+        # Get the list of all blocks in the PandA
+        block_info = await client.send(GetBlockInfo())
+        # Find and print all fields for each block
+        for block in block_info:
+            field_info = await client.send(GetFieldInfo(block))
+            pprint.pprint({block: field_info})
+
+        # Get the labels for every PCAP.BITS[n] fields
+        labels = await client.send(GetPcapBitsLabels())
+        pprint.pprint(labels)
+
+
+if __name__ == "__main__":
+    # One-shot run of a co-routine
+    asyncio.run(introspect())
diff --git a/pandablocks/_control.py b/pandablocks/_control.py
index 218c538..9416d11 100644
--- a/pandablocks/_control.py
+++ b/pandablocks/_control.py
@@ -2,15 +2,10 @@ from string import digits
 from typing import Dict, List, Optional
 
 from .blocking import BlockingClient
-from .commands import FieldType, GetBlockNumbers, GetFields, Raw, is_multiline_command
+from .commands import FieldInfo, GetBlockInfo, GetFieldInfo, Raw, is_multiline_command
 
-
-def _get_user_input(prompt) -> List[str]:
-    lines = [input(prompt)]
-    if is_multiline_command(lines[0]):
-        while lines[-1]:
-            lines.append(input(prompt))
-    return lines
+# Timeout for commands to PandA
+TIMEOUT = 2
 
 
 STATIC_STAR_COMMANDS = [
@@ -30,6 +25,14 @@ for suff in ("", ".CONFIG", ".BITS", ".POSN", ".READ", ".ATTR", ".TABLE"):
     STATIC_STAR_COMMANDS.append(f"*CHANGES{suff}=")  # Reset reported changes
 
 
+def _get_user_input(prompt) -> List[str]:
+    lines = [input(prompt)]
+    if is_multiline_command(lines[0]):
+        while lines[-1]:
+            lines.append(input(prompt))
+    return lines
+
+
 def text_matches(t1, t2):
     return t1.startswith(t2) or t2.startswith(t1)
 
@@ -38,15 +41,22 @@ class BlockCompleter:
     def __init__(self, client: BlockingClient):
         self.matches: List[str] = []
         self._client = client
-        self._blocks = self._client.send(GetBlockNumbers(), timeout=2)
+        self._blocks = self._client.send(
+            GetBlockInfo(skip_description=True), timeout=TIMEOUT
+        )
         self._fields = self._get_fields(list(self._blocks))
+        # TODO: Extend use of _fields now we have enum labels available?
 
-    def _get_fields(self, blocks: List[str]) -> Dict[str, Dict[str, FieldType]]:
-        fields = self._client.send([GetFields(block) for block in blocks], timeout=2)
+    def _get_fields(self, blocks: List[str]) -> Dict[str, Dict[str, FieldInfo]]:
+        fields = self._client.send(
+            [GetFieldInfo(block, skip_description=True) for block in blocks],
+            timeout=TIMEOUT,
+        )
         return dict(zip(blocks, fields))
 
     def _with_suffixes(self, block: str, numbers: bool) -> List[str]:
-        num = self._blocks[block]
+        block_info = self._blocks[block]
+        num = block_info.number
         if numbers and num > 1:
             return [f"{block}{i}" for i in range(1, num + 1)]
         else:
diff --git a/pandablocks/blocking.py b/pandablocks/blocking.py
index bfe4c42..fa65441 100644
--- a/pandablocks/blocking.py
+++ b/pandablocks/blocking.py
@@ -1,5 +1,5 @@
 import socket
-from typing import Any, Iterable, Iterator, List, Optional, Tuple, Union, overload
+from typing import Iterable, Iterator, List, Optional, Union, overload
 
 from .commands import Command, T
 from .connections import ControlConnection, DataConnection
@@ -91,22 +91,23 @@ class BlockingClient:
         for command in commands:
             to_send = self._ctrl_connection.send(command)
             s.sendall(to_send)
-        responses: List[Tuple[Command, Any]] = []
-        while len(responses) < len(commands):
+        # Rely on dicts being ordered, Ellipsis is shorthand for "no response yet"
+        cr = {id(command): ... for command in commands}
+        while ... in cr.values():
             received = s.recv(4096)
             to_send = self._ctrl_connection.receive_bytes(received)
             s.sendall(to_send)
-            responses += list(self._ctrl_connection.responses())
-        assert all(
-            c == r[0] for c, r in zip(commands, responses)
-        ), f"Mismatched {commands} and {responses}"
-        for _, response in responses:
+            for command, response in self._ctrl_connection.responses():
+                assert cr[id(command)] is ..., "Already got response for {command}"
+                cr[id(command)] = response
+        responses = list(cr.values())
+        for response in responses:
             if isinstance(response, Exception):
                 raise response
         if len(responses) == 1:
-            return responses[0][1]
+            return responses[0]
         else:
-            return [r[1] for r in responses]
+            return responses
 
     def data(self, scaled: bool = True, frame_timeout: int = None) -> Iterator[Data]:
         """Connect to data port and yield data frames
diff --git a/pandablocks/cli.py b/pandablocks/cli.py
index fa07693..031221e 100644
--- a/pandablocks/cli.py
+++ b/pandablocks/cli.py
@@ -50,7 +50,10 @@ def cli(ctx, log_level: str):
 
 @cli.command()
 @click.option(
-    "--num", help="Number of collections to capture", default=1, show_default=True,
+    "--num",
+    help="Number of collections to capture",
+    default=1,
+    show_default=True,
 )
 @click.option(
     "--arm",
@@ -83,7 +86,9 @@ def hdf(host: str, scheme: str, num: int, arm: bool):
 @cli.command()
 @click.option("--prompt", help="Prompt character", default=PROMPT, show_default=True)
 @click.option(
-    "--no-readline", help="Disable readline history and completion", is_flag=True,
+    "--no-readline",
+    help="Disable readline history and completion",
+    is_flag=True,
 )
 @click.argument("host", type=str)
 def control(host: str, prompt: str, no_readline: bool):
diff --git a/pandablocks/commands.py b/pandablocks/commands.py
index b48ee4c..36012ae 100644
--- a/pandablocks/commands.py
+++ b/pandablocks/commands.py
@@ -5,7 +5,7 @@ from enum import Enum
 from typing import Any, Dict, Generic, List, Tuple, TypeVar, Union, overload
 
 from ._exchange import Exchange, ExchangeGenerator
-from .responses import Changes, FieldType
+from .responses import BlockInfo, Changes, FieldInfo
 
 # Define the public API of this module
 __all__ = [
@@ -16,8 +16,8 @@ __all__ = [
     "Put",
     "Arm",
     "Disarm",
-    "GetBlockNumbers",
-    "GetFields",
+    "GetBlockInfo",
+    "GetFieldInfo",
     "GetPcapBitsLabels",
     "ChangeGroup",
     "GetChanges",
@@ -92,8 +92,8 @@ def _execute_commands(*commands: Command[Any]) -> ExchangeGenerator[Tuple[Any, .
 
 def _execute_commands(*commands):
     """Call the `Command.execute` method on each of the commands to produce
-    some `Exchange` generators. , then
-    zip together the  to produce a """
+    some `Exchange` generators, which are yielded back to the connection,
+    then zip together the responses to those exchanges into a tuple"""
     # If we add type annotations to this function then mypy complains:
     # Overloaded function implementation does not accept all possible arguments
     # As we want to type check this, we put the logic in _zip_with_return
@@ -229,49 +229,124 @@ class Disarm(Command[None]):
         assert ex.line == "OK"
 
 
-class GetBlockNumbers(Command[Dict[str, int]]):
-    """Get the name and number of each block type in a dictionary,
-    alphabetically ordered
+@dataclass
+class GetBlockInfo(Command[Dict[str, BlockInfo]]):
+    """Get the name, number, and description of each block type
+    in a dictionary, alphabetically ordered
+
+    Args:
+        skip_description: If `True`, prevents retrieving the description
+            for each Block. This will reduce network calls.
 
     For example::
 
-        GetBlockNumbers() -> {"LUT": 8, "PCAP": 1, ...}
+         GetBlockInfo() ->
+             {
+                 "LUT": BlockInfo(number=8, description="Lookup table"),
+                 "PCAP": BlockInfo(number=1, description="Position capture control"),
+                 ...
+             }
     """
 
-    def execute(self) -> ExchangeGenerator[Dict[str, int]]:
+    skip_description: bool = False
+
+    def execute(self) -> ExchangeGenerator[Dict[str, BlockInfo]]:
         ex = Exchange("*BLOCKS?")
         yield ex
-        blocks_list = []
+
+        blocks_list, commands = [], []
         for line in ex.multiline:
             block, num = line.split()
             blocks_list.append((block, int(num)))
-        return dict(sorted(blocks_list))
+            commands.append(Get(f"*DESC.{block}"))
+
+        if self.skip_description:
+            # Must use tuple() to match type returned by _execute_commands
+            description_values = tuple(None for _ in commands)
+        else:
+            description_values = yield from _execute_commands(*commands)
+
+        block_infos = {
+            block: BlockInfo(number=num, description=desc)
+            for (block, num), desc in sorted(zip(blocks_list, description_values))
+        }
+
+        return block_infos
 
 
 @dataclass
-class GetFields(Command[Dict[str, FieldType]]):
-    """Get the fields of a block, returning a `FieldType` for each one, ordered
+class GetFieldInfo(Command[Dict[str, FieldInfo]]):
+    """Get the fields of a block, returning a `FieldInfo` for each one, ordered
     to match the definition order in the PandA
 
     Args:
         block: The name of the block type
+        skip_description: If `True`, prevents retrieving the description
+            for each Field. This will reduce network calls.
 
     For example::
 
-        GetFields("LUT") -> {"INPA": FieldType("bit_mux"), ...}
+        GetFieldInfo("LUT") -> {
+            "INPA":
+                FieldInfo(type='bit_mux',
+                        subtype=None,
+                        description='Input A',
+                        label=['TTLIN1.VAL', 'TTLIN2.VAL', ...]),
+            ...}
     """
 
     block: str
+    skip_description: bool = False
 
-    def execute(self) -> ExchangeGenerator[Dict[str, FieldType]]:
+    def execute(self) -> ExchangeGenerator[Dict[str, FieldInfo]]:
         ex = Exchange(f"{self.block}.*?")
         yield ex
-        unsorted: Dict[int, Tuple[str, FieldType]] = {}
+        unsorted: Dict[int, Tuple[str, FieldInfo]] = {}
         for line in ex.multiline:
             name, index, type_subtype = line.split(maxsplit=2)
-            unsorted[int(index)] = (name, FieldType(*type_subtype.split()))
+
+            # Append "None" to list below so there are always at least 2 elements
+            # so we can always unpack into subtype, even if no split occurs.
+            field_type, subtype, *_ = [*type_subtype.split(maxsplit=1), None]
+            unsorted[int(index)] = (name, FieldInfo(field_type, subtype))
+
         # Dict keeps insertion order, so insert in the order the server said
         fields = {name: field for _, (name, field) in sorted(unsorted.items())}
+
+        # Create the list of DESC and ENUM commands to request
+        commands: List[Get] = []
+        # Map from an index in the commands list to the associated field name
+        field_mapping: Dict[int, str] = {}
+        field: str
+        field_info: FieldInfo
+        for field, field_info in fields.items():
+
+            if not self.skip_description:
+                commands.append(Get(f"*DESC.{self.block}.{field}"))
+                field_mapping[len(commands) - 1] = field
+
+            if (
+                field_info.type in ("bit_mux", "pos_mux", "ext_out")
+                or field_info.subtype == "enum"
+            ):
+                enum_str = f"*ENUMS.{self.block}.{field}"
+                if field_info.type == "ext_out":
+                    enum_str += ".CAPTURE"
+                commands.append(Get(enum_str))
+                field_mapping[len(commands) - 1] = field
+
+        returned_values = yield from _execute_commands(*commands)
+
+        # Merge the returned information back into the existing FieldInfo for each field
+        for idx, value in enumerate(returned_values):
+            command: Get = commands[idx]
+            field_info = fields[field_mapping[idx]]
+
+            if command.field.startswith("*DESC"):
+                field_info.description = value
+            elif command.field.startswith("*ENUMS"):
+                field_info.labels = value
+
         return fields
 
 
@@ -280,9 +355,26 @@ class GetPcapBitsLabels(Command):
 
     For example::
 
-        GetPcapBitsLabels() -> PcapBitsLabels()
+        GetPcapBitsLabels() -> {"BITS0" : ["TTLIN1.VAL", "TTLIN2.VAL", ...], ...}
     """
 
+    def execute(self) -> ExchangeGenerator[Dict[str, List[str]]]:
+        ex = Exchange("PCAP.*?")
+        yield ex
+        bits_fields = []
+        for line in ex.multiline:
+            split = line.split()
+            if len(split) == 4:
+                field_name, _, field_type, field_subtype = split
+
+                if field_type == "ext_out" and field_subtype == "bits":
+                    bits_fields.append(f"PCAP.{field_name}")
+
+        exchanges = [Exchange(f"{field}.BITS?") for field in bits_fields]
+        yield exchanges
+        bits = {field: ex.multiline for field, ex in zip(bits_fields, exchanges)}
+        return bits
+
 
 class ChangeGroup(Enum):
     """Which group of values to ask for ``*CHANGES`` on:
diff --git a/pandablocks/connections.py b/pandablocks/connections.py
index 93424f4..e84c759 100644
--- a/pandablocks/connections.py
+++ b/pandablocks/connections.py
@@ -22,6 +22,7 @@ from .responses import (
 # Define the public API of this module
 __all__ = [
     "NeedMoreData",
+    "NoContextAvailable",
     "Buffer",
     "ControlConnection",
     "DataConnection",
@@ -35,6 +36,12 @@ class NeedMoreData(Exception):
     """Raised if the `Buffer` isn't full enough to return the requested bytes"""
 
 
+class NoContextAvailable(Exception):
+    """Raised if there were no contexts available for this connection.
+    This may result from calling `ControlConnection.receive_bytes()` without calling
+    `ControlConnection.send()`, or if there were unmatched sends/receives"""
+
+
 class Buffer:
     """Byte storage that provides line reader and bytes reader interfaces.
     For example::
@@ -149,6 +156,8 @@ class ControlConnection:
 
     def _update_contexts(self, lines: List[str], is_multiline=False) -> bytes:
         to_send = b""
+        if len(self._contexts) == 0:
+            raise NoContextAvailable()
         context = self._contexts.popleft()
         # Update the exchange with what we've got
         context.exchange.received = lines
@@ -177,6 +186,10 @@ class ControlConnection:
     ) -> Iterator[bytes]:
         if not isinstance(exchanges, list):
             exchanges = [exchanges]
+        # No Exchanges when a Command's yield is empty e.g. unexpected/unparseable data
+        # received from PandA
+        if len(exchanges) == 0:
+            return
         for ex in exchanges:
             context = _ExchangeContext(ex, command)
             self._contexts.append(context)
@@ -308,7 +321,8 @@ class DataConnection:
                 # sent
                 name, capture = SAMPLES_FIELD.rsplit(".", maxsplit=1)
                 fields.insert(
-                    0, FieldCapture(name, np.dtype("uint32"), capture),
+                    0,
+                    FieldCapture(name, np.dtype("uint32"), capture),
                 )
             self._frame_dtype = np.dtype(
                 [(f"{f.name}.{f.capture}", f.type) for f in fields]
diff --git a/pandablocks/hdf.py b/pandablocks/hdf.py
index 456b22f..73153bc 100644
--- a/pandablocks/hdf.py
+++ b/pandablocks/hdf.py
@@ -86,7 +86,10 @@ class HDFWriter(Pipeline):
             # No processor, datatype passed through
             dtype = field.type
         return self.hdf_file.create_dataset(
-            f"/{field.name}.{field.capture}", dtype=dtype, shape=(0,), maxshape=(None,),
+            f"/{field.name}.{field.capture}",
+            dtype=dtype,
+            shape=(0,),
+            maxshape=(None,),
         )
 
     def open_file(self, data: StartData):
diff --git a/pandablocks/responses.py b/pandablocks/responses.py
index dfa5a5f..e86ed2d 100644
--- a/pandablocks/responses.py
+++ b/pandablocks/responses.py
@@ -6,7 +6,8 @@ import numpy as np
 
 # Define the public API of this module
 __all__ = [
-    "FieldType",
+    "BlockInfo",
+    "FieldInfo",
     "Changes",
     "EndReason",
     "FieldCapture",
@@ -21,17 +22,34 @@ __all__ = [
 
 
 @dataclass
-class FieldType:
-    """Field type and subtype as exposed by TCP server:
+class BlockInfo:
+    """Block number and description as exposed by the TCP server
+
+    Attributes:
+        number: The index of this block
+        description: The description for this block"""
+
+    number: int = 0
+    description: Optional[str] = None
+
+
+@dataclass
+class FieldInfo:
+    """Field type, subtype, description and labels as exposed by TCP server:
     https://pandablocks-server.readthedocs.io/en/latest/fields.html#field-types
 
     Attributes:
         type: Field type, like "param", "bit_out", "pos_mux", etc.
         subtype: Some types have subtype, like "uint", "scalar", "lut", etc.
+        description: A description of the field
+        labels: A list of the valid values for the field when there is a defined list
+            of valid values, e.g. those with sub-type "enum"
     """
 
     type: str
     subtype: Optional[str] = None
+    description: Optional[str] = None
+    labels: Optional[List[str]] = None
 
 
 @dataclass
@@ -125,7 +143,7 @@ class FrameData(Data):
         data: A numpy `Structured Array <structured_arrays>`
 
     Data is structured into complete columns. Each column name is
-    ``<name>.<capture>`` from the corresponding `FieldType`. Data
+    ``<name>.<capture>`` from the corresponding `FieldInfo`. Data
     can be accessed with these column names. For example::
 
         # Table view with 2 captured fields


Test patch:
diff --git a/tests/test_blocking.py b/tests/test_blocking.py
index f9adf1b..0cb91df 100644
--- a/tests/test_blocking.py
+++ b/tests/test_blocking.py
@@ -22,7 +22,9 @@ def test_blocking_bad_put_raises(dummy_server_in_thread):
 
 
 def test_blocking_data(
-    dummy_server_in_thread, slow_dump, slow_dump_expected,
+    dummy_server_in_thread,
+    slow_dump,
+    slow_dump_expected,
 ):
     dummy_server_in_thread.data = slow_dump
     events = []
diff --git a/tests/test_completer.py b/tests/test_completer.py
index ff4a978..5ce26aa 100644
--- a/tests/test_completer.py
+++ b/tests/test_completer.py
@@ -7,9 +7,14 @@ from pandablocks.blocking import BlockingClient
 @pytest.fixture
 def dummy_server_with_blocks(dummy_server_in_thread):
     dummy_server_in_thread.send += [
-        "!PCAP 1\n!LUT 8\n.",
-        "!TYPEA 5 param enum\n!TYPEA_INP 1 bit_mux\n.",
-        "!TS_START 6 ext_out timestamp\n!ACTIVE 7 bit_out\n.",
+        "!PCAP 1\n!LUT 8\n!SRGATE 2\n.",
+        "!INPB 1 bit_mux\n!TYPEA 5 param enum\n.",  # LUT fields
+        "!TRIG_EDGE 3 param enum\n!GATE 1 bit_mux\n.",  # PCAP fields
+        "!OUT 1 bit_out\n.",  # SRGATE fields
+        "!TTLIN1.VAL\n!LVDSIN1.VAL\n.",  # LUT.INPB labels
+        "!Input-Level\n!Pulse-On-Rising-Edge\n.",  # LUT.TYPEA labels
+        "!TTLIN1.VAL\n!LVDSIN1.VAL\n.",  # PCAP.GATE labels
+        "!Rising\n!Falling\n.",  # PCAP.TRIG_EDGE labels
     ]
     yield dummy_server_in_thread
 
@@ -19,14 +24,16 @@ def test_complete_one_block(dummy_server_with_blocks):
         completer = BlockCompleter(client)
         assert completer("PCA", 0) == "PCAP"
         assert completer.matches == ["PCAP"]
-        assert completer("PCAP.", 0) == "PCAP.TS_START"
-        assert completer.matches == ["PCAP.TS_START", "PCAP.ACTIVE"]
+        assert completer("PCAP.", 0) == "PCAP.GATE"
+        assert completer.matches == ["PCAP.GATE", "PCAP.TRIG_EDGE"]
         completer("LU", 0)
         assert completer.matches == [f"LUT{i}" for i in range(1, 9)]
         completer("LUT5.", 0)
-        assert completer.matches == ["LUT5.TYPEA_INP", "LUT5.TYPEA"]
+        assert completer.matches == ["LUT5.INPB", "LUT5.TYPEA"]
         completer("LUT5.TYPEA_IN", 0)
-        assert completer.matches == ["LUT5.TYPEA_INP"]
+        assert completer.matches == []
+        completer("LUT5.TYPE", 0)
+        assert completer.matches == ["LUT5.TYPEA"]
         assert completer("LUT5.TYPEA_INP?", 0) is None
 
 
@@ -42,8 +49,8 @@ def test_complete_stars(dummy_server_with_blocks):
             "*PCAP.DISARM=",
         ]
         assert completer("*DE", 0) == "*DESC.LUT"
-        assert completer.matches == ["*DESC.LUT", "*DESC.PCAP"]
-        assert completer("*DESC.LUT.", 0) == "*DESC.LUT.TYPEA_INP"
-        assert completer.matches == ["*DESC.LUT.TYPEA_INP", "*DESC.LUT.TYPEA"]
+        assert completer.matches == ["*DESC.LUT", "*DESC.PCAP", "*DESC.SRGATE"]
+        assert completer("*DESC.LUT.", 0) == "*DESC.LUT.INPB"
+        assert completer.matches == ["*DESC.LUT.INPB", "*DESC.LUT.TYPEA"]
         assert completer("*ENUMS.LU", 0) == "*ENUMS.LUT"
         assert completer.matches == ["*ENUMS.LUT"]
diff --git a/tests/test_pandablocks.py b/tests/test_pandablocks.py
index 9940d27..88e06ef 100644
--- a/tests/test_pandablocks.py
+++ b/tests/test_pandablocks.py
@@ -1,20 +1,25 @@
-from typing import Iterator
+from typing import Iterator, OrderedDict
 
 import pytest
 
 from pandablocks.commands import (
     CommandException,
-    FieldType,
+    FieldInfo,
     Get,
-    GetBlockNumbers,
-    GetFields,
+    GetBlockInfo,
+    GetFieldInfo,
+    GetPcapBitsLabels,
     GetState,
     Put,
     SetState,
     is_multiline_command,
 )
-from pandablocks.connections import ControlConnection, DataConnection
-from pandablocks.responses import Data
+from pandablocks.connections import (
+    ControlConnection,
+    DataConnection,
+    NoContextAvailable,
+)
+from pandablocks.responses import BlockInfo, Data
 from tests.conftest import STATE_RESPONSES, STATE_SAVEFILE
 
 
@@ -93,30 +98,362 @@ def test_connect_put_multi_line():
     assert get_responses(conn, b"OK\n") == [(cmd, None)]
 
 
-def test_get_block_numbers():
+def test_get_block_info():
     conn = ControlConnection()
-    cmd = GetBlockNumbers()
+    cmd = GetBlockInfo()
     assert conn.send(cmd) == b"*BLOCKS?\n"
-    responses = get_responses(conn, b"!PCAP 1\n!LUT 8\n.\n")
-    assert responses == [(cmd, {"PCAP": 1, "LUT": 8})]
-    assert list(responses[0][1]) == ["LUT", "PCAP"]
+
+    # Respond to first yield, the return from the BLOCKS? command
+    assert conn.receive_bytes(b"!PCAP 1\n!LUT 8\n.\n") == b"*DESC.PCAP?\n*DESC.LUT?\n"
+
+    # First of the *DESC.{block}? yields
+    assert (
+        conn.receive_bytes(b"OK =Description for PCAP field\n") == b""
+    )  # No data returned as there's still one outstanding request
+
+    # Create an OrderedDict of the output to test key order - that won't happen
+    # with a regular dict
+    ordered_dict = OrderedDict(
+        [
+            ("LUT", BlockInfo(number=8, description="Description for LUT field")),
+            ("PCAP", BlockInfo(number=1, description="Description for PCAP field")),
+        ]
+    )
+
+    # Second and last of the *DESC.{block}? yields - as this is the last response we
+    # can call get_responses to also get the overall result
+    assert not get_responses(conn)
+    assert get_responses(conn, b"OK =Description for LUT field\n") == [
+        (
+            cmd,
+            ordered_dict,
+        ),
+    ]
+
+
+def test_get_block_info_skip_description():
+    """Test that the skip_description flag causes GetBlockInfo to not retrieve
+    descriptions"""
+    conn = ControlConnection()
+    cmd = GetBlockInfo(skip_description=True)
+    assert conn.send(cmd) == b"*BLOCKS?\n"
+
+    ordered_dict = OrderedDict(
+        [
+            ("PCAP", BlockInfo(number=1, description=None)),
+        ]
+    )
+    # Only a yield for the BLOCKS.* request, not for description as well
+    assert get_responses(conn, b"!PCAP 1\n.\n") == [(cmd, ordered_dict)]
+
+
+def test_get_block_info_error():
+    """Test that any errors from *BLOCKS command are correctly reported"""
+    conn = ControlConnection()
+    cmd = GetBlockInfo()
+    assert conn.send(cmd) == b"*BLOCKS?\n"
+
+    # Provide error from PandA server
+    assert conn.receive_bytes(b"ERR Cannot read blocks\n") == b""
+
+    assert get_responses(conn) == [
+        (
+            cmd,
+            ACommandException(
+                "GetBlockInfo(skip_description=False) -> ERR Cannot read blocks"
+            ),
+        )
+    ]
+
+
+def test_get_block_info_desc_err():
+    """Test when the DESC command returns an error"""
+    conn = ControlConnection()
+    cmd = GetBlockInfo()
+    assert conn.send(cmd) == b"*BLOCKS?\n"
+
+    # Respond to first yield, the return from the BLOCKS? command
+    assert conn.receive_bytes(b"!PCAP 1\n.\n") == b"*DESC.PCAP?\n"
+
+    # First of the *DESC.{block}? yields
+    assert (
+        conn.receive_bytes(b"ERR could not get description\n") == b""
+    )  # No data returned as there's still one outstanding request
+
+    assert get_responses(conn) == [
+        (
+            cmd,
+            ACommandException(
+                "GetBlockInfo(skip_description=False) -> ERR could not get description"
+            ),
+        )
+    ]
 
 
 def test_get_fields():
     conn = ControlConnection()
-    cmd = GetFields("LUT")
+    cmd = GetFieldInfo("LUT")
     assert conn.send(cmd) == b"LUT.*?\n"
-    responses = get_responses(conn, b"!TYPEA 5 param enum\n!INPA 1 bit_mux\n.\n")
-    assert responses == [
+
+    # First yield, the response to "LUT.*?"
+    assert (
+        conn.receive_bytes(b"!TYPEA 5 param enum\n!INPA 1 bit_mux\n.\n")
+        == b"*DESC.LUT.INPA?\n*ENUMS.LUT.INPA?\n*DESC.LUT.TYPEA?\n*ENUMS.LUT.TYPEA?\n"
+    )
+
+    # Responses to the 2 *DESC and 2 *ENUM commands
+    responses = [
+        b"OK =Input A\n",
+        b"!TTLIN1.VAL\n!LVDSIN1.VAL\n.\n"
+        b"OK =Source of the value of A for calculation\n",
+        b"!Input-Level\n!Pulse-On-Rising-Edge\n.\n",
+    ]
+    for response in responses:
+        assert (
+            conn.receive_bytes(response) == b""
+        )  # Expect no bytes back as none of these trigger further commands
+
+    assert get_responses(conn) == [
+        (
+            cmd,
+            {
+                "INPA": FieldInfo(
+                    type="bit_mux",
+                    subtype=None,
+                    description="Input A",
+                    labels=["TTLIN1.VAL", "LVDSIN1.VAL"],
+                ),
+                "TYPEA": FieldInfo(
+                    type="param",
+                    subtype="enum",
+                    description="Source of the value of A for calculation",
+                    labels=["Input-Level", "Pulse-On-Rising-Edge"],
+                ),
+            },
+        )
+    ]
+
+
+def test_get_fields_type_ext_out():
+    """Test for field type == ext_out, ensuring we add .CAPTURE to the end of the
+    *ENUMS command"""
+    conn = ControlConnection()
+    cmd = GetFieldInfo("PCAP")
+    assert conn.send(cmd) == b"PCAP.*?\n"
+
+    # First yield, the response to "PCAP.*?"
+    assert (
+        conn.receive_bytes(b"!SAMPLES 9 ext_out samples\n.\n")
+        == b"*DESC.PCAP.SAMPLES?\n*ENUMS.PCAP.SAMPLES.CAPTURE?\n"
+    )
+
+    # Responses to the *DESC and *ENUM commands
+    responses = [
+        b"OK =Number of gated samples in the current capture\n",
+        b"!No\n!Value\n.\n",
+    ]
+    for response in responses:
+        assert (
+            conn.receive_bytes(response) == b""
+        )  # Expect no bytes back as none of these trigger further commands
+
+    assert get_responses(conn) == [
         (
             cmd,
-            dict(
-                INPA=FieldType(type="bit_mux"),
-                TYPEA=FieldType(type="param", subtype="enum"),
+            {
+                "SAMPLES": FieldInfo(
+                    type="ext_out",
+                    subtype="samples",
+                    description="Number of gated samples in the current capture",
+                    labels=["No", "Value"],
+                )
+            },
+        )
+    ]
+
+
+def test_get_fields_type_skip_description():
+    """Test that the skip_description flag causes no description to be retrieved
+    for the field"""
+    conn = ControlConnection()
+    cmd = GetFieldInfo("PCAP", True)
+    assert conn.send(cmd) == b"PCAP.*?\n"
+
+    assert (
+        conn.receive_bytes(b"!SAMPLES 9 ext_out samples\n.\n")
+        == b"*ENUMS.PCAP.SAMPLES.CAPTURE?\n"
+    )
+
+    assert conn.receive_bytes(b"!No\n!Value\n.\n") == b""
+
+    assert get_responses(conn) == [
+        (
+            cmd,
+            {
+                "SAMPLES": FieldInfo(
+                    type="ext_out",
+                    subtype="samples",
+                    description=None,
+                    labels=["No", "Value"],
+                )
+            },
+        )
+    ]
+
+
+def test_get_fields_non_existant_field():
+    """Test that querying for an unknown field returns a sensible error"""
+    conn = ControlConnection()
+    cmd = GetFieldInfo("FOO")
+    assert conn.send(cmd) == b"FOO.*?\n"
+
+    # Provide the error string the PandA would provide
+    assert conn.receive_bytes(b"ERR No such block\n") == b""
+
+    assert get_responses(conn) == [
+        (
+            cmd,
+            ACommandException(
+                "GetFieldInfo(block='FOO', skip_description=False) -> ERR No such block"
             ),
         )
     ]
-    assert list(responses[0][1]) == ["INPA", "TYPEA"]
+
+
+def test_get_fields_no_enums():
+    """Test that a field that does not have any enum labels does not attempt to
+    retrieve them"""
+    conn = ControlConnection()
+    cmd = GetFieldInfo("LVDSIN")
+    assert conn.send(cmd) == b"LVDSIN.*?\n"
+
+    # First yield, the response to "LVDSIN.*?"
+    assert conn.receive_bytes(b"!VAL 0 bit_out\n.\n") == b"*DESC.LVDSIN.VAL?\n"
+
+    assert get_responses(conn, b"OK =LVDS input value\n") == [
+        (
+            cmd,
+            {
+                "VAL": FieldInfo(
+                    type="bit_out",
+                    subtype=None,
+                    description="LVDS input value",
+                    labels=None,
+                ),
+            },
+        )
+    ]
+
+
+def test_get_fields_no_enums_no_description():
+    """Test that a field that does not have any enum labels does not attempt to
+    retrieve them, and also does not retrieve a description."""
+    conn = ControlConnection()
+    cmd = GetFieldInfo("LVDSIN", skip_description=True)
+    assert conn.send(cmd) == b"LVDSIN.*?\n"
+
+    assert get_responses(conn, b"!VAL 0 bit_out\n.\n") == [
+        (
+            cmd,
+            {
+                "VAL": FieldInfo(
+                    type="bit_out",
+                    subtype=None,
+                    description=None,
+                    labels=None,
+                ),
+            },
+        )
+    ]
+
+
+def test_get_pcap_bits_labels():
+    """Simple working testcase for GetPcapBitsLabels"""
+
+    # PandA's return data when it receives "PCAP.*?"
+    PCAP_RETURN = [
+        "!BITS2 12 ext_out bits",
+        "!SHIFT_SUM 4 param uint",
+        "!BITS0 10 ext_out bits",
+        ".",
+    ]
+
+    # PandA's return data when it receives "PCAP.BITS2.BITS?"
+    BITS2_RETURN = ["!PCOMP2.OUT", "!PGEN1.ACTIVE", "!PGEN2.ACTIVE", "!PULSE1.OUT", "."]
+
+    # PandA's return data when it receives "PCAP.BITS0.BITS?"
+    BITS0_RETURN = [
+        "!SFP3_SYNC_IN.BIT8",
+        "!SFP3_SYNC_IN.BIT9",
+        "!SFP3_SYNC_IN.BIT10",
+        ".",
+    ]
+
+    conn = ControlConnection()
+    cmd = GetPcapBitsLabels()
+    assert conn.send(cmd) == b"PCAP.*?\n"
+
+    # First yield, requesting response for PCAP.*?
+    response_bytes = "\n".join(PCAP_RETURN).encode() + b"\n"
+    assert conn.receive_bytes(response_bytes) == b"PCAP.BITS2.BITS?\nPCAP.BITS0.BITS?\n"
+
+    # First of the .BITS? yields
+    response_bytes = "\n".join(BITS2_RETURN).encode() + b"\n"
+    assert (
+        conn.receive_bytes(response_bytes) == b""
+    )  # No data returned as there's still one outstanding request
+
+    # Second of the .BITS? yields - as this is the last response we can call
+    # get_responses to also get the overall result
+    response_bytes = "\n".join(BITS0_RETURN).encode() + b"\n"
+
+    assert not get_responses(conn)
+    assert get_responses(conn, response_bytes) == [
+        (
+            cmd,
+            {
+                "PCAP.BITS0": [
+                    "SFP3_SYNC_IN.BIT8",
+                    "SFP3_SYNC_IN.BIT9",
+                    "SFP3_SYNC_IN.BIT10",
+                ],
+                "PCAP.BITS2": [
+                    "PCOMP2.OUT",
+                    "PGEN1.ACTIVE",
+                    "PGEN2.ACTIVE",
+                    "PULSE1.OUT",
+                ],
+            },
+        )
+    ]
+
+
+def test_get_pcap_bits_labels_no_bits_fields():
+    """Test we get no response when no BITS fields are returned by the PandA"""
+
+    # PandA's return data when it receives "PCAP.*?"
+    PCAP_RETURN = [
+        "!SHIFT_SUM 4 param uint",
+        "!ACTIVE 5 bit_out",
+        "!ENABLE 0 bit_mux",
+        ".",
+    ]
+    conn = ControlConnection()
+    cmd = GetPcapBitsLabels()
+    assert conn.send(cmd) == b"PCAP.*?\n"
+
+    # As there are no BITS fields in the PCAP return, expect no response
+    response_bytes = "\n".join(PCAP_RETURN).encode() + b"\n"
+    assert conn.receive_bytes(response_bytes) == b""
+
+
+def test_expected_exception_when_receive_without_send():
+    """Test that calling receive_bytes() without first calling send() raises the
+    expected exception"""
+
+    conn = ControlConnection()
+    with pytest.raises(NoContextAvailable):
+        conn.receive_bytes(b"abc\n")
 
 
 def test_save():
@@ -128,11 +465,11 @@ def test_save():
     )
     response_bytes = "\n".join(STATE_RESPONSES).encode() + b"\n"
     assert (
-        conn.receive_bytes(response_bytes[:109])
+        conn.receive_bytes(response_bytes[:107])
         == b"Table.B?\nMultiLineMeta1?\nMultiLineMeta2?\n"
     )
     assert not get_responses(conn)
-    assert get_responses(conn, response_bytes[109:]) == [(cmd, STATE_SAVEFILE)]
+    assert get_responses(conn, response_bytes[107:]) == [(cmd, STATE_SAVEFILE)]
 
 
 def test_load():