diff --git a/src/ipa/rpi/controller/agc_status.h b/src/ipa/rpi/controller/agc_status.h
index d4cedcf49c3c..956d6abf398c 100644
--- a/src/ipa/rpi/controller/agc_status.h
+++ b/src/ipa/rpi/controller/agc_status.h
@@ -30,6 +30,7 @@ struct AgcStatus {
 	libcamera::utils::Duration targetExposureValue; /* (unfiltered) target total exposure AGC is aiming for */
 	libcamera::utils::Duration exposureTime;
 	double analogueGain;
+	double digitalGain;
 	std::string exposureMode;
 	std::string constraintMode;
 	std::string meteringMode;
diff --git a/src/ipa/rpi/controller/rpi/agc_channel.cpp b/src/ipa/rpi/controller/rpi/agc_channel.cpp
index 9e4661616051..7da1c6dbbec7 100644
--- a/src/ipa/rpi/controller/rpi/agc_channel.cpp
+++ b/src/ipa/rpi/controller/rpi/agc_channel.cpp
@@ -266,7 +266,7 @@ int AgcConfig::read(const libcamera::YamlObject &params)
 }
 
 AgcChannel::ExposureValues::ExposureValues()
-	: exposureTime(0s), analogueGain(0),
+	: exposureTime(0s), analogueGain(0), digitalGain(0),
 	  totalExposure(0s), totalExposureNoDG(0s)
 {
 }
@@ -434,17 +434,10 @@ void AgcChannel::switchMode(CameraMode const &cameraMode,
 	mode_ = cameraMode;
 
 	Duration fixedExposureTime = limitExposureTime(fixedExposureTime_);
+	double fixedGain = limitGain(fixedGain_);
 	if (fixedExposureTime && fixedGain_) {
-		/* This is the equivalent of computeTargetExposure and applyDigitalGain. */
-		target_.totalExposureNoDG = fixedExposureTime_ * fixedGain_;
-		target_.totalExposure = target_.totalExposureNoDG;
-
-		/* Equivalent of filterExposure. This resets any "history". */
-		filtered_ = target_;
-
-		/* Equivalent of divideUpExposure. */
-		filtered_.exposureTime = fixedExposureTime;
-		filtered_.analogueGain = fixedGain_;
+		filtered_.totalExposureNoDG = fixedExposureTime * fixedGain;
+		filtered_.totalExposure = filtered_.totalExposureNoDG;
 	} else if (status_.totalExposureValue) {
 		/*
 		 * On a mode switch, various things could happen:
@@ -457,12 +450,8 @@ void AgcChannel::switchMode(CameraMode const &cameraMode,
 		 */
 
 		double ratio = lastSensitivity / cameraMode.sensitivity;
-		target_.totalExposure *= ratio;
-		target_.totalExposureNoDG = target_.totalExposure;
 		filtered_.totalExposure *= ratio;
 		filtered_.totalExposureNoDG = filtered_.totalExposure;
-
-		divideUpExposure();
 	} else {
 		/*
 		 * We come through here on startup, when at least one of the
@@ -472,55 +461,46 @@ void AgcChannel::switchMode(CameraMode const &cameraMode,
 		 * weren't set.
 		 */
 
-		/* Equivalent of divideUpExposure. */
-		filtered_.exposureTime = fixedExposureTime ? fixedExposureTime : config_.defaultExposureTime;
-		filtered_.analogueGain = fixedGain_ ? fixedGain_ : config_.defaultAnalogueGain;
+		Duration exposureTime = fixedExposureTime ? fixedExposureTime : config_.defaultExposureTime;
+		double gain = fixedGain ? fixedGain : config_.defaultAnalogueGain;
+		filtered_.totalExposure = exposureTime * gain;
+		filtered_.totalExposureNoDG = filtered_.totalExposure;
 	}
 
+	/* Setting target_ to filtered_ removes any history from before the mode switch. */
+	target_ = filtered_;
+	divideUpExposure();
+
 	writeAndFinish(metadata, false);
 }
 
 void AgcChannel::prepare(Metadata *imageMetadata)
 {
-	Duration totalExposureValue = status_.totalExposureValue;
-	AgcStatus delayedStatus;
+	DeviceStatus deviceStatus;
 	AgcPrepareStatus prepareStatus;
 
-	/* Fetch the AWB status now because AWB also sets it in the prepare method. */
-	fetchAwbStatus(imageMetadata);
-
-	if (!imageMetadata->get("agc.delayed_status", delayedStatus))
-		totalExposureValue = delayedStatus.totalExposureValue;
-
-	prepareStatus.digitalGain = 1.0;
 	prepareStatus.locked = false;
+	prepareStatus.digitalGain = 1.0;
 
-	if (status_.totalExposureValue) {
-		/* Process has run, so we have meaningful values. */
-		DeviceStatus deviceStatus;
-		if (imageMetadata->get("device.status", deviceStatus) == 0) {
-			Duration actualExposure = deviceStatus.exposureTime *
-						  deviceStatus.analogueGain;
-			if (actualExposure) {
-				double digitalGain = totalExposureValue / actualExposure;
-				LOG(RPiAgc, Debug) << "Want total exposure " << totalExposureValue;
-				/*
-				 * Never ask for a gain < 1.0, and also impose
-				 * some upper limit. Make it customisable?
-				 */
-				prepareStatus.digitalGain = std::max(1.0, std::min(digitalGain,
-										   config_.maxDigitalGain));
-				LOG(RPiAgc, Debug) << "Actual exposure " << actualExposure;
-				LOG(RPiAgc, Debug) << "Use digitalGain " << prepareStatus.digitalGain;
-				LOG(RPiAgc, Debug) << "Effective exposure "
-						   << actualExposure * prepareStatus.digitalGain;
-				/* Decide whether AEC/AGC has converged. */
-				prepareStatus.locked = updateLockStatus(deviceStatus);
-			}
-		} else
-			LOG(RPiAgc, Warning) << "AgcChannel: no device metadata";
-		imageMetadata->set("agc.prepare_status", prepareStatus);
+	if (!imageMetadata->get("device.status", deviceStatus)) {
+		prepareStatus.locked = updateLockStatus(deviceStatus);
+
+		/*
+		 * For now, the IPA code is still expecting the digital gain to come back in
+		 * the prepare_status. To keep things happy, we'll just fill in the value that
+		 * we calculated previously and put in the AgcStatus (which comes back as the
+		 * "delayed" status). Once the rest of the IPA code is updated, we'll be able
+		 * to remove this, and indeed remove the digitalGain from the AgcPrepareStatus.
+		 */
+		AgcStatus delayedStatus;
+		if (!imageMetadata->get("agc.delayed_status", delayedStatus))
+			prepareStatus.digitalGain = delayedStatus.digitalGain;
+		else
+			/* After a mode switch, this must be correct until new values come through. */
+			prepareStatus.digitalGain = status_.digitalGain;
 	}
+
+	imageMetadata->set("agc.prepare_status", prepareStatus);
 }
 
 void AgcChannel::process(StatisticsPtr &stats, DeviceStatus const &deviceStatus,
@@ -606,7 +586,7 @@ void AgcChannel::housekeepConfig()
 	/* First fetch all the up-to-date settings, so no one else has to do it. */
 	status_.ev = ev_;
 	status_.fixedExposureTime = limitExposureTime(fixedExposureTime_);
-	status_.fixedGain = fixedGain_;
+	status_.fixedGain = limitGain(fixedGain_);
 	status_.flickerPeriod = flickerPeriod_;
 	LOG(RPiAgc, Debug) << "ev " << status_.ev << " fixedExposureTime "
 			   << status_.fixedExposureTime << " fixedGain "
@@ -657,6 +637,9 @@ void AgcChannel::fetchCurrentExposure(DeviceStatus const &deviceStatus)
 	current_.analogueGain = deviceStatus.analogueGain;
 	current_.totalExposure = 0s; /* this value is unused */
 	current_.totalExposureNoDG = current_.exposureTime * current_.analogueGain;
+	LOG(RPiAgc, Debug) << "Current frame: exposure time " << current_.exposureTime
+			   << " ag " << current_.analogueGain
+			   << " (total " << current_.totalExposureNoDG << ")";
 }
 
 void AgcChannel::fetchAwbStatus(Metadata *imageMetadata)
@@ -808,14 +791,12 @@ void AgcChannel::computeTargetExposure(double gain)
 		target_.totalExposure = current_.totalExposureNoDG * gain;
 		/* The final target exposure is also limited to what the exposure mode allows. */
 		Duration maxExposureTime = status_.fixedExposureTime
-					 ? status_.fixedExposureTime
-					 : exposureMode_->exposureTime.back();
+					      ? status_.fixedExposureTime
+					      : exposureMode_->exposureTime.back();
 		maxExposureTime = limitExposureTime(maxExposureTime);
-		Duration maxTotalExposure =
-			maxExposureTime *
-			(status_.fixedGain != 0.0
-				 ? status_.fixedGain
-				 : exposureMode_->gain.back());
+		double maxGain = status_.fixedGain ? status_.fixedGain : exposureMode_->gain.back();
+		maxGain = limitGain(maxGain);
+		Duration maxTotalExposure = maxExposureTime * maxGain;
 		target_.totalExposure = std::min(target_.totalExposure, maxTotalExposure);
 	}
 	LOG(RPiAgc, Debug) << "Target totalExposure " << target_.totalExposure;
@@ -824,8 +805,6 @@ void AgcChannel::computeTargetExposure(double gain)
 bool AgcChannel::applyChannelConstraints(const AgcChannelTotalExposures &channelTotalExposures)
 {
 	bool channelBound = false;
-	LOG(RPiAgc, Debug)
-		<< "Total exposure before channel constraints " << filtered_.totalExposure;
 
 	for (const auto &constraint : config_.channelConstraints) {
 		LOG(RPiAgc, Debug)
@@ -860,7 +839,7 @@ bool AgcChannel::applyChannelConstraints(const AgcChannelTotalExposures &channel
 
 bool AgcChannel::applyDigitalGain(double gain, double targetY, bool channelBound)
 {
-	double dg = 1.0;
+	filtered_.totalExposureNoDG = filtered_.totalExposure;
 
 	/*
 	 * Finally, if we're trying to reduce exposure but the target_Y is
@@ -871,15 +850,14 @@ bool AgcChannel::applyDigitalGain(double gain, double targetY, bool channelBound
 	 * quickly (and we then approach the correct value more quickly from
 	 * below).
 	 */
-	bool desaturate = false;
-	if (config_.desaturate)
-		desaturate = !channelBound &&
-			     targetY > config_.fastReduceThreshold && gain < sqrt(targetY);
-	if (desaturate)
-		dg /= config_.fastReduceThreshold;
-	LOG(RPiAgc, Debug) << "Digital gain " << dg << " desaturate? " << desaturate;
-	filtered_.totalExposureNoDG = filtered_.totalExposure / dg;
-	LOG(RPiAgc, Debug) << "Target totalExposureNoDG " << filtered_.totalExposureNoDG;
+	bool desaturate = config_.desaturate && !channelBound &&
+			  targetY > config_.fastReduceThreshold && gain < sqrt(targetY);
+
+	if (desaturate) {
+		filtered_.totalExposureNoDG *= config_.fastReduceThreshold;
+		LOG(RPiAgc, Debug) << "Desaturating, exposure no dg " << filtered_.totalExposureNoDG;
+	}
+
 	return desaturate;
 }
 
@@ -915,8 +893,7 @@ void AgcChannel::filterExposure()
 		filtered_.totalExposure = speed * target_.totalExposure +
 					  filtered_.totalExposure * (1.0 - speed);
 	}
-	LOG(RPiAgc, Debug) << "After filtering, totalExposure " << filtered_.totalExposure
-			   << " no dg " << filtered_.totalExposureNoDG;
+	LOG(RPiAgc, Debug) << "After filtering, totalExposure " << filtered_.totalExposure;
 }
 
 void AgcChannel::divideUpExposure()
@@ -957,9 +934,7 @@ void AgcChannel::divideUpExposure()
 			}
 		}
 	}
-	LOG(RPiAgc, Debug)
-		<< "Divided up exposure time and gain are " << exposureTime
-		<< " and " << gain;
+
 	/*
 	 * Finally adjust exposure time for flicker avoidance (require both
 	 * exposure time and gain not to be fixed).
@@ -970,22 +945,30 @@ void AgcChannel::divideUpExposure()
 		if (flickerPeriods) {
 			Duration newExposureTime = flickerPeriods * status_.flickerPeriod;
 			gain *= exposureTime / newExposureTime;
-			/*
-			 * We should still not allow the ag to go over the
-			 * largest value in the exposure mode. Note that this
-			 * may force more of the total exposure into the digital
-			 * gain as a side-effect.
-			 */
-			gain = std::min(gain, exposureMode_->gain.back());
-			gain = limitGain(gain);
 			exposureTime = newExposureTime;
 		}
 		LOG(RPiAgc, Debug) << "After flicker avoidance, exposure time "
 				   << exposureTime << " gain " << gain;
 	}
+
+	/* Limit analogue gain to maximum allowed. */
+	double analogueGain = std::min(gain, mode_.maxAnalogueGain);
+
+	/* Finally work out the digital gain that we will need. */
+	filtered_.totalExposureNoDG = analogueGain * exposureTime;
+	double digitalGain = filtered_.totalExposure / filtered_.totalExposureNoDG;
+	/* Limit dg by what is allowed. */
+	digitalGain = std::min(digitalGain, config_.maxDigitalGain);
+	/* Update total exposure, in case the dg went down. */
+	filtered_.totalExposure = filtered_.totalExposureNoDG * digitalGain;
+
 	filtered_.exposureTime = exposureTime;
-	/* We ask for all the gain as analogue gain; prepare() will be told what we got. */
-	filtered_.analogueGain = gain;
+	filtered_.analogueGain = analogueGain;
+	filtered_.digitalGain = digitalGain;
+	LOG(RPiAgc, Debug) << "DivideUpExposure: total " << filtered_.totalExposure
+			   << " no dg " << filtered_.totalExposureNoDG;
+	LOG(RPiAgc, Debug) << "DivideUpExposure: exp " << exposureTime
+			   << " ag " << gain << " dg " << digitalGain;
 }
 
 void AgcChannel::writeAndFinish(Metadata *imageMetadata, bool desaturate)
@@ -994,6 +977,7 @@ void AgcChannel::writeAndFinish(Metadata *imageMetadata, bool desaturate)
 	status_.targetExposureValue = desaturate ? 0s : target_.totalExposure;
 	status_.exposureTime = filtered_.exposureTime;
 	status_.analogueGain = filtered_.analogueGain;
+	status_.digitalGain = filtered_.digitalGain;
 	/*
 	 * Write to metadata as well, in case anyone wants to update the camera
 	 * immediately.
@@ -1001,8 +985,6 @@ void AgcChannel::writeAndFinish(Metadata *imageMetadata, bool desaturate)
 	imageMetadata->set("agc.status", status_);
 	LOG(RPiAgc, Debug) << "Output written, total exposure requested is "
 			   << filtered_.totalExposure;
-	LOG(RPiAgc, Debug) << "Camera exposure update: exposure time " << filtered_.exposureTime
-			   << " analogue gain " << filtered_.analogueGain;
 }
 
 Duration AgcChannel::limitExposureTime(Duration exposureTime)
@@ -1031,6 +1013,7 @@ double AgcChannel::limitGain(double gain) const
 	if (!gain)
 		return gain;
 
-	gain = std::max(gain, mode_.minAnalogueGain);
+	gain = std::clamp(gain, mode_.minAnalogueGain,
+			  mode_.maxAnalogueGain * config_.maxDigitalGain);
 	return gain;
 }
diff --git a/src/ipa/rpi/controller/rpi/agc_channel.h b/src/ipa/rpi/controller/rpi/agc_channel.h
index 93229128abf1..42d85ec15e8d 100644
--- a/src/ipa/rpi/controller/rpi/agc_channel.h
+++ b/src/ipa/rpi/controller/rpi/agc_channel.h
@@ -135,6 +135,7 @@ private:
 
 		libcamera::utils::Duration exposureTime;
 		double analogueGain;
+		double digitalGain;
 		libcamera::utils::Duration totalExposure;
 		libcamera::utils::Duration totalExposureNoDG; /* without digital gain */
 	};
