diff --git a/include/ipa/ipa_interface.h b/include/ipa/ipa_interface.h
index dfb1bcbee516..8fd658af5fdb 100644
--- a/include/ipa/ipa_interface.h
+++ b/include/ipa/ipa_interface.h
@@ -43,7 +43,7 @@ public:
 	virtual int init() = 0;
 
 	virtual void configure(const std::map<unsigned int, IPAStream> &streamConfig,
-			       const std::map<unsigned int, V4L2ControlInfoMap> &entityControls) = 0;
+			       const std::map<unsigned int, ControlInfoMap> &entityControls) = 0;
 
 	virtual void mapBuffers(const std::vector<IPABuffer> &buffers) = 0;
 	virtual void unmapBuffers(const std::vector<unsigned int> &ids) = 0;
diff --git a/include/libcamera/controls.h b/include/libcamera/controls.h
index 5534a2edb567..80414c6f0748 100644
--- a/include/libcamera/controls.h
+++ b/include/libcamera/controls.h
@@ -118,7 +118,50 @@ private:
 };
 
 using ControlIdMap = std::unordered_map<unsigned int, const ControlId *>;
-using ControlInfoMap = std::unordered_map<const ControlId *, ControlRange>;
+
+class ControlInfoMap : private std::unordered_map<const ControlId *, ControlRange>
+{
+public:
+	using Map = std::unordered_map<const ControlId *, ControlRange>;
+
+	ControlInfoMap() = default;
+	ControlInfoMap(const ControlInfoMap &other) = default;
+	ControlInfoMap(std::initializer_list<Map::value_type> init);
+
+	ControlInfoMap &operator=(const ControlInfoMap &other) = default;
+	ControlInfoMap &operator=(std::initializer_list<Map::value_type> init);
+	ControlInfoMap &operator=(Map &&info);
+
+	using Map::key_type;
+	using Map::mapped_type;
+	using Map::value_type;
+	using Map::size_type;
+	using Map::iterator;
+	using Map::const_iterator;
+
+	using Map::begin;
+	using Map::cbegin;
+	using Map::end;
+	using Map::cend;
+	using Map::at;
+	using Map::empty;
+	using Map::size;
+	using Map::count;
+	using Map::find;
+
+	mapped_type &at(unsigned int key);
+	const mapped_type &at(unsigned int key) const;
+	size_type count(unsigned int key) const;
+	iterator find(unsigned int key);
+	const_iterator find(unsigned int key) const;
+
+	const ControlIdMap &idmap() const { return idmap_; }
+
+private:
+	void generateIdmap();
+
+	ControlIdMap idmap_;
+};
 
 class ControlList
 {
diff --git a/src/ipa/ipa_vimc.cpp b/src/ipa/ipa_vimc.cpp
index 620dc25f456e..63d578b4e2aa 100644
--- a/src/ipa/ipa_vimc.cpp
+++ b/src/ipa/ipa_vimc.cpp
@@ -31,7 +31,7 @@ public:
 
 	int init() override;
 	void configure(const std::map<unsigned int, IPAStream> &streamConfig,
-		       const std::map<unsigned int, V4L2ControlInfoMap> &entityControls) override {}
+		       const std::map<unsigned int, ControlInfoMap> &entityControls) override {}
 	void mapBuffers(const std::vector<IPABuffer> &buffers) override {}
 	void unmapBuffers(const std::vector<unsigned int> &ids) override {}
 	void processEvent(const IPAOperationData &event) override {}
diff --git a/src/ipa/rkisp1/rkisp1.cpp b/src/ipa/rkisp1/rkisp1.cpp
index d64c334cbf0c..bd703898c523 100644
--- a/src/ipa/rkisp1/rkisp1.cpp
+++ b/src/ipa/rkisp1/rkisp1.cpp
@@ -33,7 +33,7 @@ public:
 	int init() override { return 0; }
 
 	void configure(const std::map<unsigned int, IPAStream> &streamConfig,
-		       const std::map<unsigned int, V4L2ControlInfoMap> &entityControls) override;
+		       const std::map<unsigned int, ControlInfoMap> &entityControls) override;
 	void mapBuffers(const std::vector<IPABuffer> &buffers) override;
 	void unmapBuffers(const std::vector<unsigned int> &ids) override;
 	void processEvent(const IPAOperationData &event) override;
@@ -49,7 +49,7 @@ private:
 
 	std::map<unsigned int, BufferMemory> bufferInfo_;
 
-	V4L2ControlInfoMap ctrls_;
+	ControlInfoMap ctrls_;
 
 	/* Camera sensor controls. */
 	bool autoExposure_;
@@ -62,7 +62,7 @@ private:
 };
 
 void IPARkISP1::configure(const std::map<unsigned int, IPAStream> &streamConfig,
-			  const std::map<unsigned int, V4L2ControlInfoMap> &entityControls)
+			  const std::map<unsigned int, ControlInfoMap> &entityControls)
 {
 	if (entityControls.empty())
 		return;
diff --git a/src/libcamera/camera_sensor.cpp b/src/libcamera/camera_sensor.cpp
index 1b8e8c0e07da..86f0c772861b 100644
--- a/src/libcamera/camera_sensor.cpp
+++ b/src/libcamera/camera_sensor.cpp
@@ -251,7 +251,7 @@ int CameraSensor::setFormat(V4L2SubdeviceFormat *format)
  * \brief Retrieve the supported V4L2 controls and their information
  * \return A map of the V4L2 controls supported by the sensor
  */
-const V4L2ControlInfoMap &CameraSensor::controls() const
+const ControlInfoMap &CameraSensor::controls() const
 {
 	return subdev_->controls();
 }
diff --git a/src/libcamera/controls.cpp b/src/libcamera/controls.cpp
index 6a0301f3a2ae..bf7634aab470 100644
--- a/src/libcamera/controls.cpp
+++ b/src/libcamera/controls.cpp
@@ -393,10 +393,147 @@ std::string ControlRange::toString() const
  */
 
 /**
- * \typedef ControlInfoMap
+ * \class ControlInfoMap
  * \brief A map of ControlId to ControlRange
+ *
+ * The ControlInfoMap class describes controls supported by an object as an
+ * unsorted map of ControlId pointers to ControlRange instances. Unlike the
+ * standard std::unsorted_map<> class, it is designed the be immutable once
+ * constructed, and thus only exposes the read accessors of the
+ * std::unsorted_map<> base class.
+ *
+ * In addition to the features of the standard unsorted map, this class also
+ * provides access to the mapped elements using numerical ID keys. It maintains
+ * an internal map of numerical ID to ControlId for this purpose, and exposes it
+ * through the idmap() method to help construction of ControlList instances.
  */
 
+/**
+ * \typedef ControlInfoMap::Map
+ * \brief The base std::unsorted_map<> container
+ */
+
+/**
+ * \fn ControlInfoMap::ControlInfoMap(const ControlInfoMap &other)
+ * \brief Copy constructor, construct a ControlInfoMap from a copy of \a other
+ * \param[in] other The other ControlInfoMap
+ */
+
+/**
+ * \brief Construct a ControlInfoMap from an initializer list
+ * \param[in] init The initializer list
+ */
+ControlInfoMap::ControlInfoMap(std::initializer_list<Map::value_type> init)
+	: Map(init)
+{
+	generateIdmap();
+}
+
+/**
+ * \fn ControlInfoMap &ControlInfoMap::operator=(const ControlInfoMap &other)
+ * \brief Copy assignment operator, replace the contents with a copy of \a other
+ * \param[in] other The other ControlInfoMap
+ * \return A reference to the ControlInfoMap
+ */
+
+/**
+ * \brief Replace the contents with those from the initializer list
+ * \param[in] init The initializer list
+ * \return A reference to the ControlInfoMap
+ */
+ControlInfoMap &ControlInfoMap::operator=(std::initializer_list<Map::value_type> init)
+{
+	Map::operator=(init);
+	generateIdmap();
+	return *this;
+}
+
+/**
+ * \brief Move assignment operator from a plain map
+ * \param[in] info The control info plain map
+ *
+ * Populate the map by replacing its contents with those of \a info using move
+ * semantics. Upon return the \a info map will be empty.
+ *
+ * \return A reference to the populated ControlInfoMap
+ */
+ControlInfoMap &ControlInfoMap::operator=(Map &&info)
+{
+	Map::operator=(std::move(info));
+	generateIdmap();
+	return *this;
+}
+
+/**
+ * \brief Access specified element by numerical ID
+ * \param[in] id The numerical ID
+ * \return A reference to the element whose ID is equal to \a id
+ */
+ControlInfoMap::mapped_type &ControlInfoMap::at(unsigned int id)
+{
+	return at(idmap_.at(id));
+}
+
+/**
+ * \brief Access specified element by numerical ID
+ * \param[in] id The numerical ID
+ * \return A const reference to the element whose ID is equal to \a id
+ */
+const ControlInfoMap::mapped_type &ControlInfoMap::at(unsigned int id) const
+{
+	return at(idmap_.at(id));
+}
+
+/**
+ * \brief Count the number of elements matching a numerical ID
+ * \param[in] id The numerical ID
+ * \return The number of elements matching the numerical \a id
+ */
+ControlInfoMap::size_type ControlInfoMap::count(unsigned int id) const
+{
+	return count(idmap_.at(id));
+}
+
+/**
+ * \brief Find the element matching a numerical ID
+ * \param[in] id The numerical ID
+ * \return An iterator pointing to the element matching the numerical \a id, or
+ * end() if no such element exists
+ */
+ControlInfoMap::iterator ControlInfoMap::find(unsigned int id)
+{
+	return find(idmap_.at(id));
+}
+
+/**
+ * \brief Find the element matching a numerical ID
+ * \param[in] id The numerical ID
+ * \return A const iterator pointing to the element matching the numerical
+ * \a id, or end() if no such element exists
+ */
+ControlInfoMap::const_iterator ControlInfoMap::find(unsigned int id) const
+{
+	return find(idmap_.at(id));
+}
+
+/**
+ * \fn const ControlIdMap &ControlInfoMap::idmap() const
+ * \brief Retrieve the ControlId map
+ *
+ * Constructing ControlList instances for V4L2 controls requires a ControlIdMap
+ * for the V4L2 device that the control list targets. This helper method
+ * returns a suitable idmap for that purpose.
+ *
+ * \return The ControlId map
+ */
+
+void ControlInfoMap::generateIdmap()
+{
+	idmap_.clear();
+	for (const auto &ctrl : *this)
+		idmap_[ctrl.first->id()] = ctrl.first;
+}
+
 /**
  * \class ControlList
  * \brief Associate a list of ControlId with their values for an object
diff --git a/src/libcamera/include/camera_sensor.h b/src/libcamera/include/camera_sensor.h
index f426e29b84bf..1fb36a4f4951 100644
--- a/src/libcamera/include/camera_sensor.h
+++ b/src/libcamera/include/camera_sensor.h
@@ -16,9 +16,9 @@
 
 namespace libcamera {
 
+class ControlInfoMap;
 class ControlList;
 class MediaEntity;
-class V4L2ControlInfoMap;
 class V4L2Subdevice;
 
 struct V4L2SubdeviceFormat;
@@ -43,7 +43,7 @@ public:
 				      const Size &size) const;
 	int setFormat(V4L2SubdeviceFormat *format);
 
-	const V4L2ControlInfoMap &controls() const;
+	const ControlInfoMap &controls() const;
 	int getControls(ControlList *ctrls);
 	int setControls(ControlList *ctrls);
 
diff --git a/src/libcamera/include/v4l2_controls.h b/src/libcamera/include/v4l2_controls.h
index c427b845d8b4..e16c4957f9d1 100644
--- a/src/libcamera/include/v4l2_controls.h
+++ b/src/libcamera/include/v4l2_controls.h
@@ -31,44 +31,10 @@ public:
 	V4L2ControlRange(const struct v4l2_query_ext_ctrl &ctrl);
 };
 
-class V4L2ControlInfoMap : private ControlInfoMap
-{
-public:
-	V4L2ControlInfoMap &operator=(ControlInfoMap &&info);
-
-	using ControlInfoMap::key_type;
-	using ControlInfoMap::mapped_type;
-	using ControlInfoMap::value_type;
-	using ControlInfoMap::size_type;
-	using ControlInfoMap::iterator;
-	using ControlInfoMap::const_iterator;
-
-	using ControlInfoMap::begin;
-	using ControlInfoMap::cbegin;
-	using ControlInfoMap::end;
-	using ControlInfoMap::cend;
-	using ControlInfoMap::at;
-	using ControlInfoMap::empty;
-	using ControlInfoMap::size;
-	using ControlInfoMap::count;
-	using ControlInfoMap::find;
-
-	mapped_type &at(unsigned int key);
-	const mapped_type &at(unsigned int key) const;
-	size_type count(unsigned int key) const;
-	iterator find(unsigned int key);
-	const_iterator find(unsigned int key) const;
-
-	const ControlIdMap &idmap() const { return idmap_; }
-
-private:
-	ControlIdMap idmap_;
-};
-
 class V4L2ControlList : public ControlList
 {
 public:
-	V4L2ControlList(const V4L2ControlInfoMap &info)
+	V4L2ControlList(const ControlInfoMap &info)
 		: ControlList(info.idmap())
 	{
 	}
diff --git a/src/libcamera/include/v4l2_device.h b/src/libcamera/include/v4l2_device.h
index f30b1c2cde34..6bfddefe336c 100644
--- a/src/libcamera/include/v4l2_device.h
+++ b/src/libcamera/include/v4l2_device.h
@@ -24,7 +24,7 @@ public:
 	void close();
 	bool isOpen() const { return fd_ != -1; }
 
-	const V4L2ControlInfoMap &controls() const { return controls_; }
+	const ControlInfoMap &controls() const { return controls_; }
 
 	int getControls(ControlList *ctrls);
 	int setControls(ControlList *ctrls);
@@ -49,7 +49,7 @@ private:
 			    unsigned int count);
 
 	std::vector<std::unique_ptr<V4L2ControlId>> controlIds_;
-	V4L2ControlInfoMap controls_;
+	ControlInfoMap controls_;
 	std::string deviceNode_;
 	int fd_;
 };
diff --git a/src/libcamera/pipeline/rkisp1/rkisp1.cpp b/src/libcamera/pipeline/rkisp1/rkisp1.cpp
index 9b19bde8a274..7a28b03b8d38 100644
--- a/src/libcamera/pipeline/rkisp1/rkisp1.cpp
+++ b/src/libcamera/pipeline/rkisp1/rkisp1.cpp
@@ -776,7 +776,7 @@ int PipelineHandlerRkISP1::start(Camera *camera)
 		.size = data->stream_.configuration().size,
 	};
 
-	std::map<unsigned int, V4L2ControlInfoMap> entityControls;
+	std::map<unsigned int, ControlInfoMap> entityControls;
 	entityControls.emplace(0, data->sensor_->controls());
 
 	data->ipa_->configure(streamConfig, entityControls);
@@ -875,9 +875,12 @@ int PipelineHandlerRkISP1::createCamera(MediaEntity *sensor)
 	std::unique_ptr<RkISP1CameraData> data =
 		utils::make_unique<RkISP1CameraData>(this);
 
-	data->controlInfo_.emplace(std::piecewise_construct,
-				   std::forward_as_tuple(&controls::AeEnable),
-				   std::forward_as_tuple(false, true));
+	ControlInfoMap::Map ctrls;
+	ctrls.emplace(std::piecewise_construct,
+		      std::forward_as_tuple(&controls::AeEnable),
+		      std::forward_as_tuple(false, true));
+
+	data->controlInfo_ = std::move(ctrls);
 
 	data->sensor_ = new CameraSensor(sensor);
 	ret = data->sensor_->init();
diff --git a/src/libcamera/pipeline/uvcvideo.cpp b/src/libcamera/pipeline/uvcvideo.cpp
index 018c7691d263..a64e1af4c550 100644
--- a/src/libcamera/pipeline/uvcvideo.cpp
+++ b/src/libcamera/pipeline/uvcvideo.cpp
@@ -335,7 +335,9 @@ int UVCCameraData::init(MediaEntity *entity)
 	video_->bufferReady.connect(this, &UVCCameraData::bufferReady);
 
 	/* Initialise the supported controls. */
-	const V4L2ControlInfoMap &controls = video_->controls();
+	const ControlInfoMap &controls = video_->controls();
+	ControlInfoMap::Map ctrls;
+
 	for (const auto &ctrl : controls) {
 		const ControlRange &range = ctrl.second;
 		const ControlId *id;
@@ -360,11 +362,13 @@ int UVCCameraData::init(MediaEntity *entity)
 			continue;
 		}
 
-		controlInfo_.emplace(std::piecewise_construct,
-				     std::forward_as_tuple(id),
-				     std::forward_as_tuple(range));
+		ctrls.emplace(std::piecewise_construct,
+			      std::forward_as_tuple(id),
+			      std::forward_as_tuple(range));
 	}
 
+	controlInfo_ = std::move(ctrls);
+
 	return 0;
 }
 
diff --git a/src/libcamera/pipeline/vimc.cpp b/src/libcamera/pipeline/vimc.cpp
index f7f82edc6530..6a4244119dc5 100644
--- a/src/libcamera/pipeline/vimc.cpp
+++ b/src/libcamera/pipeline/vimc.cpp
@@ -411,7 +411,9 @@ int VimcCameraData::init(MediaDevice *media)
 		return -ENODEV;
 
 	/* Initialise the supported controls. */
-	const V4L2ControlInfoMap &controls = sensor_->controls();
+	const ControlInfoMap &controls = sensor_->controls();
+	ControlInfoMap::Map ctrls;
+
 	for (const auto &ctrl : controls) {
 		const ControlRange &range = ctrl.second;
 		const ControlId *id;
@@ -430,11 +432,12 @@ int VimcCameraData::init(MediaDevice *media)
 			continue;
 		}
 
-		controlInfo_.emplace(std::piecewise_construct,
-				     std::forward_as_tuple(id),
-				     std::forward_as_tuple(range));
+		ctrls.emplace(std::piecewise_construct,
+			      std::forward_as_tuple(id),
+			      std::forward_as_tuple(range));
 	}
 
+	controlInfo_ = std::move(ctrls);
 	return 0;
 }
 
diff --git a/src/libcamera/proxy/ipa_proxy_linux.cpp b/src/libcamera/proxy/ipa_proxy_linux.cpp
index 41bd965f0284..4e6fa6899e07 100644
--- a/src/libcamera/proxy/ipa_proxy_linux.cpp
+++ b/src/libcamera/proxy/ipa_proxy_linux.cpp
@@ -28,7 +28,7 @@ public:
 
 	int init() override { return 0; }
 	void configure(const std::map<unsigned int, IPAStream> &streamConfig,
-		       const std::map<unsigned int, V4L2ControlInfoMap> &entityControls) override {}
+		       const std::map<unsigned int, ControlInfoMap> &entityControls) override {}
 	void mapBuffers(const std::vector<IPABuffer> &buffers) override {}
 	void unmapBuffers(const std::vector<unsigned int> &ids) override {}
 	void processEvent(const IPAOperationData &event) override {}
diff --git a/src/libcamera/v4l2_controls.cpp b/src/libcamera/v4l2_controls.cpp
index e783457caf94..bd5e18590b76 100644
--- a/src/libcamera/v4l2_controls.cpp
+++ b/src/libcamera/v4l2_controls.cpp
@@ -134,102 +134,12 @@ V4L2ControlRange::V4L2ControlRange(const struct v4l2_query_ext_ctrl &ctrl)
 						     static_cast<int32_t>(ctrl.maximum)));
 }
 
-/**
- * \class V4L2ControlInfoMap
- * \brief A map of controlID to ControlRange for V4L2 controls
- */
-
-/**
- * \brief Move assignment operator from a ControlInfoMap
- * \param[in] info The control info map
- *
- * Populate the map by replacing its contents with those of \a info using move
- * semantics. Upon return the \a info map will be empty.
- *
- * This is the only supported way to populate a V4L2ControlInfoMap.
- *
- * \return The populated V4L2ControlInfoMap
- */
-V4L2ControlInfoMap &V4L2ControlInfoMap::operator=(ControlInfoMap &&info)
-{
-	ControlInfoMap::operator=(std::move(info));
-
-	idmap_.clear();
-	for (const auto &ctrl : *this)
-		idmap_[ctrl.first->id()] = ctrl.first;
-
-	return *this;
-}
-
-/**
- * \brief Access specified element by numerical ID
- * \param[in] id The numerical ID
- * \return A reference to the element whose ID is equal to \a id
- */
-V4L2ControlInfoMap::mapped_type &V4L2ControlInfoMap::at(unsigned int id)
-{
-	return at(idmap_.at(id));
-}
-
-/**
- * \brief Access specified element by numerical ID
- * \param[in] id The numerical ID
- * \return A const reference to the element whose ID is equal to \a id
- */
-const V4L2ControlInfoMap::mapped_type &V4L2ControlInfoMap::at(unsigned int id) const
-{
-	return at(idmap_.at(id));
-}
-
-/**
- * \brief Count the number of elements matching a numerical ID
- * \param[in] id The numerical ID
- * \return The number of elements matching the numerical \a id
- */
-V4L2ControlInfoMap::size_type V4L2ControlInfoMap::count(unsigned int id) const
-{
-	return count(idmap_.at(id));
-}
-
-/**
- * \brief Find the element matching a numerical ID
- * \param[in] id The numerical ID
- * \return An iterator pointing to the element matching the numerical \a id, or
- * end() if no such element exists
- */
-V4L2ControlInfoMap::iterator V4L2ControlInfoMap::find(unsigned int id)
-{
-	return find(idmap_.at(id));
-}
-
-/**
- * \brief Find the element matching a numerical ID
- * \param[in] id The numerical ID
- * \return A const iterator pointing to the element matching the numerical
- * \a id, or end() if no such element exists
- */
-V4L2ControlInfoMap::const_iterator V4L2ControlInfoMap::find(unsigned int id) const
-{
-	return find(idmap_.at(id));
-}
-
-/**
- * \fn const ControlIdMap &V4L2ControlInfoMap::idmap() const
- * \brief Retrieve the ControlId map
- *
- * Constructing ControlList instances for V4L2 controls requires a ControlIdMap
- * for the V4L2 device that the control list targets. This helper method
- * returns a suitable idmap for that purpose.
- *
- * \return The ControlId map
- */
-
 /**
  * \class V4L2ControlList
  * \brief A list of controls for a V4L2 device
  *
  * This class specialises the ControList class for use with V4L2 devices. It
- * offers a convenience API to create a ControlList from a V4L2ControlInfoMap.
+ * offers a convenience API to create a ControlList from a ControlInfoMap.
  *
  * V4L2ControlList allows easy construction of a ControlList containing V4L2
  * controls for a device. It can be used to construct the list of controls
@@ -239,7 +149,7 @@ V4L2ControlInfoMap::const_iterator V4L2ControlInfoMap::find(unsigned int id) con
  */
 
 /**
- * \fn V4L2ControlList::V4L2ControlList(const V4L2ControlInfoMap &info)
+ * \fn V4L2ControlList::V4L2ControlList(const ControlInfoMap &info)
  * \brief Construct a V4L2ControlList associated with a V4L2 device
  * \param[in] info The V4L2 device control info map
  */
diff --git a/src/libcamera/v4l2_device.cpp b/src/libcamera/v4l2_device.cpp
index 06155eb51c03..a2b0d891b12f 100644
--- a/src/libcamera/v4l2_device.cpp
+++ b/src/libcamera/v4l2_device.cpp
@@ -342,7 +342,7 @@ int V4L2Device::ioctl(unsigned long request, void *argp)
  */
 void V4L2Device::listControls()
 {
-	ControlInfoMap ctrls;
+	ControlInfoMap::Map ctrls;
 	struct v4l2_query_ext_ctrl ctrl = {};
 
 	/* \todo Add support for menu and compound controls. */
diff --git a/test/v4l2_videodevice/controls.cpp b/test/v4l2_videodevice/controls.cpp
index 182228f3a5b1..31879b794ed0 100644
--- a/test/v4l2_videodevice/controls.cpp
+++ b/test/v4l2_videodevice/controls.cpp
@@ -26,7 +26,7 @@ public:
 protected:
 	int run()
 	{
-		const V4L2ControlInfoMap &info = capture_->controls();
+		const ControlInfoMap &info = capture_->controls();
 
 		/* Test control enumeration. */
 		if (info.empty()) {
