diff --git a/include/libcamera/meson.build b/include/libcamera/meson.build
index d90a8615e52d..a969a95dbf7a 100644
--- a/include/libcamera/meson.build
+++ b/include/libcamera/meson.build
@@ -88,6 +88,7 @@ foreach mode, entry : controls_map
                                      command : [gen_controls, '-o', '@OUTPUT@',
                                                 '--mode', mode, '-t', template_file,
                                                 '-r', ranges_file, '@INPUT@'],
+                                     env : py_build_env,
                                      install : true,
                                      install_dir : libcamera_headers_install_dir)
 endforeach
diff --git a/src/libcamera/meson.build b/src/libcamera/meson.build
index 3fd3a87e9f95..aa9ab0291854 100644
--- a/src/libcamera/meson.build
+++ b/src/libcamera/meson.build
@@ -151,7 +151,8 @@ foreach mode, inout_files : controls_mode_files
                                      output : output_file,
                                      command : [gen_controls, '-o', '@OUTPUT@',
                                                 '--mode', mode, '-t', template_file,
-                                                '-r', ranges_file, '@INPUT@'])
+                                                '-r', ranges_file, '@INPUT@'],
+                                     env : py_build_env)
 endforeach
 
 libcamera_public_sources += control_sources
diff --git a/utils/codegen/controls.py b/utils/codegen/controls.py
new file mode 100644
index 000000000000..7bafee599c80
--- /dev/null
+++ b/utils/codegen/controls.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-or-later
+# Copyright (C) 2019, Google Inc.
+#
+# Author: Laurent Pinchart <laurent.pinchart@ideasonboard.com>
+#
+# Helper classes to handle source code generation for libcamera controls
+
+
+class ControlEnum(object):
+    def __init__(self, data):
+        self.__data = data
+
+    @property
+    def description(self):
+        """The enum description"""
+        return self.__data.get('description')
+
+    @property
+    def name(self):
+        """The enum name"""
+        return self.__data.get('name')
+
+    @property
+    def value(self):
+        """The enum value"""
+        return self.__data.get('value')
+
+
+class Control(object):
+    def __init__(self, name, data, vendor):
+        self.__name = name
+        self.__data = data
+        self.__enum_values = None
+        self.__size = None
+        self.__vendor = vendor
+
+        enum_values = data.get('enum')
+        if enum_values is not None:
+            self.__enum_values = [ControlEnum(enum) for enum in enum_values]
+
+        size = self.__data.get('size')
+        if size is not None:
+            if len(size) == 0:
+                raise RuntimeError(f'Control `{self.__name}` size must have at least one dimension')
+
+            # Compute the total number of elements in the array. If any of the
+            # array dimension is a string, the array is variable-sized.
+            num_elems = 1
+            for dim in size:
+                if type(dim) is str:
+                    num_elems = 0
+                    break
+
+                dim = int(dim)
+                if dim <= 0:
+                    raise RuntimeError(f'Control `{self.__name}` size must have positive values only')
+
+                num_elems *= dim
+
+            self.__size = num_elems
+
+    @property
+    def description(self):
+        """The control description"""
+        return self.__data.get('description')
+
+    @property
+    def enum_values(self):
+        """The enum values, if the control is an enumeration"""
+        if self.__enum_values is None:
+            return
+        for enum in self.__enum_values:
+            yield enum
+
+    @property
+    def enum_values_count(self):
+        """The number of enum values, if the control is an enumeration"""
+        if self.__enum_values is None:
+            return 0
+        return len(self.__enum_values)
+
+    @property
+    def is_enum(self):
+        """Is the control an enumeration"""
+        return self.__enum_values is not None
+
+    @property
+    def vendor(self):
+        """The vendor string, or None"""
+        return self.__vendor
+
+    @property
+    def name(self):
+        """The control name (CamelCase)"""
+        return self.__name
+
+    @property
+    def type(self):
+        typ = self.__data.get('type')
+        size = self.__data.get('size')
+
+        if typ == 'string':
+            return 'std::string'
+
+        if self.__size is None:
+            return typ
+
+        if self.__size:
+            return f"Span<const {typ}, {self.__size}>"
+        else:
+            return f"Span<const {typ}>"
diff --git a/utils/codegen/gen-controls.py b/utils/codegen/gen-controls.py
index 685ef7a00d5f..2968eb9a5d4e 100755
--- a/utils/codegen/gen-controls.py
+++ b/utils/codegen/gen-controls.py
@@ -12,110 +12,7 @@ import os
 import sys
 import yaml
 
-
-class ControlEnum(object):
-    def __init__(self, data):
-        self.__data = data
-
-    @property
-    def description(self):
-        """The enum description"""
-        return self.__data.get('description')
-
-    @property
-    def name(self):
-        """The enum name"""
-        return self.__data.get('name')
-
-    @property
-    def value(self):
-        """The enum value"""
-        return self.__data.get('value')
-
-
-class Control(object):
-    def __init__(self, name, data, vendor):
-        self.__name = name
-        self.__data = data
-        self.__enum_values = None
-        self.__size = None
-        self.__vendor = vendor
-
-        enum_values = data.get('enum')
-        if enum_values is not None:
-            self.__enum_values = [ControlEnum(enum) for enum in enum_values]
-
-        size = self.__data.get('size')
-        if size is not None:
-            if len(size) == 0:
-                raise RuntimeError(f'Control `{self.__name}` size must have at least one dimension')
-
-            # Compute the total number of elements in the array. If any of the
-            # array dimension is a string, the array is variable-sized.
-            num_elems = 1
-            for dim in size:
-                if type(dim) is str:
-                    num_elems = 0
-                    break
-
-                dim = int(dim)
-                if dim <= 0:
-                    raise RuntimeError(f'Control `{self.__name}` size must have positive values only')
-
-                num_elems *= dim
-
-            self.__size = num_elems
-
-    @property
-    def description(self):
-        """The control description"""
-        return self.__data.get('description')
-
-    @property
-    def enum_values(self):
-        """The enum values, if the control is an enumeration"""
-        if self.__enum_values is None:
-            return
-        for enum in self.__enum_values:
-            yield enum
-
-    @property
-    def enum_values_count(self):
-        """The number of enum values, if the control is an enumeration"""
-        if self.__enum_values is None:
-            return 0
-        return len(self.__enum_values)
-
-    @property
-    def is_enum(self):
-        """Is the control an enumeration"""
-        return self.__enum_values is not None
-
-    @property
-    def vendor(self):
-        """The vendor string, or None"""
-        return self.__vendor
-
-    @property
-    def name(self):
-        """The control name (CamelCase)"""
-        return self.__name
-
-    @property
-    def type(self):
-        typ = self.__data.get('type')
-        size = self.__data.get('size')
-
-        if typ == 'string':
-            return 'std::string'
-
-        if self.__size is None:
-            return typ
-
-        if self.__size:
-            return f"Span<const {typ}, {self.__size}>"
-        else:
-            return f"Span<const {typ}>"
+from controls import Control
 
 
 def snake_case(s):
