diff --git a/src/gstreamer/gstlibcamera-controls.cpp.in b/src/gstreamer/gstlibcamera-controls.cpp.in
new file mode 100644
index 00000000..ace36b71
--- /dev/null
+++ b/src/gstreamer/gstlibcamera-controls.cpp.in
@@ -0,0 +1,332 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2024, Jaslo Ziska
+ *
+ * GStreamer Camera Controls
+ *
+ * This file is auto-generated. Do not edit.
+ */
+
+#include <vector>
+
+#include <libcamera/control_ids.h>
+#include <libcamera/controls.h>
+#include <libcamera/geometry.h>
+
+#include "gstlibcamera-controls.h"
+
+using namespace libcamera;
+
+static void value_set_rectangle(GValue *value, const Rectangle &rect)
+{
+	Point top_left = rect.topLeft();
+	Size size = rect.size();
+
+	GValue x = G_VALUE_INIT;
+	g_value_init(&x, G_TYPE_INT);
+	g_value_set_int(&x, top_left.x);
+	gst_value_array_append_and_take_value(value, &x);
+
+	GValue y = G_VALUE_INIT;
+	g_value_init(&y, G_TYPE_INT);
+	g_value_set_int(&y, top_left.y);
+	gst_value_array_append_and_take_value(value, &y);
+
+	GValue width = G_VALUE_INIT;
+	g_value_init(&width, G_TYPE_INT);
+	g_value_set_int(&width, size.width);
+	gst_value_array_append_and_take_value(value, &width);
+
+	GValue height = G_VALUE_INIT;
+	g_value_init(&height, G_TYPE_INT);
+	g_value_set_int(&x, size.height);
+	gst_value_array_append_and_take_value(value, &height);
+}
+
+static Rectangle value_get_rectangle(const GValue *value)
+{
+	const GValue *r;
+	r = gst_value_array_get_value(value, 0);
+	int x = g_value_get_int(r);
+	r = gst_value_array_get_value(value, 1);
+	int y = g_value_get_int(r);
+	r = gst_value_array_get_value(value, 2);
+	int w = g_value_get_int(r);
+	r = gst_value_array_get_value(value, 3);
+	int h = g_value_get_int(r);
+
+	return Rectangle(x, y, w, h);
+}
+
+{% for vendor, ctrls in controls %}
+{%- for ctrl in ctrls if ctrl.is_enum %}
+static const GEnumValue {{ ctrl.name|snake_case }}_types[] = {
+{%- for enum in ctrl.enum_values %}
+	{
+		controls::{{ ctrl.namespace }}{{ enum.name }},
+		{{ enum.description|format_description|indent_str('\t\t') }},
+		"{{ enum.gst_name }}"
+	},
+{%- endfor %}
+	{0, NULL, NULL}
+};
+
+#define TYPE_{{ ctrl.name|snake_case|upper }} \
+	({{ ctrl.name|snake_case }}_get_type())
+static GType {{ ctrl.name|snake_case }}_get_type()
+{
+	static GType {{ ctrl.name|snake_case }}_type = 0;
+
+	if (!{{ ctrl.name|snake_case }}_type)
+		{{ ctrl.name|snake_case }}_type =
+			g_enum_register_static("{{ ctrl.name }}",
+					       {{ ctrl.name|snake_case }}_types);
+
+	return {{ ctrl.name|snake_case }}_type;
+}
+{% endfor %}
+{%- endfor %}
+
+void GstCameraControls::installProperties(GObjectClass *klass, int lastPropId)
+{
+{%- for vendor, ctrls in controls %}
+{%- for ctrl in ctrls %}
+
+{%- set spec %}
+{%- if ctrl.is_rectangle -%}
+gst_param_spec_array(
+{%- else -%}
+g_param_spec_{{ ctrl.gtype }}(
+{%- endif -%}
+{%- if ctrl.is_array %}
+	"{{ ctrl.vendor_prefix }}{{ ctrl.name|kebab_case }}-value",
+	"{{ ctrl.name }} Value",
+	"One {{ ctrl.name }} element value",
+{%- else %}
+	"{{ ctrl.vendor_prefix }}{{ ctrl.name|kebab_case }}",
+	"{{ ctrl.name }}",
+	{{ ctrl.description|format_description|indent_str('\t') }},
+{%- endif %}
+{%- if ctrl.is_enum %}
+	TYPE_{{ ctrl.name|snake_case|upper }},
+	{{ ctrl.default }},
+{%- elif ctrl.is_rectangle %}
+	g_param_spec_int(
+		"rectangle-value",
+		"Rectangle Value",
+		"One rectangle value, either x, y, width or height.",
+		{{ ctrl.min }}, {{ ctrl.max }}, {{ ctrl.default }},
+		(GParamFlags) (GST_PARAM_CONTROLLABLE | G_PARAM_READWRITE |
+			       G_PARAM_STATIC_STRINGS)
+	),
+{%- elif ctrl.gtype == 'boolean' %}
+	{{ ctrl.default }},
+{%- elif ctrl.gtype in ['float', 'int', 'int64', 'uchar'] %}
+	{{ ctrl.min }}, {{ ctrl.max }}, {{ ctrl.default }},
+{%- endif %}
+	(GParamFlags) (GST_PARAM_CONTROLLABLE | G_PARAM_READWRITE |
+		       G_PARAM_STATIC_STRINGS)
+)
+{%- endset %}
+
+	g_object_class_install_property(
+		klass,
+		lastPropId + controls::{{ ctrl.namespace }}{{ ctrl.name|snake_case|upper }},
+{%- if ctrl.is_array %}
+		gst_param_spec_array(
+			"{{ ctrl.vendor_prefix }}{{ ctrl.name|kebab_case }}",
+			"{{ ctrl.name }}",
+			{{ ctrl.description|format_description|indent_str('\t\t\t') }},
+			{{ spec|indent_str('\t\t\t') }},
+			(GParamFlags) (GST_PARAM_CONTROLLABLE |
+				       G_PARAM_READWRITE |
+				       G_PARAM_STATIC_STRINGS)
+		)
+{%- else %}
+		{{ spec|indent_str('\t\t') }}
+{%- endif %}
+	);
+{%- endfor %}
+{%- endfor %}
+}
+
+bool GstCameraControls::getProperty(guint propId, GValue *value,
+				    [[maybe_unused]] GParamSpec *pspec)
+{
+	if (!controls_acc_.contains(propId)) {
+		GST_WARNING("Control '%s' is not available, default value will "
+			    "be returned",
+			    controls::controls.at(propId)->name().c_str());
+		return true;
+	}
+	const ControlValue &cv = controls_acc_.get(propId);
+
+	switch (propId) {
+{%- for vendor, ctrls in controls %}
+{%- for ctrl in ctrls %}
+
+	case controls::{{ ctrl.namespace }}{{ ctrl.name|snake_case|upper }}: {
+		auto control = cv.get<{{ ctrl.type }}>();
+
+{%- if ctrl.is_array %}
+		for (size_t i = 0; i < control.size(); ++i) {
+			GValue element = G_VALUE_INIT;
+{%- if ctrl.is_rectangle %}
+			g_value_init(&element, GST_TYPE_PARAM_ARRAY_LIST);
+			value_set_rectangle(&element, control[i]);
+{%- else %}
+			g_value_init(&element, G_TYPE_{{ ctrl.gtype|upper }});
+			g_value_set_{{ ctrl.gtype }}(&element, control[i]);
+{%- endif %}
+			gst_value_array_append_and_take_value(value, &element);
+		}
+{%- else %}
+{%- if ctrl.is_rectangle %}
+		value_set_rectangle(value, control);
+{%- else %}
+		g_value_set_{{ ctrl.gtype }}(value, control);
+{%- endif %}
+{%- endif %}
+
+		return true;
+	}
+{%- endfor %}
+{%- endfor %}
+
+	default:
+		return false;
+	}
+}
+
+bool GstCameraControls::setProperty(guint propId, const GValue *value,
+				    [[maybe_unused]] GParamSpec *pspec)
+{
+	/*
+	 * Check whether the camera capabilities are already available.
+	 * They might not be available if the pipeline has not started yet.
+	 */
+	if (!capabilities_.empty()) {
+ 		/* If so, check that the control is supported by the camera. */
+		const ControlId *cid = capabilities_.idmap().at(propId);
+		auto info = capabilities_.find(cid);
+
+		if (info == capabilities_.end()) {
+			GST_WARNING("Control '%s' is not supported by the "
+				    "camera and will be ignored",
+				    cid->name().c_str());
+			return true;
+		}
+	}
+
+	switch (propId) {
+{%- for vendor, ctrls in controls %}
+{%- for ctrl in ctrls %}
+
+	case controls::{{ ctrl.namespace }}{{ ctrl.name|snake_case|upper }}: {
+		ControlValue control;
+{%- if ctrl.is_array %}
+		size_t size = gst_value_array_get_size(value);
+{%- if ctrl.size != 0 %}
+		if (size != {{ ctrl.size }}) {
+			GST_ERROR("Incorrect array size for control "
+				  "'{{ ctrl.name|kebab_case }}', must be of "
+				  "size {{ ctrl.size }}");
+			return true;
+		}
+{%- endif %}
+
+		std::vector<{{ ctrl.element_type }}> values(size);
+		for (size_t i = 0; i < size; ++i) {
+			const GValue *element =
+				gst_value_array_get_value(value, i);
+{%- if ctrl.is_rectangle %}
+			if (gst_value_array_get_size(element) != 4) {
+				GST_ERROR("Rectangle in control "
+					  "'{{ ctrl.name|kebab_case }}' at"
+					  "index %zu must be an array of size 4",
+					  i);
+				return true;
+			}
+			values[i] = value_get_rectangle(element);
+{%- else %}
+			values[i] = g_value_get_{{ ctrl.gtype }}(element);
+{%- endif %}
+		}
+
+{%- if ctrl.size == 0 %}
+		control.set(Span<const {{ ctrl.element_type }}>(values.data(),
+								size));
+{%- else %}
+		control.set(Span<const {{ ctrl.element_type }},
+			         {{ ctrl.size }}>(values.data(),
+						  {{ ctrl.size }}));
+{%- endif %}
+{%- else %}
+{%- if ctrl.is_rectangle %}
+		if (gst_value_array_get_size(value) != 4) {
+			GST_ERROR("Rectangle in control "
+				  "'{{ ctrl.name|kebab_case }}' must be an "
+				  "array of size 4");
+			return true;
+		}
+		Rectangle val = value_get_rectangle(value);
+{%- else %}
+		auto val = g_value_get_{{ ctrl.gtype }}(value);
+{%- endif %}
+		control.set(val);
+{%- endif %}
+		controls_.set(propId, control);
+		controls_acc_.set(propId, control);
+		return true;
+	}
+{%- endfor %}
+{%- endfor %}
+
+	default:
+		return false;
+	}
+}
+
+void GstCameraControls::setCamera(const std::shared_ptr<libcamera::Camera> &cam)
+{
+	capabilities_ = cam->controls();
+
+	/*
+	 * Check the controls which were set before the camera capabilities were
+	 * known. This is required because GStreamer may set properties before
+	 * the pipeline has started and thus before the camera was known.
+	 */
+	ControlList new_controls;
+	for (auto control = controls_acc_.begin();
+	     control != controls_acc_.end();
+	     ++control) {
+		unsigned int id = control->first;
+		ControlValue value = control->second;
+
+		const ControlId *cid = capabilities_.idmap().at(id);
+		auto info = capabilities_.find(cid);
+
+		/* Only add controls which are supported. */
+		if (info != capabilities_.end())
+			new_controls.set(id, value);
+		else
+			GST_WARNING("Control '%s' is not supported by the "
+				    "camera and will be ignored",
+				    cid->name().c_str());
+	}
+
+	controls_acc_ = new_controls;
+	controls_ = new_controls;
+}
+
+void GstCameraControls::applyControls(std::unique_ptr<libcamera::Request> &request)
+{
+	request->controls().merge(controls_);
+	controls_.clear();
+}
+
+void GstCameraControls::readMetadata(libcamera::Request *request)
+{
+	controls_acc_.merge(request->metadata(),
+			    ControlList::MergePolicy::OverwriteExisting);
+}
diff --git a/src/gstreamer/gstlibcamera-controls.h b/src/gstreamer/gstlibcamera-controls.h
new file mode 100644
index 00000000..749220b5
--- /dev/null
+++ b/src/gstreamer/gstlibcamera-controls.h
@@ -0,0 +1,43 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+/*
+ * Copyright (C) 2023, Collabora Ltd.
+ *     Author: Nicolas Dufresne <nicolas.dufresne@collabora.com>
+ *
+ * GStreamer Camera Controls
+ */
+
+#pragma once
+
+#include <memory>
+
+#include <libcamera/camera.h>
+#include <libcamera/controls.h>
+#include <libcamera/request.h>
+
+#include "gstlibcamerasrc.h"
+
+namespace libcamera {
+
+class GstCameraControls
+{
+public:
+	static void installProperties(GObjectClass *klass, int lastProp);
+
+	bool getProperty(guint propId, GValue *value, GParamSpec *pspec);
+	bool setProperty(guint propId, const GValue *value, GParamSpec *pspec);
+
+	void setCamera(const std::shared_ptr<libcamera::Camera> &cam);
+
+	void applyControls(std::unique_ptr<libcamera::Request> &request);
+	void readMetadata(libcamera::Request *request);
+
+private:
+	/* Supported controls and limits of camera. */
+	ControlInfoMap capabilities_;
+	/* Set of user modified controls. */
+	ControlList controls_;
+	/* Accumulator of all controls ever set and metadata returned by camera */
+	ControlList controls_acc_;
+};
+
+} /* namespace libcamera */
diff --git a/src/gstreamer/gstlibcamerasrc.cpp b/src/gstreamer/gstlibcamerasrc.cpp
index 40b787c8..8efa25f4 100644
--- a/src/gstreamer/gstlibcamerasrc.cpp
+++ b/src/gstreamer/gstlibcamerasrc.cpp
@@ -37,10 +37,11 @@
 
 #include <gst/base/base.h>
 
+#include "gstlibcamera-controls.h"
+#include "gstlibcamera-utils.h"
 #include "gstlibcameraallocator.h"
 #include "gstlibcamerapad.h"
 #include "gstlibcamerapool.h"
-#include "gstlibcamera-utils.h"
 
 using namespace libcamera;
 
@@ -128,6 +129,7 @@ struct GstLibcameraSrcState {
 
 	ControlList initControls_;
 	guint group_id_;
+	GstCameraControls controls_;
 
 	int queueRequest();
 	void requestCompleted(Request *request);
@@ -153,6 +155,7 @@ struct _GstLibcameraSrc {
 enum {
 	PROP_0,
 	PROP_CAMERA_NAME,
+	PROP_LAST
 };
 
 static void gst_libcamera_src_child_proxy_init(gpointer g_iface,
@@ -183,6 +186,9 @@ int GstLibcameraSrcState::queueRequest()
 	if (!request)
 		return -ENOMEM;
 
+	/* Apply controls */
+	controls_.applyControls(request);
+
 	std::unique_ptr<RequestWrap> wrap =
 		std::make_unique<RequestWrap>(std::move(request));
 
@@ -226,6 +232,9 @@ GstLibcameraSrcState::requestCompleted(Request *request)
 
 	{
 		GLibLocker locker(&lock_);
+
+		controls_.readMetadata(request);
+
 		wrap = std::move(queuedRequests_.front());
 		queuedRequests_.pop();
 	}
@@ -408,6 +417,8 @@ gst_libcamera_src_open(GstLibcameraSrc *self)
 		return false;
 	}
 
+	self->state->controls_.setCamera(cam);
+
 	cam->requestCompleted.connect(self->state, &GstLibcameraSrcState::requestCompleted);
 
 	/* No need to lock here, we didn't start our threads yet. */
@@ -722,6 +733,7 @@ gst_libcamera_src_set_property(GObject *object, guint prop_id,
 {
 	GLibLocker lock(GST_OBJECT(object));
 	GstLibcameraSrc *self = GST_LIBCAMERA_SRC(object);
+	GstLibcameraSrcState *state = self->state;
 
 	switch (prop_id) {
 	case PROP_CAMERA_NAME:
@@ -729,7 +741,8 @@ gst_libcamera_src_set_property(GObject *object, guint prop_id,
 		self->camera_name = g_value_dup_string(value);
 		break;
 	default:
-		G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
+		if (!state->controls_.setProperty(prop_id - PROP_LAST, value, pspec))
+			G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
 		break;
 	}
 }
@@ -740,13 +753,15 @@ gst_libcamera_src_get_property(GObject *object, guint prop_id, GValue *value,
 {
 	GLibLocker lock(GST_OBJECT(object));
 	GstLibcameraSrc *self = GST_LIBCAMERA_SRC(object);
+	GstLibcameraSrcState *state = self->state;
 
 	switch (prop_id) {
 	case PROP_CAMERA_NAME:
 		g_value_set_string(value, self->camera_name);
 		break;
 	default:
-		G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
+		if (!state->controls_.getProperty(prop_id - PROP_LAST, value, pspec))
+			G_OBJECT_WARN_INVALID_PROPERTY_ID(object, prop_id, pspec);
 		break;
 	}
 }
@@ -947,6 +962,7 @@ gst_libcamera_src_class_init(GstLibcameraSrcClass *klass)
 							     | G_PARAM_STATIC_STRINGS));
 	g_object_class_install_property(object_class, PROP_CAMERA_NAME, spec);
 
+	GstCameraControls::installProperties(object_class, PROP_LAST);
 }
 
 /* GstChildProxy implementation */
diff --git a/src/gstreamer/meson.build b/src/gstreamer/meson.build
index c2a01e7b..6b7e53b5 100644
--- a/src/gstreamer/meson.build
+++ b/src/gstreamer/meson.build
@@ -25,6 +25,16 @@ libcamera_gst_sources = [
     'gstlibcamerasrc.cpp',
 ]
 
+# Generate gstreamer control properties
+
+gen_gst_controls_template = files('gstlibcamera-controls.cpp.in')
+libcamera_gst_sources += custom_target('gstlibcamera-controls.cpp',
+                                       input : controls_files,
+                                       output : 'gstlibcamera-controls.cpp',
+                                       command : [gen_gst_controls, '-o', '@OUTPUT@',
+                                                  '-t', gen_gst_controls_template, '@INPUT@'],
+                                       env : py_build_env)
+
 libcamera_gst_cpp_args = [
     '-DVERSION="@0@"'.format(libcamera_git_version),
     '-DPACKAGE="@0@"'.format(meson.project_name()),
diff --git a/utils/codegen/controls.py b/utils/codegen/controls.py
index 7bafee59..03c77cc6 100644
--- a/utils/codegen/controls.py
+++ b/utils/codegen/controls.py
@@ -110,3 +110,11 @@ class Control(object):
             return f"Span<const {typ}, {self.__size}>"
         else:
             return f"Span<const {typ}>"
+
+    @property
+    def element_type(self):
+        return self.__data.get('type')
+
+    @property
+    def size(self):
+        return self.__size
diff --git a/utils/codegen/gen-gst-controls.py b/utils/codegen/gen-gst-controls.py
new file mode 100755
index 00000000..2601a675
--- /dev/null
+++ b/utils/codegen/gen-gst-controls.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-or-later
+# Copyright (C) 2019, Google Inc.
+# Copyright (C) 2024, Jaslo Ziska
+#
+# Authors:
+# Laurent Pinchart <laurent.pinchart@ideasonboard.com>
+# Jaslo Ziska <jaslo@ziska.de>
+#
+# Generate gstreamer control properties from YAML
+
+import argparse
+import jinja2
+import re
+import sys
+import yaml
+
+from controls import Control
+
+
+exposed_controls = [
+    'AeEnable', 'AeMeteringMode', 'AeConstraintMode', 'AeExposureMode',
+    'ExposureValue', 'ExposureTime', 'AnalogueGain', 'AeFlickerPeriod',
+    'Brightness', 'Contrast', 'AwbEnable', 'AwbMode', 'ColourGains',
+    'Saturation', 'Sharpness', 'ColourCorrectionMatrix', 'ScalerCrop',
+    'DigitalGain', 'AfMode', 'AfRange', 'AfSpeed', 'AfMetering', 'AfWindows',
+    'LensPosition', 'Gamma',
+]
+
+
+def find_common_prefix(strings):
+    prefix = strings[0]
+
+    for string in strings[1:]:
+        while string[:len(prefix)] != prefix and prefix:
+            prefix = prefix[:len(prefix) - 1]
+        if not prefix:
+            break
+
+    return prefix
+
+
+def format_description(description):
+    # Substitute doxygen keywords \sa (see also) and \todo
+    description = re.sub(r'\\sa((?: \w+)+)',
+                         lambda match: 'See also: ' + ', '.join(
+                             map(kebab_case, match.group(1).strip().split(' '))
+                         ) + '.', description)
+    description = re.sub(r'\\todo', 'Todo:', description)
+
+    description = description.strip().split('\n')
+    return '\n'.join([
+        '"' + line.replace('\\', r'\\').replace('"', r'\"') + ' "' for line in description if line
+    ]).rstrip()
+
+
+# Custom filter to allow indenting by a string prior to Jinja version 3.0
+#
+# This function can be removed and the calls to indent_str() replaced by the
+# built-in indent() filter when dropping Jinja versions older than 3.0
+def indent_str(s, indention):
+    s += '\n'
+
+    lines = s.splitlines()
+    rv = lines.pop(0)
+
+    if lines:
+        rv += '\n' + '\n'.join(
+            indention + line if line else line for line in lines
+        )
+
+    return rv
+
+
+def snake_case(s):
+    return ''.join([
+        c.isupper() and ('_' + c.lower()) or c for c in s
+    ]).strip('_')
+
+
+def kebab_case(s):
+    return snake_case(s).replace('_', '-')
+
+
+def extend_control(ctrl):
+    if ctrl.vendor != 'libcamera':
+        ctrl.namespace = f'{ctrl.vendor}::'
+        ctrl.vendor_prefix = f'{ctrl.vendor}-'
+    else:
+        ctrl.namespace = ''
+        ctrl.vendor_prefix = ''
+
+    ctrl.is_array = ctrl.size is not None
+
+    if ctrl.is_enum:
+        # Remove common prefix from enum variant names
+        prefix = find_common_prefix([enum.name for enum in ctrl.enum_values])
+        for enum in ctrl.enum_values:
+            enum.gst_name = kebab_case(enum.name.removeprefix(prefix))
+
+        ctrl.gtype = 'enum'
+        ctrl.default = '0'
+    elif ctrl.element_type == 'bool':
+        ctrl.gtype = 'boolean'
+        ctrl.default = 'false'
+    elif ctrl.element_type == 'float':
+        ctrl.gtype = 'float'
+        ctrl.default = '0'
+        ctrl.min = '-G_MAXFLOAT'
+        ctrl.max = 'G_MAXFLOAT'
+    elif ctrl.element_type == 'int32_t':
+        ctrl.gtype = 'int'
+        ctrl.default = '0'
+        ctrl.min = 'G_MININT'
+        ctrl.max = 'G_MAXINT'
+    elif ctrl.element_type == 'int64_t':
+        ctrl.gtype = 'int64'
+        ctrl.default = '0'
+        ctrl.min = 'G_MININT64'
+        ctrl.max = 'G_MAXINT64'
+    elif ctrl.element_type == 'uint8_t':
+        ctrl.gtype = 'uchar'
+        ctrl.default = '0'
+        ctrl.min = '0'
+        ctrl.max = 'G_MAXUINT8'
+    elif ctrl.element_type == 'Rectangle':
+        ctrl.is_rectangle = True
+        ctrl.default = '0'
+        ctrl.min = '0'
+        ctrl.max = 'G_MAXINT'
+    else:
+        raise RuntimeError(f'The type `{ctrl.element_type}` is unknown')
+
+    return ctrl
+
+
+def main(argv):
+    # Parse command line arguments
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--output', '-o', metavar='file', type=str,
+                        help='Output file name. Defaults to standard output if not specified.')
+    parser.add_argument('--template', '-t', dest='template', type=str, required=True,
+                        help='Template file name.')
+    parser.add_argument('input', type=str, nargs='+',
+                        help='Input file name.')
+
+    args = parser.parse_args(argv[1:])
+
+    controls = {}
+    for input in args.input:
+        data = yaml.safe_load(open(input, 'rb').read())
+
+        vendor = data['vendor']
+        ctrls = controls.setdefault(vendor, [])
+
+        for ctrl in data['controls']:
+            ctrl = Control(*ctrl.popitem(), vendor)
+
+            if ctrl.name in exposed_controls:
+                ctrls.append(extend_control(ctrl))
+
+    data = {'controls': list(controls.items())}
+
+    env = jinja2.Environment()
+    env.filters['format_description'] = format_description
+    env.filters['indent_str'] = indent_str
+    env.filters['snake_case'] = snake_case
+    env.filters['kebab_case'] = kebab_case
+    template = env.from_string(open(args.template, 'r', encoding='utf-8').read())
+    string = template.render(data)
+
+    if args.output:
+        with open(args.output, 'w', encoding='utf-8') as output:
+            output.write(string)
+    else:
+        sys.stdout.write(string)
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))
diff --git a/utils/codegen/meson.build b/utils/codegen/meson.build
index adf33bba..904dd66d 100644
--- a/utils/codegen/meson.build
+++ b/utils/codegen/meson.build
@@ -11,6 +11,7 @@ py_modules += ['jinja2', 'yaml']
 
 gen_controls = files('gen-controls.py')
 gen_formats = files('gen-formats.py')
+gen_gst_controls = files('gen-gst-controls.py')
 gen_header = files('gen-header.sh')
 gen_ipa_pub_key = files('gen-ipa-pub-key.py')
 gen_tracepoints = files('gen-tp-header.py')
