More Field Types
In the previous chapter, we considered a hypothetical weather station packet. To introduce some more field types, let's imagine we've upgraded our weather station to a new version with a different protocol, defined as follows:
| Bits | Field | Type (N bits) | Description |
|---|---|---|---|
| 0-7 | (Header) | bytes (8) | Packet header, always 0xFF |
| 8-39 | Station UUID | bytes (32) | Unique identifier for the station (4 bytes) |
| 40-103 | Station Name | str (64) | Name of the weather station (8 bytes) |
| 104-111 | Temperature | float (8) | Temperature, degrees Celsius (uint value * 0.5 - 40) |
| 112-119 | Wind Speed | float (8) | Wind speed, m/h (uint value * 0.25) |
| 120-122 | Wind Direction | enum (3) | Wind direction (0 = N, 1 = NE, ..., 7 = NW) |
| 123 | Sensor Error | bool (1) | Sensor error flag (1 = error) |
| 124-127 | (Pad) | uint (4) | Padding bits, always 0 |
With Bydantic, definition can be translated into a Bitfield class that looks like this:
import typing as t
import bydantic as bd
class WindDirection(IntEnum):
N = 0
NE = 1
E = 2
SE = 3
S = 4
SW = 5
W = 6
NW = 7
class WeatherPacket2(bd.Bitfield):
_header: t.Literal[b'\xFF'] = bd.lit_bytes_field(default = b'\xFF')
station_uuid: bytes = bd.bytes_field(n_bytes = 4)
station_name: str = bd.str_field(n_bytes = 8)
temperature: float = bd.mapped_field(
bd.uint_field(8),
bd.Scale(by = 0.5, offset = -40)
)
wind_speed: float = bd.mapped_field(
bd.uint_field(8),
bd.Scale(by = 0.25)
)
wind_direction: WindDirection = bd.uint_enum_field(WindDirection, 3)
sensor_error: bool = bd.bool_field()
_pad: t.Literal[0] = bd.lit_uint_field(4, default=0)
Before we dive into the details, let's serialize and deserialize a packet to see it in action:
WeatherPacket2(
station_uuid = b'\x00\x00\x00\x01',
station_name = 'Foo',
temperature = 25.0,
wind_speed = 10.0,
wind_direction = WindDirection.NE,
sensor_error = False
).to_bytes()
# b'\xff\x00\x00\x00\x01Foo\x00\x00\x00\x00\x00\x82( '
WeatherPacket2.from_bytes_exact(
b'\xff\x00\x00\x00\x01Foo\x00\x00\x00\x00\x00\x82( '
)
# WeatherPacket2(
# _header=b'\xFF',
# station_uuid=b'\x00\x00\x00\x01',
# station_name='Foo',
# temperature=25.0,
# wind_speed=10.0,
# wind_direction=WindDirection.NE,
# sensor_error=False
# _pad=0
Ok, it works! Now let's take a look at the definition, field by field.
Field-by-Field Explanation
_header
The _header field is defined as follows:
This field is our first example of a literal field. Literal fields parse
constant values that result in typing.Literal field types. In this case, the
_header field is a literal field with a fixed value of b'\xFF'.
Here we use an underscore prefix in the field name (_header) and provide a
default value in the definition to indicate the field is private and not
necessary to provide when creating a new instance of the class.
In addition to lit_bytes_field(), other literal field types available in
Bydantic include lit_uint_field, lit_int_field, and lit_str_field.
Because the size of a literal bytes or str field can be inferred from the
literal value, Bydantic allows you to omit the lit_bytes_field() or
lit_str_field() call and simply use the value itself. So the following is
equivalent to the above definition of _header:
station_uuid
The station_uuid field is defined as follows:
This field is a bytes field with a size of 4 bytes. Not much to say here!
station_name
The station_name field is defined as follows:
This field is a str field with a fixed width of 8 bytes. Fixed-width string
values are padded with null bytes (b'\x00') to the specified size. The str
field is encoded using the utf-8 encoding by default, but you can specify a
different encoding using the encoding parameter:
temperature, wind_speed
The temperature and wind_speed fields are defined as follows:
temperature: float = bd.mapped_field(
bd.uint_field(8),
bd.Scale(by = 0.5, offset = -40)
)
wind_speed: float = bd.mapped_field(
bd.uint_field(8),
bd.Scale(by = 0.25)
)
These definitions introduce the mapped_field(), our first field combinator.
Field combinators are used to create new field types by combining existing field
types. The mapped_field() combinator takes two arguments: a field type and a
object conforming to the ValueMapper protocol, a protcol that defines how to
map that field type to and from a different type:
import typing as t
T = t.TypeVar('T')
P = t.TypeVar('P')
class ValueMapper(t.Protocol[T, P]):
def deserialize(self, value: T) -> P:
...
def serialize(self, value: P) -> T:
...
Here, we're using Scale, a built-in ValueMapper defined in Bydantic as
follows:
class Scale:
def __init__(self, by: float, offset: float = 0.0, n_digits: int | None = None):
self.by = by
self.offset = offset
self.n_digits = n_digits
def deserialize(self, x: int):
value = x * self.by + self.offset
return value if self.n_digits is None else round(value, self.n_digits)
def serialize(self, y: float):
return round((y - self.offset) / self.by)
In the case of temperature, we're using Scale() to map the uint8 value to
a float value by scaling it by 0.5 and offsetting it by -40. So a value of
0 in the uint8 field will be mapped to -40.0 in the float field, and a
value of 255 in the uint8 field will be mapped to 127.5 in the float
field.
In the case of wind_speed, we're using Scale() to map the uint8 value to a
float value by scaling it by 0.25. So a value of 0 in the uint8 field
will be mapped to 0.0 in the float field, and a value of 255 in the
uint8 field will be mapped to 63.75 in the float field.
The mapped_field() combinator can be used with any field type, as long as you
provide a ValueMapper object that defines how to map the field type to and
from the target type. This allows you to easily create your own custom field
types.
wind_direction, sensor_error
The wind_direction and sensor_error fields are defined as follows:
wind_direction: WindDirection = bd.uint_enum_field(WindDirection, 3)
sensor_error: bool = bd.bool_field()
Both enum and bool fields were already covered in the previous chapter, so we won't go into detail here.
_pad
The _pad field is defined as follows:
Here is another example of a literal field. This field is a literal 4-bit uint
field with a fixed value of 0. The _pad field is used to pad the packet to a
multiple of 8 bytes. Like the _header field, the _pad field is declared
private, and specifies a default value so it does not need to be provided when
creating a new instance of the class.
Unlike the lit_bytes_field() and lit_str_field() fields, the size of a
numeric literal field cannot be inferred from the literal value, so it is allows
necessary to specify the field type, and shortcuts are not allowed:
# This will not work, because the number of
# bits cannot be determined from the value!
_pad: t.Literal[0] = 0
Next Steps
In this chapter, we introduced some more field types along with our first field
combinator, mapped_field(). In the next chapter,
we'll introduce some more field combinator types that allow you to compose your
definitions into more complex data structures.