diff --git a/src/packet.rs b/src/packet.rs
index 12288e7bb9c680ce6be2258126f1fb2038070491..8f134ede4dcd67ec02fecd307ba7fb0c2eb822b8 100644
--- a/src/packet.rs
+++ b/src/packet.rs
@@ -7,8 +7,8 @@ use crate::{
     error::{CommentError, DecodeError, DigipeatError, EncodeError, PacketRouteAppendError},
     interleaver, ldpc, utf8,
     whisker::{
-        Arbitrary, Comment, Destination, Gps, Identification, Route, RouteNode, Timestamp,
-        ValidatedWhiskerIter, Whisker, WhiskerIter, COMMENT_TYPE,
+        Arbitrary, Comment, Destination, Gps, Identification, NodeInfo, Route, RouteNode,
+        Timestamp, ValidatedWhiskerIter, Whisker, WhiskerIter, COMMENT_TYPE,
     },
     whitener,
 };
@@ -18,15 +18,15 @@ const X25: Crc<u16> = Crc::<u16>::new(&CRC_16_IBM_SDLC);
 macro_rules! uniq_whisker {
     ($t:meta) => {
         ::paste::paste! {
-            pub fn [<$t:lower>](&self) -> Option<$t> {
+            pub fn [<$t:snake:lower>](&self) -> Option<$t> {
                 self.iter().find_map(|w| match w {
                     Whisker::$t(x) => Some(x),
                     _ => None,
                 })
             }
 
-            pub fn [<add_ $t:lower>](&mut self, w: $t) -> Result<(), EncodeError> {
-                if self.[<$t:lower>]().is_some() {
+            pub fn [<add_ $t:snake:lower>](&mut self, w: $t) -> Result<(), EncodeError> {
+                if self.[<$t:snake:lower>]().is_some() {
                     return Err(EncodeError::DuplicateData);
                 }
 
@@ -35,7 +35,7 @@ macro_rules! uniq_whisker {
                     // safe to unwrap since we know we have enough space
                     let out = w.encode(&mut buf).unwrap();
 
-                    data.try_push(crate::whisker::[<$t:upper _TYPE>]).ok().ok_or(EncodeError::CatsOverflow)?;
+                    data.try_push(crate::whisker::[<$t:snake:upper _TYPE>]).ok().ok_or(EncodeError::CatsOverflow)?;
                     data.try_extend_from_slice(out).ok().ok_or(EncodeError::CatsOverflow)?;
 
                     Ok(())
@@ -44,8 +44,8 @@ macro_rules! uniq_whisker {
                 Ok(())
             }
 
-			pub fn [<clear_ $t:lower>](&mut self) {
-				self.clear_by_type(crate::whisker::[<$t:upper _TYPE>], false);
+			pub fn [<clear_ $t:snake:lower>](&mut self) {
+				self.clear_by_type(crate::whisker::[<$t:snake:upper _TYPE>], false);
 			}
         }
     };
@@ -201,6 +201,7 @@ impl<'a, const N: usize> Packet<'a, N> {
     uniq_whisker!(Timestamp);
     uniq_whisker!(Gps);
     uniq_whisker!(Route);
+    uniq_whisker!(NodeInfo);
     poly_whisker!(Destination);
     poly_whisker!(Arbitrary);
 
@@ -392,6 +393,8 @@ fn try_lock<'a, const N: usize, T, E, F: Fn(&mut Buffer<'a, N>) -> Result<T, E>>
 mod tests {
     use arrayvec::ArrayString;
 
+    use crate::whisker::NodeInfoBuilder;
+
     use super::*;
 
     #[test]
@@ -517,6 +520,80 @@ mod tests {
         assert_eq!(comment, packet2.comment(&mut buf).unwrap());
     }
 
+    #[test]
+    fn node_info_e2e() {
+        let mut buf = [0; 4096];
+        let mut packet = Packet::new(&mut buf);
+        packet
+            .add_node_info(
+                NodeInfoBuilder::default()
+                    .hardware_id(0xBEEF)
+                    .software_id(0xBC)
+                    .uptime(2304)
+                    .antenna_height(5)
+                    .antenna_gain(3.0)
+                    .tx_power(30.0)
+                    .voltage(12.6)
+                    .xcvr_temperature(-15)
+                    .battery_charge(65.0)
+                    .build(),
+            )
+            .unwrap();
+
+        let mut buf2 = [0; 4096];
+        let mut encoded = Buffer::new_empty(&mut buf2);
+        packet.fully_encode(&mut encoded).unwrap();
+
+        let mut buf3 = [0; 4096];
+        let packet2 = Packet::fully_decode(&encoded[2..], &mut buf3).unwrap();
+
+        let node_info = packet2.node_info().unwrap();
+        assert_eq!(0xBEEF, node_info.hardware_id().unwrap());
+        assert_eq!(0xBC, node_info.software_id().unwrap());
+        assert_eq!(2304, node_info.uptime().unwrap());
+        assert_eq!(5, node_info.antenna_height().unwrap());
+        assert_eq!(3.0, node_info.antenna_gain().unwrap());
+        assert_eq!(30.0, node_info.tx_power().unwrap());
+        assert_eq!(12.6, node_info.voltage().unwrap());
+        assert_eq!(-15, node_info.xcvr_temperature().unwrap());
+        assert_eq!(64.70588235294117, node_info.battery_charge().unwrap());
+    }
+
+    #[test]
+    fn node_info_e2e_some_unpopulated() {
+        let mut buf = [0; 4096];
+        let mut packet = Packet::new(&mut buf);
+        packet
+            .add_node_info(
+                NodeInfoBuilder::default()
+                    .software_id(0xBC)
+                    .uptime(2304)
+                    .antenna_gain(3.0)
+                    .voltage(12.6)
+                    .xcvr_temperature(-15)
+                    .build(),
+            )
+            .unwrap();
+
+        let mut buf2 = [0; 4096];
+        let mut encoded = Buffer::new_empty(&mut buf2);
+        packet.fully_encode(&mut encoded).unwrap();
+
+        let mut buf3 = [0; 4096];
+        let packet2 = Packet::fully_decode(&encoded[2..], &mut buf3).unwrap();
+
+        let node_info = packet2.node_info().unwrap();
+        assert_eq!(None, node_info.hardware_id());
+        assert_eq!(0xBC, node_info.software_id().unwrap());
+        assert_eq!(2304, node_info.uptime().unwrap());
+        assert_eq!(None, node_info.antenna_height());
+        assert_eq!(3.0, node_info.antenna_gain().unwrap());
+        assert_eq!(None, node_info.tx_power());
+        assert_eq!(12.6, node_info.voltage().unwrap());
+        assert_eq!(-15, node_info.xcvr_temperature().unwrap());
+        assert_eq!(None, node_info.battery_charge());
+    }
+
     #[test]
     fn fully_decode_fuzz_tests() {
         let data = [
diff --git a/src/whisker/mod.rs b/src/whisker/mod.rs
index b32ea3a475be13247fedd713896166226dd50839..48142250ae789ea6af28f0404cbd2a2d3b4414d0 100644
--- a/src/whisker/mod.rs
+++ b/src/whisker/mod.rs
@@ -3,6 +3,7 @@ mod comment;
 mod destination;
 mod gps;
 mod identification;
+mod node_info;
 mod route;
 mod timestamp;
 mod unknown;
@@ -15,6 +16,7 @@ pub use self::{
     destination::Destination,
     gps::Gps,
     identification::Identification,
+    node_info::{NodeInfo, NodeInfoBuilder},
     route::{Route, RouteIdentity, RouteIter, RouteNode},
     timestamp::Timestamp,
     unknown::Unknown,
@@ -27,6 +29,7 @@ pub(crate) const COMMENT_TYPE: u8 = 0x03;
 pub(crate) const ROUTE_TYPE: u8 = 0x04;
 pub(crate) const DESTINATION_TYPE: u8 = 0x05;
 pub(crate) const ARBITRARY_TYPE: u8 = 0x06;
+pub(crate) const NODE_INFO_TYPE: u8 = 0x09;
 
 #[derive(Debug)]
 pub enum Whisker {
@@ -37,6 +40,7 @@ pub enum Whisker {
     Route(Route),
     Destination(Destination),
     Arbitrary(Arbitrary),
+    NodeInfo(NodeInfo),
     Unknown(Unknown),
 }
 
@@ -137,6 +141,9 @@ impl<'a> WhiskerIter<'a> {
             ARBITRARY_TYPE => Whisker::Arbitrary(
                 Arbitrary::decode(data).ok_or(DecodeError::MalformedWhisker { position })?,
             ),
+            NODE_INFO_TYPE => Whisker::NodeInfo(
+                NodeInfo::decode(data).ok_or(DecodeError::MalformedWhisker { position })?,
+            ),
 
             // safe to unwrap because we know len has to be 255 or less, since it's a u8
             whisker_type => {
diff --git a/src/whisker/node_info.rs b/src/whisker/node_info.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ac4d689b4201f0eb16f136913916bde9101750c5
--- /dev/null
+++ b/src/whisker/node_info.rs
@@ -0,0 +1,321 @@
+#[derive(Copy, Clone, Debug)]
+pub struct NodeInfo {
+    hardware_id: Option<u16>,
+    software_id: Option<u8>,
+    uptime: Option<u32>,
+    antenna_height: Option<u8>,
+    antenna_gain: Option<u8>,
+    tx_power: Option<u8>,
+    voltage: Option<u8>,
+    xcvr_temperature: Option<i8>,
+    battery_charge: Option<u8>,
+}
+
+impl NodeInfo {
+    pub fn builder() -> NodeInfoBuilder {
+        NodeInfoBuilder::default()
+    }
+
+    pub fn hardware_id(&self) -> Option<u16> {
+        self.hardware_id
+    }
+
+    pub fn software_id(&self) -> Option<u8> {
+        self.software_id
+    }
+
+    /// Seconds
+    pub fn uptime(&self) -> Option<u32> {
+        self.uptime
+    }
+
+    /// Meters
+    pub fn antenna_height(&self) -> Option<u8> {
+        self.antenna_height
+    }
+
+    /// dBd
+    pub fn antenna_gain(&self) -> Option<f64> {
+        self.antenna_gain.map(|g| g as f64 / 4.0)
+    }
+
+    /// dBm
+    pub fn tx_power(&self) -> Option<f64> {
+        self.tx_power.map(|p| p as f64 / 4.0)
+    }
+
+    /// Volts
+    pub fn voltage(&self) -> Option<f64> {
+        self.voltage.map(|v| v as f64 / 10.0)
+    }
+
+    /// Degrees C
+    pub fn xcvr_temperature(&self) -> Option<i8> {
+        self.xcvr_temperature
+    }
+
+    /// Percent
+    pub fn battery_charge(&self) -> Option<f64> {
+        self.battery_charge.map(|b| b as f64 / 2.55)
+    }
+
+    pub fn encode<'a>(&self, buf: &'a mut [u8]) -> Option<&'a [u8]> {
+        let mut bitmask: u32 = 0;
+
+        let mut i = 4;
+
+        // TODO these could be macros
+        if let Some(x) = self.hardware_id {
+            bitmask |= 1;
+
+            buf.get_mut(i..(i + 2))?.copy_from_slice(&x.to_le_bytes());
+            i += 2;
+        }
+
+        if let Some(x) = self.software_id {
+            bitmask |= 2;
+
+            *buf.get_mut(i)? = x;
+            i += 1;
+        }
+
+        if let Some(x) = self.uptime {
+            bitmask |= 4;
+
+            buf.get_mut(i..(i + 4))?.copy_from_slice(&x.to_le_bytes());
+            i += 4;
+        }
+
+        if let Some(x) = self.antenna_height {
+            bitmask |= 8;
+
+            *buf.get_mut(i)? = x;
+            i += 1;
+        }
+
+        if let Some(x) = self.antenna_gain {
+            bitmask |= 16;
+
+            *buf.get_mut(i)? = x;
+            i += 1;
+        }
+
+        if let Some(x) = self.tx_power {
+            bitmask |= 32;
+
+            *buf.get_mut(i)? = x;
+            i += 1;
+        }
+
+        if let Some(x) = self.voltage {
+            bitmask |= 64;
+
+            *buf.get_mut(i)? = x;
+            i += 1;
+        }
+
+        if let Some(x) = self.xcvr_temperature {
+            bitmask |= 128;
+
+            *buf.get_mut(i)? = x.to_le_bytes()[0];
+            i += 1;
+        }
+
+        if let Some(x) = self.battery_charge {
+            bitmask |= 256;
+
+            *buf.get_mut(i)? = x;
+            i += 1;
+        }
+
+        buf[0] = (i - 1).try_into().ok()?;
+        buf[1..4].copy_from_slice(&bitmask.to_be_bytes()[1..]);
+
+        Some(&buf[..i])
+    }
+
+    pub fn decode(data: &[u8]) -> Option<Self> {
+        let bitmask = u32::from_be_bytes([0, *data.get(1)?, *data.get(2)?, *data.get(3)?]);
+
+        let mut builder = NodeInfoBuilder::default();
+        let mut i = 4;
+
+        if bitmask & 1 > 0 {
+            builder = builder.hardware_id(u16::from_le_bytes([*data.get(i)?, *data.get(i + 1)?]));
+            i += 2;
+        }
+
+        if bitmask & 2 > 0 {
+            builder = builder.software_id(*data.get(i)?);
+            i += 1;
+        }
+
+        if bitmask & 4 > 0 {
+            builder = builder.uptime(u32::from_le_bytes([
+                *data.get(i)?,
+                *data.get(i + 1)?,
+                *data.get(i + 2)?,
+                *data.get(i + 3)?,
+            ]));
+
+            i += 4;
+        }
+
+        if bitmask & 8 > 0 {
+            builder = builder.antenna_height(*data.get(i)?);
+            i += 1;
+        }
+
+        if bitmask & 16 > 0 {
+            builder.antenna_gain = Some(*data.get(i)?);
+            i += 1;
+        }
+
+        if bitmask & 32 > 0 {
+            builder.tx_power = Some(*data.get(i)?);
+            i += 1;
+        }
+
+        if bitmask & 64 > 0 {
+            builder.voltage = Some(*data.get(i)?);
+            i += 1;
+        }
+
+        if bitmask & 128 > 0 {
+            builder = builder.xcvr_temperature(i8::from_le_bytes([*data.get(i)?]));
+            i += 1;
+        }
+
+        if bitmask & 256 > 0 {
+            builder.battery_charge = Some(*data.get(i)?);
+        }
+
+        Some(builder.build())
+    }
+}
+
+#[derive(Default, Copy, Clone)]
+pub struct NodeInfoBuilder {
+    hardware_id: Option<u16>,
+    software_id: Option<u8>,
+    uptime: Option<u32>,
+    antenna_height: Option<u8>,
+    antenna_gain: Option<u8>,
+    tx_power: Option<u8>,
+    voltage: Option<u8>,
+    xcvr_temperature: Option<i8>,
+    battery_charge: Option<u8>,
+}
+
+impl NodeInfoBuilder {
+    pub fn build(self) -> NodeInfo {
+        let NodeInfoBuilder {
+            hardware_id,
+            software_id,
+            uptime,
+            antenna_height,
+            antenna_gain,
+            tx_power,
+            voltage,
+            xcvr_temperature,
+            battery_charge,
+        } = self;
+
+        NodeInfo {
+            hardware_id,
+            software_id,
+            uptime,
+            antenna_height,
+            antenna_gain,
+            tx_power,
+            voltage,
+            xcvr_temperature,
+            battery_charge,
+        }
+    }
+
+    pub fn hardware_id(mut self, val: u16) -> Self {
+        self.hardware_id = Some(val);
+
+        self
+    }
+
+    pub fn software_id(mut self, val: u8) -> Self {
+        self.software_id = Some(val);
+
+        self
+    }
+
+    /// Seconds
+    pub fn uptime(mut self, val: u32) -> Self {
+        self.uptime = Some(val);
+
+        self
+    }
+
+    /// Meters
+    pub fn antenna_height(mut self, val: u8) -> Self {
+        self.antenna_height = Some(val);
+
+        self
+    }
+
+    /// dBd
+    pub fn antenna_gain(mut self, val: f64) -> Self {
+        self.antenna_gain = Some((val * 4.0).min(255.0) as u8);
+
+        self
+    }
+
+    /// dBm
+    pub fn tx_power(mut self, val: f64) -> Self {
+        self.tx_power = Some((val * 4.0).min(255.0) as u8);
+
+        self
+    }
+
+    /// Volts
+    pub fn voltage(mut self, val: f64) -> Self {
+        self.voltage = Some((val * 10.0).min(255.0) as u8);
+
+        self
+    }
+
+    /// Degrees C
+    pub fn xcvr_temperature(mut self, val: i8) -> Self {
+        self.xcvr_temperature = Some(val);
+
+        self
+    }
+
+    /// Percent
+    pub fn battery_charge(mut self, val: f64) -> Self {
+        self.battery_charge = Some((val * 2.55).min(255.0) as u8);
+
+        self
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // verify examples in the standard doc
+    #[test]
+    fn node_info_doc_examples() {
+        let node_info = NodeInfoBuilder::default()
+            .hardware_id(7408)
+            .uptime(98)
+            .tx_power(30.0)
+            .voltage(12.8)
+            .build();
+
+        let mut buf = [0; 255];
+        let encoded = node_info.encode(&mut buf).unwrap();
+
+        assert_eq!(
+            [0x0B, 0x00, 0x00, 0x65, 0xF0, 0x1C, 0x62, 0x00, 0x00, 0x00, 0x78, 0x80],
+            encoded
+        );
+    }
+}