diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
index 3fe5978..15c5624 100644
--- a/IMPLEMENTATION.md
+++ b/IMPLEMENTATION.md
@@ -33,7 +33,7 @@ When working through this file, mark increments complete by changing `[ ]` to `[
Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.Tests/`.
-### [ ] 1.1 `MacAddress`
+### [x] 1.1 `MacAddress`
- `record struct` storing 6 bytes.
- `static bool TryParse(string, out MacAddress)` accepting `AA:BB:CC:DD:EE:FF`, `aa-bb-cc-dd-ee-ff`, `AABBCCDDEEFF`, surrounding whitespace.
- `ToString()` → canonical lowercase no-separator (`aabbccddeeff`).
diff --git a/src/FrameProcessor/Domain/MacAddress.cs b/src/FrameProcessor/Domain/MacAddress.cs
new file mode 100644
index 0000000..ebb88bc
--- /dev/null
+++ b/src/FrameProcessor/Domain/MacAddress.cs
@@ -0,0 +1,156 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace FrameProcessor.Domain;
+
+///
+/// A 48-bit MAC address. Canonical form is lowercase hex without separators (e.g. aabbccddeeff);
+/// parsing accepts colon, hyphen, or unseparated hex with arbitrary surrounding whitespace.
+///
+[JsonConverter(typeof(MacAddressJsonConverter))]
+public readonly record struct MacAddress : IParsable, ISpanParsable
+{
+ private readonly byte _b0;
+ private readonly byte _b1;
+ private readonly byte _b2;
+ private readonly byte _b3;
+ private readonly byte _b4;
+ private readonly byte _b5;
+
+ public MacAddress(ReadOnlySpan bytes)
+ {
+ if (bytes.Length != 6)
+ {
+ throw new ArgumentException("MAC address must be exactly 6 bytes.", nameof(bytes));
+ }
+
+ _b0 = bytes[0];
+ _b1 = bytes[1];
+ _b2 = bytes[2];
+ _b3 = bytes[3];
+ _b4 = bytes[4];
+ _b5 = bytes[5];
+ }
+
+ public override string ToString() => string.Create(12, this, static (span, mac) =>
+ {
+ WriteHex(span[..2], mac._b0);
+ WriteHex(span.Slice(2, 2), mac._b1);
+ WriteHex(span.Slice(4, 2), mac._b2);
+ WriteHex(span.Slice(6, 2), mac._b3);
+ WriteHex(span.Slice(8, 2), mac._b4);
+ WriteHex(span.Slice(10, 2), mac._b5);
+ });
+
+ public static bool TryParse(string? s, out MacAddress result)
+ {
+ if (s is null)
+ {
+ result = default;
+ return false;
+ }
+
+ return TryParse(s.AsSpan(), out result);
+ }
+
+ public static bool TryParse(ReadOnlySpan s, out MacAddress result)
+ {
+ result = default;
+ s = s.Trim();
+ if (s.IsEmpty)
+ {
+ return false;
+ }
+
+ Span hex = stackalloc char[12];
+ var written = 0;
+ foreach (var c in s)
+ {
+ if (c is ':' or '-')
+ {
+ continue;
+ }
+
+ if (written == 12)
+ {
+ return false;
+ }
+
+ if (!IsHexDigit(c))
+ {
+ return false;
+ }
+
+ hex[written++] = c;
+ }
+
+ if (written != 12)
+ {
+ return false;
+ }
+
+ Span bytes = stackalloc byte[6];
+ for (var i = 0; i < 6; i++)
+ {
+ if (!byte.TryParse(hex.Slice(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
+ {
+ return false;
+ }
+
+ bytes[i] = b;
+ }
+
+ result = new MacAddress(bytes);
+ return true;
+ }
+
+ public static MacAddress Parse(string s)
+ {
+ ArgumentNullException.ThrowIfNull(s);
+ return TryParse(s, out var result)
+ ? result
+ : throw new FormatException($"'{s}' is not a valid MAC address.");
+ }
+
+ public static MacAddress Parse(string s, IFormatProvider? provider) => Parse(s);
+
+ public static bool TryParse(string? s, IFormatProvider? provider, out MacAddress result)
+ => TryParse(s, out result);
+
+ public static MacAddress Parse(ReadOnlySpan s, IFormatProvider? provider)
+ => TryParse(s, out var result)
+ ? result
+ : throw new FormatException($"'{s}' is not a valid MAC address.");
+
+ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out MacAddress result)
+ => TryParse(s, out result);
+
+ private static bool IsHexDigit(char c)
+ => (uint)(c - '0') <= 9 || (uint)(c - 'a') <= 5 || (uint)(c - 'A') <= 5;
+
+ private static void WriteHex(Span dest, byte value)
+ {
+ dest[0] = HexChar(value >> 4);
+ dest[1] = HexChar(value & 0xF);
+ }
+
+ private static char HexChar(int nibble) => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10));
+}
+
+internal sealed class MacAddressJsonConverter : JsonConverter
+{
+ public override MacAddress Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var s = reader.GetString();
+ if (!MacAddress.TryParse(s, out var mac))
+ {
+ throw new JsonException($"'{s}' is not a valid MAC address.");
+ }
+
+ return mac;
+ }
+
+ public override void Write(Utf8JsonWriter writer, MacAddress value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString());
+}
diff --git a/tests/FrameProcessor.Tests/MacAddressTests.cs b/tests/FrameProcessor.Tests/MacAddressTests.cs
new file mode 100644
index 0000000..56ae400
--- /dev/null
+++ b/tests/FrameProcessor.Tests/MacAddressTests.cs
@@ -0,0 +1,112 @@
+using System.Text.Json;
+using FrameProcessor.Domain;
+
+namespace FrameProcessor.Tests;
+
+public class MacAddressTests
+{
+ private static readonly byte[] ExpectedBytes = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF];
+ private const string Canonical = "aabbccddeeff";
+
+ [Theory]
+ [InlineData("AA:BB:CC:DD:EE:FF")]
+ [InlineData("aa:bb:cc:dd:ee:ff")]
+ [InlineData("AA-BB-CC-DD-EE-FF")]
+ [InlineData("aa-bb-cc-dd-ee-ff")]
+ [InlineData("AABBCCDDEEFF")]
+ [InlineData("aabbccddeeff")]
+ [InlineData(" AA:BB:CC:DD:EE:FF ")]
+ [InlineData("\tAA-bb-CC-dd-EE-ff\n")]
+ public void TryParse_AcceptedForms_ProduceCanonicalString(string input)
+ {
+ Assert.True(MacAddress.TryParse(input, out var mac));
+ Assert.Equal(Canonical, mac.ToString());
+ }
+
+ [Fact]
+ public void Equality_IsByteWise_AcrossInputForms()
+ {
+ Assert.True(MacAddress.TryParse("AA:BB:CC:DD:EE:FF", out var a));
+ Assert.True(MacAddress.TryParse("aa-bb-cc-dd-ee-ff", out var b));
+ Assert.True(MacAddress.TryParse("AABBCCDDEEFF", out var c));
+
+ Assert.Equal(a, b);
+ Assert.Equal(b, c);
+ Assert.Equal(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [Fact]
+ public void Equality_DiffersOnAnySingleByte()
+ {
+ Assert.True(MacAddress.TryParse("aabbccddeeff", out var a));
+ Assert.True(MacAddress.TryParse("aabbccddeefe", out var b));
+
+ Assert.NotEqual(a, b);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not-a-mac")]
+ [InlineData("AA:BB:CC:DD:EE")] // too short
+ [InlineData("AA:BB:CC:DD:EE:FF:00")] // too long
+ [InlineData("ZZ:BB:CC:DD:EE:FF")] // non-hex digit
+ [InlineData("AA:BB:CC:DD:EE:F")] // odd hex count
+ [InlineData("AA BB CC DD EE FF")] // unsupported space separator
+ public void TryParse_RejectsBadInput(string input)
+ {
+ Assert.False(MacAddress.TryParse(input, out _));
+ }
+
+ [Fact]
+ public void TryParse_Null_ReturnsFalse()
+ {
+ Assert.False(MacAddress.TryParse((string?)null, out _));
+ }
+
+ [Fact]
+ public void Parse_Throws_OnBadInput()
+ {
+ Assert.Throws(() => MacAddress.Parse("nope"));
+ }
+
+ [Fact]
+ public void Constructor_FromBytes_RoundTripsThroughToString()
+ {
+ var mac = new MacAddress(ExpectedBytes);
+ Assert.Equal(Canonical, mac.ToString());
+ }
+
+ [Fact]
+ public void Constructor_FromWrongLength_Throws()
+ {
+ Assert.Throws(() => new MacAddress(new byte[] { 0x00 }));
+ Assert.Throws(() => new MacAddress(new byte[7]));
+ }
+
+ [Fact]
+ public void JsonRoundTrip_NormalizesToCanonicalForm()
+ {
+ var json = "\"AA:BB:CC:DD:EE:FF\"";
+ var mac = JsonSerializer.Deserialize(json);
+ var written = JsonSerializer.Serialize(mac);
+
+ Assert.Equal($"\"{Canonical}\"", written);
+ }
+
+ [Fact]
+ public void JsonDeserialize_BadInput_Throws()
+ {
+ Assert.Throws(() => JsonSerializer.Deserialize("\"not-a-mac\""));
+ }
+
+ [Fact]
+ public void IParsable_Hookup_Works()
+ {
+ var mac = MacAddress.Parse("AA:BB:CC:DD:EE:FF", provider: null);
+ Assert.Equal(Canonical, mac.ToString());
+
+ Assert.True(MacAddress.TryParse("AA:BB:CC:DD:EE:FF", provider: null, out var mac2));
+ Assert.Equal(mac, mac2);
+ }
+}
diff --git a/tests/FrameProcessor.Tests/UnitTest1.cs b/tests/FrameProcessor.Tests/UnitTest1.cs
index 5b2f905..e43dfbc 100644
--- a/tests/FrameProcessor.Tests/UnitTest1.cs
+++ b/tests/FrameProcessor.Tests/UnitTest1.cs
@@ -1,10 +1 @@
-namespace FrameProcessor.Tests;
-
-public class UnitTest1
-{
- [Fact]
- public void Test1()
- {
-
- }
-}
+namespace FrameProcessor.Tests;