diff --git a/include/libcamera/controls.h b/include/libcamera/controls.h
index cf94205577a5..488663a7ba04 100644
--- a/include/libcamera/controls.h
+++ b/include/libcamera/controls.h
@@ -270,7 +270,8 @@ class ControlInfo
 public:
 	explicit ControlInfo(const ControlValue &min = {},
 			     const ControlValue &max = {},
-			     const ControlValue &def = {});
+			     const ControlValue &def = {},
+			     bool readOnly = false);
 	explicit ControlInfo(Span<const ControlValue> values,
 			     const ControlValue &def = {});
 	explicit ControlInfo(std::set<bool> values, bool def);
@@ -279,6 +280,7 @@ public:
 	const ControlValue &min() const { return min_; }
 	const ControlValue &max() const { return max_; }
 	const ControlValue &def() const { return def_; }
+	bool readOnly() const { return readOnly_; }
 	const std::vector<ControlValue> &values() const { return values_; }
 
 	std::string toString() const;
@@ -297,6 +299,7 @@ private:
 	ControlValue min_;
 	ControlValue max_;
 	ControlValue def_;
+	bool readOnly_;
 	std::vector<ControlValue> values_;
 };
 
diff --git a/src/libcamera/controls.cpp b/src/libcamera/controls.cpp
index b808116c01e5..8af304bdeac9 100644
--- a/src/libcamera/controls.cpp
+++ b/src/libcamera/controls.cpp
@@ -484,11 +484,13 @@ void ControlValue::reserve(ControlType type, bool isArray, std::size_t numElemen
  * \param[in] min The control minimum value
  * \param[in] max The control maximum value
  * \param[in] def The control default value
+ * \param[in] readOnly True if the control reports a dynamic property
  */
 ControlInfo::ControlInfo(const ControlValue &min,
 			 const ControlValue &max,
-			 const ControlValue &def)
-	: min_(min), max_(max), def_(def)
+			 const ControlValue &def,
+			 bool readOnly)
+	: min_(min), max_(max), def_(def), readOnly_(readOnly)
 {
 }
 
@@ -508,6 +510,7 @@ ControlInfo::ControlInfo(Span<const ControlValue> values,
 	min_ = values.front();
 	max_ = values.back();
 	def_ = !def.isNone() ? def : values.front();
+	readOnly_ = false;
 
 	values_.reserve(values.size());
 	for (const ControlValue &value : values)
@@ -525,7 +528,8 @@ ControlInfo::ControlInfo(Span<const ControlValue> values,
  * default value is \a def.
  */
 ControlInfo::ControlInfo(std::set<bool> values, bool def)
-	: min_(false), max_(true), def_(def), values_({ false, true })
+	: min_(false), max_(true), def_(def), readOnly_(false),
+	  values_({ false, true })
 {
 	ASSERT(values.count(def) && values.size() == 2);
 }
@@ -538,7 +542,7 @@ ControlInfo::ControlInfo(std::set<bool> values, bool def)
  * value. The minimum, maximum, and default values will all be \a value.
  */
 ControlInfo::ControlInfo(bool value)
-	: min_(value), max_(value), def_(value)
+	: min_(value), max_(value), def_(value), readOnly_(true)
 {
 	values_ = { value };
 }
@@ -571,6 +575,16 @@ ControlInfo::ControlInfo(bool value)
  * \return A ControlValue with the default value for the control
  */
 
+/**
+ * \fn ControlInfo::readOnly()
+ * \brief Identifies if a control is flagged as read-only
+ * \return True if the control is read-only, false otherwise
+ *
+ * Read-only controls may signify that the control is a volatile property and
+ * can not be set. Adding a read-only control to a control list may cause the
+ * list to fail when the list is submitted.
+ */
+
 /**
  * \fn ControlInfo::values()
  * \brief Retrieve the list of valid values
diff --git a/src/libcamera/v4l2_device.cpp b/src/libcamera/v4l2_device.cpp
index 24d208ef77dc..8563bbd2595f 100644
--- a/src/libcamera/v4l2_device.cpp
+++ b/src/libcamera/v4l2_device.cpp
@@ -536,17 +536,20 @@ std::optional<ControlInfo> V4L2Device::v4l2ControlInfo(const v4l2_query_ext_ctrl
 	case V4L2_CTRL_TYPE_U8:
 		return ControlInfo(static_cast<uint8_t>(ctrl.minimum),
 				   static_cast<uint8_t>(ctrl.maximum),
-				   static_cast<uint8_t>(ctrl.default_value));
+				   static_cast<uint8_t>(ctrl.default_value),
+				   !!(ctrl.flags & V4L2_CTRL_FLAG_READ_ONLY));
 
 	case V4L2_CTRL_TYPE_BOOLEAN:
 		return ControlInfo(static_cast<bool>(ctrl.minimum),
 				   static_cast<bool>(ctrl.maximum),
-				   static_cast<bool>(ctrl.default_value));
+				   static_cast<bool>(ctrl.default_value),
+				   !!(ctrl.flags & V4L2_CTRL_FLAG_READ_ONLY));
 
 	case V4L2_CTRL_TYPE_INTEGER64:
 		return ControlInfo(static_cast<int64_t>(ctrl.minimum),
 				   static_cast<int64_t>(ctrl.maximum),
-				   static_cast<int64_t>(ctrl.default_value));
+				   static_cast<int64_t>(ctrl.default_value),
+				   !!(ctrl.flags & V4L2_CTRL_FLAG_READ_ONLY));
 
 	case V4L2_CTRL_TYPE_INTEGER_MENU:
 	case V4L2_CTRL_TYPE_MENU:
@@ -555,7 +558,8 @@ std::optional<ControlInfo> V4L2Device::v4l2ControlInfo(const v4l2_query_ext_ctrl
 	default:
 		return ControlInfo(static_cast<int32_t>(ctrl.minimum),
 				   static_cast<int32_t>(ctrl.maximum),
-				   static_cast<int32_t>(ctrl.default_value));
+				   static_cast<int32_t>(ctrl.default_value),
+				   !!(ctrl.flags & V4L2_CTRL_FLAG_READ_ONLY));
 	}
 }
 
