diff --git a/grc/CMakeLists.txt b/grc/CMakeLists.txt
index cd500415b647c169f6783d716d007a5bd76362b0..adfaa90b8ae8174de1914afa6f3bcbc02a4b8eb7 100644
--- a/grc/CMakeLists.txt
+++ b/grc/CMakeLists.txt
@@ -9,5 +9,6 @@
 install(FILES
     elen90089_corr_est_cc.block.yml
     elen90089_moe_symbol_sync_cc.block.yml
+    elen90089_symbol_mapper_c.block.yml
     DESTINATION share/gnuradio/grc/blocks
 )
diff --git a/grc/elen90089_symbol_mapper_c.block.yml b/grc/elen90089_symbol_mapper_c.block.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e10c2d5b35612308548b540bc209fec5602b621e
--- /dev/null
+++ b/grc/elen90089_symbol_mapper_c.block.yml
@@ -0,0 +1,33 @@
+id: elen90089_symbol_mapper_c
+label: CDC Symbol Mapper
+category: '[elen90089]'
+
+templates:
+  imports: import elen90089
+  make: elen90089.symbol_mapper_c(elen90089.symbol_mapper_c.constel.${constel_header}, {tsb_tag_key})
+
+parameters:
+- id: constel_header
+  label: Header Constellation
+  dtype: enum
+  options: [CONSTEL_BPSK, CONSTEL_QPSK, CONSTEL_8PSK, CONSTEL_16QAM]
+  option_labels: ['BPSK', 'QPSK', '8PSK', '16QAM']
+- id: tsb_tag_key
+  label: TSB Tag Key
+  default: 'packet_len'
+  dtype: string
+
+inputs:
+- label: hdr
+  domain: message
+  optional: True
+- label: pld
+  domain: message
+  optional: True
+
+outputs:
+- label: out
+  domain: stream
+  dtype: complex
+
+file_format: 1
diff --git a/include/elen90089/CMakeLists.txt b/include/elen90089/CMakeLists.txt
index 7ad10f5f6c1f20a95cda5262f0e84db3e3ec0866..e47a60c06608643b7f30bf5d5de8147b3f53e85c 100644
--- a/include/elen90089/CMakeLists.txt
+++ b/include/elen90089/CMakeLists.txt
@@ -13,5 +13,6 @@ install(FILES
     api.h
     corr_est_cc.h
     moe_symbol_sync_cc.h
+    symbol_mapper_c.h
     DESTINATION include/elen90089
 )
diff --git a/include/elen90089/symbol_mapper_c.h b/include/elen90089/symbol_mapper_c.h
new file mode 100644
index 0000000000000000000000000000000000000000..9cd7f16cd3fc06a45efa898b4ad77595c2fd7028
--- /dev/null
+++ b/include/elen90089/symbol_mapper_c.h
@@ -0,0 +1,95 @@
+/* -*- c++ -*- */
+/*
+ * Copyright 2022 University of Melbourne.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef INCLUDED_ELEN90089_SYMBOL_MAPPER_C_H
+#define INCLUDED_ELEN90089_SYMBOL_MAPPER_C_H
+
+#include <elen90089/api.h>
+#include <gnuradio/tagged_stream_block.h>
+
+namespace gr {
+namespace elen90089 {
+
+/*!
+ * \brief Maps header and payload bytes of CDC packet to constellation symbols.
+ * \ingroup elen90089
+ *
+ * \details
+ * This block takes in the header and payload PDUs of a CDC packet, mapping
+ * the respective bytes of each PDU to constellation symbol points to form
+ * a modulated packet. Mapping of header and payload bytes is done using
+ * different constellations. The default header constellation is BPSK and
+ * but can be set via the \p constel_header parameter. The default payload
+ * constellation is BPSK but can be chosen on a packet-by-packet basis by
+ * setting the \p bps (bits-per-symbol) parameter in the metadata dictionary
+ * of the header PDU. The following constellation types are supported for
+ * both header and payload mapping:
+ *
+ * \li BPSK:  bps = 1
+ * \li QPSK:  bps = 2
+ * \li 8PSK:  bsp = 3
+ * \li 16QAM: bps = 4
+ *
+ * This block uses asynchronous message passing interfaces to receive PDUs.
+ * The input message ports are:
+ *
+ * \li hdr: header PDU of CDC packet with metadata dict specifying payload bps.
+ * \li pld: payload PDU of CDC packet
+ *
+ * The block output is a complex stream of symbol constellation points with
+ * the following stream tags:
+ *
+ * \li metadata:        first packet symbol is tagged with all entries in
+ *                      header PDU metadata dictionary
+ * \li \p tsb_tag_key:  first packet symbol is tagged with burst duration /
+ *                      packet length in number of symbols
+ * \li tx_sob:          indicates first symbol in packet/burst
+ * \li tx_eob:          indicates last symbol in packet/burst
+ */
+class ELEN90089_API symbol_mapper_c : virtual public gr::tagged_stream_block
+{
+public:
+    typedef std::shared_ptr<symbol_mapper_c> sptr;
+
+    /*!
+     * \brief Enum to represent constellation type.
+     */
+    enum constel_t {
+        CONSTEL_BPSK,  /*! BPSK constellation (bps=1) */
+        CONSTEL_QPSK,  /*! QPSK constellation (bps=2) */
+        CONSTEL_8PSK,  /*! 8PSK constellation (bps=3) */
+        CONSTEL_16QAM, /*! 16QAM constellation (bps=4) */
+    };
+
+    /*!
+     * \brief Make a CDC Symbol Mapper block.
+     *
+     * \param constel_header    Constellation used for header bytes (BPSK, QPSK,
+     *                          8PSK, or 16QAM)
+     * \param tsb_tag_key       Output tag key indicating packet/burst duration
+     */
+    static sptr make(constel_t constel_header = CONSTEL_BPSK,
+                     const std::string& tsb_tag_key = "packet_len");
+
+    /*!
+     * \brief Returns the constellation used to map header bits.
+     */
+    virtual constel_t constel_header() const = 0;
+
+    /*!
+     * \brief Sets the constellation used to map header bits.
+     *
+     * \param constel_header    (constel_t) Constellation used for header bits
+     *                          (BPSK, QPSK, 8PSK, or 16QAM)
+     */
+    virtual void set_constel_header(constel_t constel_header) = 0;
+};
+
+} // namespace elen90089
+} // namespace gr
+
+#endif /* INCLUDED_ELEN90089_SYMBOL_MAPPER_C_H */
diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt
index f05d33b12649a0da5d038c2d595bb3478072f802..8b8158b74b647b76cec672be3b5720e3e577d60b 100644
--- a/lib/CMakeLists.txt
+++ b/lib/CMakeLists.txt
@@ -14,6 +14,7 @@ include(GrPlatform) #define LIB_SUFFIX
 list(APPEND elen90089_sources
     corr_est_cc_impl.cc
     moe_symbol_sync_cc_impl.cc
+    symbol_mapper_c_impl.cc
 )
 
 set(elen90089_sources "${elen90089_sources}" PARENT_SCOPE)
@@ -27,7 +28,8 @@ add_library(gnuradio-elen90089 SHARED ${elen90089_sources})
 target_link_libraries(
     gnuradio-elen90089
     gnuradio::gnuradio-runtime
-    gnuradio-filter)
+    gnuradio-filter
+    gnuradio-digital)
 
 target_include_directories(gnuradio-elen90089
     PUBLIC $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../include>
diff --git a/lib/symbol_mapper_c_impl.cc b/lib/symbol_mapper_c_impl.cc
new file mode 100644
index 0000000000000000000000000000000000000000..02ee584903488a407d5cbfab190c326177a7dfec
--- /dev/null
+++ b/lib/symbol_mapper_c_impl.cc
@@ -0,0 +1,203 @@
+/* -*- c++ -*- */
+/*
+ * Copyright 2022 University of Melbourne.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include <gnuradio/io_signature.h>
+#include "symbol_mapper_c_impl.h"
+
+namespace gr {
+namespace elen90089 {
+
+symbol_mapper_c::sptr symbol_mapper_c::make(constel_t constel_header,
+                                            const std::string& tsb_tag_key)
+{
+    return gnuradio::make_block_sptr<symbol_mapper_c_impl>(
+        constel_header, tsb_tag_key);
+}
+
+symbol_mapper_c_impl::symbol_mapper_c_impl(constel_t constel_header,
+                                           const std::string& tsb_tag_key)
+    : gr::tagged_stream_block("symbol_mapper_c",
+                              gr::io_signature::make(0, 0, 0),
+                              gr::io_signature::make(1, 1, sizeof(gr_complex)),
+                              tsb_tag_key),
+      d_bps_header(0),
+      d_bps_payload(0),
+      d_len_header(0),
+      d_len_payload(0)
+{
+    // register message ports to receive header and payload pdus
+    message_port_register_in(pmt::mp("hdr"));
+    message_port_register_in(pmt::mp("pld"));
+
+    // set header bps
+    set_constel_header(constel_header);
+
+    // create constellation objects
+    d_bpsk  = digital::constellation_bpsk::make();
+    d_8psk  = digital::constellation_8psk::make();
+    d_16qam = digital::constellation_16qam::make();
+
+    // digital::constellation_qpsk is unnormalized so create manually
+    d_qpsk = digital::constellation_calcdist::make(
+        {gr_complex(-1, -1), gr_complex( 1, -1),
+         gr_complex(-1,  1), gr_complex( 1,  1)}, // constel
+        {0x0, 0x2, 0x3, 0x1},                     // pre_diff_code
+        4,                                        // rotational_symmetry
+        1,                                        // dimensionality
+        digital::constellation::POWER_NORMALIZATION);
+}
+
+symbol_mapper_c_impl::~symbol_mapper_c_impl() { }
+
+symbol_mapper_c::constel_t symbol_mapper_c_impl::constel_header() const
+{
+    constel_t constel;
+
+    switch(d_bps_header) {
+    case 1: constel = CONSTEL_BPSK; break;
+    case 2: constel = CONSTEL_QPSK; break;
+    case 3: constel = CONSTEL_8PSK; break;
+    case 4: constel = CONSTEL_16QAM; break;
+    }
+
+    return constel;
+}
+
+void symbol_mapper_c_impl::set_constel_header(constel_t constel)
+{
+    switch(constel) {
+    case CONSTEL_BPSK:  d_bps_header = 1; break;
+    case CONSTEL_QPSK:  d_bps_header = 2; break;
+    case CONSTEL_8PSK:  d_bps_header = 3; break;
+    case CONSTEL_16QAM: d_bps_header = 4; break;
+    }
+}
+
+int symbol_mapper_c_impl::calculate_output_stream_length(const gr_vector_int &ninput_items)
+{
+    int noutput_items = 0;
+
+    // get next header pdu
+    if (d_len_header == 0) {
+        pmt::pmt_t msg(delete_head_nowait(pmt::mp("hdr")));
+
+        if (msg.get() != nullptr) {
+            assert(!pmt::is_pair(msg));
+
+            d_metadata = pmt::car(msg);
+            d_bps_payload = pmt::to_long(pmt::dict_ref(
+                d_metadata, pmt::mp("bps"), pmt::PMT_NIL));
+
+            d_header = pmt::cdr(msg);
+            d_len_header = pmt::blob_length(d_header);
+        }
+    }
+
+    // get next payload pdu
+    if (d_len_payload == 0) {
+        pmt::pmt_t msg(delete_head_nowait(pmt::mp("pld")));
+
+        if (msg.get() != nullptr) {
+            d_payload = pmt::cdr(msg);
+            d_len_payload = pmt::blob_length(d_payload);
+        }
+    }
+
+    if (d_len_header > 0 && d_len_payload > 0) {
+        noutput_items += ((d_len_header << 3) + (d_bps_header - 1)) / d_bps_header;
+        noutput_items += ((d_len_payload << 3) + (d_bps_payload - 1)) / d_bps_payload;
+    }
+
+    return noutput_items;
+}
+
+void symbol_mapper_c_impl::map_to_symbols(const uint8_t* bytes,
+                                          int nbytes,
+                                          gr_complex* symbols,
+                                          int bps)
+{
+    digital::constellation_sptr constel;
+
+    switch (bps) {
+    case 1: constel = d_bpsk; break;
+    case 2: constel = d_qpsk; break;
+    case 3: constel = d_8psk; break;
+    case 4: constel = d_16qam; break;
+    }
+
+    int nsymbols = (nbytes << 3) / bps;
+    int index = 0;
+    for (int i = 0; i < nsymbols; i++) {
+        // MSB unpacking of bps bits from input bytes
+        int x = 0;
+        for (unsigned int j = 0; j < bps; j++, index++) {
+            int bit = 0x1 & (bytes[index>>3] >> (7 - (index & 0x7)));
+            x = (x << 1) | bit;
+        }
+
+        constel->map_to_points(x, symbols++);
+    }
+
+    // zero pad and map left over bits
+    if (index & 0x7 != 0) {
+        int x = bytes[index>>3] << (bps - (8 - (index & 0x7)));
+        x &= (0x1 << bps) - 1;
+
+        constel->map_to_points(x, symbols);
+    }
+}
+
+int symbol_mapper_c_impl::work(int noutput_items,
+                               gr_vector_int &ninput_items,
+                               gr_vector_const_void_star &input_items,
+                               gr_vector_void_star &output_items)
+{
+    auto out = static_cast<gr_complex*>(output_items[0]);
+
+    if (d_len_header == 0 || d_len_payload == 0) {
+        return 0;
+    }
+
+    int nout = ((d_len_header << 3) + (d_bps_header - 1)) / d_bps_header;
+    nout += ((d_len_payload << 3) + (d_bps_payload - 1)) / d_bps_payload;
+
+    assert (noutput_items >= nout);
+
+    // modulate header bytes
+    size_t io(0);
+    const uint8_t* bytes = static_cast<const uint8_t*>(pmt::uniform_vector_elements(d_header, io));
+    map_to_symbols(bytes, d_len_header, out, d_bps_header);
+    out += ((d_len_header << 3) + (d_bps_header - 1)) / d_bps_header;
+
+    // modulate payload bytes
+    bytes = static_cast<const uint8_t*>(pmt::uniform_vector_elements(d_payload, io));
+    map_to_symbols(bytes, d_len_payload, out, d_bps_payload);
+
+    // convert header metadata to stream tags
+    pmt::pmt_t items(pmt::dict_items(d_metadata));
+    for (size_t i = 0; i < pmt::length(items); i++) {
+        pmt::pmt_t item(pmt::nth(i, items));
+        pmt::pmt_t key(pmt::car(item));
+        pmt::pmt_t val(pmt::cdr(item));
+
+        // determine absolute sample offset of tag
+        int offset = nitems_written(0);
+        if (pmt::equal(key, pmt::string_to_symbol("tx_eob"))) {
+            offset += nout - 1;
+        }
+
+        add_item_tag(0, offset, key, val, alias_pmt());
+    }
+
+    d_len_header = 0;
+    d_len_payload = 0;
+
+    return nout;
+}
+
+} /* namespace elen90089 */
+} /* namespace gr */
diff --git a/lib/symbol_mapper_c_impl.h b/lib/symbol_mapper_c_impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..8334b5865ae44464a4de9ecf92ab7a3a7af3534b
--- /dev/null
+++ b/lib/symbol_mapper_c_impl.h
@@ -0,0 +1,61 @@
+/* -*- c++ -*- */
+/*
+ * Copyright 2022 University of Melbourne.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef INCLUDED_ELEN90089_SYMBOL_MAPPER_C_IMPL_H
+#define INCLUDED_ELEN90089_SYMBOL_MAPPER_C_IMPL_H
+
+#include <elen90089/symbol_mapper_c.h>
+#include <gnuradio/digital/constellation.h>
+
+namespace gr {
+namespace elen90089 {
+
+class symbol_mapper_c_impl : public symbol_mapper_c
+{
+private:
+    int d_bps_header;
+    int d_bps_payload;
+    int d_len_header;
+    int d_len_payload;
+
+    pmt::pmt_t d_header;
+    pmt::pmt_t d_payload;
+    pmt::pmt_t d_metadata;
+
+    // constellation objects
+    digital::constellation_sptr d_bpsk;
+    digital::constellation_sptr d_qpsk;
+    digital::constellation_sptr d_8psk;
+    digital::constellation_sptr d_16qam;
+
+    void map_to_symbols(const uint8_t* bytes,
+                        int nbytes,
+                        gr_complex* symbols,
+                        int bps);
+
+protected:
+    int calculate_output_stream_length(const gr_vector_int &ninput_items) override;
+
+public:
+    symbol_mapper_c_impl(constel_t constel_header = CONSTEL_BPSK,
+                         const std::string& tsb_tag_key = "packet_len");
+
+    ~symbol_mapper_c_impl();
+
+    constel_t constel_header() const override;
+    void set_constel_header(constel_t constel_header) override;
+
+    int work(int noutput_items,
+             gr_vector_int &ninput_items,
+             gr_vector_const_void_star &input_items,
+             gr_vector_void_star &output_items);
+};
+
+} // namespace elen90089
+} // namespace gr
+
+#endif /* INCLUDED_ELEN90089_SYMBOL_MAPPER_C_IMPL_H */
diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 6c242766ff2cb44608eeda67c542ef988be61004..a984ec18889711b40eaf9669b58b83d1a0abc92a 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -41,3 +41,4 @@ add_custom_target(
 )
 GR_ADD_TEST(qa_corr_est_cc ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/qa_corr_est_cc.py)
 GR_ADD_TEST(qa_moe_symbol_sync_cc ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/qa_moe_symbol_sync_cc.py)
+GR_ADD_TEST(qa_symbol_mapper_c ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/qa_symbol_mapper_c.py)
diff --git a/python/bindings/CMakeLists.txt b/python/bindings/CMakeLists.txt
index 9ed43664351ad1cc647674b233ebd94d58006b7e..6c6cd4df39a1d296aa4aaaf1bdd78be62e903cdf 100644
--- a/python/bindings/CMakeLists.txt
+++ b/python/bindings/CMakeLists.txt
@@ -31,6 +31,7 @@ include(GrPybind)
 list(APPEND elen90089_python_files
     corr_est_cc_python.cc
     moe_symbol_sync_cc_python.cc
+    symbol_mapper_c_python.cc
     python_bindings.cc)
 
 GR_PYBIND_MAKE_OOT(elen90089
diff --git a/python/bindings/docstrings/symbol_mapper_c_pydoc_template.h b/python/bindings/docstrings/symbol_mapper_c_pydoc_template.h
new file mode 100644
index 0000000000000000000000000000000000000000..97b25e35220fc4bb2eb12fc45ebba10c32663473
--- /dev/null
+++ b/python/bindings/docstrings/symbol_mapper_c_pydoc_template.h
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2022 Free Software Foundation, Inc.
+ *
+ * This file is part of GNU Radio
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+#include "pydoc_macros.h"
+#define D(...) DOC(gr,elen90089, __VA_ARGS__ )
+/*
+  This file contains placeholders for docstrings for the Python bindings.
+  Do not edit! These were automatically extracted during the binding process
+  and will be overwritten during the build process
+ */
+
+
+ 
+ static const char *__doc_gr_elen90089_symbol_mapper_c = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_symbol_mapper_c_symbol_mapper_c_0 = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_symbol_mapper_c_symbol_mapper_c_1 = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_symbol_mapper_c_make = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_symbol_mapper_c_constel_header = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_symbol_mapper_c_set_constel_header = R"doc()doc";
+
+  
diff --git a/python/bindings/python_bindings.cc b/python/bindings/python_bindings.cc
index fff3f49b3ae58c0bf1446a5b9bad7880c0d35c6b..a6f73a6a9618aff5ff266d5f636df852b18314a9 100644
--- a/python/bindings/python_bindings.cc
+++ b/python/bindings/python_bindings.cc
@@ -23,6 +23,7 @@ namespace py = pybind11;
 // BINDING_FUNCTION_PROTOTYPES(
     void bind_corr_est_cc(py::module& m);
     void bind_moe_symbol_sync_cc(py::module& m);
+    void bind_symbol_mapper_c(py::module& m);
 // ) END BINDING_FUNCTION_PROTOTYPES
 
 
@@ -53,5 +54,6 @@ PYBIND11_MODULE(elen90089_python, m)
     // BINDING_FUNCTION_CALLS(
     bind_corr_est_cc(m);
     bind_moe_symbol_sync_cc(m);
+    bind_symbol_mapper_c(m);
     // ) END BINDING_FUNCTION_CALLS
 }
\ No newline at end of file
diff --git a/python/bindings/symbol_mapper_c_python.cc b/python/bindings/symbol_mapper_c_python.cc
new file mode 100644
index 0000000000000000000000000000000000000000..bf416b4a6b0a3d1298a6aa618a60eea80610ee43
--- /dev/null
+++ b/python/bindings/symbol_mapper_c_python.cc
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2022 Free Software Foundation, Inc.
+ *
+ * This file is part of GNU Radio
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+/***********************************************************************************/
+/* This file is automatically generated using bindtool and can be manually edited  */
+/* The following lines can be configured to regenerate this file during cmake      */
+/* If manual edits are made, the following tags should be modified accordingly.    */
+/* BINDTOOL_GEN_AUTOMATIC(0)                                                       */
+/* BINDTOOL_USE_PYGCCXML(0)                                                        */
+/* BINDTOOL_HEADER_FILE(symbol_mapper_c.h)                                        */
+/* BINDTOOL_HEADER_FILE_HASH(b347387bb1dad3898ceb8adbe91a8e32)                     */
+/***********************************************************************************/
+
+#include <pybind11/complex.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+namespace py = pybind11;
+
+#include <elen90089/symbol_mapper_c.h>
+// pydoc.h is automatically generated in the build directory
+#include <symbol_mapper_c_pydoc.h>
+
+void bind_symbol_mapper_c(py::module& m)
+{
+
+    using symbol_mapper_c = ::gr::elen90089::symbol_mapper_c;
+
+    py::class_<symbol_mapper_c,
+               gr::tagged_stream_block,
+               gr::block,
+               gr::basic_block,
+               std::shared_ptr<symbol_mapper_c>> symbol_mapper_c_class(m, "symbol_mapper_c", D(symbol_mapper_c));
+
+    py::enum_<symbol_mapper_c::constel_t>(symbol_mapper_c_class, "constel")
+        .value("CONSTEL_BPSK", symbol_mapper_c::CONSTEL_BPSK)
+        .value("CONSTEL_QPSK", symbol_mapper_c::CONSTEL_QPSK)
+        .value("CONSTEL_8PSK", symbol_mapper_c::CONSTEL_8PSK)
+        .value("CONSTEL_16QAM", symbol_mapper_c::CONSTEL_16QAM)
+        .export_values();
+
+    py::implicitly_convertible<int, symbol_mapper_c::constel_t>();
+
+    symbol_mapper_c_class
+        .def(py::init(&symbol_mapper_c::make),
+             py::arg("constel_header") = symbol_mapper_c::constel_t::CONSTEL_BPSK,
+             py::arg("tsb_tag_key") = "packet_len",
+             D(symbol_mapper_c,make))
+
+        .def("constel_header",
+             &symbol_mapper_c::constel_header,
+             D(symbol_mapper_c, constel_header))
+
+        .def("set_constel_header",
+             &symbol_mapper_c::set_constel_header,
+             py::arg("constel_header"),
+             D(symbol_mapper_c, set_constel_header));
+}
diff --git a/python/qa_symbol_mapper_c.py b/python/qa_symbol_mapper_c.py
new file mode 100755
index 0000000000000000000000000000000000000000..52a3f08e1b7600d6ddd52b22a1997240898b1dc4
--- /dev/null
+++ b/python/qa_symbol_mapper_c.py
@@ -0,0 +1,110 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright 2022 University of Melbourne.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+
+from gnuradio import gr, gr_unittest
+from gnuradio import blocks, digital
+import pmt
+from time import sleep
+import numpy as np
+try:
+    from elen90089 import symbol_mapper_c
+except ImportError:
+    import os
+    import sys
+    dirname, filename = os.path.split(os.path.abspath(__file__))
+    sys.path.append(os.path.join(dirname, "bindings"))
+    from elen90089 import symbol_mapper_c
+
+
+def modulate_u8vector(cnst, vec):
+    bits = np.unpackbits(vec)
+    bps = cnst.bits_per_symbol()
+    
+    symb = []
+    for ii in range(0, len(bits), bps):
+        x = np.flip(bits[ii:ii+bps])
+        s = np.packbits(x, bitorder='little')[0]
+        symb.append(cnst.points()[s])
+
+    return symb
+
+
+def create_pdu(bps_pld, vec):
+    meta = pmt.make_dict();
+    meta = pmt.dict_add(meta, pmt.intern('bps'), pmt.from_long(bps_pld))
+    pld = pmt.init_u8vector(len(vec), vec)
+    
+    return pmt.cons(meta, pld)
+
+
+class qa_symbol_mapper_c(gr_unittest.TestCase):
+
+    def setUp(self):
+        self.tb = gr.top_block()
+
+    def tearDown(self):
+        self.tb = None
+
+    def test_001_qpsk(self):
+        data_hdr = np.random.randint(256, size=(2,), dtype=np.uint8)
+        data_pld = np.random.randint(256, size=(4,), dtype=np.uint8)
+        cnst_hdr = digital.constellation_bpsk()
+        cnst_pld = digital.constellation_calcdist( # normalized QPSK
+            [-1-1j, 1-1j, -1+1j, 1+1j],
+            [0x0, 0x2, 0x3, 0x1],
+            4,
+            1,
+            digital.constellation.normalization.POWER_NORMALIZATION)
+        expected_result = modulate_u8vector(cnst_hdr, data_hdr)
+        expected_result += modulate_u8vector(cnst_pld, data_pld)
+        # create and run flowgraph
+        mapper = symbol_mapper_c(symbol_mapper_c.constel.CONSTEL_BPSK)
+        mapper.to_basic_block()._post(
+            pmt.intern('hdr'),
+            create_pdu(cnst_pld.bits_per_symbol(), data_hdr))
+        mapper.to_basic_block()._post(
+            pmt.intern('pld'),
+            create_pdu(cnst_pld.bits_per_symbol(), data_pld))
+        dst = blocks.vector_sink_c()
+        self.tb.connect(mapper, dst)
+        self.tb.start()
+        sleep(1) # wait for flowgraph to complete
+        self.tb.stop()
+        # check data
+        actual_result = dst.data()
+        self.assertEqual(len(actual_result), len(expected_result))
+        self.assertFloatTuplesAlmostEqual(actual_result, expected_result)
+
+    def test_002_16qam(self):
+        data_hdr = np.random.randint(256, size=(2,), dtype=np.uint8)
+        data_pld = np.random.randint(256, size=(4,), dtype=np.uint8)
+        cnst_hdr = digital.constellation_bpsk()
+        cnst_pld = digital.constellation_16qam()
+        expected_result = modulate_u8vector(cnst_hdr, data_hdr)
+        expected_result += modulate_u8vector(cnst_pld, data_pld)
+        # create and run flowgraph
+        mapper = symbol_mapper_c(symbol_mapper_c.constel.CONSTEL_BPSK)
+        mapper.to_basic_block()._post(
+            pmt.intern('hdr'),
+            create_pdu(cnst_pld.bits_per_symbol(), data_hdr))
+        mapper.to_basic_block()._post(
+            pmt.intern('pld'),
+            create_pdu(cnst_pld.bits_per_symbol(), data_pld))
+        dst = blocks.vector_sink_c()
+        self.tb.connect(mapper, dst)
+        self.tb.start()
+        sleep(1) # wait for flowgraph to complete
+        self.tb.stop()
+        # check data
+        actual_result = dst.data()
+        self.assertEqual(len(actual_result), len(expected_result))
+        self.assertFloatTuplesAlmostEqual(actual_result, expected_result)
+
+
+if __name__ == '__main__':
+    gr_unittest.run(qa_symbol_mapper_c)