From a8d149e9ef6635a6405edcc7d168a71a616a8a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Thu, 30 Jul 2026 06:57:27 +0200 Subject: [PATCH 01/33] dt-bindings: power: supply: qcom,pmi8998-charger: add the PMI632 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver behind this binding was generalised into qcom_smbx and now serves the PMI632's SMB5 charger alongside the PMI8998/PM660 SMB2, so document the third compatible here rather than in a second file: the two generations differ in their register layout and in which analog channels the PMIC routes, not in the shape of the binding. That reach shows up as three additions: - three further optional io-channels. usbin_i and usbin_v stay required; vbat, bat_therm and bat_id are each taken with devm_iio_channel_get() and dropped on anything but -EPROBE_DEFER, so a board describes only what it wires. The schema says minItems: 2 for the same reason. - qcom,thermal-mitigation and #cooling-cells, for the cooling device the charger registers. The description states what the driver enforces: at least two states, most permissive first, never increasing. - qcom,batt-id-pullup-ohm, the board's pull-up on the BAT_ID net, which turns the voltage on the bat_id channel into the resistance of the resistor inside the pack. Checked with dt_binding_check (both examples) and with dtbs_check against sdm632-fairphone-fp3.dtb, where the charger node validates clean; yamllint is clean against the bindings' own configuration. The maxItems: 16 on qcom,thermal-mitigation is a schema-side bound only. The driver sizes the table from the property and imposes no maximum, so this is a sanity limit rather than a hardware fact. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- .../power/supply/qcom,pmi8998-charger.yaml | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml b/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml index 70f5cd6eaeabe8..5783007887476e 100644 --- a/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml +++ b/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml @@ -4,16 +4,23 @@ $id: http://devicetree.org/schemas/power/supply/qcom,pmi8998-charger.yaml# $schema: http://devicetree.org/meta-schemas/core.yaml# -title: Qualcomm PMI8998/PM660 Switch-Mode Battery Charger "2" +title: Qualcomm Switch-Mode Battery Charger "2" and "5" maintainers: - Casey Connolly +description: | + The charger peripheral of a Qualcomm PMIC, in either of two generations: + SMB2 on the PMI8998 and PM660, and SMB5 on the PMI632. They differ in the + register layout and in which analog channels the PMIC routes, not in the + shape of this binding. + properties: compatible: enum: - qcom,pmi8998-charger - qcom,pm660-charger + - qcom,pmi632-charger reg: maxItems: 1 @@ -29,17 +36,48 @@ properties: - const: usbin-icl-change io-channels: + minItems: 2 items: - description: USB in current in uA - description: USB in voltage in uV + - description: Battery voltage in uV + - description: Battery thermistor, as a raw ADC code + - description: Battery ID resistor divider voltage in uV io-channel-names: + minItems: 2 items: - const: usbin_i - const: usbin_v + - const: vbat + - const: bat_therm + - const: bat_id monitored-battery: true + qcom,batt-id-pullup-ohm: + $ref: /schemas/types.yaml#/definitions/uint32 + description: | + Resistance of the pull-up from the PMIC reference to the BAT_ID net, in + ohms. Together with the voltage read on the bat_id channel this gives the + resistance of the identification resistor inside the pack, which is + compared against the qcom,batt-id-ohm of the monitored battery. Describe + it only on boards that route BAT_ID. + + qcom,thermal-mitigation: + $ref: /schemas/types.yaml#/definitions/uint32-array + minItems: 2 + maxItems: 16 + description: | + Fast-charge current limit, in microamperes, for each state of the cooling + device the charger registers. The first entry is cooling state 0 and must + be the most permissive; entries must not increase. A thermal zone reduces + the charge current by requesting a higher state, so state 0 is what the + charger falls back to when nothing is throttling it. + + '#cooling-cells': + const: 2 + required: - compatible - reg @@ -80,3 +118,37 @@ examples: monitored-battery = <&battery>; }; }; + + - | + #include + #include + + pmic { + #address-cells = <1>; + #size-cells = <0>; + + charger@1000 { + compatible = "qcom,pmi632-charger"; + reg = <0x1000>; + + interrupts = <0x2 0x13 0x4 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x12 0x2 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x16 0x1 IRQ_TYPE_EDGE_RISING>, + <0x2 0x13 0x6 IRQ_TYPE_EDGE_RISING>; + interrupt-names = "usb-plugin", "bat-ov", "wdog-bark", "usbin-icl-change"; + + io-channels = <&pmi632_adc ADC5_USB_IN_I>, + <&pmi632_adc ADC5_USB_IN_V_16>, + <&pmi632_adc ADC5_VBAT_SNS>, + <&pmi632_adc ADC5_BAT_THERM_100K_PU>, + <&pmi632_adc ADC5_BAT_ID_100K_PU>; + io-channel-names = "usbin_i", "usbin_v", "vbat", + "bat_therm", "bat_id"; + + monitored-battery = <&battery>; + qcom,batt-id-pullup-ohm = <100000>; + qcom,thermal-mitigation = <2000000 1500000 1000000 500000>; + + #cooling-cells = <2>; + }; + }; From ea49dd4db5c5ca1eadafc4a52476c477434bc792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 12 Aug 2026 22:30:16 +0200 Subject: [PATCH 02/33] dt-bindings: power: supply: battery: add id-resistor-ohms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board that can be fitted with more than one pack tells them apart by a resistor built into the pack, and a charger that measures it can refuse to apply one battery's limits to a different battery - the failure that actually hurts, because the device tree names only one battery and cannot choose. Name the resistance here, generically. It is not a Qualcomm idea, and the alternative this port used first - a vendor-prefixed property on the battery node - is rejected outright by this schema, which sets additionalProperties: false and carries no vendor-prefixed property at all. How the resistance is measured is deliberately not described here: the divider, its pull-up and the ADC belong to the board and to the charger, not to the cell. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- .../devicetree/bindings/power/supply/battery.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Documentation/devicetree/bindings/power/supply/battery.yaml b/Documentation/devicetree/bindings/power/supply/battery.yaml index 8ebf05d9497c43..ff799ea20bc341 100644 --- a/Documentation/devicetree/bindings/power/supply/battery.yaml +++ b/Documentation/devicetree/bindings/power/supply/battery.yaml @@ -93,6 +93,15 @@ properties: factory-internal-resistance-micro-ohms: description: battery factory internal resistance + id-resistor-ohms: + description: | + Resistance of the identification resistor built into the pack, on boards + where more than one battery may be fitted and a charger can tell them + apart by measuring it. Naming it here lets a driver refuse to apply this + battery's limits to a pack that is demonstrably a different one; how the + resistance is measured is a property of the board and of the charger, not + of the cell. + resistance-temp-table: $ref: /schemas/types.yaml#/definitions/uint32-matrix items: From 0e562bae6470263660c21157f0650a4404064803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 12 Aug 2026 22:30:29 +0200 Subject: [PATCH 03/33] dt-bindings: power: supply: qcom,pmi8998-charger: describe the charging policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JEITA thresholds, the currents to fall back to inside the soft zones and the recharge voltage were described on the monitored battery. They cannot stay there: battery.yaml sets additionalProperties: false and carries no vendor-prefixed property, so dtbs_check rejects every one of them, and the only JEITA precedent in tree - qcom,jeita-extended-temp-range in qcom,pm8941-charger.yaml - is on a charger node. There is a layering argument as well as a schema one, and it is the reason this is not merely moved to get past a checker. A threshold here is a raw BAT_THERM ADC code, and which code a given temperature produces depends on the PMIC's ADC full scale and on the board's pull-up as much as on the cell. A code is therefore not a property of the pack and cannot travel with one; a board that fits two packs would have to repeat the same codes on both. Rename qcom,batt-id-pullup-ohm to qcom,batt-id-pullup-ohms in the same change. The canonical unit suffix is plural, and the property is this port's own addition, so nothing outside this tree can be relying on the old spelling. The battery-ID tolerance joins it here for the same reason the pull-up is here: it has to cover the divider and the ADC, not just the resistor. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- .../power/supply/qcom,pmi8998-charger.yaml | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml b/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml index 5783007887476e..0018bc72d4d350 100644 --- a/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml +++ b/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml @@ -55,15 +55,63 @@ properties: monitored-battery: true - qcom,batt-id-pullup-ohm: + qcom,batt-id-pullup-ohms: $ref: /schemas/types.yaml#/definitions/uint32 description: | Resistance of the pull-up from the PMIC reference to the BAT_ID net, in ohms. Together with the voltage read on the bat_id channel this gives the resistance of the identification resistor inside the pack, which is - compared against the qcom,batt-id-ohm of the monitored battery. Describe + compared against the id-resistor-ohms of the monitored battery. Describe it only on boards that route BAT_ID. + qcom,batt-id-tolerance-percent: + $ref: /schemas/types.yaml#/definitions/uint32 + maximum: 100 + description: | + How far the resistance measured through that divider may differ from the + monitored battery's id-resistor-ohms and still count as the same pack. + The tolerance belongs here rather than to the cell because it has to + cover the divider and the ADC as well as the resistor itself. + + qcom,auto-recharge-microvolt: + description: | + Battery voltage, in microvolts, below which a finished charge is started + again. Must be below the monitored battery's + constant-charge-voltage-max-microvolt. Absent, the comparator keeps + whatever threshold it powers up with. + + qcom,jeita-hard-thresholds: + $ref: /schemas/types.yaml#/definitions/uint32-array + minItems: 2 + maxItems: 2 + description: | + The pair of raw BAT_THERM ADC codes { cold, hot } outside which charging + stops outright, cold first. These are codes rather than temperatures + because the comparators take codes, and which code a temperature produces + depends on this PMIC's ADC full scale and on the board's pull-up as much + as on the cell - which is also why they are described here and not on the + battery. + + qcom,jeita-soft-thresholds: + $ref: /schemas/types.yaml#/definitions/uint32-array + minItems: 2 + maxItems: 2 + description: | + The pair of raw BAT_THERM ADC codes { cold, hot } inside which the pack + may be charged at full current, in the same units and for the same reason + as qcom,jeita-hard-thresholds. Between a soft and a hard threshold the + charge current is reduced instead of stopped. + + qcom,jeita-soft-fcc-microamp: + $ref: /schemas/types.yaml#/definitions/uint32-array + minItems: 2 + maxItems: 2 + description: | + Fast-charge current, in microamperes, to be left with in the cool and the + warm soft zone, in that order. The hardware subtracts a fixed offset from + whatever current is programmed, so describing the wanted result rather + than the offset keeps the property meaningful when the current changes. + qcom,thermal-mitigation: $ref: /schemas/types.yaml#/definitions/uint32-array minItems: 2 @@ -146,7 +194,7 @@ examples: "bat_therm", "bat_id"; monitored-battery = <&battery>; - qcom,batt-id-pullup-ohm = <100000>; + qcom,batt-id-pullup-ohms = <100000>; qcom,thermal-mitigation = <2000000 1500000 1000000 500000>; #cooling-cells = <2>; From 9ce3b87cfedd98dfc0851c1d4533453cf52bb3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 12 Aug 2026 23:23:28 +0200 Subject: [PATCH 04/33] dt-bindings: power: supply: drop the type refs the unit suffixes already imply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by running the checker rather than by reading the file: with the property renamed to qcom,batt-id-pullup-ohms, dt-validate rejected the board with "100000 is not of type 'array'". A recognised unit suffix already gives a property its type - uint32-array - and an explicit $ref to uint32 contradicts it rather than narrowing it. The old spelling, -ohm, was not a recognised suffix, so the $ref was doing the work and the rename silently took that away. So: -ohms, -percent and -microamp properties get maxItems (or minItems and maxItems) and no $ref, which is what dt-doc-validate asks for in as many words. The -thresholds arrays keep theirs, since that is not a unit suffix and nothing else would type them. Measured after the fix: the board's charger node no longer reports a type error, and the battery node reports nothing at all - which was the point of moving those properties in the first place. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- .../devicetree/bindings/power/supply/battery.yaml | 1 + .../power/supply/qcom,pmi8998-charger.yaml | 15 +++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Documentation/devicetree/bindings/power/supply/battery.yaml b/Documentation/devicetree/bindings/power/supply/battery.yaml index ff799ea20bc341..632ef885104c6d 100644 --- a/Documentation/devicetree/bindings/power/supply/battery.yaml +++ b/Documentation/devicetree/bindings/power/supply/battery.yaml @@ -94,6 +94,7 @@ properties: description: battery factory internal resistance id-resistor-ohms: + maxItems: 1 description: | Resistance of the identification resistor built into the pack, on boards where more than one battery may be fitted and a charger can tell them diff --git a/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml b/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml index 0018bc72d4d350..620782d32f5e43 100644 --- a/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml +++ b/Documentation/devicetree/bindings/power/supply/qcom,pmi8998-charger.yaml @@ -56,7 +56,7 @@ properties: monitored-battery: true qcom,batt-id-pullup-ohms: - $ref: /schemas/types.yaml#/definitions/uint32 + maxItems: 1 description: | Resistance of the pull-up from the PMIC reference to the BAT_ID net, in ohms. Together with the voltage read on the bat_id channel this gives the @@ -65,13 +65,13 @@ properties: it only on boards that route BAT_ID. qcom,batt-id-tolerance-percent: - $ref: /schemas/types.yaml#/definitions/uint32 - maximum: 100 + maxItems: 1 description: | - How far the resistance measured through that divider may differ from the - monitored battery's id-resistor-ohms and still count as the same pack. - The tolerance belongs here rather than to the cell because it has to - cover the divider and the ADC as well as the resistor itself. + How far, as a percentage, the resistance measured through that divider + may differ from the monitored battery's id-resistor-ohms and still count + as the same pack. The tolerance belongs here rather than to the cell + because it has to cover the divider and the ADC as well as the resistor + itself. qcom,auto-recharge-microvolt: description: | @@ -103,7 +103,6 @@ properties: charge current is reduced instead of stopped. qcom,jeita-soft-fcc-microamp: - $ref: /schemas/types.yaml#/definitions/uint32-array minItems: 2 maxItems: 2 description: | From 9e0fb1ba6ddf52c6127150849c9e34ae72f6f97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 25 Jul 2026 21:01:56 +0200 Subject: [PATCH 05/33] power: supply: qcom_smbx: add SMB5 (PMI632) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qcom_smbx currently drives the SMB2 charger in the pmi8998/pm660 family. The PMI632 carries an SMB5 block: same overall design, but several registers moved peripheral and a few fields changed width or position. Describe the differences in the variant structure rather than open-coding them: the status register prefix (MISC 0x600 on SMB2, DCDC 0x100 on SMB5), the current step (25 mA vs 50 mA), the charge-status bit positions, and the JEITA status register, which SMB5 splits out into BATTERY_CHARGER_STATUS_7 instead of sharing BATTERY_CHARGER_STATUS_2. Register layout and field values are taken from the Qualcomm downstream qpnp-smb2 and qpnp-smb5 drivers, published in the Fairphone 3 kernel source release. PMI632 has no coulomb-counting fuel gauge in mainline, so capacity comes from the OCV table in the board's simple-battery node. Based on the upstream driver by Casey Connolly (drivers/power/supply/qcom_smbx.c, Linaro). Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-4-8 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 363 ++++++++++++++++++++++++++++--- 1 file changed, 330 insertions(+), 33 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index bf2e2ccc454a6d..119b1ca162f298 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -35,6 +35,7 @@ #define INPUT_CURRENT_LIMITED_BIT BIT(7) #define CHARGER_ERROR_STATUS_SFT_EXPIRE_BIT BIT(6) #define CHARGER_ERROR_STATUS_BAT_OV_BIT BIT(5) +#define SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT BIT(1) #define CHARGER_ERROR_STATUS_BAT_TERM_MISSING_BIT BIT(4) #define BAT_TEMP_STATUS_MASK GENMASK(3, 0) #define BAT_TEMP_STATUS_SOFT_LIMIT_MASK GENMASK(3, 2) @@ -303,10 +304,16 @@ #define AICL_SWITCH_ENABLE_BIT BIT(1) #define ZIN_ICL_ENABLE_BIT BIT(0) -#define ICL_STATUS 0x607 +/* + * ICL_STATUS and POWER_PATH_STATUS live in the MISC peripheral (base offset + * 0x600) on SMB2 (pmi8998/pm660) but in the DCDC peripheral (0x100) on SMB5 + * (pmi632). The generation-dependent prefix is held in smb_variant::status_base + * and added to chip->base at the call sites, so these are in-peripheral offsets. + */ +#define ICL_STATUS 0x07 #define INPUT_CURRENT_LIMIT_MASK GENMASK(7, 0) -#define POWER_PATH_STATUS 0x60B +#define POWER_PATH_STATUS 0x0B #define P_PATH_INPUT_SS_DONE_BIT BIT(7) #define P_PATH_USBIN_SUSPEND_STS_BIT BIT(6) #define P_PATH_DCIN_SUSPEND_STS_BIT BIT(5) @@ -371,6 +378,41 @@ struct smb_init_register { u8 val; }; +/** + * struct smb_variant - per-PMIC-generation parameters + * @name: Model name, used for the power_supply name + * @status_base: Peripheral prefix for ICL_STATUS / POWER_PATH_STATUS + * (0x600 = MISC on SMB2, 0x100 = DCDC on SMB5) + * @current_scale_ua: uA per LSB of the FCC / ICL registers + * (25000 on SMB2/pmi8998, 50000 on SMB5/pmi632) + * @float_base_uv: Float-voltage register value 0 corresponds to this voltage + * @float_step_uv: uV per LSB of FLOAT_VOLTAGE_CFG + * @ov_bit: BAT_OV (overvoltage) bit within BATTERY_CHARGER_STATUS_2 + * (BIT(5) on SMB2, BIT(1) on SMB5) + * @temp_status_reg: Register holding the JEITA temperature-status bits, + * relative to @base (BATTERY_CHARGER_STATUS_2 = 0x07 on + * SMB2, BATTERY_CHARGER_STATUS_7 = 0x0D on SMB5) + * @temp_status_shift: Left-shift of the HOT_SOFT/COLD_SOFT/TOO_HOT/TOO_COLD + * bits versus the SMB2 baseline (0 on SMB2, 2 on SMB5) + * @init_seq: HW init register write sequence for this generation + * @init_seq_len: Number of entries in @init_seq + * + * All values are taken from the Qualcomm downstream qpnp-smb2 / qpnp-smb5 + * drivers (smb_chg_param tables) so the current/voltage scaling is exact. + */ +struct smb_variant { + const char *name; + u16 status_base; + u32 current_scale_ua; + u32 float_base_uv; + u32 float_step_uv; + u8 ov_bit; + u16 temp_status_reg; + u8 temp_status_shift; + const struct smb_init_register *init_seq; + int init_seq_len; +}; + /** * struct smb_chip - smb chip structure * @dev: Device reference for power_supply @@ -383,12 +425,15 @@ struct smb_init_register { * @wakeup_enabled: If the cable IRQ will cause a wakeup * @usb_in_i_chan: USB_IN current measurement channel * @usb_in_v_chan: USB_IN voltage measurement channel + * @vbat_chan: Battery voltage (VBAT_SNS) measurement channel * @chg_psy: Charger power supply instance + * @batt_psy: Battery (fuel-gauge) power supply instance */ struct smb_chip { struct device *dev; const char *name; unsigned int base; + const struct smb_variant *var; struct regmap *regmap; struct power_supply_battery_info *batt_info; @@ -398,8 +443,10 @@ struct smb_chip { struct iio_channel *usb_in_i_chan; struct iio_channel *usb_in_v_chan; + struct iio_channel *vbat_chan; struct power_supply *chg_psy; + struct power_supply *batt_psy; }; static enum power_supply_property smb_properties[] = { @@ -419,7 +466,9 @@ static int smb_get_prop_usb_online(struct smb_chip *chip, int *val) unsigned int stat; int rc; - rc = regmap_read(chip->regmap, chip->base + POWER_PATH_STATUS, &stat); + rc = regmap_read(chip->regmap, + chip->base + chip->var->status_base + POWER_PATH_STATUS, + &stat); if (rc < 0) { dev_err(chip->dev, "Couldn't read power path status: %d\n", rc); return rc; @@ -525,10 +574,12 @@ static int smb_get_prop_status(struct smb_chip *chip, int *val) static inline int smb_get_current_limit(struct smb_chip *chip, unsigned int *val) { - int rc = regmap_read(chip->regmap, chip->base + ICL_STATUS, val); + int rc = regmap_read(chip->regmap, + chip->base + chip->var->status_base + ICL_STATUS, + val); if (rc >= 0) - *val *= CURRENT_SCALE_FACTOR; + *val *= chip->var->current_scale_ua; return rc; } @@ -541,7 +592,7 @@ static int smb_set_current_limit(struct smb_chip *chip, unsigned int val) "Can't set current limit higher than 4800000uA"); return -EINVAL; } - val_raw = val / CURRENT_SCALE_FACTOR; + val_raw = val / chip->var->current_scale_ua; return regmap_write(chip->regmap, chip->base + USBIN_CURRENT_LIMIT_CFG, val_raw); @@ -597,6 +648,8 @@ static void smb_status_change_work(struct work_struct *work) smb_set_current_limit(chip, current_ua); power_supply_changed(chip->chg_psy); + if (chip->batt_psy) + power_supply_changed(chip->batt_psy); } static int smb_get_iio_chan(struct smb_chip *chip, struct iio_channel *chan, @@ -625,6 +678,10 @@ static int smb_get_prop_health(struct smb_chip *chip, int *val) int rc; unsigned int stat; + /* + * Battery overvoltage is reported in BATTERY_CHARGER_STATUS_2 on both + * generations, but at a different bit (smb_variant::ov_bit). + */ rc = regmap_read(chip->regmap, chip->base + BATTERY_CHARGER_STATUS_2, &stat); if (rc < 0) { @@ -632,26 +689,37 @@ static int smb_get_prop_health(struct smb_chip *chip, int *val) return rc; } - switch (stat) { - case CHARGER_ERROR_STATUS_BAT_OV_BIT: + if (stat & chip->var->ov_bit) { *val = POWER_SUPPLY_HEALTH_OVERVOLTAGE; return 0; - case BAT_TEMP_STATUS_TOO_COLD_BIT: - *val = POWER_SUPPLY_HEALTH_COLD; - return 0; - case BAT_TEMP_STATUS_TOO_HOT_BIT: + } + + /* + * JEITA temperature status. On SMB2 it shares BATTERY_CHARGER_STATUS_2; + * on SMB5 it lives in BATTERY_CHARGER_STATUS_7 with the bits shifted up + * by two. The per-variant register and shift select the right layout. + * Test bits individually (a register may have several set) and let the + * hard limits take precedence over the soft ones. + */ + rc = regmap_read(chip->regmap, chip->base + chip->var->temp_status_reg, + &stat); + if (rc < 0) { + dev_err(chip->dev, "Couldn't read temp status rc=%d\n", rc); + return rc; + } + + if (stat & (BAT_TEMP_STATUS_TOO_HOT_BIT << chip->var->temp_status_shift)) *val = POWER_SUPPLY_HEALTH_OVERHEAT; - return 0; - case BAT_TEMP_STATUS_COLD_SOFT_LIMIT_BIT: - *val = POWER_SUPPLY_HEALTH_COOL; - return 0; - case BAT_TEMP_STATUS_HOT_SOFT_LIMIT_BIT: + else if (stat & (BAT_TEMP_STATUS_TOO_COLD_BIT << chip->var->temp_status_shift)) + *val = POWER_SUPPLY_HEALTH_COLD; + else if (stat & (BAT_TEMP_STATUS_HOT_SOFT_LIMIT_BIT << chip->var->temp_status_shift)) *val = POWER_SUPPLY_HEALTH_WARM; - return 0; - default: + else if (stat & (BAT_TEMP_STATUS_COLD_SOFT_LIMIT_BIT << chip->var->temp_status_shift)) + *val = POWER_SUPPLY_HEALTH_COOL; + else *val = POWER_SUPPLY_HEALTH_GOOD; - return 0; - } + + return 0; } static int smb_get_property(struct power_supply *psy, @@ -727,7 +795,7 @@ static irqreturn_t smb_handle_batt_overvoltage(int irq, void *data) regmap_read(chip->regmap, chip->base + BATTERY_CHARGER_STATUS_2, &status); - if (status & CHARGER_ERROR_STATUS_BAT_OV_BIT) { + if (status & chip->var->ov_bit) { /* The hardware stops charging automatically */ dev_err(chip->dev, "battery overvoltage detected\n"); power_supply_changed(chip->chg_psy); @@ -741,6 +809,8 @@ static irqreturn_t smb_handle_usb_plugin(int irq, void *data) struct smb_chip *chip = data; power_supply_changed(chip->chg_psy); + if (chip->batt_psy) + power_supply_changed(chip->batt_psy); schedule_delayed_work(&chip->status_change_work, msecs_to_jiffies(1500)); @@ -772,6 +842,94 @@ static irqreturn_t smb_handle_wdog_bark(int irq, void *data) return IRQ_HANDLED; } +/* + * Battery (fuel-gauge) power supply. + * + * PMI632 has no coulomb-counting fuel gauge in mainline; the downstream + * QG block is a voltage-based gauge. We replicate the essential algorithm: + * read VBAT_SNS via the PMIC ADC and look up the state-of-charge from the + * OCV->capacity table carried in the monitored-battery DT node (which was + * extracted from the downstream QG battery profile). The power-supply core + * does the interpolation. Reading is non-invasive (ADC only). + */ +static enum power_supply_property smb_batt_properties[] = { + POWER_SUPPLY_PROP_STATUS, + POWER_SUPPLY_PROP_HEALTH, + POWER_SUPPLY_PROP_PRESENT, + POWER_SUPPLY_PROP_TECHNOLOGY, + POWER_SUPPLY_PROP_CAPACITY, + POWER_SUPPLY_PROP_VOLTAGE_NOW, +}; + +static int smb_get_vbat(struct smb_chip *chip, int *val) +{ + if (IS_ERR_OR_NULL(chip->vbat_chan)) + return -ENODATA; + + /* ADC5 processed voltage channels return microvolts */ + return iio_read_channel_processed(chip->vbat_chan, val); +} + +static int smb_get_batt_capacity(struct smb_chip *chip, int *val) +{ + int ocv_uv, cap, rc; + + if (!chip->batt_info) + return -ENODATA; + + rc = smb_get_vbat(chip, &ocv_uv); + if (rc < 0) + return rc; + + /* + * Use the instantaneous battery voltage as the OCV estimate. Under + * load this sags (reads low while discharging, high while charging); + * a future ESR/IR-drop compensation could refine it. Assume room + * temperature (25 degC), matching the single OCV table we ship. + */ + cap = power_supply_batinfo_ocv2cap(chip->batt_info, ocv_uv, 25); + if (cap < 0) + return cap; + + *val = clamp(cap, 0, 100); + return 0; +} + +static int smb_batt_get_property(struct power_supply *psy, + enum power_supply_property psp, + union power_supply_propval *val) +{ + struct smb_chip *chip = power_supply_get_drvdata(psy); + + switch (psp) { + case POWER_SUPPLY_PROP_STATUS: + return smb_get_prop_status(chip, &val->intval); + case POWER_SUPPLY_PROP_HEALTH: + return smb_get_prop_health(chip, &val->intval); + case POWER_SUPPLY_PROP_PRESENT: + val->intval = 1; + return 0; + case POWER_SUPPLY_PROP_TECHNOLOGY: + val->intval = POWER_SUPPLY_TECHNOLOGY_LION; + return 0; + case POWER_SUPPLY_PROP_CAPACITY: + return smb_get_batt_capacity(chip, &val->intval); + case POWER_SUPPLY_PROP_VOLTAGE_NOW: + return smb_get_vbat(chip, &val->intval); + default: + dev_err(chip->dev, "invalid battery property: %d\n", psp); + return -EINVAL; + } +} + +static const struct power_supply_desc smb_batt_psy_desc = { + .name = "pmi632-battery", + .type = POWER_SUPPLY_TYPE_BATTERY, + .properties = smb_batt_properties, + .num_properties = ARRAY_SIZE(smb_batt_properties), + .get_property = smb_batt_get_property, +}; + static const struct power_supply_desc smb_psy_desc = { .name = "pmi8998_charger", .type = POWER_SUPPLY_TYPE_USB, @@ -786,8 +944,8 @@ static const struct power_supply_desc smb_psy_desc = { .property_is_writeable = smb_property_is_writable, }; -/* Init sequence derived from vendor downstream driver */ -static const struct smb_init_register smb_init_seq[] = { +/* Init sequence derived from vendor downstream driver (SMB2: pmi8998/pm660) */ +static const struct smb_init_register smb2_init_seq[] = { { .addr = AICL_RERUN_TIME_CFG, .mask = AICL_RERUN_TIME_MASK, .val = 0 }, /* * By default configure us as an upstream facing port @@ -889,17 +1047,122 @@ static const struct smb_init_register smb_init_seq[] = { .val = 1000000 / CURRENT_SCALE_FACTOR }, }; +/* + * SMB5 (pmi632) current registers step in 50mA (not 25mA), so the FCC/pre-charge + * raw values below use 50000 uA/LSB. Type-C, OTG and STAT/recharge-select writes + * from the SMB2 sequence are intentionally omitted: on SMB5 the Type-C block lives + * at its own base (0x1500, owned here by the qcom,pmi632-typec / tcpm driver) and + * those SMB2 offsets would hit the wrong registers. Only CHGR, USBIN (AICL/ICL/ + * HVDCP) and MISC (watchdog) writes are kept — these share offsets with SMB2. + */ +#define SMB5_CURRENT_SCALE_FACTOR 50000 +static const struct smb_init_register pmi632_init_seq[] = { + /* Enable charging */ + { .addr = USBIN_OPTIONS_1_CFG, .mask = HVDCP_EN_BIT, .val = 0 }, + { .addr = CHARGING_ENABLE_CMD, + .mask = CHARGING_ENABLE_CMD_BIT, + .val = CHARGING_ENABLE_CMD_BIT }, + /* Charger enable controlled by software; inhibit based on battery voltage */ + { .addr = CHGR_CFG2, + .mask = CHG_EN_SRC_BIT | CHG_EN_POLARITY_BIT | + PRETOFAST_TRANSITION_CFG_BIT | BAT_OV_ECC_BIT | I_TERM_BIT | + AUTO_RECHG_BIT | EN_ANALOG_DROP_IN_VBATT_BIT | + CHARGER_INHIBIT_BIT, + .val = CHARGER_INHIBIT_BIT }, + /* Default SDP charger to a 500mA USB 2.0 port */ + { .addr = USBIN_ICL_OPTIONS, + .mask = USB51_MODE_BIT | USBIN_MODE_CHG_BIT, + .val = USB51_MODE_BIT }, + /* Disable the charger watchdog */ + { .addr = SNARL_BARK_BITE_WD_CFG, .mask = 0xff, .val = 0 }, + { .addr = WD_CFG, + .mask = WATCHDOG_TRIGGER_AFP_EN_BIT | WDOG_TIMER_EN_ON_PLUGIN_BIT | + BARK_WDOG_INT_EN_BIT, + .val = 0 }, + { .addr = USBIN_5V_AICL_THRESHOLD_CFG, + .mask = USBIN_5V_AICL_THRESHOLD_CFG_MASK, + .val = 0x3 }, + { .addr = USBIN_CONT_AICL_THRESHOLD_CFG, + .mask = USBIN_CONT_AICL_THRESHOLD_CFG_MASK, + .val = 0x3 }, + /* Enable Automatic Input Current Limit */ + { .addr = USBIN_AICL_OPTIONS_CFG, + .mask = USBIN_AICL_START_AT_MAX_BIT | USBIN_AICL_ADC_EN_BIT | + USBIN_AICL_EN_BIT | SUSPEND_ON_COLLAPSE_USBIN_BIT | + USBIN_HV_COLLAPSE_RESPONSE_BIT | + USBIN_LV_COLLAPSE_RESPONSE_BIT, + .val = USBIN_HV_COLLAPSE_RESPONSE_BIT | + USBIN_LV_COLLAPSE_RESPONSE_BIT | USBIN_AICL_EN_BIT }, + { .addr = PRE_CHARGE_CURRENT_CFG, + .mask = PRE_CHARGE_CURRENT_SETTING_MASK, + .val = 500000 / SMB5_CURRENT_SCALE_FACTOR }, + /* + * Cap fast-charge current at ~1A. This is a deliberately conservative + * limit for first bring-up to minimise any fire-hazard risk; the DT + * monitored-battery may lower it further but not raise it past this. + */ + { .addr = FAST_CHARGE_CURRENT_CFG, + .mask = FAST_CHARGE_CURRENT_SETTING_MASK, + .val = 1000000 / SMB5_CURRENT_SCALE_FACTOR }, +}; + +static const struct smb_variant smb_variant_pmi8998 = { + .name = "pmi8998", + .status_base = 0x600, + .current_scale_ua = CURRENT_SCALE_FACTOR, + .float_base_uv = 3480000, /* (v - 3487500) / 7500 + 1 == (v - 3480000) / 7500 */ + .float_step_uv = 7500, + .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, + .temp_status_reg = BATTERY_CHARGER_STATUS_2, + .temp_status_shift = 0, + .init_seq = smb2_init_seq, + .init_seq_len = ARRAY_SIZE(smb2_init_seq), +}; + +static const struct smb_variant smb_variant_pm660 = { + .name = "pm660", + .status_base = 0x600, + .current_scale_ua = CURRENT_SCALE_FACTOR, + .float_base_uv = 3480000, + .float_step_uv = 7500, + .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, + .temp_status_reg = BATTERY_CHARGER_STATUS_2, + .temp_status_shift = 0, + .init_seq = smb2_init_seq, + .init_seq_len = ARRAY_SIZE(smb2_init_seq), +}; + +static const struct smb_variant smb_variant_pmi632 = { + .name = "pmi632", + .status_base = 0x100, /* ICL/POWER_PATH_STATUS in DCDC, not MISC */ + .current_scale_ua = SMB5_CURRENT_SCALE_FACTOR, + .float_base_uv = 3600000, /* qpnp-smb5 fv: min 3600000, step 10000 */ + .float_step_uv = 10000, + .ov_bit = SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT, + /* + * On SMB5 the JEITA temperature-status bits moved out of + * BATTERY_CHARGER_STATUS_2 into BATTERY_CHARGER_STATUS_7, and the + * HOT_SOFT/COLD_SOFT/TOO_HOT/TOO_COLD bits shifted up by two + * (verified against the downstream qpnp-smb5 smb5-reg.h and read back + * on a Fairphone 3: STATUS_2=0x28 is reserved data, STATUS_7=0x00). + */ + .temp_status_reg = BATTERY_CHARGER_STATUS_7, + .temp_status_shift = 2, + .init_seq = pmi632_init_seq, + .init_seq_len = ARRAY_SIZE(pmi632_init_seq), +}; + static int smb_init_hw(struct smb_chip *chip) { int rc, i; - for (i = 0; i < ARRAY_SIZE(smb_init_seq); i++) { + for (i = 0; i < chip->var->init_seq_len; i++) { dev_dbg(chip->dev, "%d: Writing 0x%02x to 0x%02x\n", i, - smb_init_seq[i].val, smb_init_seq[i].addr); + chip->var->init_seq[i].val, chip->var->init_seq[i].addr); rc = regmap_update_bits(chip->regmap, - chip->base + smb_init_seq[i].addr, - smb_init_seq[i].mask, - smb_init_seq[i].val); + chip->base + chip->var->init_seq[i].addr, + chip->var->init_seq[i].mask, + chip->var->init_seq[i].val); if (rc < 0) return dev_err_probe(chip->dev, rc, "%s: init command %d failed\n", @@ -945,6 +1208,11 @@ static int smb_probe(struct platform_device *pdev) chip->dev = &pdev->dev; chip->name = pdev->name; + chip->var = device_get_match_data(&pdev->dev); + if (!chip->var) + return dev_err_probe(chip->dev, -ENODEV, + "no match data for device\n"); + chip->regmap = dev_get_regmap(pdev->dev.parent, NULL); if (!chip->regmap) return dev_err_probe(chip->dev, -ENODEV, @@ -966,6 +1234,19 @@ static int smb_probe(struct platform_device *pdev) "Couldn't get usbin_i IIO channel\n"); } + /* + * VBAT_SNS is optional: it drives the voltage-based fuel gauge and is + * only present on variants that wire it up (PMI632). Defer if the ADC + * isn't ready yet, otherwise carry on without a battery gauge. + */ + chip->vbat_chan = devm_iio_channel_get(chip->dev, "vbat"); + if (IS_ERR(chip->vbat_chan)) { + rc = PTR_ERR(chip->vbat_chan); + if (rc == -EPROBE_DEFER) + return rc; + chip->vbat_chan = NULL; + } + rc = smb_init_hw(chip); if (rc < 0) return rc; @@ -979,7 +1260,7 @@ static int smb_probe(struct platform_device *pdev) memcpy(desc, &smb_psy_desc, sizeof(smb_psy_desc)); desc->name = devm_kasprintf(chip->dev, GFP_KERNEL, "%s-charger", - (const char *)device_get_match_data(chip->dev)); + chip->var->name); if (!desc->name) return -ENOMEM; @@ -994,13 +1275,28 @@ static int smb_probe(struct platform_device *pdev) return dev_err_probe(chip->dev, rc, "Failed to get battery info\n"); + /* + * Register a battery (fuel-gauge) power supply only when we can + * actually estimate state of charge: a VBAT channel plus an + * OCV->capacity table from the monitored-battery node. + */ + if (chip->vbat_chan && chip->batt_info->ocv_table[0]) { + chip->batt_psy = devm_power_supply_register(chip->dev, + &smb_batt_psy_desc, + &supply_config); + if (IS_ERR(chip->batt_psy)) + return dev_err_probe(chip->dev, PTR_ERR(chip->batt_psy), + "failed to register battery power supply\n"); + } + rc = devm_delayed_work_autocancel(chip->dev, &chip->status_change_work, smb_status_change_work); if (rc) return dev_err_probe(chip->dev, rc, "Failed to init status change work\n"); - rc = (chip->batt_info->voltage_max_design_uv - 3487500) / 7500 + 1; + rc = (chip->batt_info->voltage_max_design_uv - chip->var->float_base_uv) / + chip->var->float_step_uv; rc = regmap_update_bits(chip->regmap, chip->base + FLOAT_VOLTAGE_CFG, FLOAT_VOLTAGE_SETTING_MASK, rc); if (rc < 0) @@ -1038,8 +1334,9 @@ static int smb_probe(struct platform_device *pdev) } static const struct of_device_id smb_match_id_table[] = { - { .compatible = "qcom,pmi8998-charger", .data = "pmi8998" }, - { .compatible = "qcom,pm660-charger", .data = "pm660" }, + { .compatible = "qcom,pmi8998-charger", .data = &smb_variant_pmi8998 }, + { .compatible = "qcom,pm660-charger", .data = &smb_variant_pm660 }, + { .compatible = "qcom,pmi632-charger", .data = &smb_variant_pmi632 }, { /* sentinal */ } }; MODULE_DEVICE_TABLE(of, smb_match_id_table); From d3ad58b1d14f33c6ff3028f9aca421e3eccef0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 14:14:11 +0200 Subject: [PATCH 06/33] power: supply: qcom_smbx: report battery temperature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver already reads VBAT_SNS through the PMIC ADC to drive the voltage-based fuel gauge; the pack thermistor sits on the same ADC and needs nothing more than a second channel. Take it as optional, like VBAT_SNS: a board that does not route the thermistor to the PMIC simply keeps a battery without POWER_SUPPLY_PROP_TEMP, rather than failing to probe. Provenance: new here, but nothing about it is invented. It is modelled directly on this driver's existing vbat_chan handling - the same optional devm_iio_channel_get(), the same -EPROBE_DEFER passthrough, the same iio_read_channel_processed() - so battery temperature arrives the same way battery voltage already does. That the thermistor is on the PMIC ADC at all, rather than behind the fuel gauge, is Qualcomm's design, visible in the downstream pmi632 device tree. The only judgement call is the unit conversion: the ADC returns millidegrees C and the power supply class wants decidegrees. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 119b1ca162f298..c1446eff4e42bf 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -426,6 +426,7 @@ struct smb_variant { * @usb_in_i_chan: USB_IN current measurement channel * @usb_in_v_chan: USB_IN voltage measurement channel * @vbat_chan: Battery voltage (VBAT_SNS) measurement channel + * @bat_therm_chan: Battery thermistor (BAT_THERM) measurement channel * @chg_psy: Charger power supply instance * @batt_psy: Battery (fuel-gauge) power supply instance */ @@ -444,6 +445,7 @@ struct smb_chip { struct iio_channel *usb_in_i_chan; struct iio_channel *usb_in_v_chan; struct iio_channel *vbat_chan; + struct iio_channel *bat_therm_chan; struct power_supply *chg_psy; struct power_supply *batt_psy; @@ -859,6 +861,7 @@ static enum power_supply_property smb_batt_properties[] = { POWER_SUPPLY_PROP_TECHNOLOGY, POWER_SUPPLY_PROP_CAPACITY, POWER_SUPPLY_PROP_VOLTAGE_NOW, + POWER_SUPPLY_PROP_TEMP, }; static int smb_get_vbat(struct smb_chip *chip, int *val) @@ -870,6 +873,22 @@ static int smb_get_vbat(struct smb_chip *chip, int *val) return iio_read_channel_processed(chip->vbat_chan, val); } +static int smb_get_batt_temp(struct smb_chip *chip, int *val) +{ + int temp, rc; + + if (IS_ERR_OR_NULL(chip->bat_therm_chan)) + return -ENODATA; + + /* The ADC returns millidegrees C, the power supply class decidegrees */ + rc = iio_read_channel_processed(chip->bat_therm_chan, &temp); + if (rc < 0) + return rc; + + *val = temp / 100; + return 0; +} + static int smb_get_batt_capacity(struct smb_chip *chip, int *val) { int ocv_uv, cap, rc; @@ -916,6 +935,8 @@ static int smb_batt_get_property(struct power_supply *psy, return smb_get_batt_capacity(chip, &val->intval); case POWER_SUPPLY_PROP_VOLTAGE_NOW: return smb_get_vbat(chip, &val->intval); + case POWER_SUPPLY_PROP_TEMP: + return smb_get_batt_temp(chip, &val->intval); default: dev_err(chip->dev, "invalid battery property: %d\n", psp); return -EINVAL; @@ -1247,6 +1268,18 @@ static int smb_probe(struct platform_device *pdev) chip->vbat_chan = NULL; } + /* + * BAT_THERM is optional in the same way: only boards that route the + * pack thermistor to the PMIC can report a battery temperature. + */ + chip->bat_therm_chan = devm_iio_channel_get(chip->dev, "bat_therm"); + if (IS_ERR(chip->bat_therm_chan)) { + rc = PTR_ERR(chip->bat_therm_chan); + if (rc == -EPROBE_DEFER) + return rc; + chip->bat_therm_chan = NULL; + } + rc = smb_init_hw(chip); if (rc < 0) return rc; From b6ba33afd341cf91bd4cf371a6377c00e1f859ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 17:15:07 +0200 Subject: [PATCH 07/33] power: supply: qcom_smbx: program the hardware JEITA thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The charger has a JEITA block that compares the battery thermistor against four comparator thresholds and acts on the result in hardware: outside the hard pair it stops charging, inside the soft pair it subtracts a fixed offset from the fast-charge current. The driver already reads the resulting status bits for POWER_SUPPLY_PROP_HEALTH, but nothing programs the thresholds, so every board runs on the PMIC's power-on defaults. Measured on a Fairphone 3 (PMI632, 2026-07-29) those defaults are hard limits at roughly 11 and 58 degC, soft limits at 16 and 50 degC, and a 250 mA compensation, with JEITA_EN_CFG reading 0x1f - that is, the soft compensation is already enabled, just against generic thresholds. That is tolerable while a board charges at 1 A. It is not what a board wants once it charges at its battery's rated current, which is the point of describing the thresholds. Add three optional properties, all no-ops when absent so no existing board changes behaviour: qcom,jeita-hard-thresholds = qcom,jeita-soft-thresholds = qcom,jeita-soft-fcc-microamp = The thresholds are raw BAT_THERM ADC codes, which is how the comparators are specified and how Qualcomm's own device trees carry them. Deriving them from degrees Celsius instead was tried and rejected: mainline has the inverse conversion (qcom_adc_tm5_temp_volt_scale), but it uses the generic 100k pull-up curve, and against Fairphone's characterisation of this pack it is late at every one of the four thresholds - by 1.5 degC at 0 degC, 0.3 at 20, 1.3 at 45 and 2.0 at 55 - which widens each limit in the unsafe direction. A raw code involves no curve at all. The third property is the current to be left in each soft zone, rather than the register's own offset, so the device tree describes a charge current and the driver works out the delta from whatever is programmed. It reads the fast-charge current back out of the hardware for that, rather than trusting the device tree to match. Only the two charge-current compensation bits are enabled. The float-voltage halves are left alone: the register they scale by is not documented for this generation in any source available here, so there is nothing to program it from. Provenance: the register offsets, the big-endian hot-then-cold layout of each threshold block and the 25 mA compensation step are read out of Qualcomm's downstream qpnp-smb5 driver (smb5-reg.h, smblib_update_jeita(), the jeita_cc_comp entries of smb5_pmi632_params) as shipped in Fairphone's published Fairphone 3 kernel source release. The default register values above were read off a live device. New here is the device-tree interface and expressing the compensation as a target current. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 163 +++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index c1446eff4e42bf..4c2aa6915748b0 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -21,6 +21,7 @@ #include #include #include +#include #include /* clang-format off */ @@ -92,6 +93,21 @@ #define JEITA_EN_HOT_SL_CCC_BIT BIT(1) #define JEITA_EN_COLD_SL_CCC_BIT BIT(0) +#define JEITA_CCCOMP_CFG_HOT 0x92 +#define JEITA_CCCOMP_CFG_COLD 0x93 +#define JEITA_CCCOMP_MASK GENMASK(5, 0) +#define JEITA_CCCOMP_STEP_UA 25000 + +/* + * Two four-byte blocks of comparator thresholds, each holding the hot + * threshold followed by the cold one, big-endian, as raw BAT_THERM ADC codes. + * A higher code is a colder battery, so the hot threshold is the smaller + * number of the pair. + */ +#define JEITA_SOFT_THRESHOLDS 0x94 +#define JEITA_HARD_THRESHOLDS 0x98 +#define JEITA_THRESHOLDS_LEN 4 + #define INT_RT_STS 0x310 #define TYPE_C_CHANGE_RT_STS_BIT BIT(7) #define USBIN_ICL_CHANGE_RT_STS_BIT BIT(6) @@ -600,6 +616,22 @@ static int smb_set_current_limit(struct smb_chip *chip, unsigned int val) val_raw); } +static int smb_get_fast_charge_current(struct smb_chip *chip, unsigned int *val) +{ + unsigned int val_raw; + int rc; + + rc = regmap_read(chip->regmap, chip->base + FAST_CHARGE_CURRENT_CFG, + &val_raw); + if (rc < 0) + return rc; + + *val = (val_raw & FAST_CHARGE_CURRENT_SETTING_MASK) * + chip->var->current_scale_ua; + + return 0; +} + static void smb_status_change_work(struct work_struct *work) { unsigned int charger_type, current_ua; @@ -1193,6 +1225,133 @@ static int smb_init_hw(struct smb_chip *chip) return 0; } +/* + * Write one four-byte JEITA comparator block from a device-tree property + * holding the pair of raw BAT_THERM ADC codes { cold, hot }. Absent property + * leaves the PMIC's power-on defaults in place. + */ +static int smb_set_jeita_thresholds(struct smb_chip *chip, const char *prop, + unsigned int reg) +{ + u32 thresh[2]; + u8 buf[JEITA_THRESHOLDS_LEN]; + int rc; + + rc = device_property_read_u32_array(chip->dev, prop, thresh, + ARRAY_SIZE(thresh)); + if (rc == -EINVAL) + return 0; + if (rc < 0) + return dev_err_probe(chip->dev, rc, "Couldn't read %s\n", prop); + + if (thresh[0] > U16_MAX || thresh[1] > U16_MAX || thresh[1] >= thresh[0]) + return dev_err_probe(chip->dev, -EINVAL, + "%s: expected { cold, hot } ADC codes with cold > hot, got { %u, %u }\n", + prop, thresh[0], thresh[1]); + + put_unaligned_be16(thresh[1], &buf[0]); + put_unaligned_be16(thresh[0], &buf[2]); + + rc = regmap_bulk_write(chip->regmap, chip->base + reg, buf, sizeof(buf)); + if (rc < 0) + return dev_err_probe(chip->dev, rc, "Couldn't write %s\n", prop); + + return 0; +} + +/* + * Hardware JEITA. The hard thresholds stop charging outright and need no + * enabling; the soft ones only do something once the corresponding + * compensation bit is set, and then they subtract a fixed offset from the + * fast-charge current. Express that offset as the current we want to be left + * with in each soft zone, so the device tree carries a charge current rather + * than a register delta. + */ +static int smb_init_jeita(struct smb_chip *chip) +{ + unsigned int fcc_ua, comp_hot, comp_cold; + u32 soft_fcc_ua[2]; + int rc; + + rc = smb_set_jeita_thresholds(chip, "qcom,jeita-hard-thresholds", + JEITA_HARD_THRESHOLDS); + if (rc < 0) + return rc; + + rc = smb_set_jeita_thresholds(chip, "qcom,jeita-soft-thresholds", + JEITA_SOFT_THRESHOLDS); + if (rc < 0) + return rc; + + rc = device_property_read_u32_array(chip->dev, + "qcom,jeita-soft-fcc-microamp", + soft_fcc_ua, ARRAY_SIZE(soft_fcc_ua)); + if (rc == -EINVAL) + return 0; + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't read qcom,jeita-soft-fcc-microamp\n"); + + /* + * Read the fast-charge current back out of the hardware rather than + * taking it from the device tree: the compensation is a subtraction + * from whatever is actually programmed. + */ + rc = smb_get_fast_charge_current(chip, &fcc_ua); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't read the fast-charge current\n"); + + if (soft_fcc_ua[0] > fcc_ua || soft_fcc_ua[1] > fcc_ua) + return dev_err_probe(chip->dev, -EINVAL, + "JEITA soft-zone current { %u, %u } exceeds the fast-charge current %u\n", + soft_fcc_ua[0], soft_fcc_ua[1], fcc_ua); + + /* Round the reduction up, so the result is never above what was asked. */ + comp_cold = DIV_ROUND_UP(fcc_ua - soft_fcc_ua[0], JEITA_CCCOMP_STEP_UA); + comp_hot = DIV_ROUND_UP(fcc_ua - soft_fcc_ua[1], JEITA_CCCOMP_STEP_UA); + + if (comp_cold > JEITA_CCCOMP_MASK || comp_hot > JEITA_CCCOMP_MASK) + return dev_err_probe(chip->dev, -ERANGE, + "JEITA soft-zone current { %u, %u } is more than %u uA below the fast-charge current %u\n", + soft_fcc_ua[0], soft_fcc_ua[1], + (unsigned int)JEITA_CCCOMP_MASK * + JEITA_CCCOMP_STEP_UA, + fcc_ua); + + rc = regmap_update_bits(chip->regmap, + chip->base + JEITA_CCCOMP_CFG_COLD, + JEITA_CCCOMP_MASK, comp_cold); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't set the cold JEITA compensation\n"); + + rc = regmap_update_bits(chip->regmap, chip->base + JEITA_CCCOMP_CFG_HOT, + JEITA_CCCOMP_MASK, comp_hot); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't set the hot JEITA compensation\n"); + + /* + * Only the charge-current halves are enabled here. The float-voltage + * ones are left as the PMIC defaults them: this driver has no way to + * describe the voltage reduction they apply. + */ + rc = regmap_update_bits(chip->regmap, chip->base + JEITA_EN_CFG, + JEITA_EN_HOT_SL_CCC_BIT | JEITA_EN_COLD_SL_CCC_BIT, + JEITA_EN_HOT_SL_CCC_BIT | JEITA_EN_COLD_SL_CCC_BIT); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't enable JEITA compensation\n"); + + dev_dbg(chip->dev, + "JEITA: fcc %u uA, soft-zone cold %u uA (-%u), hot %u uA (-%u)\n", + fcc_ua, soft_fcc_ua[0], comp_cold * JEITA_CCCOMP_STEP_UA, + soft_fcc_ua[1], comp_hot * JEITA_CCCOMP_STEP_UA); + + return 0; +} + static int smb_init_irq(struct smb_chip *chip, int *irq, const char *name, irqreturn_t (*handler)(int irq, void *data)) { @@ -1284,6 +1443,10 @@ static int smb_probe(struct platform_device *pdev) if (rc < 0) return rc; + rc = smb_init_jeita(chip); + if (rc < 0) + return rc; + supply_config.drv_data = chip; supply_config.fwnode = dev_fwnode(&pdev->dev); From d72e57a3f0dadb1dee28f1f360b2ebf602d11178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 17:16:25 +0200 Subject: [PATCH 08/33] power: supply: qcom_smbx: expose the charge current as a cooling device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no path in this driver from "the phone is hot" to "charge slower". The charger is a substantial heat source at its rated current - on a Fairphone 3 charging a 3.8 V cell at 2 A from a 5 V port, over two watts land in the PMIC - and the only thing that currently limits it is the fixed fast-charge current programmed at probe. Register the fast-charge current as a thermal cooling device, so a thermal zone throttles charging the way it throttles a CPU. The states come from an optional qcom,thermal-mitigation array of currents, most permissive first; state 0 is what the board asked for unmitigated. Boards that do not describe it keep exactly today's behaviour. The array is checked at probe rather than trusted: at least two states, never increasing, and the first one no higher than the fast-charge current actually programmed in the hardware. This composes with the JEITA compensation added in the previous change without either needing to know about the other, because the compensation is a subtraction from whatever is programmed here - a mitigated current stays mitigated inside the JEITA soft zones. Provenance: qcom,thermal-mitigation is Qualcomm's own property name and unit, used by the downstream qpnp-smb5 driver, where the equivalent table is driven through a POWER_SUPPLY_PROP_SYSTEM_TEMP_LEVEL vendor property from a userspace thermal daemon. Driving it from a thermal zone instead is what is new here. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 137 +++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 4c2aa6915748b0..540693835417ed 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -445,6 +446,10 @@ struct smb_variant { * @bat_therm_chan: Battery thermistor (BAT_THERM) measurement channel * @chg_psy: Charger power supply instance * @batt_psy: Battery (fuel-gauge) power supply instance + * @thermal_mitigation_ua: Fast-charge current for each thermal cooling state, + * from qcom,thermal-mitigation, most permissive first + * @thermal_levels: Number of entries in @thermal_mitigation_ua + * @thermal_level: Cooling state currently in effect */ struct smb_chip { struct device *dev; @@ -465,6 +470,10 @@ struct smb_chip { struct power_supply *chg_psy; struct power_supply *batt_psy; + + u32 *thermal_mitigation_ua; + unsigned int thermal_levels; + unsigned long thermal_level; }; static enum power_supply_property smb_properties[] = { @@ -632,6 +641,18 @@ static int smb_get_fast_charge_current(struct smb_chip *chip, unsigned int *val) return 0; } +static int smb_set_fast_charge_current(struct smb_chip *chip, unsigned int val) +{ + unsigned int val_raw = val / chip->var->current_scale_ua; + + if (val_raw > FAST_CHARGE_CURRENT_SETTING_MASK) + return -EINVAL; + + return regmap_update_bits(chip->regmap, + chip->base + FAST_CHARGE_CURRENT_CFG, + FAST_CHARGE_CURRENT_SETTING_MASK, val_raw); +} + static void smb_status_change_work(struct work_struct *work) { unsigned int charger_type, current_ua; @@ -1352,6 +1373,118 @@ static int smb_init_jeita(struct smb_chip *chip) return 0; } +static int smb_tcd_get_max_state(struct thermal_cooling_device *tcd, + unsigned long *state) +{ + struct smb_chip *chip = tcd->devdata; + + *state = chip->thermal_levels - 1; + + return 0; +} + +static int smb_tcd_get_cur_state(struct thermal_cooling_device *tcd, + unsigned long *state) +{ + struct smb_chip *chip = tcd->devdata; + + *state = chip->thermal_level; + + return 0; +} + +static int smb_tcd_set_cur_state(struct thermal_cooling_device *tcd, + unsigned long state) +{ + struct smb_chip *chip = tcd->devdata; + int rc; + + if (state >= chip->thermal_levels) + return -EINVAL; + + rc = smb_set_fast_charge_current(chip, + chip->thermal_mitigation_ua[state]); + if (rc < 0) + return rc; + + chip->thermal_level = state; + + return 0; +} + +static const struct thermal_cooling_device_ops smb_tcd_ops = { + .get_max_state = smb_tcd_get_max_state, + .get_cur_state = smb_tcd_get_cur_state, + .set_cur_state = smb_tcd_set_cur_state, +}; + +/* + * Expose the fast-charge current as a cooling device, so a thermal zone can + * throttle charging the way it throttles a CPU. State 0 is the unmitigated + * current the board asked for and each further state is lower. + * + * The JEITA soft-zone compensation is a fixed subtraction from whatever is + * programmed here, so a mitigated current stays mitigated in the soft zones + * too. + */ +static int smb_init_cooling(struct smb_chip *chip) +{ + struct thermal_cooling_device *tcd; + unsigned int fcc_ua; + int count, i, rc; + + count = device_property_count_u32(chip->dev, "qcom,thermal-mitigation"); + if (count == -EINVAL) + return 0; + if (count < 0) + return dev_err_probe(chip->dev, count, + "Couldn't read qcom,thermal-mitigation\n"); + if (count < 2) + return dev_err_probe(chip->dev, -EINVAL, + "qcom,thermal-mitigation needs at least two states\n"); + + chip->thermal_mitigation_ua = devm_kcalloc(chip->dev, count, + sizeof(*chip->thermal_mitigation_ua), + GFP_KERNEL); + if (!chip->thermal_mitigation_ua) + return -ENOMEM; + + rc = device_property_read_u32_array(chip->dev, "qcom,thermal-mitigation", + chip->thermal_mitigation_ua, count); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't read qcom,thermal-mitigation\n"); + + rc = smb_get_fast_charge_current(chip, &fcc_ua); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't read the fast-charge current\n"); + + if (chip->thermal_mitigation_ua[0] > fcc_ua) + return dev_err_probe(chip->dev, -EINVAL, + "qcom,thermal-mitigation starts at %u uA, above the fast-charge current %u\n", + chip->thermal_mitigation_ua[0], fcc_ua); + + for (i = 1; i < count; i++) + if (chip->thermal_mitigation_ua[i] > + chip->thermal_mitigation_ua[i - 1]) + return dev_err_probe(chip->dev, -EINVAL, + "qcom,thermal-mitigation must not increase (state %d)\n", + i); + + chip->thermal_levels = count; + + tcd = devm_thermal_of_cooling_device_register(chip->dev, + dev_of_node(chip->dev), + "qcom-smbx-charger", chip, + &smb_tcd_ops); + if (IS_ERR(tcd)) + return dev_err_probe(chip->dev, PTR_ERR(tcd), + "Couldn't register the cooling device\n"); + + return 0; +} + static int smb_init_irq(struct smb_chip *chip, int *irq, const char *name, irqreturn_t (*handler)(int irq, void *data)) { @@ -1447,6 +1580,10 @@ static int smb_probe(struct platform_device *pdev) if (rc < 0) return rc; + rc = smb_init_cooling(chip); + if (rc < 0) + return rc; + supply_config.drv_data = chip; supply_config.fwnode = dev_fwnode(&pdev->dev); From f789ee940e012f29c68b222c57b6040f250f1dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 17:18:40 +0200 Subject: [PATCH 09/33] power: supply: qcom_smbx: let the battery set the fast-charge current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit power_supply_get_battery_info() is called at probe, but the only field this driver applies to the hardware is voltage_max_design_uv. In particular constant-charge-current-max-microamp does nothing at all: the fast-charge current is whatever the per-generation init sequence wrote, ~1 A, chosen as a deliberately conservative bring-up limit to minimise fire-hazard risk. A board that has characterised its pack has no way to say so. Program the fast-charge current from the monitored battery, bounded by a new per-generation ceiling, smb_variant::fcc_max_ua. The ceiling is what keeps the original intent: a device tree can ask for a current, but not for one this driver has not been taught is safe on that PMIC generation, and raising the ceiling stays a change to the driver that someone has to justify. Set to 1 A on SMB2 (pmi8998, pm660), which is exactly what those boards get today - sdm845-oneplus-enchilada and -fajita already ask for 1.8 A in their battery nodes and continue to charge at 1 A, unchanged by this patch. Set to 2 A on SMB5 (pmi632). That number is measured rather than picked: it is the most a Fairphone 3 can usefully take, since without high-voltage negotiation the input side tops out near 1.5 A at 5 V, and it leaves the JEITA soft-zone compensation - a six-bit field, at most 1575 mA of reduction - able to express this battery's characterised 600 mA cool-zone current, which it could not at the pack's rated 2.7 A. Also move the JEITA and cooling-device setup below this point, since both read the fast-charge current back out of the hardware. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 48 ++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 540693835417ed..d85840baab846a 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -402,6 +402,11 @@ struct smb_init_register { * (0x600 = MISC on SMB2, 0x100 = DCDC on SMB5) * @current_scale_ua: uA per LSB of the FCC / ICL registers * (25000 on SMB2/pmi8998, 50000 on SMB5/pmi632) + * @fcc_max_ua: Highest fast-charge current a monitored-battery may ask + * for on this generation. This is a fire-hazard limit, + * not a hardware one: raising it for a generation means + * someone has charged a board of that generation at the + * new current and watched the temperatures * @float_base_uv: Float-voltage register value 0 corresponds to this voltage * @float_step_uv: uV per LSB of FLOAT_VOLTAGE_CFG * @ov_bit: BAT_OV (overvoltage) bit within BATTERY_CHARGER_STATUS_2 @@ -421,6 +426,7 @@ struct smb_variant { const char *name; u16 status_base; u32 current_scale_ua; + u32 fcc_max_ua; u32 float_base_uv; u32 float_step_uv; u8 ov_bit; @@ -1114,7 +1120,8 @@ static const struct smb_init_register smb2_init_seq[] = { /* * This overrides all of the current limit options exposed to userspace * and prevents the device from pulling more than ~1A. This is done - * to minimise potential fire hazard risks. + * to minimise potential fire hazard risks. smb_variant::fcc_max_ua + * holds this generation to the same ~1A once the battery is known. */ { .addr = FAST_CHARGE_CURRENT_CFG, .mask = FAST_CHARGE_CURRENT_SETTING_MASK, @@ -1171,9 +1178,9 @@ static const struct smb_init_register pmi632_init_seq[] = { .mask = PRE_CHARGE_CURRENT_SETTING_MASK, .val = 500000 / SMB5_CURRENT_SCALE_FACTOR }, /* - * Cap fast-charge current at ~1A. This is a deliberately conservative - * limit for first bring-up to minimise any fire-hazard risk; the DT - * monitored-battery may lower it further but not raise it past this. + * Fast-charge at ~1A until the battery is known. A board that describes + * a monitored-battery gets its constant-charge-current-max-microamp + * instead, up to smb_variant::fcc_max_ua. */ { .addr = FAST_CHARGE_CURRENT_CFG, .mask = FAST_CHARGE_CURRENT_SETTING_MASK, @@ -1184,6 +1191,7 @@ static const struct smb_variant smb_variant_pmi8998 = { .name = "pmi8998", .status_base = 0x600, .current_scale_ua = CURRENT_SCALE_FACTOR, + .fcc_max_ua = 1000000, .float_base_uv = 3480000, /* (v - 3487500) / 7500 + 1 == (v - 3480000) / 7500 */ .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, @@ -1197,6 +1205,7 @@ static const struct smb_variant smb_variant_pm660 = { .name = "pm660", .status_base = 0x600, .current_scale_ua = CURRENT_SCALE_FACTOR, + .fcc_max_ua = 1000000, .float_base_uv = 3480000, .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, @@ -1210,6 +1219,7 @@ static const struct smb_variant smb_variant_pmi632 = { .name = "pmi632", .status_base = 0x100, /* ICL/POWER_PATH_STATUS in DCDC, not MISC */ .current_scale_ua = SMB5_CURRENT_SCALE_FACTOR, + .fcc_max_ua = 2000000, .float_base_uv = 3600000, /* qpnp-smb5 fv: min 3600000, step 10000 */ .float_step_uv = 10000, .ov_bit = SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT, @@ -1576,14 +1586,6 @@ static int smb_probe(struct platform_device *pdev) if (rc < 0) return rc; - rc = smb_init_jeita(chip); - if (rc < 0) - return rc; - - rc = smb_init_cooling(chip); - if (rc < 0) - return rc; - supply_config.drv_data = chip; supply_config.fwnode = dev_fwnode(&pdev->dev); @@ -1635,6 +1637,28 @@ static int smb_probe(struct platform_device *pdev) if (rc < 0) return dev_err_probe(chip->dev, rc, "Couldn't set vbat max\n"); + /* + * Let the battery say what it will take, bounded by what this driver is + * willing to allow the generation to draw (smb_variant::fcc_max_ua). + */ + if (chip->batt_info->constant_charge_current_max_ua > 0) { + unsigned int fcc_ua = min_t(u32, chip->var->fcc_max_ua, + chip->batt_info->constant_charge_current_max_ua); + + rc = smb_set_fast_charge_current(chip, fcc_ua); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't set the fast-charge current\n"); + } + + rc = smb_init_jeita(chip); + if (rc < 0) + return rc; + + rc = smb_init_cooling(chip); + if (rc < 0) + return rc; + rc = smb_init_irq(chip, &irq, "bat-ov", smb_handle_batt_overvoltage); if (rc < 0) return rc; From 7ae20bd51fcf52508958a299e4cc76e2b23feb13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 20:42:27 +0200 Subject: [PATCH 10/33] power: supply: qcom_smbx: bound the charge current by hardware, not by policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous change made the monitored battery's constant-charge-current-max reach the hardware, and then bounded it with a new per-generation constant - 1 A on SMB2, 2 A on SMB5. That constant is the same defect the change set out to remove, one layer over. Neither number is a fact about the PMIC: - the PMI632's fast-charge register goes to 3 A, not 2. The 2 A was the rating of the battery in one Fairphone 3, which every other PMI632 board would then have been held to; - the pmi8998's goes to 4.5 A, not 1. Set smb_variant::fcc_max_ua to the datasheet maxima Qualcomm's own drivers carry (smb_params.fcc.max_u in qpnp-smb2 and qpnp-smb5), and let the battery node decide everything below that. How much current a pack should take, and whether the board can carry the heat, are properties of the pack and of the board; this file knows neither. That does change the two SMB2 boards in mainline: sdm845-oneplus-enchilada and -fajita ask for 1.8 A in their battery nodes and have been charging at 1 A because of the init-sequence cap. They will now charge at the current their own device trees describe. That is the point of the property, but it is a behaviour change on hardware not tested here, and it should be acked by someone with those boards. Boards that describe no monitored-battery are unaffected: the init sequence still leaves ~1 A. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index d85840baab846a..38a7274707dbc2 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -402,11 +402,11 @@ struct smb_init_register { * (0x600 = MISC on SMB2, 0x100 = DCDC on SMB5) * @current_scale_ua: uA per LSB of the FCC / ICL registers * (25000 on SMB2/pmi8998, 50000 on SMB5/pmi632) - * @fcc_max_ua: Highest fast-charge current a monitored-battery may ask - * for on this generation. This is a fire-hazard limit, - * not a hardware one: raising it for a generation means - * someone has charged a board of that generation at the - * new current and watched the temperatures + * @fcc_max_ua: Highest fast-charge current this PMIC generation can + * deliver, from the datasheet values Qualcomm's own + * drivers carry (qpnp-smb2 / qpnp-smb5 smb_params.fcc.max_u). + * A hardware bound, not a policy one - how much of it a + * board may use is the board's business, not this file's * @float_base_uv: Float-voltage register value 0 corresponds to this voltage * @float_step_uv: uV per LSB of FLOAT_VOLTAGE_CFG * @ov_bit: BAT_OV (overvoltage) bit within BATTERY_CHARGER_STATUS_2 @@ -1120,8 +1120,8 @@ static const struct smb_init_register smb2_init_seq[] = { /* * This overrides all of the current limit options exposed to userspace * and prevents the device from pulling more than ~1A. This is done - * to minimise potential fire hazard risks. smb_variant::fcc_max_ua - * holds this generation to the same ~1A once the battery is known. + * to minimise potential fire hazard risks. A board that describes a + * monitored-battery gets that battery's current instead. */ { .addr = FAST_CHARGE_CURRENT_CFG, .mask = FAST_CHARGE_CURRENT_SETTING_MASK, @@ -1180,7 +1180,7 @@ static const struct smb_init_register pmi632_init_seq[] = { /* * Fast-charge at ~1A until the battery is known. A board that describes * a monitored-battery gets its constant-charge-current-max-microamp - * instead, up to smb_variant::fcc_max_ua. + * instead. */ { .addr = FAST_CHARGE_CURRENT_CFG, .mask = FAST_CHARGE_CURRENT_SETTING_MASK, @@ -1191,7 +1191,7 @@ static const struct smb_variant smb_variant_pmi8998 = { .name = "pmi8998", .status_base = 0x600, .current_scale_ua = CURRENT_SCALE_FACTOR, - .fcc_max_ua = 1000000, + .fcc_max_ua = 4500000, .float_base_uv = 3480000, /* (v - 3487500) / 7500 + 1 == (v - 3480000) / 7500 */ .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, @@ -1205,7 +1205,7 @@ static const struct smb_variant smb_variant_pm660 = { .name = "pm660", .status_base = 0x600, .current_scale_ua = CURRENT_SCALE_FACTOR, - .fcc_max_ua = 1000000, + .fcc_max_ua = 4500000, .float_base_uv = 3480000, .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, @@ -1219,7 +1219,7 @@ static const struct smb_variant smb_variant_pmi632 = { .name = "pmi632", .status_base = 0x100, /* ICL/POWER_PATH_STATUS in DCDC, not MISC */ .current_scale_ua = SMB5_CURRENT_SCALE_FACTOR, - .fcc_max_ua = 2000000, + .fcc_max_ua = 3000000, .float_base_uv = 3600000, /* qpnp-smb5 fv: min 3600000, step 10000 */ .float_step_uv = 10000, .ov_bit = SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT, @@ -1638,8 +1638,10 @@ static int smb_probe(struct platform_device *pdev) return dev_err_probe(chip->dev, rc, "Couldn't set vbat max\n"); /* - * Let the battery say what it will take, bounded by what this driver is - * willing to allow the generation to draw (smb_variant::fcc_max_ua). + * Let the battery say what it will take, bounded only by what the PMIC + * can physically deliver. Whether that current is appropriate is a + * property of the pack and of the board's thermal design, both of which + * the device tree describes and this file does not know. */ if (chip->batt_info->constant_charge_current_max_ua > 0) { unsigned int fcc_ua = min_t(u32, chip->var->fcc_max_ua, From 3f66bb72bc3227eed1a72eef5618c3431c0b0737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 20:48:42 +0200 Subject: [PATCH 11/33] power: supply: qcom_smbx: read the JEITA description from the battery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JEITA thresholds and the per-zone charge currents were read off the charger node, which is where the driver already had a struct device and therefore the convenient place to put them. It is the wrong place: which temperatures a cell may be charged at, and how hard, is a property of the cell, and Qualcomm's own trees keep those numbers with the battery data for exactly that reason. The cost of having them on the charger was not theoretical. A board that fits more than one pack cannot describe both - and the board this was developed on, the Fairphone 3, ships two 3000 mAh packs with different cool-band thresholds, told apart at runtime by a battery-ID resistor. Read them through the monitored-battery reference instead, so each described battery carries its own. No functional change for a board with one battery beyond where the properties are written. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 39 +++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 38a7274707dbc2..8d0314bab85ba3 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -9,6 +9,7 @@ */ #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -1257,19 +1259,20 @@ static int smb_init_hw(struct smb_chip *chip) } /* - * Write one four-byte JEITA comparator block from a device-tree property - * holding the pair of raw BAT_THERM ADC codes { cold, hot }. Absent property - * leaves the PMIC's power-on defaults in place. + * Write one four-byte JEITA comparator block from a property on the battery + * node holding the pair of raw BAT_THERM ADC codes { cold, hot }. Absent + * property leaves the PMIC's power-on defaults in place. */ -static int smb_set_jeita_thresholds(struct smb_chip *chip, const char *prop, - unsigned int reg) +static int smb_set_jeita_thresholds(struct smb_chip *chip, + struct fwnode_handle *batt, + const char *prop, unsigned int reg) { u32 thresh[2]; u8 buf[JEITA_THRESHOLDS_LEN]; int rc; - rc = device_property_read_u32_array(chip->dev, prop, thresh, - ARRAY_SIZE(thresh)); + rc = fwnode_property_read_u32_array(batt, prop, thresh, + ARRAY_SIZE(thresh)); if (rc == -EINVAL) return 0; if (rc < 0) @@ -1295,28 +1298,38 @@ static int smb_set_jeita_thresholds(struct smb_chip *chip, const char *prop, * enabling; the soft ones only do something once the corresponding * compensation bit is set, and then they subtract a fixed offset from the * fast-charge current. Express that offset as the current we want to be left - * with in each soft zone, so the device tree carries a charge current rather + * with in each soft zone, so the battery node carries a charge current rather * than a register delta. + * + * All of it is read from the monitored battery, not from this device: which + * temperatures a cell may be charged at, and how hard, is a property of the + * cell. A board that fits more than one pack describes each of them. */ static int smb_init_jeita(struct smb_chip *chip) { + struct fwnode_handle *batt __free(fwnode_handle) = + fwnode_find_reference(dev_fwnode(chip->dev), + "monitored-battery", 0); unsigned int fcc_ua, comp_hot, comp_cold; u32 soft_fcc_ua[2]; int rc; - rc = smb_set_jeita_thresholds(chip, "qcom,jeita-hard-thresholds", + if (IS_ERR(batt)) + return 0; + + rc = smb_set_jeita_thresholds(chip, batt, "qcom,jeita-hard-thresholds", JEITA_HARD_THRESHOLDS); if (rc < 0) return rc; - rc = smb_set_jeita_thresholds(chip, "qcom,jeita-soft-thresholds", + rc = smb_set_jeita_thresholds(chip, batt, "qcom,jeita-soft-thresholds", JEITA_SOFT_THRESHOLDS); if (rc < 0) return rc; - rc = device_property_read_u32_array(chip->dev, - "qcom,jeita-soft-fcc-microamp", - soft_fcc_ua, ARRAY_SIZE(soft_fcc_ua)); + rc = fwnode_property_read_u32_array(batt, + "qcom,jeita-soft-fcc-microamp", + soft_fcc_ua, ARRAY_SIZE(soft_fcc_ua)); if (rc == -EINVAL) return 0; if (rc < 0) From 3aea14d3bd04213609569bd9ac877b36dee6a992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 20:49:57 +0200 Subject: [PATCH 12/33] power: supply: qcom_smbx: do not apply one battery's limits to another MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A power supply has one monitored-battery, so a board that can be fitted with more than one pack has to name a single one and hope. The Fairphone 3 ships two 3000 mAh packs - different part numbers, 2.0 A against 2.7 A, and JEITA cool bands at 15 and 20 degC - and picking the wrong one in the device tree is undetectable from software today: the charger simply applies one cell's currents and temperature limits to the other. The hardware does provide the answer. The packs carry different ID resistors, read through a divider against the ADC's 1.875 V reference, and that is how the vendor stack tells them apart. This does not add battery *selection*; there is no binding for a second monitored-battery, and inventing one belongs in a separate discussion. It adds the check that makes the single description honest: given - a "bat_id" io-channel on the charger, - qcom,batt-id-pullup-ohm on the charger (which pull-up the board wires), and - qcom,batt-id-ohm on the battery (what that pack should read), the measured resistance is compared against the described one, within qcom,batt-id-tolerance-percent (default 15, matching the vendor's batt-id-range-pct on this board). A mismatch, or an open or shorted ID line, leaves the charger on the init sequence's conservative defaults instead of on another cell's limits, and says so in the log. Boards that describe none of the three are unaffected. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 122 +++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 14 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 8d0314bab85ba3..5099f4c3629f9e 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -111,6 +111,10 @@ #define JEITA_HARD_THRESHOLDS 0x98 #define JEITA_THRESHOLDS_LEN 4 +/* The BAT_ID divider is biased from the ADC's own 1.875 V reference. */ +#define BATT_ID_VREF_UV 1875000 +#define BATT_ID_DEFAULT_TOLERANCE_PCT 15 + #define INT_RT_STS 0x310 #define TYPE_C_CHANGE_RT_STS_BIT BIT(7) #define USBIN_ICL_CHANGE_RT_STS_BIT BIT(6) @@ -452,6 +456,8 @@ struct smb_variant { * @usb_in_v_chan: USB_IN voltage measurement channel * @vbat_chan: Battery voltage (VBAT_SNS) measurement channel * @bat_therm_chan: Battery thermistor (BAT_THERM) measurement channel + * @bat_id_chan: Battery-ID resistor measurement channel, if the board + * routes one * @chg_psy: Charger power supply instance * @batt_psy: Battery (fuel-gauge) power supply instance * @thermal_mitigation_ua: Fast-charge current for each thermal cooling state, @@ -475,6 +481,7 @@ struct smb_chip { struct iio_channel *usb_in_v_chan; struct iio_channel *vbat_chan; struct iio_channel *bat_therm_chan; + struct iio_channel *bat_id_chan; struct power_supply *chg_psy; struct power_supply *batt_psy; @@ -1293,6 +1300,67 @@ static int smb_set_jeita_thresholds(struct smb_chip *chip, return 0; } +/* + * A board that can be fitted with more than one battery tells them apart by a + * resistor in the pack, read through a divider against the ADC's reference. + * + * This driver cannot *choose* between two batteries - a power supply has one + * monitored-battery and there is no binding for more - but it can refuse to + * apply one battery's limits to a different battery, which is the failure that + * actually hurts: a cell charged to another cell's currents and temperature + * limits, silently, because the device tree names only one. + * + * Returns 1 when the described battery is the one fitted, or when nothing here + * says otherwise; 0 when it demonstrably is not; negative on error. + */ +static int smb_verify_battery_id(struct smb_chip *chip) +{ + struct fwnode_handle *batt __free(fwnode_handle) = + fwnode_find_reference(dev_fwnode(chip->dev), + "monitored-battery", 0); + u32 expect_ohm, pullup_ohm; + u32 tol_pct = BATT_ID_DEFAULT_TOLERANCE_PCT; + int rc, uv, ohm; + + if (IS_ERR(batt)) + return 1; + + /* All three have to be described before there is anything to check. */ + if (fwnode_property_read_u32(batt, "qcom,batt-id-ohm", &expect_ohm) || + device_property_read_u32(chip->dev, "qcom,batt-id-pullup-ohm", + &pullup_ohm) || + !chip->bat_id_chan) + return 1; + + fwnode_property_read_u32(batt, "qcom,batt-id-tolerance-percent", + &tol_pct); + + rc = iio_read_channel_processed(chip->bat_id_chan, &uv); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't read the battery ID\n"); + + if (uv <= 0 || uv >= BATT_ID_VREF_UV) { + dev_err(chip->dev, + "Battery ID line reads %d uV: open or shorted\n", uv); + return 0; + } + + ohm = div_u64((u64)pullup_ohm * uv, BATT_ID_VREF_UV - uv); + + if (abs(ohm - (int)expect_ohm) * 100 > (int)expect_ohm * (int)tol_pct) { + dev_err(chip->dev, + "Battery ID is %d ohm, but the described battery is %u ohm +/-%u%%: not applying its charging limits\n", + ohm, expect_ohm, tol_pct); + return 0; + } + + dev_dbg(chip->dev, "Battery ID %d ohm matches the described %u ohm\n", + ohm, expect_ohm); + + return 1; +} + /* * Hardware JEITA. The hard thresholds stop charging outright and need no * enabling; the soft ones only do something once the corresponding @@ -1595,6 +1663,18 @@ static int smb_probe(struct platform_device *pdev) chip->bat_therm_chan = NULL; } + /* + * BAT_ID likewise: only boards that can be fitted with more than one + * battery have a reason to route it. + */ + chip->bat_id_chan = devm_iio_channel_get(chip->dev, "bat_id"); + if (IS_ERR(chip->bat_id_chan)) { + rc = PTR_ERR(chip->bat_id_chan); + if (rc == -EPROBE_DEFER) + return rc; + chip->bat_id_chan = NULL; + } + rc = smb_init_hw(chip); if (rc < 0) return rc; @@ -1651,25 +1731,39 @@ static int smb_probe(struct platform_device *pdev) return dev_err_probe(chip->dev, rc, "Couldn't set vbat max\n"); /* - * Let the battery say what it will take, bounded only by what the PMIC - * can physically deliver. Whether that current is appropriate is a - * property of the pack and of the board's thermal design, both of which - * the device tree describes and this file does not know. + * Everything below describes the *battery*, so none of it may be + * applied to a battery that is not the one described. Where the board + * gives us a way to tell, check first; a mismatch leaves the charger on + * the init sequence's conservative defaults rather than on another + * cell's limits. */ - if (chip->batt_info->constant_charge_current_max_ua > 0) { - unsigned int fcc_ua = min_t(u32, chip->var->fcc_max_ua, - chip->batt_info->constant_charge_current_max_ua); + rc = smb_verify_battery_id(chip); + if (rc < 0) + return rc; - rc = smb_set_fast_charge_current(chip, fcc_ua); + if (rc > 0) { + /* + * Let the battery say what it will take, bounded only by what + * the PMIC can physically deliver. Whether that current is + * appropriate is a property of the pack and of the board's + * thermal design, both of which the device tree describes and + * this file does not know. + */ + if (chip->batt_info->constant_charge_current_max_ua > 0) { + unsigned int fcc_ua = min_t(u32, chip->var->fcc_max_ua, + chip->batt_info->constant_charge_current_max_ua); + + rc = smb_set_fast_charge_current(chip, fcc_ua); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't set the fast-charge current\n"); + } + + rc = smb_init_jeita(chip); if (rc < 0) - return dev_err_probe(chip->dev, rc, - "Couldn't set the fast-charge current\n"); + return rc; } - rc = smb_init_jeita(chip); - if (rc < 0) - return rc; - rc = smb_init_cooling(chip); if (rc < 0) return rc; From 5c1e9ee5899f9c0538098fb4b6e768831f49f4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 21:18:28 +0200 Subject: [PATCH 13/33] power: supply: qcom_smbx: let thermal mitigation only ever reduce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cooling device rejected a qcom,thermal-mitigation table whose first state was above the programmed fast-charge current. That check is wrong in exactly the case the battery-ID verification exists to create: when the fitted battery cannot be identified the charger stays on the init sequence's ~1 A, the board's table still starts at the current it was written for, and probe then fails with -EINVAL - leaving the phone with no charger driver at all, as the outcome of a safety fallback. Clamp each state to the current actually programmed instead. Mitigation may only ever reduce, so a table written for 2 A collapses harmlessly onto 1 A when that is all the charger is running, and the states stay monotonic. The DT-sanity check that the table does not increase is kept. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 5099f4c3629f9e..265faeff816b84 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -1551,11 +1551,6 @@ static int smb_init_cooling(struct smb_chip *chip) return dev_err_probe(chip->dev, rc, "Couldn't read the fast-charge current\n"); - if (chip->thermal_mitigation_ua[0] > fcc_ua) - return dev_err_probe(chip->dev, -EINVAL, - "qcom,thermal-mitigation starts at %u uA, above the fast-charge current %u\n", - chip->thermal_mitigation_ua[0], fcc_ua); - for (i = 1; i < count; i++) if (chip->thermal_mitigation_ua[i] > chip->thermal_mitigation_ua[i - 1]) @@ -1563,6 +1558,16 @@ static int smb_init_cooling(struct smb_chip *chip) "qcom,thermal-mitigation must not increase (state %d)\n", i); + /* + * Mitigation may only ever reduce. The table is written for the current + * the board expects to charge at, and the charger may be running below + * that - on the init-sequence default, because the fitted battery could + * not be identified - in which case a state must not raise it back up. + */ + for (i = 0; i < count; i++) + chip->thermal_mitigation_ua[i] = + min(chip->thermal_mitigation_ua[i], fcc_ua); + chip->thermal_levels = count; tcd = devm_thermal_of_cooling_device_register(chip->dev, From e288b8758533af22b399cd04b3366af3dca614f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Thu, 30 Jul 2026 05:39:45 +0200 Subject: [PATCH 14/33] power: supply: qcom_smbx: fix three checkpatch --strict complaints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two continuation lines were one space short of the open parenthesis, and one exceeded 100 columns; give the long expression a local instead. No functional change. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 265faeff816b84..04f862dd3d5c4b 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -1279,7 +1279,7 @@ static int smb_set_jeita_thresholds(struct smb_chip *chip, int rc; rc = fwnode_property_read_u32_array(batt, prop, thresh, - ARRAY_SIZE(thresh)); + ARRAY_SIZE(thresh)); if (rc == -EINVAL) return 0; if (rc < 0) @@ -1396,8 +1396,9 @@ static int smb_init_jeita(struct smb_chip *chip) return rc; rc = fwnode_property_read_u32_array(batt, - "qcom,jeita-soft-fcc-microamp", - soft_fcc_ua, ARRAY_SIZE(soft_fcc_ua)); + "qcom,jeita-soft-fcc-microamp", + soft_fcc_ua, + ARRAY_SIZE(soft_fcc_ua)); if (rc == -EINVAL) return 0; if (rc < 0) @@ -1755,8 +1756,8 @@ static int smb_probe(struct platform_device *pdev) * this file does not know. */ if (chip->batt_info->constant_charge_current_max_ua > 0) { - unsigned int fcc_ua = min_t(u32, chip->var->fcc_max_ua, - chip->batt_info->constant_charge_current_max_ua); + u32 batt_ua = chip->batt_info->constant_charge_current_max_ua; + unsigned int fcc_ua = min(chip->var->fcc_max_ua, batt_ua); rc = smb_set_fast_charge_current(chip, fcc_ua); if (rc < 0) From ab781e5bcbc3c145dd1eecbb07c28ba646230fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 8 Aug 2026 06:43:33 +0200 Subject: [PATCH 15/33] power: supply: qcom_smbx: gauge the battery from the PMIC's QG samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported capacity came from looking the battery's terminal voltage up in the monitored-battery OCV table. That table maps an open-circuit voltage, and a phone's terminal voltage is anything but: on a Fairphone 3 a stretch of CPU load moves it by 250 mV, while the same table spends eighteen points of charge on the 40 mV between 3.80 V and 3.84 V. The result was not an imprecise gauge but one that tracked the load. Measured on that phone against the vendor stack, which held 57% throughout: this driver read 65% at rest, fell to 6% over three minutes of eight-thread CPU burn, and climbed back to 60% within a minute of the burn ending. SMB5 PMICs sit next to a QG fuel-gauge peripheral, and it is already running - the PMIC's own boot sequence starts it, so its sample registers are live with nothing here configuring them. That gives us the battery current alongside the voltage, from one converter at one instant, which is what the usual arrangement needs: integrate the current, and correct the integral against the OCV table only while the current is small enough for the IR term to be worth trusting. Two currents gate that correction, both well below what this phone draws awake, so the gauge steers while it idles or charges gently and coasts on the integral otherwise. A poll that arrives more than a minute late means the system was suspended, and the sample says nothing about the interval that passed - but a suspended phone is a rested battery, the one state where the table needs no correction at all, so those re-anchor outright. The gauge starts from the open-circuit voltage the PMIC measured with nothing drawing, in preference to a live sample taken while the machine is busy booting. The battery current and the open-circuit voltage are worth having on their own, so report both. A PMIC without a QG peripheral keeps the old voltage lookup, with the caveat above it written down. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 419 +++++++++++++++++++++++++++++-- 1 file changed, 403 insertions(+), 16 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 04f862dd3d5c4b..32d82a6e8f5b7c 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -14,8 +14,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -23,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -375,6 +378,48 @@ #define STAT_FUNCTION_CFG_BIT BIT(1) #define STAT_IRQ_PULSING_EN_BIT BIT(0) +/* + * Fuel-gauge (QG) peripheral, present on SMB5 PMICs. Its base is absolute + * inside the PMIC rather than relative to the charger's own base, and is + * carried in smb_variant::qg_base. The offsets and the LSB sizes below are + * the downstream qpnp-qg driver's (qg-reg.h, qg-defs.h). + * + * This driver only ever reads the peripheral: the PMIC's own boot sequence + * starts the gauge, so the sample registers are live without anything here + * configuring them. Verified on a Fairphone 3 running this driver, where the + * ADC registers track a load step within one sample period. + */ +#define QG_S7_PON_OCV_V_DATA0 0x70 +#define QG_S7_PON_OCV_I_DATA0 0x72 +#define QG_S3_GOOD_OCV_V_DATA0 0x74 +#define QG_S3_GOOD_OCV_I_DATA0 0x76 +#define QG_LAST_ADC_V_DATA0 0xc0 +#define QG_LAST_ADC_I_DATA0 0xc2 + +/* Both sample registers read back as 0x8000 while they hold no measurement */ +#define QG_ADC_INVALID 0x8000 +/* nV per LSB of the voltage ADC, nA per LSB of the current ADC */ +#define QG_V_LSB_NV 194637 +#define QG_I_LSB_NA 152588 + +/* + * How often the gauge integrates, and the currents at which an open-circuit + * voltage reading is trusted enough to correct the integral. The wider band + * still carries an IR error of a few millivolts, which is why it only nudges; + * the narrow one is small enough to steer with. + */ +#define SMB_FG_POLL_MS 10000 +#define SMB_FG_OCV_QUIET_UA 50000 +#define SMB_FG_OCV_SETTLED_UA 150000 +#define SMB_FG_OCV_WEIGHT_QUIET 25 +#define SMB_FG_OCV_WEIGHT_SETTLED 3 +/* + * A poll this late means the CPU was suspended rather than merely busy, so + * the last current sample says nothing about the interval. Re-anchor on the + * open-circuit voltage instead, which a rested battery reports accurately. + */ +#define SMB_FG_STALE_MS 60000 + #define SDP_CURRENT_UA 500000 #define CDP_CURRENT_UA 1500000 #define DCP_CURRENT_UA 1500000 @@ -424,6 +469,8 @@ struct smb_init_register { * bits versus the SMB2 baseline (0 on SMB2, 2 on SMB5) * @init_seq: HW init register write sequence for this generation * @init_seq_len: Number of entries in @init_seq + * @qg_base: Absolute base of the QG fuel-gauge peripheral, or 0 on + * a PMIC where this driver has no gauge to read * * All values are taken from the Qualcomm downstream qpnp-smb2 / qpnp-smb5 * drivers (smb_chg_param tables) so the current/voltage scaling is exact. @@ -440,6 +487,7 @@ struct smb_variant { u8 temp_status_shift; const struct smb_init_register *init_seq; int init_seq_len; + u16 qg_base; }; /** @@ -464,6 +512,13 @@ struct smb_variant { * from qcom,thermal-mitigation, most permissive first * @thermal_levels: Number of entries in @thermal_mitigation_ua * @thermal_level: Cooling state currently in effect + * @fg_work: Worker that integrates the fuel gauge, see smb_fg_work() + * @fg_lock: Serialises @soc_permyriad and @fg_last against readers + * @soc_permyriad: State of charge in hundredths of a percent + * @fg_residue: Charge counted but too small to have moved + * @soc_permyriad yet, in microamp-milliseconds + * @fg_last: When the gauge last integrated + * @fg_ready: Set once @soc_permyriad holds a real estimate */ struct smb_chip { struct device *dev; @@ -489,6 +544,14 @@ struct smb_chip { u32 *thermal_mitigation_ua; unsigned int thermal_levels; unsigned long thermal_level; + + struct delayed_work fg_work; + /* Serialises the gauge state below against readers of it */ + struct mutex fg_lock; + int soc_permyriad; + s64 fg_residue; + ktime_t fg_last; + bool fg_ready; }; static enum power_supply_property smb_properties[] = { @@ -915,12 +978,24 @@ static irqreturn_t smb_handle_wdog_bark(int irq, void *data) /* * Battery (fuel-gauge) power supply. * - * PMI632 has no coulomb-counting fuel gauge in mainline; the downstream - * QG block is a voltage-based gauge. We replicate the essential algorithm: - * read VBAT_SNS via the PMIC ADC and look up the state-of-charge from the - * OCV->capacity table carried in the monitored-battery DT node (which was - * extracted from the downstream QG battery profile). The power-supply core - * does the interpolation. Reading is non-invasive (ADC only). + * The state of charge is carried in the OCV->capacity table of the + * monitored-battery node, which is the downstream QG battery profile. That + * table maps an *open-circuit* voltage, so what is fed into it decides how + * good the answer is: the battery's terminal voltage moves by hundreds of + * millivolts with load, while the same table spans eighteen points of charge + * over the forty millivolts between 3.80 V and 3.84 V. Reading the terminal + * voltage into it therefore does not give a poor estimate, it gives a meter + * that tracks the CPU rather than the battery. + * + * So the terminal voltage is not what this gauge reports. On a PMIC with a QG + * peripheral we have both the battery voltage and the battery current from + * the same converter, and the two together support the usual arrangement: + * integrate the current continuously, and correct that integral against the + * OCV table only while the current is small enough for the IR term to be + * worth trusting. The result no longer moves under load, because a load does + * not change how much charge is in the cell. + * + * Reading is non-invasive: ADC channels and QG sample registers only. */ static enum power_supply_property smb_batt_properties[] = { POWER_SUPPLY_PROP_STATUS, @@ -929,9 +1004,77 @@ static enum power_supply_property smb_batt_properties[] = { POWER_SUPPLY_PROP_TECHNOLOGY, POWER_SUPPLY_PROP_CAPACITY, POWER_SUPPLY_PROP_VOLTAGE_NOW, + POWER_SUPPLY_PROP_VOLTAGE_OCV, + POWER_SUPPLY_PROP_CURRENT_NOW, POWER_SUPPLY_PROP_TEMP, }; +/** + * smb_qg_read_sample() - read one QG voltage/current sample register pair + * @chip: charger + * @v_off: in-peripheral offset of the voltage half of the pair + * @v_uv: where to put the voltage, in microvolts + * @i_ua: where to put the current, in microamps, positive while charging + * + * The pairs are always voltage then current two registers apart. The gauge + * reports current negative into the battery; this returns it the way the + * power-supply class states it, positive while charging. + * + * Return: 0, or -ENODATA when the pair holds no measurement. + */ +static int smb_qg_read_sample(struct smb_chip *chip, unsigned int v_off, + int *v_uv, int *i_ua) +{ + u8 buf[4]; + u16 v_raw; + s16 i_raw; + int rc; + + rc = regmap_bulk_read(chip->regmap, chip->var->qg_base + v_off, buf, + sizeof(buf)); + if (rc < 0) + return rc; + + v_raw = get_unaligned_le16(&buf[0]); + i_raw = get_unaligned_le16(&buf[2]); + + if (v_raw == QG_ADC_INVALID) + return -ENODATA; + + /* + * Both products run past two billion at ordinary readings - a 3.9 V + * battery is already 4.0e9 nV - so neither may be evaluated in an int. + */ + *v_uv = (int)div_u64((u64)v_raw * QG_V_LSB_NV + 500, 1000); + *i_ua = -(int)div_s64((s64)i_raw * QG_I_LSB_NA, 1000); + + return 0; +} + +/* + * The battery's internal resistance, so an open-circuit voltage can be + * recovered from a loaded one. Both DT properties are optional; without them + * the gauge simply confines itself to samples taken at a current low enough + * for the correction not to matter. + */ +static int smb_batt_resistance_uohm(struct smb_chip *chip, int i_ua) +{ + int r = chip->batt_info->factory_internal_resistance_uohm; + + if (i_ua > 0 && chip->batt_info->factory_internal_resistance_charging_uohm > 0) + r = chip->batt_info->factory_internal_resistance_charging_uohm; + + return r > 0 ? r : 0; +} + +/* Open-circuit voltage behind a terminal voltage loaded with @i_ua */ +static int smb_batt_ocv(struct smb_chip *chip, int v_uv, int i_ua) +{ + s64 ir_uv = (s64)i_ua * smb_batt_resistance_uohm(chip, i_ua); + + return v_uv - (int)div_s64(ir_uv, 1000000); +} + static int smb_get_vbat(struct smb_chip *chip, int *val) { if (IS_ERR_OR_NULL(chip->vbat_chan)) @@ -957,24 +1100,223 @@ static int smb_get_batt_temp(struct smb_chip *chip, int *val) return 0; } +/* + * Whether this board can be gauged by integration: a PMIC with the peripheral + * to sample the current, and a pack size to integrate the current against. The + * second is what the charge-to-percent division divides by, so it has to be a + * real one. + */ +static bool smb_fg_available(struct smb_chip *chip) +{ + return chip->var->qg_base && chip->batt_info && + chip->batt_info->charge_full_design_uah > 0; +} + +/* Capacity, in hundredths of a percent, of an open-circuit voltage */ +static int smb_ocv_to_permyriad(struct smb_chip *chip, int ocv_uv) +{ + /* The single table we carry characterises the cell at 25 degC */ + int cap = power_supply_batinfo_ocv2cap(chip->batt_info, ocv_uv, 25); + + if (cap < 0) + return cap; + + return clamp(cap, 0, 100) * 100; +} + +/** + * smb_fg_work() - integrate the fuel gauge over one poll interval + * @work: the delayed work in struct smb_chip + * + * Charge counting is what keeps the reported capacity still while the load + * moves, but on its own it only ever accumulates error, so it is steered by + * the OCV table whenever the current is low enough for the table to be worth + * more than the integral. The two currents that gate that are deliberately + * far below what this phone draws when it is awake and doing something: the + * intent is to correct while it idles or charges gently, and to coast on the + * integral the rest of the time. + */ +static bool smb_fg_update(struct smb_chip *chip) +{ + int v_uv, i_ua, ocv_uv, soc_ocv, status, was, weight; + ktime_t now = ktime_get_boottime(); + s64 elapsed_ms; + + if (smb_qg_read_sample(chip, QG_LAST_ADC_V_DATA0, &v_uv, &i_ua) < 0) + return false; + + /* + * Termination is the one point on the curve the charger knows better + * than any gauge: it stopped because the cell reached the float + * voltage at below the termination current, which is what full means. + * Take it, rather than leaving the integral a few percent short of a + * battery that will not accept any more. + */ + if (!smb_get_prop_status(chip, &status) && + status == POWER_SUPPLY_STATUS_FULL) { + guard(mutex)(&chip->fg_lock); + + was = chip->fg_ready ? chip->soc_permyriad : -1; + chip->soc_permyriad = 100 * 100; + chip->fg_residue = 0; + chip->fg_ready = true; + chip->fg_last = now; + + return was != chip->soc_permyriad; + } + + ocv_uv = smb_batt_ocv(chip, v_uv, i_ua); + soc_ocv = smb_ocv_to_permyriad(chip, ocv_uv); + if (soc_ocv < 0) + return false; + + if (abs(i_ua) <= SMB_FG_OCV_QUIET_UA) + weight = SMB_FG_OCV_WEIGHT_QUIET; + else if (abs(i_ua) <= SMB_FG_OCV_SETTLED_UA) + weight = SMB_FG_OCV_WEIGHT_SETTLED; + else + weight = 0; + + guard(mutex)(&chip->fg_lock); + + elapsed_ms = ktime_ms_delta(now, chip->fg_last); + was = chip->fg_ready ? chip->soc_permyriad : -1; + + if (!chip->fg_ready || elapsed_ms > SMB_FG_STALE_MS) { + /* + * A gap this long means the system was suspended, so the + * sample says nothing about what happened during it. A + * suspended phone is also a rested battery, which is the one + * condition where the OCV table needs no help at all - take it + * outright rather than integrating a current nobody drew. + */ + chip->soc_permyriad = soc_ocv; + chip->fg_residue = 0; + chip->fg_ready = true; + } else { + /* + * charge (uAh) = i_ua * elapsed / 3600, and one percent of the + * pack is charge_full_design_uah / 100, so a hundredth of a + * percent per millisecond is i_ua * elapsed_ms / (360 * full). + * + * At a tenth of an amp that quotient is under one per poll, so + * carry the undivided remainder rather than rounding it away: + * discarded it would be a standing error in the rate itself, + * always in the direction of counting too little. + */ + s64 per_permyriad = 360LL * chip->batt_info->charge_full_design_uah; + s64 step; + + chip->fg_residue += (s64)i_ua * elapsed_ms; + step = div_s64(chip->fg_residue, per_permyriad); + chip->fg_residue -= step * per_permyriad; + + chip->soc_permyriad += step; + chip->soc_permyriad += (soc_ocv - chip->soc_permyriad) * weight / 100; + chip->soc_permyriad = clamp(chip->soc_permyriad, 0, 100 * 100); + } + + chip->fg_last = now; + + dev_dbg(chip->dev, "fg: %d.%02d%% vbat=%duV ibat=%duA ocv=%duV w=%d\n", + chip->soc_permyriad / 100, chip->soc_permyriad % 100, v_uv, + i_ua, ocv_uv, weight); + + return DIV_ROUND_CLOSEST(chip->soc_permyriad, 100) != + DIV_ROUND_CLOSEST(was, 100); +} + +static void smb_fg_work(struct work_struct *work) +{ + struct smb_chip *chip = container_of(work, struct smb_chip, + fg_work.work); + + if (smb_fg_update(chip)) + power_supply_changed(chip->batt_psy); + + schedule_delayed_work(&chip->fg_work, msecs_to_jiffies(SMB_FG_POLL_MS)); +} + +/** + * smb_fg_start() - seed the fuel gauge and start integrating it + * @chip: charger + * + * An integrating gauge is only ever as good as what it started from, and the + * live sample is a poor start: at probe the machine is booting, which is the + * least rested the battery gets. The gauge itself has a better answer already + * measured. It samples an open-circuit voltage twice under conditions we + * cannot recreate here - once at power-on before anything draws, and again + * whenever the PMIC has seen the current stay near zero for long enough - and + * keeps both. Take the second in preference to the first, since the first can + * be arbitrarily old, and fall back to the live sample if the PMIC holds + * neither. + */ +static int smb_fg_start(struct smb_chip *chip) +{ + static const unsigned int rest_ocv[] = { + QG_S3_GOOD_OCV_V_DATA0, + QG_S7_PON_OCV_V_DATA0, + }; + int v_uv, i_ua, soc, rc, i; + + rc = devm_mutex_init(chip->dev, &chip->fg_lock); + if (rc) + return rc; + + for (i = 0; i < ARRAY_SIZE(rest_ocv); i++) { + if (smb_qg_read_sample(chip, rest_ocv[i], &v_uv, &i_ua) < 0) + continue; + + soc = smb_ocv_to_permyriad(chip, smb_batt_ocv(chip, v_uv, i_ua)); + if (soc < 0) + continue; + + chip->soc_permyriad = soc; + chip->fg_ready = true; + /* so the first poll integrates rather than re-anchoring */ + chip->fg_last = ktime_get_boottime(); + dev_dbg(chip->dev, "fg: seeded at %d.%02d%% from %duV\n", + soc / 100, soc % 100, v_uv); + break; + } + + rc = devm_delayed_work_autocancel(chip->dev, &chip->fg_work, + smb_fg_work); + if (rc) + return dev_err_probe(chip->dev, rc, + "Failed to init fuel-gauge work\n"); + + schedule_delayed_work(&chip->fg_work, 0); + return 0; +} + static int smb_get_batt_capacity(struct smb_chip *chip, int *val) { - int ocv_uv, cap, rc; + int v_uv, cap, rc; if (!chip->batt_info) return -ENODATA; - rc = smb_get_vbat(chip, &ocv_uv); - if (rc < 0) - return rc; + if (smb_fg_available(chip)) { + guard(mutex)(&chip->fg_lock); + + if (!chip->fg_ready) + return -EAGAIN; + + *val = DIV_ROUND_CLOSEST(chip->soc_permyriad, 100); + return 0; + } /* - * Use the instantaneous battery voltage as the OCV estimate. Under - * load this sags (reads low while discharging, high while charging); - * a future ESR/IR-drop compensation could refine it. Assume room - * temperature (25 degC), matching the single OCV table we ship. + * Without a gauge to integrate there is nothing to feed the OCV table + * but the terminal voltage, load and all - see the caveat above + * smb_batt_properties for what that costs. */ - cap = power_supply_batinfo_ocv2cap(chip->batt_info, ocv_uv, 25); + rc = smb_get_vbat(chip, &v_uv); + if (rc < 0) + return rc; + + cap = power_supply_batinfo_ocv2cap(chip->batt_info, v_uv, 25); if (cap < 0) return cap; @@ -982,6 +1324,40 @@ static int smb_get_batt_capacity(struct smb_chip *chip, int *val) return 0; } +static int smb_get_batt_current(struct smb_chip *chip, int *val) +{ + int v_uv; + + if (!chip->var->qg_base) + return -ENODATA; + + return smb_qg_read_sample(chip, QG_LAST_ADC_V_DATA0, &v_uv, val); +} + +static int smb_get_batt_voltage(struct smb_chip *chip, bool open_circuit, + int *val) +{ + int v_uv, i_ua, rc; + + /* + * Prefer the gauge's own converter over the ADC channel: it measures + * the voltage and the current at the same instant, which is what makes + * the pair of them usable together. + */ + if (chip->var->qg_base) { + rc = smb_qg_read_sample(chip, QG_LAST_ADC_V_DATA0, &v_uv, &i_ua); + if (!rc) { + *val = open_circuit ? smb_batt_ocv(chip, v_uv, i_ua) : v_uv; + return 0; + } + } + + if (open_circuit) + return -ENODATA; + + return smb_get_vbat(chip, val); +} + static int smb_batt_get_property(struct power_supply *psy, enum power_supply_property psp, union power_supply_propval *val) @@ -1002,7 +1378,11 @@ static int smb_batt_get_property(struct power_supply *psy, case POWER_SUPPLY_PROP_CAPACITY: return smb_get_batt_capacity(chip, &val->intval); case POWER_SUPPLY_PROP_VOLTAGE_NOW: - return smb_get_vbat(chip, &val->intval); + return smb_get_batt_voltage(chip, false, &val->intval); + case POWER_SUPPLY_PROP_VOLTAGE_OCV: + return smb_get_batt_voltage(chip, true, &val->intval); + case POWER_SUPPLY_PROP_CURRENT_NOW: + return smb_get_batt_current(chip, &val->intval); case POWER_SUPPLY_PROP_TEMP: return smb_get_batt_temp(chip, &val->intval); default: @@ -1243,6 +1623,7 @@ static const struct smb_variant smb_variant_pmi632 = { .temp_status_shift = 2, .init_seq = pmi632_init_seq, .init_seq_len = ARRAY_SIZE(pmi632_init_seq), + .qg_base = 0x4800, }; static int smb_init_hw(struct smb_chip *chip) @@ -1721,6 +2102,12 @@ static int smb_probe(struct platform_device *pdev) if (IS_ERR(chip->batt_psy)) return dev_err_probe(chip->dev, PTR_ERR(chip->batt_psy), "failed to register battery power supply\n"); + + if (smb_fg_available(chip)) { + rc = smb_fg_start(chip); + if (rc < 0) + return rc; + } } rc = devm_delayed_work_autocancel(chip->dev, &chip->status_change_work, From 594f86d7ec027b4c546e6c5b0286e91be2534328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 8 Aug 2026 09:18:39 +0200 Subject: [PATCH 16/33] power: supply: qcom_smbx: decode the charge status per generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things in this register are generation-dependent and were being read as if they were not, so an SMB5 PMIC reported the wrong charge status at both ends of a charge. BAT_OV is bit 5 of STATUS_2 on SMB2 and bit 1 on SMB5. Every other reader of that register already takes the bit from smb_variant::ov_bit; this one had the SMB2 constant compiled in. On a Fairphone 3 finishing a charge the register reads 0x28, whose bit 5 is not an overvoltage, and the driver called it "Not charging" while 300 mA was flowing into the battery. The eight status codes themselves were also renumbered: SMB5 moved INHIBIT from 6 down to 0, shifted the three codes below it up by one, and gave the vacated 6 to PAUSE, which SMB2 does not have. Codes 3, 4, 5 and 7 agree, which is why the SMB2 table looks right in the middle of a charge - but an inhibited SMB5 charger read as trickle-charging, and a paused one read as full. Reporting a paused charge as full is the worse of the two, because anything that trusts a full battery will pin its own reading to it. Both now come from the variant. The mapping is a table per generation rather than a switch, since what changed between them is which code means what and not what the meanings are. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 76 +++++++++++++++++++------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 32d82a6e8f5b7c..86d5c05eab6efc 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -429,15 +429,38 @@ #define CURRENT_SCALE_FACTOR 25000 /* clang-format on */ -enum charger_status { - TRICKLE_CHARGE = 0, - PRE_CHARGE, - FAST_CHARGE, - FULLON_CHARGE, - TAPER_CHARGE, - TERMINATE_CHARGE, - INHIBIT_CHARGE, - DISABLE_CHARGE, +/* + * What the eight BATTERY_CHARGER_STATUS_1 codes mean, per generation. SMB5 + * renumbered them: INHIBIT moved from 6 down to 0, the three codes below it + * shifted up by one, and PAUSE - which SMB2 does not have - took the 6 that + * INHIBIT left behind. Codes 3, 4, 5 and 7 mean the same thing on both, which + * is why reading an SMB5 PMIC through the SMB2 table looks right in the middle + * of a charge and is wrong at either end of it. + * + * Inhibit is reported as full because that is what inhibits: the cell is above + * the recharge threshold. Pause is not - it is a charge that stopped for a + * reason of its own. + */ +static const u8 smb2_charge_status[8] = { + POWER_SUPPLY_STATUS_CHARGING, /* 0 trickle */ + POWER_SUPPLY_STATUS_CHARGING, /* 1 pre */ + POWER_SUPPLY_STATUS_CHARGING, /* 2 fast */ + POWER_SUPPLY_STATUS_CHARGING, /* 3 full-on */ + POWER_SUPPLY_STATUS_CHARGING, /* 4 taper */ + POWER_SUPPLY_STATUS_FULL, /* 5 terminate */ + POWER_SUPPLY_STATUS_FULL, /* 6 inhibit */ + POWER_SUPPLY_STATUS_NOT_CHARGING, /* 7 disable */ +}; + +static const u8 smb5_charge_status[8] = { + POWER_SUPPLY_STATUS_FULL, /* 0 inhibit */ + POWER_SUPPLY_STATUS_CHARGING, /* 1 trickle */ + POWER_SUPPLY_STATUS_CHARGING, /* 2 pre */ + POWER_SUPPLY_STATUS_CHARGING, /* 3 full-on */ + POWER_SUPPLY_STATUS_CHARGING, /* 4 taper */ + POWER_SUPPLY_STATUS_FULL, /* 5 terminate */ + POWER_SUPPLY_STATUS_NOT_CHARGING, /* 6 pause */ + POWER_SUPPLY_STATUS_NOT_CHARGING, /* 7 disable */ }; struct smb_init_register { @@ -462,6 +485,8 @@ struct smb_init_register { * @float_step_uv: uV per LSB of FLOAT_VOLTAGE_CFG * @ov_bit: BAT_OV (overvoltage) bit within BATTERY_CHARGER_STATUS_2 * (BIT(5) on SMB2, BIT(1) on SMB5) + * @charge_status: What each of the eight BATTERY_CHARGER_STATUS_1 codes + * means on this generation, see smb2_charge_status * @temp_status_reg: Register holding the JEITA temperature-status bits, * relative to @base (BATTERY_CHARGER_STATUS_2 = 0x07 on * SMB2, BATTERY_CHARGER_STATUS_7 = 0x0D on SMB5) @@ -483,6 +508,7 @@ struct smb_variant { u32 float_base_uv; u32 float_step_uv; u8 ov_bit; + const u8 *charge_status; u16 temp_status_reg; u8 temp_status_shift; const struct smb_init_register *init_seq; @@ -648,32 +674,19 @@ static int smb_get_prop_status(struct smb_chip *chip, int *val) return rc; } - if (stat[1] & CHARGER_ERROR_STATUS_BAT_OV_BIT) { + /* + * Which bit of STATUS_2 carries BAT_OV also moved between the + * generations, so it has to come from the variant here as it does + * everywhere else this register is read. + */ + if (stat[1] & chip->var->ov_bit) { *val = POWER_SUPPLY_STATUS_NOT_CHARGING; return 0; } - stat[0] = stat[0] & BATTERY_CHARGER_STATUS_MASK; + *val = chip->var->charge_status[stat[0] & BATTERY_CHARGER_STATUS_MASK]; - switch (stat[0]) { - case TRICKLE_CHARGE: - case PRE_CHARGE: - case FAST_CHARGE: - case FULLON_CHARGE: - case TAPER_CHARGE: - *val = POWER_SUPPLY_STATUS_CHARGING; - return rc; - case DISABLE_CHARGE: - *val = POWER_SUPPLY_STATUS_NOT_CHARGING; - return rc; - case TERMINATE_CHARGE: - case INHIBIT_CHARGE: - *val = POWER_SUPPLY_STATUS_FULL; - return rc; - default: - *val = POWER_SUPPLY_STATUS_UNKNOWN; - return rc; - } + return 0; } static inline int smb_get_current_limit(struct smb_chip *chip, @@ -1584,6 +1597,7 @@ static const struct smb_variant smb_variant_pmi8998 = { .float_base_uv = 3480000, /* (v - 3487500) / 7500 + 1 == (v - 3480000) / 7500 */ .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, + .charge_status = smb2_charge_status, .temp_status_reg = BATTERY_CHARGER_STATUS_2, .temp_status_shift = 0, .init_seq = smb2_init_seq, @@ -1598,6 +1612,7 @@ static const struct smb_variant smb_variant_pm660 = { .float_base_uv = 3480000, .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, + .charge_status = smb2_charge_status, .temp_status_reg = BATTERY_CHARGER_STATUS_2, .temp_status_shift = 0, .init_seq = smb2_init_seq, @@ -1612,6 +1627,7 @@ static const struct smb_variant smb_variant_pmi632 = { .float_base_uv = 3600000, /* qpnp-smb5 fv: min 3600000, step 10000 */ .float_step_uv = 10000, .ov_bit = SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT, + .charge_status = smb5_charge_status, /* * On SMB5 the JEITA temperature-status bits moved out of * BATTERY_CHARGER_STATUS_2 into BATTERY_CHARGER_STATUS_7, and the From aa39c0d5bb2a80d51aa7c4d76cd61a2ca334349e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 8 Aug 2026 09:53:22 +0200 Subject: [PATCH 17/33] power: supply: qcom_smbx: only call a terminated charge full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two states report as full and only one of them is a finished charge, so the gauge was pinning itself to 100% on the wrong one. Caught on a Fairphone 3 whose charger was inhibiting at 4.30 V of a 4.39 V float: the gauge read 100% where its own open-circuit voltage put the pack at 93, and where it had read 92 a minute earlier. On SMB5 the inhibit code is not the post-termination resting state SMB2 uses it as - it is also where the charger sits whenever it is held off, with no statement about how full the battery is. Report that as not charging, which is all the state supports saying, and leave the reported capacity to the gauge, whose open-circuit reading is accurate precisely here because an inhibited charger draws no current. The gauge now asks the status register for the termination code directly rather than asking for the reported status, so that what pins it to full is the charger having finished, not a status that several states share. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 86d5c05eab6efc..25eb1bfccc4322 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -441,6 +441,12 @@ * the recharge threshold. Pause is not - it is a charge that stopped for a * reason of its own. */ +/* + * Charge completion, the one code both generations put at the same value and + * the only one that says the charger stopped because the battery is full. + */ +#define CHARGE_STATUS_TERMINATE 5 + static const u8 smb2_charge_status[8] = { POWER_SUPPLY_STATUS_CHARGING, /* 0 trickle */ POWER_SUPPLY_STATUS_CHARGING, /* 1 pre */ @@ -453,7 +459,14 @@ static const u8 smb2_charge_status[8] = { }; static const u8 smb5_charge_status[8] = { - POWER_SUPPLY_STATUS_FULL, /* 0 inhibit */ + /* + * Inhibit is where SMB2 reports full, but on SMB5 it is also where a + * charger sits whenever it is being held off, and this one was found + * inhibiting at 4.30 V of a 4.39 V float with six percent still to + * go. Not charging is the most this state supports saying; charge + * completion has its own code below. + */ + POWER_SUPPLY_STATUS_NOT_CHARGING, /* 0 inhibit */ POWER_SUPPLY_STATUS_CHARGING, /* 1 trickle */ POWER_SUPPLY_STATUS_CHARGING, /* 2 pre */ POWER_SUPPLY_STATUS_CHARGING, /* 3 full-on */ @@ -1151,7 +1164,8 @@ static int smb_ocv_to_permyriad(struct smb_chip *chip, int ocv_uv) */ static bool smb_fg_update(struct smb_chip *chip) { - int v_uv, i_ua, ocv_uv, soc_ocv, status, was, weight; + unsigned int status; + int v_uv, i_ua, ocv_uv, soc_ocv, was, weight; ktime_t now = ktime_get_boottime(); s64 elapsed_ms; @@ -1164,9 +1178,14 @@ static bool smb_fg_update(struct smb_chip *chip) * voltage at below the termination current, which is what full means. * Take it, rather than leaving the integral a few percent short of a * battery that will not accept any more. + * + * This asks the register for that one code rather than asking for the + * reported status, because more than one state reports as full and + * only this one is evidence of a finished charge. */ - if (!smb_get_prop_status(chip, &status) && - status == POWER_SUPPLY_STATUS_FULL) { + if (!regmap_read(chip->regmap, chip->base + BATTERY_CHARGER_STATUS_1, + &status) && + (status & BATTERY_CHARGER_STATUS_MASK) == CHARGE_STATUS_TERMINATE) { guard(mutex)(&chip->fg_lock); was = chip->fg_ready ? chip->soc_permyriad : -1; From 143cfb346be5256b313efd3231d8b36757ccb358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 8 Aug 2026 14:15:24 +0200 Subject: [PATCH 18/33] power: supply: qcom_smbx: hold the pack full while the charger says it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gauge takes charge completion from the charger, because termination is the one point on the curve the charger knows better: it stopped because the cell reached the float voltage below the termination current. It took it only at the instant the register showed that code, and that is not how the hardware behaves. Having terminated, the cell is by definition above the recharge threshold, so the charger moves straight on to inhibit and stays there for as long as the cable is in. Two things follow. A ten-second poll has to land inside a window the hardware leaves as fast as it can, and even when it does the next poll resumes correcting toward the OCV table, at a quarter of the gap per poll while the current is quiet - so a hundred percent that was latched correctly is walked back down within a minute. Where it lands is the second half of the problem, and it is not an error in the table. The table's top entry is an OCV of 4.3756 V, which a cell held at a 4.39 V float only shows while it is still being held there; let go, it settles some seventy millivolts below that, which on this curve is six percent. So a battery that is as full as this charger will ever make it reads in the mid nineties, and keeps reading that for as long as it stays on the cable. Measured on a Fairphone 3 that had just finished charging: STATUS_1 0x40 - CC_SOFT_TERMINATE set, status code 0, inhibit - with STATUS_7 clear, no JEITA, the float register at 0x4f (4.39 V), 0 mA into the pack at 4.3149 V, and a reported capacity of 94%. Remember the completion instead of catching it. Inhibit alone is not evidence of a full pack - it is equally what a charger does when handed a cell that was already above the threshold when the cable went in - but inhibit after a charge that was actually running is the tail of that charge. Tracking that pairing needs no knowledge of where the inhibit threshold sits, survives any sampling interval, and clears itself when the input goes away or the charger starts charging again. Report the status as full there too, which the code alone cannot support saying. What this does not fix: unplugging a full pack drops the reading to what the table says about a rested cell, because nothing has re-anchored the table's top to the OCV this pack actually rests at once full. Learning that anchor is a separate change and needs a charge and a discharge measured against a reference gauge. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 117 ++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 25eb1bfccc4322..56fb651733a516 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -461,10 +461,12 @@ static const u8 smb2_charge_status[8] = { static const u8 smb5_charge_status[8] = { /* * Inhibit is where SMB2 reports full, but on SMB5 it is also where a - * charger sits whenever it is being held off, and this one was found - * inhibiting at 4.30 V of a 4.39 V float with six percent still to - * go. Not charging is the most this state supports saying; charge - * completion has its own code below. + * charger sits whenever it is being held off - including with a cell + * that was already above the recharge threshold when the cable went + * in and so was never charged at all. Not charging is the most the + * code alone supports saying; smb_get_prop_status() upgrades it to + * full where the gauge watched the charge that led into it, and + * charge completion has its own code below. */ POWER_SUPPLY_STATUS_NOT_CHARGING, /* 0 inhibit */ POWER_SUPPLY_STATUS_CHARGING, /* 1 trickle */ @@ -500,6 +502,9 @@ struct smb_init_register { * (BIT(5) on SMB2, BIT(1) on SMB5) * @charge_status: What each of the eight BATTERY_CHARGER_STATUS_1 codes * means on this generation, see smb2_charge_status + * @inhibit_code: Which of those codes is charge inhibit - the state a + * finished charge settles into once the cell is above the + * recharge threshold (6 on SMB2, 0 on SMB5) * @temp_status_reg: Register holding the JEITA temperature-status bits, * relative to @base (BATTERY_CHARGER_STATUS_2 = 0x07 on * SMB2, BATTERY_CHARGER_STATUS_7 = 0x0D on SMB5) @@ -522,6 +527,7 @@ struct smb_variant { u32 float_step_uv; u8 ov_bit; const u8 *charge_status; + u8 inhibit_code; u16 temp_status_reg; u8 temp_status_shift; const struct smb_init_register *init_seq; @@ -558,6 +564,12 @@ struct smb_variant { * @soc_permyriad yet, in microamp-milliseconds * @fg_last: When the gauge last integrated * @fg_ready: Set once @soc_permyriad holds a real estimate + * @fg_full: The charger has finished a charge on the input that is + * still attached, so the pack is full whatever the curve + * says, see smb_fg_track_completion() + * @fg_charge_seen: A charge has actually been in progress since that input + * appeared, which is what makes a later inhibit mean + * completion rather than a top-up that was never needed */ struct smb_chip { struct device *dev; @@ -591,6 +603,8 @@ struct smb_chip { s64 fg_residue; ktime_t fg_last; bool fg_ready; + bool fg_full; + bool fg_charge_seen; }; static enum power_supply_property smb_properties[] = { @@ -699,6 +713,19 @@ static int smb_get_prop_status(struct smb_chip *chip, int *val) *val = chip->var->charge_status[stat[0] & BATTERY_CHARGER_STATUS_MASK]; + /* + * Inhibit is where a finished charge comes to rest, and by itself it + * cannot be told apart from a charge that never needed to start - so + * the table has to call it not-charging. Where the gauge has watched + * the charge that led into it, say so. + */ + if ((stat[0] & BATTERY_CHARGER_STATUS_MASK) == chip->var->inhibit_code) { + guard(mutex)(&chip->fg_lock); + + if (chip->fg_full) + *val = POWER_SUPPLY_STATUS_FULL; + } + return 0; } @@ -1150,6 +1177,63 @@ static int smb_ocv_to_permyriad(struct smb_chip *chip, int ocv_uv) return clamp(cap, 0, 100) * 100; } +/** + * smb_fg_track_completion() - follow the charger through the end of a charge + * @chip: the charger + * + * Termination is the one point on the curve the charger knows better than any + * gauge: it stopped because the cell reached the float voltage at below the + * termination current, which is what full means. But it is also a state the + * charger passes through rather than sits in - having terminated, the cell is + * by definition above the recharge threshold, so the hardware moves on to + * inhibit and stays there. A gauge that only recognises the instant of + * termination therefore has to sample it inside that window, and then watches + * the OCV correction walk its answer back down over the next few polls. + * + * So remember it instead. Inhibit is not evidence of a full pack on its own - + * it is equally what a charger does when it is handed a cell that was already + * above the threshold when the cable went in - but inhibit *after* a charge was + * running is the tail of that charge. That pairing is what this tracks, and it + * needs no knowledge of where the inhibit threshold happens to be set. + * + * Returns: true while the attached input has finished charging the pack. + */ +static bool smb_fg_track_completion(struct smb_chip *chip) +{ + unsigned int status, code; + int online = 0; + + guard(mutex)(&chip->fg_lock); + + smb_get_prop_usb_online(chip, &online); + if (!online) { + chip->fg_full = false; + chip->fg_charge_seen = false; + return false; + } + + if (regmap_read(chip->regmap, chip->base + BATTERY_CHARGER_STATUS_1, + &status)) + return chip->fg_full; + + code = status & BATTERY_CHARGER_STATUS_MASK; + if (code == CHARGE_STATUS_TERMINATE) { + chip->fg_full = true; + } else if (chip->var->charge_status[code] == + POWER_SUPPLY_STATUS_CHARGING) { + chip->fg_charge_seen = true; + chip->fg_full = false; + } else if (code == chip->var->inhibit_code && chip->fg_charge_seen) { + chip->fg_full = true; + } + + /* + * The remaining codes - pause, disable - say nothing either way about + * how much charge is in the pack, so they leave the answer alone. + */ + return chip->fg_full; +} + /** * smb_fg_work() - integrate the fuel gauge over one poll interval * @work: the delayed work in struct smb_chip @@ -1164,7 +1248,6 @@ static int smb_ocv_to_permyriad(struct smb_chip *chip, int ocv_uv) */ static bool smb_fg_update(struct smb_chip *chip) { - unsigned int status; int v_uv, i_ua, ocv_uv, soc_ocv, was, weight; ktime_t now = ktime_get_boottime(); s64 elapsed_ms; @@ -1173,19 +1256,16 @@ static bool smb_fg_update(struct smb_chip *chip) return false; /* - * Termination is the one point on the curve the charger knows better - * than any gauge: it stopped because the cell reached the float - * voltage at below the termination current, which is what full means. - * Take it, rather than leaving the integral a few percent short of a - * battery that will not accept any more. - * - * This asks the register for that one code rather than asking for the - * reported status, because more than one state reports as full and - * only this one is evidence of a finished charge. + * Hold the pack at full for as long as the charger says it finished + * charging it. This is not a shortcut past the curve, it is the one + * place the curve cannot answer: the table's top entry is an OCV of + * over 4.37 V, which a cell charged to a 4.39 V float only shows while + * it is still being held there. Left to relax it settles some seventy + * millivolts lower, six percent down the table, and a full battery + * reads as not quite full for the rest of the time it stays on the + * cable. */ - if (!regmap_read(chip->regmap, chip->base + BATTERY_CHARGER_STATUS_1, - &status) && - (status & BATTERY_CHARGER_STATUS_MASK) == CHARGE_STATUS_TERMINATE) { + if (smb_fg_track_completion(chip)) { guard(mutex)(&chip->fg_lock); was = chip->fg_ready ? chip->soc_permyriad : -1; @@ -1617,6 +1697,7 @@ static const struct smb_variant smb_variant_pmi8998 = { .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, .charge_status = smb2_charge_status, + .inhibit_code = 6, .temp_status_reg = BATTERY_CHARGER_STATUS_2, .temp_status_shift = 0, .init_seq = smb2_init_seq, @@ -1632,6 +1713,7 @@ static const struct smb_variant smb_variant_pm660 = { .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, .charge_status = smb2_charge_status, + .inhibit_code = 6, .temp_status_reg = BATTERY_CHARGER_STATUS_2, .temp_status_shift = 0, .init_seq = smb2_init_seq, @@ -1647,6 +1729,7 @@ static const struct smb_variant smb_variant_pmi632 = { .float_step_uv = 10000, .ov_bit = SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT, .charge_status = smb5_charge_status, + .inhibit_code = 0, /* * On SMB5 the JEITA temperature-status bits moved out of * BATTERY_CHARGER_STATUS_2 into BATTERY_CHARGER_STATUS_7, and the From 229e0a726c2bbd09cdf31d79e6d4cf54e3586604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 8 Aug 2026 16:01:46 +0200 Subject: [PATCH 19/33] power: supply: qcom_smbx: restart a finished charge on pmi632 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminated charge is meant to begin again once the cell has fallen back by a little, and on this PMIC it never did: the pack came off the charger's attention at the float voltage and then simply drifted down, for as long as the cable stayed in. CHGR_CFG2 is where that is configured, and its lower bits are not the same on the two generations. SMB2 has AUTO_RECHG at BIT(2) and EN_ANALOG_DROP_IN_VBATT at BIT(1); SMB5 replaces both with one two-bit field naming what a recharge is decided by - clear for nothing at all, BIT(2) for the battery voltage, both bits for the state of charge. The init sequence for pmi632 was derived from the SMB2 one and wrote the same value, so the field came out clear, which is the one setting under which no recharge ever happens. State of charge is not an option here in any case: the gauge that would report one to the PMIC is Qualcomm's own and is not part of this driver. So select the battery voltage, take three comparator samples before acting on it as the vendor driver does whenever it makes that choice, and program the threshold itself - CHGR_ADC_RECHARGE_THRESHOLD_MSB/LSB, in the same 194637 nV units the gauge reports, because it is the same ADC. The threshold comes from the board, since it is a property of the cell rather than of the PMIC, and is left at the hardware default where the board does not name one. The Fairphone 3 names 4.30 V, which is what its own downstream node asks for under a dated vendor comment, ninety millivolts below its float. Measured before the change, on a phone that had finished charging and was still on the cable: BATTERY_CHARGER_STATUS_1 0x40 - CC_SOFT_TERMINATE set, status code inhibit - BATTERY_CHARGER_STATUS_7 clear so no charging stage enabled at all, and the gauge reading exactly zero current into the pack at 4.24 V of a 4.39 V float. Pulling the cell to 4.14 V under load did not restart it either, which is what rules out the inhibit threshold as the explanation and leaves the recharge field. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 111 +++++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 56fb651733a516..cdad25fef24868 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -75,6 +75,14 @@ #define I_TERM_BIT BIT(3) #define AUTO_RECHG_BIT BIT(2) #define EN_ANALOG_DROP_IN_VBATT_BIT BIT(1) +/* + * Where SMB2 has the two bits above, SMB5 has a single two-bit field naming + * what restarts a finished charge: 0 nothing, BIT(2) the battery voltage, + * both bits the state of charge (smb5-reg.h RECHG_MASK / VBAT_BASED_RECHG_BIT + * / SOC_BASED_RECHG_BIT). + */ +#define SMB5_RECHG_MASK GENMASK(2, 1) +#define SMB5_VBAT_BASED_RECHG_BIT BIT(2) #define CHARGER_INHIBIT_BIT BIT(0) #define PRE_CHARGE_CURRENT_CFG 0x60 @@ -83,9 +91,22 @@ #define FAST_CHARGE_CURRENT_CFG 0x61 #define FAST_CHARGE_CURRENT_SETTING_MASK GENMASK(7, 0) +/* + * SMB5 only: how many comparator samples a recharge decision takes, and the + * battery-voltage threshold it compares against, in the same 194637 nV units + * the gauge reports (smb5-reg.h CHGR_NO_SAMPLE_TERM_RCHG_CFG_REG and + * CHGR_ADC_RECHARGE_THRESHOLD_MSB/LSB_REG). + */ +#define NO_SAMPLE_TERM_RCHG_CFG 0x6B +#define NO_OF_SAMPLE_FOR_RCHG GENMASK(3, 2) +#define NO_OF_SAMPLE_FOR_RCHG_SHIFT 2 + #define FLOAT_VOLTAGE_CFG 0x70 #define FLOAT_VOLTAGE_SETTING_MASK GENMASK(7, 0) +#define ADC_RECHARGE_THRESHOLD_MSB 0x7E +#define ADC_RECHARGE_THRESHOLD_LSB 0x7F + #define FG_UPDATE_CFG_2_SEL 0x7D #define SOC_LT_OTG_THRESH_SEL_BIT BIT(3) #define SOC_LT_CHG_RECHARGE_THRESH_SEL_BIT BIT(2) @@ -502,6 +523,9 @@ struct smb_init_register { * (BIT(5) on SMB2, BIT(1) on SMB5) * @charge_status: What each of the eight BATTERY_CHARGER_STATUS_1 codes * means on this generation, see smb2_charge_status + * @rechg_thresh_reg: Register holding the battery-voltage recharge threshold, + * relative to @base, or 0 where this generation does not + * express one this way (SMB2) * @inhibit_code: Which of those codes is charge inhibit - the state a * finished charge settles into once the cell is above the * recharge threshold (6 on SMB2, 0 on SMB5) @@ -527,6 +551,7 @@ struct smb_variant { u32 float_step_uv; u8 ov_bit; const u8 *charge_status; + u16 rechg_thresh_reg; u8 inhibit_code; u16 temp_status_reg; u8 temp_status_shift; @@ -1644,13 +1669,33 @@ static const struct smb_init_register pmi632_init_seq[] = { { .addr = CHARGING_ENABLE_CMD, .mask = CHARGING_ENABLE_CMD_BIT, .val = CHARGING_ENABLE_CMD_BIT }, - /* Charger enable controlled by software; inhibit based on battery voltage */ + /* + * Charger enable controlled by software, inhibit based on battery + * voltage, and recharge on the battery voltage too. + * + * ☠️ Bits 2 and 1 of this register are not what SMB2 puts there. On + * SMB5 they are a two-bit field naming what a finished charge is + * restarted by - clear for nothing at all, BIT(2) for the battery + * voltage, both bits for the state of charge - where SMB2 has + * AUTO_RECHG and EN_ANALOG_DROP_IN_VBATT. Carrying the SMB2 value + * across therefore left the field clear, which is the one setting that + * means a charge that has terminated is never restarted for as long as + * the cable stays in. The state of charge is not an option here in any + * case: the gauge that would report one to the PMIC is Qualcomm's own, + * and it is not part of this driver. + */ { .addr = CHGR_CFG2, .mask = CHG_EN_SRC_BIT | CHG_EN_POLARITY_BIT | PRETOFAST_TRANSITION_CFG_BIT | BAT_OV_ECC_BIT | I_TERM_BIT | - AUTO_RECHG_BIT | EN_ANALOG_DROP_IN_VBATT_BIT | - CHARGER_INHIBIT_BIT, - .val = CHARGER_INHIBIT_BIT }, + SMB5_RECHG_MASK | CHARGER_INHIBIT_BIT, + .val = SMB5_VBAT_BASED_RECHG_BIT | CHARGER_INHIBIT_BIT }, + /* + * Take three samples before acting on the voltage comparator, which is + * what the vendor driver asks for whenever it selects VBAT recharge. + */ + { .addr = NO_SAMPLE_TERM_RCHG_CFG, + .mask = NO_OF_SAMPLE_FOR_RCHG, + .val = 3 << NO_OF_SAMPLE_FOR_RCHG_SHIFT }, /* Default SDP charger to a 500mA USB 2.0 port */ { .addr = USBIN_ICL_OPTIONS, .mask = USB51_MODE_BIT | USBIN_MODE_CHG_BIT, @@ -1729,6 +1774,7 @@ static const struct smb_variant smb_variant_pmi632 = { .float_step_uv = 10000, .ov_bit = SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT, .charge_status = smb5_charge_status, + .rechg_thresh_reg = ADC_RECHARGE_THRESHOLD_MSB, .inhibit_code = 0, /* * On SMB5 the JEITA temperature-status bits moved out of @@ -1799,6 +1845,59 @@ static int smb_set_jeita_thresholds(struct smb_chip *chip, return 0; } +/** + * smb_set_recharge_threshold() - tell the charger when to top the pack up again + * @chip: the charger + * + * The recharge comparator has been pointed at the battery voltage by the init + * sequence; this is the voltage it compares against. Where the board does not + * name one the hardware default is left alone - a comparator watching the right + * quantity at whatever threshold it powers up with is still a working + * comparator, and inventing a threshold for an unknown cell is not an + * improvement on that. + * + * The value is in the same 194637 nV units the gauge reports, which is not a + * coincidence: it is the same ADC. + * + * Returns: 0, or negative on error. + */ +static int smb_set_recharge_threshold(struct smb_chip *chip) +{ + struct fwnode_handle *batt __free(fwnode_handle) = + fwnode_find_reference(dev_fwnode(chip->dev), + "monitored-battery", 0); + u32 uv, raw; + int rc; + + if (!chip->var->rechg_thresh_reg) + return 0; + + if (IS_ERR(batt) || + fwnode_property_read_u32(batt, "qcom,auto-recharge-microvolt", &uv)) + return 0; + + if (uv >= chip->batt_info->voltage_max_design_uv) { + dev_warn(chip->dev, + "recharge threshold %u uV is not below the float voltage, ignoring\n", + uv); + return 0; + } + + raw = div_u64((u64)uv * 1000, QG_V_LSB_NV); + + rc = regmap_write(chip->regmap, chip->base + chip->var->rechg_thresh_reg, + raw >> 8); + if (!rc) + rc = regmap_write(chip->regmap, + chip->base + chip->var->rechg_thresh_reg + 1, + raw & 0xff); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't set the recharge threshold\n"); + + return 0; +} + /* * A board that can be fitted with more than one battery tells them apart by a * resistor in the pack, read through a divider against the ADC's reference. @@ -2241,6 +2340,10 @@ static int smb_probe(struct platform_device *pdev) if (rc < 0) return dev_err_probe(chip->dev, rc, "Couldn't set vbat max\n"); + rc = smb_set_recharge_threshold(chip); + if (rc < 0) + return rc; + /* * Everything below describes the *battery*, so none of it may be * applied to a battery that is not the one described. Where the board From cc9c03d3128a4ba9f00e821081c7ca0d17e10337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 8 Aug 2026 18:09:38 +0200 Subject: [PATCH 20/33] power: supply: qcom_smbx: do not read the OCV table while the charger drives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gauge steers its integral with the OCV table whenever the current is low enough for the table to be worth more, and a charger in its constant-voltage phase looks exactly like that condition: the current has decayed to tens of milliamps and everything appears quiet. It is not. In constant voltage the charger holds the terminal at the float voltage and lets the current fall, so what the ADC reads is the charger's number rather than the cell's, and subtracting I*R does not recover an open-circuit voltage from it - at those currents the correction is a few millivolts against a hold of tens. The table, asked about a voltage above its top entry, answers a hundred percent. Measured on a Fairphone 3 in taper: 4.4122 V terminal at 56 mA, so 4.405 V after the IR correction, against a table whose top entry is 4.3756 V. The reading reached a hundred percent while the pack was still filling and the charger was still charging - some five points of that climb were the correction rather than charge that had gone in. So count charge and nothing else while the charger is driving the pack. The completion the charger reports is what ends that, and it is the honest hundred percent. The same reasoning applies to the re-anchor a long poll gap takes as evidence of a rested battery: a phone that suspended on a charger has the least rested cell it ever has. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 33 ++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index cdad25fef24868..f59b3f595a5cbd 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -595,6 +595,9 @@ struct smb_variant { * @fg_charge_seen: A charge has actually been in progress since that input * appeared, which is what makes a later inhibit mean * completion rather than a top-up that was never needed + * @fg_charging: The charger is driving the pack right now, so its terminal + * voltage is imposed rather than chosen and says nothing + * the OCV table can answer */ struct smb_chip { struct device *dev; @@ -630,6 +633,7 @@ struct smb_chip { bool fg_ready; bool fg_full; bool fg_charge_seen; + bool fg_charging; }; static enum power_supply_property smb_properties[] = { @@ -1234,6 +1238,7 @@ static bool smb_fg_track_completion(struct smb_chip *chip) if (!online) { chip->fg_full = false; chip->fg_charge_seen = false; + chip->fg_charging = false; return false; } @@ -1242,10 +1247,12 @@ static bool smb_fg_track_completion(struct smb_chip *chip) return chip->fg_full; code = status & BATTERY_CHARGER_STATUS_MASK; + chip->fg_charging = chip->var->charge_status[code] == + POWER_SUPPLY_STATUS_CHARGING; + if (code == CHARGE_STATUS_TERMINATE) { chip->fg_full = true; - } else if (chip->var->charge_status[code] == - POWER_SUPPLY_STATUS_CHARGING) { + } else if (chip->fg_charging) { chip->fg_charge_seen = true; chip->fg_full = false; } else if (code == chip->var->inhibit_code && chip->fg_charge_seen) { @@ -1307,7 +1314,20 @@ static bool smb_fg_update(struct smb_chip *chip) if (soc_ocv < 0) return false; - if (abs(i_ua) <= SMB_FG_OCV_QUIET_UA) + /* + * A charger in its constant-voltage phase holds the terminal at the + * float voltage and lets the current decay, so what the ADC reads + * there is the charger's number and not the cell's. Subtracting I*R + * does not recover an open-circuit voltage from it - the current is + * small by then, a few millivolts' worth - and the table, asked about + * a voltage above its top entry, answers a hundred percent while the + * pack is still filling. Count charge and nothing else while the + * charger is driving; the completion the charger reports is what ends + * that, and it is the honest hundred percent. + */ + if (chip->fg_charging) + weight = 0; + else if (abs(i_ua) <= SMB_FG_OCV_QUIET_UA) weight = SMB_FG_OCV_WEIGHT_QUIET; else if (abs(i_ua) <= SMB_FG_OCV_SETTLED_UA) weight = SMB_FG_OCV_WEIGHT_SETTLED; @@ -1319,13 +1339,18 @@ static bool smb_fg_update(struct smb_chip *chip) elapsed_ms = ktime_ms_delta(now, chip->fg_last); was = chip->fg_ready ? chip->soc_permyriad : -1; - if (!chip->fg_ready || elapsed_ms > SMB_FG_STALE_MS) { + if (!chip->fg_ready || + (elapsed_ms > SMB_FG_STALE_MS && !chip->fg_charging)) { /* * A gap this long means the system was suspended, so the * sample says nothing about what happened during it. A * suspended phone is also a rested battery, which is the one * condition where the OCV table needs no help at all - take it * outright rather than integrating a current nobody drew. + * + * Unless it suspended on a charger, which is the exception: a + * cell being held at the float voltage is the least rested it + * ever gets, whatever the CPU was doing. */ chip->soc_permyriad = soc_ocv; chip->fg_residue = 0; From cb6b0752a9810cf83105345dadba186c56e53721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Mon, 10 Aug 2026 19:33:58 +0200 Subject: [PATCH 21/33] power: supply: qcom_smbx: correct the fuel gauge from rested readings only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gauge counts charge, which holds the reported capacity steady while the load moves, and corrects that count against an open-circuit voltage. It used to take that correction from the live sample whenever the current was low enough, but on this pack the discharge curve is nearly flat from 28 to 46 percent - eighteen points inside forty-one millivolts - and the overpotential a loaded sample carries, which the constant series resistance does not model, becomes a large, one-directional error there. Measured on a Fairphone 3: a gauge seeded at 42% walked down into the low teens over a session while the cell was near thirty, and under a one-amp load the recovered OCV read one percent. So correct only from readings the hardware took at rest: - The gauge captures an open-circuit voltage on its own each time the current stays near zero long enough, and keeps it in S3_GOOD_OCV. Take a fresh one outright - from the poll, and where the device tree wires it, from the good-OCV interrupt, which catches the capture the instant it happens rather than whenever the next poll runs. - The charger's own completion is the honest hundred percent. - A suspend rests the cell, but the sample the first poll after it reads can be loaded; take that one only when it is itself at rest, and otherwise keep the count, which a suspend draws too little to have moved. And persist the state of charge to the gauge's scratch SRAM, which a warm reboot keeps, restoring it at probe instead of re-deriving it from a rest OCV that has gone stale between the rare captures this pack allows - which had the reported capacity jump on every reboot. Both the good-OCV interrupt and the SRAM store mirror what the downstream qpnp-qg driver does with the same hardware. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 287 ++++++++++++++++++++++++------- 1 file changed, 226 insertions(+), 61 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index f59b3f595a5cbd..5bc320270e0caa 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -424,16 +424,25 @@ #define QG_I_LSB_NA 152588 /* - * How often the gauge integrates, and the currents at which an open-circuit - * voltage reading is trusted enough to correct the integral. The wider band - * still carries an IR error of a few millivolts, which is why it only nudges; - * the narrow one is small enough to steer with. + * A scratch SRAM in the gauge that a warm reboot keeps. The downstream driver + * persists the state of charge here and restores it at boot rather than + * re-deriving it from a rest OCV that has gone stale between the rare captures + * this pack allows; mirror that. Offsets are the downstream qg-reg.h ones; the + * magic is our own, so only a value this driver wrote is ever restored. + */ +#define QG_SDAM_BASE 0xb100 +#define QG_SDAM_SOC (QG_SDAM_BASE + 0x47) /* 1 byte, percent */ +#define QG_SDAM_MAGIC (QG_SDAM_BASE + 0x80) /* 4 bytes */ +#define QG_SDAM_MAGIC_VALUE 0x736d6278 /* "smbx" */ + +/* + * How often the gauge integrates, and the current below which a wake sample is + * rested enough for its voltage to be read as an open-circuit one. Above it the + * reading is loaded and steers nothing - correction then comes only from the + * hardware's own rested capture (see smb_fg_update()). */ #define SMB_FG_POLL_MS 10000 #define SMB_FG_OCV_QUIET_UA 50000 -#define SMB_FG_OCV_SETTLED_UA 150000 -#define SMB_FG_OCV_WEIGHT_QUIET 25 -#define SMB_FG_OCV_WEIGHT_SETTLED 3 /* * A poll this late means the CPU was suspended rather than merely busy, so * the last current sample says nothing about the interval. Re-anchor on the @@ -634,6 +643,8 @@ struct smb_chip { bool fg_full; bool fg_charge_seen; bool fg_charging; + /* Last hardware rest-OCV harvested, to tell a fresh capture from a stale one */ + int fg_good_ocv_uv; }; static enum power_supply_property smb_properties[] = { @@ -1267,20 +1278,78 @@ static bool smb_fg_track_completion(struct smb_chip *chip) } /** - * smb_fg_work() - integrate the fuel gauge over one poll interval - * @work: the delayed work in struct smb_chip + * smb_fg_take_good_ocv() - anchor to the gauge's own rested OCV when it is fresh + * @chip: the charger + * @changed: set to whether the reported percent moved, if a reading is taken + * + * The gauge captures an open-circuit voltage on its own each time the current + * has stayed near zero long enough for a rest reading, and keeps it in + * S3_GOOD_OCV. Measured at rest, it carries none of the overpotential a loaded + * live sample does and none of the drift the integral accumulates - the same + * thing the boot seed is taken from, refreshed while running. A capture whose + * raw value has not changed is the rest already used, not a new one, so it is + * left alone; re-taking it after the pack has moved would walk the answer back. + * + * Runs from the poll and, where the device tree wires it, from the good-OCV + * interrupt, so it takes the lock and de-dups on the raw value. Returns whether + * a fresh reading was taken. + */ +static bool smb_fg_take_good_ocv(struct smb_chip *chip, bool *changed) +{ + int gv_uv, gi_ua, gsoc, was; + + if (smb_qg_read_sample(chip, QG_S3_GOOD_OCV_V_DATA0, &gv_uv, &gi_ua) < 0) + return false; + + guard(mutex)(&chip->fg_lock); + + if (gv_uv == chip->fg_good_ocv_uv) + return false; + chip->fg_good_ocv_uv = gv_uv; + + gsoc = smb_ocv_to_permyriad(chip, smb_batt_ocv(chip, gv_uv, gi_ua)); + if (gsoc < 0) + return false; + + was = chip->fg_ready ? chip->soc_permyriad : -1; + chip->soc_permyriad = gsoc; + chip->fg_residue = 0; + chip->fg_ready = true; + chip->fg_last = ktime_get_boottime(); + dev_dbg(chip->dev, "fg: re-anchor good_ocv=%duV -> %d.%02d%%\n", + gv_uv, gsoc / 100, gsoc % 100); + + if (changed) + *changed = was != chip->soc_permyriad; + return true; +} + +static irqreturn_t smb_handle_good_ocv(int irq, void *data) +{ + struct smb_chip *chip = data; + bool changed = false; + + if (smb_fg_take_good_ocv(chip, &changed) && changed) + power_supply_changed(chip->batt_psy); + + return IRQ_HANDLED; +} + +/** + * smb_fg_update() - carry the fuel gauge across one poll interval + * @chip: the charger * - * Charge counting is what keeps the reported capacity still while the load - * moves, but on its own it only ever accumulates error, so it is steered by - * the OCV table whenever the current is low enough for the table to be worth - * more than the integral. The two currents that gate that are deliberately - * far below what this phone draws when it is awake and doing something: the - * intent is to correct while it idles or charges gently, and to coast on the - * integral the rest of the time. + * The reported capacity is counted from charge, which keeps it still while the + * load moves. Counting only accumulates error, so it is corrected - but never + * from a loaded live sample, which the flat middle of the curve turns into a + * large one-directional error. Correction comes only from readings the hardware + * took at rest: the gauge's own S3_GOOD_OCV, the charger's completion, and the + * re-anchor a suspend allows. */ static bool smb_fg_update(struct smb_chip *chip) { - int v_uv, i_ua, ocv_uv, soc_ocv, was, weight; + int v_uv, i_ua, ocv_uv, soc_ocv, was; + bool changed = false; ktime_t now = ktime_get_boottime(); s64 elapsed_ms; @@ -1309,52 +1378,55 @@ static bool smb_fg_update(struct smb_chip *chip) return was != chip->soc_permyriad; } + /* Prefer the gauge's own rested reading whenever it has a fresh one. */ + if (smb_fg_take_good_ocv(chip, &changed)) + return changed; + ocv_uv = smb_batt_ocv(chip, v_uv, i_ua); soc_ocv = smb_ocv_to_permyriad(chip, ocv_uv); if (soc_ocv < 0) return false; /* - * A charger in its constant-voltage phase holds the terminal at the - * float voltage and lets the current decay, so what the ADC reads - * there is the charger's number and not the cell's. Subtracting I*R - * does not recover an open-circuit voltage from it - the current is - * small by then, a few millivolts' worth - and the table, asked about - * a voltage above its top entry, answers a hundred percent while the - * pack is still filling. Count charge and nothing else while the - * charger is driving; the completion the charger reports is what ends - * that, and it is the honest hundred percent. + * Between the fixed points the state of charge is counted, not read off + * the live voltage. A voltage taken under load is not an open-circuit + * voltage: the constant series resistance recovers the ohmic step but not + * the slower overpotential, and on the flat middle of the discharge curve + * - eighteen points inside forty millivolts here - the tens of millivolts + * left over become a large, one-directional error in the answer. So the + * live sample steers nothing. Correction comes only from readings the + * hardware took at rest: the S3_GOOD_OCV re-anchor above, the charger's + * own completion, and the re-anchor a suspend allows below. */ - if (chip->fg_charging) - weight = 0; - else if (abs(i_ua) <= SMB_FG_OCV_QUIET_UA) - weight = SMB_FG_OCV_WEIGHT_QUIET; - else if (abs(i_ua) <= SMB_FG_OCV_SETTLED_UA) - weight = SMB_FG_OCV_WEIGHT_SETTLED; - else - weight = 0; - guard(mutex)(&chip->fg_lock); elapsed_ms = ktime_ms_delta(now, chip->fg_last); was = chip->fg_ready ? chip->soc_permyriad : -1; - if (!chip->fg_ready || - (elapsed_ms > SMB_FG_STALE_MS && !chip->fg_charging)) { + if (!chip->fg_ready) { /* - * A gap this long means the system was suspended, so the - * sample says nothing about what happened during it. A - * suspended phone is also a rested battery, which is the one - * condition where the OCV table needs no help at all - take it - * outright rather than integrating a current nobody drew. - * - * Unless it suspended on a charger, which is the exception: a - * cell being held at the float voltage is the least rested it - * ever gets, whatever the CPU was doing. + * Nothing has seeded the gauge yet - the boot OCV registers were + * unreadable. Bootstrap from the live sample whatever its current, + * since there is no better number to start from; the first rest + * correction puts it right. */ chip->soc_permyriad = soc_ocv; chip->fg_residue = 0; chip->fg_ready = true; + } else if (elapsed_ms > SMB_FG_STALE_MS && !chip->fg_charging) { + /* + * A gap this long is a suspend: nothing was counted across it, and + * the live sample describes only the instant of waking. A phone + * busy the moment it resumes gives a loaded reading, which on the + * flat curve is a large, one-directional error - so do not anchor + * to it. A suspend draws too little to have moved the charge, so + * keep the count as it was and let the hardware rest-OCV re-anchor + * correct any residual. Only a wake sample that is itself at rest + * is a real open-circuit voltage worth taking outright. + */ + if (abs(i_ua) <= SMB_FG_OCV_QUIET_UA) + chip->soc_permyriad = soc_ocv; + chip->fg_residue = 0; } else { /* * charge (uAh) = i_ua * elapsed / 3600, and one percent of the @@ -1374,20 +1446,56 @@ static bool smb_fg_update(struct smb_chip *chip) chip->fg_residue -= step * per_permyriad; chip->soc_permyriad += step; - chip->soc_permyriad += (soc_ocv - chip->soc_permyriad) * weight / 100; chip->soc_permyriad = clamp(chip->soc_permyriad, 0, 100 * 100); } chip->fg_last = now; - dev_dbg(chip->dev, "fg: %d.%02d%% vbat=%duV ibat=%duA ocv=%duV w=%d\n", + dev_dbg(chip->dev, + "fg: %d.%02d%% vbat=%duV ibat=%duA ocv=%duV(soc %d)\n", chip->soc_permyriad / 100, chip->soc_permyriad % 100, v_uv, - i_ua, ocv_uv, weight); + i_ua, ocv_uv, soc_ocv); return DIV_ROUND_CLOSEST(chip->soc_permyriad, 100) != DIV_ROUND_CLOSEST(was, 100); } +/* + * Persist the state of charge to the gauge's scratch SRAM so a warm reboot can + * restore it. One byte of percent is enough; the magic that guards it is + * written once, when the gauge starts. + */ +static void smb_fg_sdam_store(struct smb_chip *chip) +{ + unsigned int soc; + + scoped_guard(mutex, &chip->fg_lock) + soc = clamp(DIV_ROUND_CLOSEST(chip->soc_permyriad, 100), 0, 100); + + regmap_write(chip->regmap, QG_SDAM_SOC, soc); +} + +/* + * Restore the state of charge the last boot persisted, if this driver is what + * wrote it. A warm reboot keeps the SRAM; a battery swap clears it and the magic + * no longer matches, so the caller falls back to seeding from an OCV instead. + */ +static bool smb_fg_sdam_restore(struct smb_chip *chip, int *soc_permyriad) +{ + __le32 magic; + unsigned int soc; + + if (regmap_bulk_read(chip->regmap, QG_SDAM_MAGIC, &magic, sizeof(magic))) + return false; + if (le32_to_cpu(magic) != QG_SDAM_MAGIC_VALUE) + return false; + if (regmap_read(chip->regmap, QG_SDAM_SOC, &soc) || soc > 100) + return false; + + *soc_permyriad = soc * 100; + return true; +} + static void smb_fg_work(struct work_struct *work) { struct smb_chip *chip = container_of(work, struct smb_chip, @@ -1396,6 +1504,8 @@ static void smb_fg_work(struct work_struct *work) if (smb_fg_update(chip)) power_supply_changed(chip->batt_psy); + smb_fg_sdam_store(chip); + schedule_delayed_work(&chip->fg_work, msecs_to_jiffies(SMB_FG_POLL_MS)); } @@ -1425,21 +1535,53 @@ static int smb_fg_start(struct smb_chip *chip) if (rc) return rc; - for (i = 0; i < ARRAY_SIZE(rest_ocv); i++) { - if (smb_qg_read_sample(chip, rest_ocv[i], &v_uv, &i_ua) < 0) - continue; - - soc = smb_ocv_to_permyriad(chip, smb_batt_ocv(chip, v_uv, i_ua)); - if (soc < 0) - continue; - + /* + * Restore the state of charge the last boot persisted to the scratch + * SRAM. A warm reboot keeps it, and unlike a rest OCV it does not go + * stale between the rare captures this pack allows - so prefer it, and + * seed from an OCV only when the SRAM holds nothing this driver wrote. + */ + if (smb_fg_sdam_restore(chip, &soc)) { chip->soc_permyriad = soc; chip->fg_ready = true; - /* so the first poll integrates rather than re-anchoring */ chip->fg_last = ktime_get_boottime(); - dev_dbg(chip->dev, "fg: seeded at %d.%02d%% from %duV\n", - soc / 100, soc % 100, v_uv); - break; + dev_dbg(chip->dev, "fg: restored %d.%02d%% from sdam\n", + soc / 100, soc % 100); + } else { + for (i = 0; i < ARRAY_SIZE(rest_ocv); i++) { + if (smb_qg_read_sample(chip, rest_ocv[i], &v_uv, &i_ua) < 0) + continue; + + soc = smb_ocv_to_permyriad(chip, smb_batt_ocv(chip, v_uv, i_ua)); + if (soc < 0) + continue; + + chip->soc_permyriad = soc; + chip->fg_ready = true; + /* so the first poll integrates rather than re-anchoring */ + chip->fg_last = ktime_get_boottime(); + /* + * If this seed is the hardware rest-OCV the poll also + * watches, record it so the first poll does not re-take + * the same capture. + */ + if (rest_ocv[i] == QG_S3_GOOD_OCV_V_DATA0) + chip->fg_good_ocv_uv = v_uv; + dev_dbg(chip->dev, "fg: seeded at %d.%02d%% from %duV\n", + soc / 100, soc % 100, v_uv); + break; + } + } + + /* + * Claim the SRAM store so the next boot restores our own value rather + * than whatever wrote it last. + */ + if (chip->fg_ready) { + __le32 magic = cpu_to_le32(QG_SDAM_MAGIC_VALUE); + + regmap_bulk_write(chip->regmap, QG_SDAM_MAGIC, &magic, + sizeof(magic)); } rc = devm_delayed_work_autocancel(chip->dev, &chip->fg_work, @@ -2424,6 +2566,29 @@ static int smb_probe(struct platform_device *pdev) if (rc < 0) return rc; + /* + * The gauge raises this the moment it captures a fresh rested + * open-circuit voltage, which is exactly when the state of charge should + * re-anchor on it - sooner and more reliably than the poll, which can + * only catch a reading that is still valid when it happens to run. It is + * optional: a device tree without it falls back to that poll, and there + * is nothing to wire when the fuel gauge itself is not registered. + */ + if (chip->batt_psy) { + irq = platform_get_irq_byname_optional(to_platform_device(chip->dev), + "good-ocv"); + if (irq == -EPROBE_DEFER) + return irq; + if (irq > 0) { + rc = devm_request_threaded_irq(chip->dev, irq, NULL, + smb_handle_good_ocv, + IRQF_ONESHOT, "good-ocv", chip); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't request good-ocv irq\n"); + } + } + devm_device_init_wakeup(chip->dev); rc = devm_pm_set_wake_irq(chip->dev, chip->cable_irq); From 610cfa86d4962f72d546ca1c503c8f8404913485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Tue, 11 Aug 2026 20:44:40 +0200 Subject: [PATCH 22/33] power: supply: qcom_smbx: complete the charge-end path and report more of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five changes to what this driver tells the charger about finishing a charge, and to what it reports back afterwards. All are taken from the downstream qpnp-smb5 driver in the Fairphone 3 vendor tree and were measured against a Fairphone 3 running this driver. - Program the ADC termination-current threshold from the battery node's charge-term-current-microamp. This comparator was not missing: read back on a Fairphone 3 it held -101.8 mA with the ADC source already selected. What was wrong is whose number it was - a threshold the boot left behind, that nothing in this kernel chose or could state. Downstream takes it from qcom,chg-term-current-ma, and every battery profile this phone ships declares 100 mA. - Do not let the state of charge persisted in the gauge's scratch SRAM outvote a rested reading it disagrees with by more than ten points. What is stored is where a count had reached, and a count is only as good as the corrections it received; what the gauge captured at rest is a measurement of the pack itself. - Write 2 rather than 3 into NO_OF_SAMPLE_FOR_RCHG. The field counts from zero - downstream writes 2 and its comment calls that three samples - so the comment here claiming to match downstream did not. - Report POWER_SUPPLY_PROP_CHARGE_TYPE, which downstream exposes and this driver did not. Mainline has no value meaning taper, and the drivers that meet a constant-voltage phase do not report one, so the taper code maps to STANDARD against FULLON's FAST: the names fit loosely, but the transition userspace cares about still shows. - Regulate the input current against a connector thermistor on boards that describe one, as smb5_configure_mitigation() does: bias the thermistor, enable the ADC channel that reads it, and name that channel as a regulation source. A board that says nothing gets none of it, since biasing a pin with no thermistor on it measures the pull-up. None of this makes the charger terminate on a Fairphone 3, which it still does not do: measured over a four-hour charge, the current sat below the programmed threshold for one hour forty-nine without the charger ever leaving taper. That remains open. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 326 ++++++++++++++++++++++++++++++- 1 file changed, 324 insertions(+), 2 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 5bc320270e0caa..3ccf929225e08e 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -101,9 +101,26 @@ #define NO_OF_SAMPLE_FOR_RCHG GENMASK(3, 2) #define NO_OF_SAMPLE_FOR_RCHG_SHIFT 2 +/* + * SMB5 only: the current the charger calls a charge finished at, compared by + * the same ADC the gauge reports from and in the same 152588 nA units - so the + * threshold is written in the gauge's sign convention, negative into the + * battery (smb5-reg.h CHGR_ADC_ITERM_UP_THD_MSB/LSB_REG). + */ +#define ADC_ITERM_UP_THD_MSB 0x67 +#define ADC_ITERM_UP_THD_LSB 0x68 + #define FLOAT_VOLTAGE_CFG 0x70 #define FLOAT_VOLTAGE_SETTING_MASK GENMASK(7, 0) +/* + * Which of the two termination comparators the charger acts on. Clear selects + * the ADC threshold above; set selects an analog comparator this driver does + * not program (smb5-reg.h CHGR_ENG_CHARGING_CFG_REG). + */ +#define ENG_CHARGING_CFG 0xC0 +#define ITERM_USE_ANALOG_BIT BIT(3) + #define ADC_RECHARGE_THRESHOLD_MSB 0x7E #define ADC_RECHARGE_THRESHOLD_LSB 0x7F @@ -399,6 +416,33 @@ #define STAT_FUNCTION_CFG_BIT BIT(1) #define STAT_IRQ_PULSING_EN_BIT BIT(0) +/* + * SMB5 only: the connector thermistor, and what the charger does about it. + * The PMIC biases the thermistor from an internal pull-up, measures it on one + * of the BATIF ADC channels, and - once that channel is named as a + * thermal-regulation source - pulls the input current back on its own when the + * connector gets hot. All three have to be set for any of it to happen + * (smb5-reg.h BATIF_ADC_INTERNAL_PULL_UP_REG, BATIF_ADC_CHANNEL_EN_REG and + * MISC_THERMREG_SRC_CFG_REG). + * + * The pull-up register holds two bits per thermistor, in the order the + * downstream driver enumerates them - battery, misc, connector, smb - so the + * connector's are bits 5:4. + */ +#define BATIF_ADC_INTERNAL_PULL_UP 0x286 +#define CONN_THM_PULL_UP_MASK GENMASK(5, 4) +#define CONN_THM_PULL_UP_SHIFT 4 +#define PULL_UP_NONE 0 +#define PULL_UP_30K 1 +#define PULL_UP_100K 2 +#define PULL_UP_400K 3 + +#define BATIF_ADC_CHANNEL_EN 0x282 +#define CONN_THM_CHANNEL_EN_BIT BIT(4) + +#define MISC_THERMREG_SRC_CFG 0x670 +#define THERMREG_CONNECTOR_ADC_SRC_EN_BIT BIT(4) + /* * Fuel-gauge (QG) peripheral, present on SMB5 PMICs. Its base is absolute * inside the PMIC rather than relative to the charger's own base, and is @@ -450,6 +494,19 @@ */ #define SMB_FG_STALE_MS 60000 +/* + * How far the persisted state of charge may sit from the gauge's own rested + * capture before the measurement is preferred to it at boot, in hundredths of + * a percent. The two disagree by a few points in ordinary use: the OCV table + * is characterised at one temperature and read at whatever the pack happens to + * be, and the count it is compared against has been integrating since the last + * rest. Ten points is past what either explains, and what is left is a stored + * value that no longer describes the pack - a battery changed while the phone + * was off, or a count that drifted through a session which never terminated a + * charge. + */ +#define SMB_FG_SEED_DISAGREE (10 * 100) + #define SDP_CURRENT_UA 500000 #define CDP_CURRENT_UA 1500000 #define DCP_CURRENT_UA 1500000 @@ -477,6 +534,41 @@ */ #define CHARGE_STATUS_TERMINATE 5 +/* + * Which kind of charging each of the eight codes is, for CHARGE_TYPE. Status + * says whether the pack is being charged; this says how hard, and it is the + * difference between a charge that is progressing and one that has reached the + * float voltage and is tapering off. Taken from the downstream + * smblib_get_prop_batt_charge_type(), which draws the same distinctions. + * + * Mainline has no value meaning taper, and the drivers that meet a CV phase do + * not report one - rk817 puts constant-current and constant-voltage together + * under STANDARD. So the taper code is reported as STANDARD against FULLON's + * FAST: the names fit loosely, but the transition userspace cares about, the + * one where the current starts falling away, still shows up as a change. + */ +static const u8 smb2_charge_type[8] = { + POWER_SUPPLY_CHARGE_TYPE_TRICKLE, /* 0 trickle */ + POWER_SUPPLY_CHARGE_TYPE_TRICKLE, /* 1 pre */ + POWER_SUPPLY_CHARGE_TYPE_FAST, /* 2 fast */ + POWER_SUPPLY_CHARGE_TYPE_FAST, /* 3 full-on */ + POWER_SUPPLY_CHARGE_TYPE_STANDARD, /* 4 taper */ + POWER_SUPPLY_CHARGE_TYPE_NONE, /* 5 terminate */ + POWER_SUPPLY_CHARGE_TYPE_NONE, /* 6 inhibit */ + POWER_SUPPLY_CHARGE_TYPE_NONE, /* 7 disable */ +}; + +static const u8 smb5_charge_type[8] = { + POWER_SUPPLY_CHARGE_TYPE_NONE, /* 0 inhibit */ + POWER_SUPPLY_CHARGE_TYPE_TRICKLE, /* 1 trickle */ + POWER_SUPPLY_CHARGE_TYPE_TRICKLE, /* 2 pre */ + POWER_SUPPLY_CHARGE_TYPE_FAST, /* 3 full-on */ + POWER_SUPPLY_CHARGE_TYPE_STANDARD, /* 4 taper */ + POWER_SUPPLY_CHARGE_TYPE_NONE, /* 5 terminate */ + POWER_SUPPLY_CHARGE_TYPE_NONE, /* 6 pause */ + POWER_SUPPLY_CHARGE_TYPE_NONE, /* 7 disable */ +}; + static const u8 smb2_charge_status[8] = { POWER_SUPPLY_STATUS_CHARGING, /* 0 trickle */ POWER_SUPPLY_STATUS_CHARGING, /* 1 pre */ @@ -532,9 +624,17 @@ struct smb_init_register { * (BIT(5) on SMB2, BIT(1) on SMB5) * @charge_status: What each of the eight BATTERY_CHARGER_STATUS_1 codes * means on this generation, see smb2_charge_status + * @charge_type: What kind of charging each of the eight codes is, + * see smb2_charge_type * @rechg_thresh_reg: Register holding the battery-voltage recharge threshold, * relative to @base, or 0 where this generation does not * express one this way (SMB2) + * @iterm_thresh_reg: Register pair holding the ADC termination-current + * threshold, relative to @base, or 0 where this generation + * does not express one this way (SMB2) + * @thermreg_src_reg: Register naming which measurements the charger regulates + * its input current against, relative to @base, or 0 where + * this generation has no connector thermistor to add (SMB2) * @inhibit_code: Which of those codes is charge inhibit - the state a * finished charge settles into once the cell is above the * recharge threshold (6 on SMB2, 0 on SMB5) @@ -560,7 +660,10 @@ struct smb_variant { u32 float_step_uv; u8 ov_bit; const u8 *charge_status; + const u8 *charge_type; u16 rechg_thresh_reg; + u16 iterm_thresh_reg; + u16 thermreg_src_reg; u8 inhibit_code; u16 temp_status_reg; u8 temp_status_shift; @@ -769,6 +872,37 @@ static int smb_get_prop_status(struct smb_chip *chip, int *val) return 0; } +/* + * How hard the pack is being charged, which is the distinction the status + * above cannot draw: a charge climbing at the full current and one that has + * reached the float voltage and is tapering off are both "charging", and only + * the second is nearly over. Without this the difference lives solely in the + * charger's own registers, which is a poor place to have to go looking - it + * needs root, a debugfs regmap, and knowing which of eight codes this + * generation numbers taper as. + */ +static int smb_get_prop_charge_type(struct smb_chip *chip, int *val) +{ + unsigned int stat; + int usb_online = 0; + int rc; + + rc = smb_get_prop_usb_online(chip, &usb_online); + if (!usb_online) { + *val = POWER_SUPPLY_CHARGE_TYPE_NONE; + return rc; + } + + rc = regmap_read(chip->regmap, chip->base + BATTERY_CHARGER_STATUS_1, + &stat); + if (rc < 0) + return rc; + + *val = chip->var->charge_type[stat & BATTERY_CHARGER_STATUS_MASK]; + + return 0; +} + static inline int smb_get_current_limit(struct smb_chip *chip, unsigned int *val) { @@ -1092,6 +1226,7 @@ static irqreturn_t smb_handle_wdog_bark(int irq, void *data) */ static enum power_supply_property smb_batt_properties[] = { POWER_SUPPLY_PROP_STATUS, + POWER_SUPPLY_PROP_CHARGE_TYPE, POWER_SUPPLY_PROP_HEALTH, POWER_SUPPLY_PROP_PRESENT, POWER_SUPPLY_PROP_TECHNOLOGY, @@ -1529,19 +1664,43 @@ static int smb_fg_start(struct smb_chip *chip) QG_S3_GOOD_OCV_V_DATA0, QG_S7_PON_OCV_V_DATA0, }; - int v_uv, i_ua, soc, rc, i; + int v_uv, i_ua, soc, rest_soc = -1, rest_uv = 0, rc, i; rc = devm_mutex_init(chip->dev, &chip->fg_lock); if (rc) return rc; + /* The gauge's freshest rested capture, where it holds one. */ + if (smb_qg_read_sample(chip, QG_S3_GOOD_OCV_V_DATA0, &v_uv, &i_ua) == 0) { + rest_soc = smb_ocv_to_permyriad(chip, smb_batt_ocv(chip, v_uv, i_ua)); + rest_uv = v_uv; + } + /* * Restore the state of charge the last boot persisted to the scratch * SRAM. A warm reboot keeps it, and unlike a rest OCV it does not go * stale between the rare captures this pack allows - so prefer it, and * seed from an OCV only when the SRAM holds nothing this driver wrote. + * + * Prefer it, but do not believe it against the evidence. What is stored + * is what a count had reached, and a count is only ever as good as the + * corrections it received; what the gauge captured at rest is a + * measurement of the pack itself. Where the two are far enough apart + * that neither the table's temperature nor a session's drift explains + * it, the measurement is the one describing the battery that is fitted + * now - so take it, and say so, because a seed being overruled is worth + * knowing about. */ if (smb_fg_sdam_restore(chip, &soc)) { + if (rest_soc >= 0 && abs(rest_soc - soc) > SMB_FG_SEED_DISAGREE) { + dev_info(chip->dev, + "fg: stored %d.%02d%% disagrees with the rested %d.%02d%%, taking the measurement\n", + soc / 100, soc % 100, + rest_soc / 100, rest_soc % 100); + soc = rest_soc; + chip->fg_good_ocv_uv = rest_uv; + } + chip->soc_permyriad = soc; chip->fg_ready = true; chip->fg_last = ktime_get_boottime(); @@ -1671,6 +1830,8 @@ static int smb_batt_get_property(struct power_supply *psy, switch (psp) { case POWER_SUPPLY_PROP_STATUS: return smb_get_prop_status(chip, &val->intval); + case POWER_SUPPLY_PROP_CHARGE_TYPE: + return smb_get_prop_charge_type(chip, &val->intval); case POWER_SUPPLY_PROP_HEALTH: return smb_get_prop_health(chip, &val->intval); case POWER_SUPPLY_PROP_PRESENT: @@ -1859,10 +2020,12 @@ static const struct smb_init_register pmi632_init_seq[] = { /* * Take three samples before acting on the voltage comparator, which is * what the vendor driver asks for whenever it selects VBAT recharge. + * The field counts from zero - downstream writes 2 here and calls it + * three samples - so this is 2, not 3. */ { .addr = NO_SAMPLE_TERM_RCHG_CFG, .mask = NO_OF_SAMPLE_FOR_RCHG, - .val = 3 << NO_OF_SAMPLE_FOR_RCHG_SHIFT }, + .val = 2 << NO_OF_SAMPLE_FOR_RCHG_SHIFT }, /* Default SDP charger to a 500mA USB 2.0 port */ { .addr = USBIN_ICL_OPTIONS, .mask = USB51_MODE_BIT | USBIN_MODE_CHG_BIT, @@ -1909,6 +2072,7 @@ static const struct smb_variant smb_variant_pmi8998 = { .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, .charge_status = smb2_charge_status, + .charge_type = smb2_charge_type, .inhibit_code = 6, .temp_status_reg = BATTERY_CHARGER_STATUS_2, .temp_status_shift = 0, @@ -1925,6 +2089,7 @@ static const struct smb_variant smb_variant_pm660 = { .float_step_uv = 7500, .ov_bit = CHARGER_ERROR_STATUS_BAT_OV_BIT, .charge_status = smb2_charge_status, + .charge_type = smb2_charge_type, .inhibit_code = 6, .temp_status_reg = BATTERY_CHARGER_STATUS_2, .temp_status_shift = 0, @@ -1941,7 +2106,10 @@ static const struct smb_variant smb_variant_pmi632 = { .float_step_uv = 10000, .ov_bit = SMB5_CHARGER_ERROR_STATUS_BAT_OV_BIT, .charge_status = smb5_charge_status, + .charge_type = smb5_charge_type, .rechg_thresh_reg = ADC_RECHARGE_THRESHOLD_MSB, + .iterm_thresh_reg = ADC_ITERM_UP_THD_MSB, + .thermreg_src_reg = MISC_THERMREG_SRC_CFG, .inhibit_code = 0, /* * On SMB5 the JEITA temperature-status bits moved out of @@ -2065,6 +2233,152 @@ static int smb_set_recharge_threshold(struct smb_chip *chip) return 0; } +/** + * smb_set_term_current() - tell the charger when a charge is finished + * @chip: the charger + * + * The charger stops at the float voltage once the current it is still pushing + * falls below this, and reports that it did so - a state nothing else on the + * PMIC produces, and the only moment either the hardware or this driver can + * call the pack full. + * + * The boot leaves a value here, so this is not a comparator that was missing: + * read back on a Fairphone 3 the threshold held -101.8 mA and the ADC source + * was already selected. What is wrong is whose number it is. A termination + * current is a property of the cell; the device tree is where this driver is + * told about the cell; and a threshold inherited from a bootloader is one that + * nothing in this kernel chose, states, or would notice changing under it. + * + * The threshold is compared by the gauge's own ADC, so it is written in the + * gauge's units and its sign convention - negative into the battery - while the + * battery node states a magnitude. A cell whose node does not name one is left + * alone rather than given an invented value, for the reason spelled out in + * smb_set_recharge_threshold(). + * + * Returns: 0, or negative on error. + */ +static int smb_set_term_current(struct smb_chip *chip) +{ + int term_ua = chip->batt_info->charge_term_current_ua; + int raw, rc; + + if (!chip->var->iterm_thresh_reg || term_ua <= 0) + return 0; + + /* + * The register is a signed 16-bit count of ADC LSBs, so the largest + * current it can express is what fills it - the same +-5 A the + * downstream driver bounds this property by. + */ + raw = -div_s64((s64)term_ua * 1000, QG_I_LSB_NA); + if (raw < S16_MIN) { + dev_warn(chip->dev, + "termination current %u uA is beyond what the comparator can express, ignoring\n", + term_ua); + return 0; + } + + /* Act on the ADC comparator this programs, not the analog one */ + rc = regmap_update_bits(chip->regmap, chip->base + ENG_CHARGING_CFG, + ITERM_USE_ANALOG_BIT, 0); + if (!rc) + rc = regmap_write(chip->regmap, + chip->base + chip->var->iterm_thresh_reg, + (raw >> 8) & 0xff); + if (!rc) + rc = regmap_write(chip->regmap, + chip->base + chip->var->iterm_thresh_reg + 1, + raw & 0xff); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't set the termination current\n"); + + return 0; +} + +/** + * smb_init_connector_therm() - let the charger protect its own connector + * @chip: the charger + * + * A USB connector heats up under a fast charge, and it is the one part of the + * path no thermal zone on the SoC can see: it is off-die, at the far end of + * the board, and what warms it is the current going through it. This PMIC can + * measure it directly, where the board wires a thermistor to the pin for it, + * and will then pull the input current back by itself when it gets hot - no + * software in the loop, and none of the latency that implies. + * + * Three writes turn that on and none of them does anything alone: bias the + * thermistor, enable the ADC channel that reads it, and name that channel as + * something the charger regulates against. What the board has to say is which + * pull-up its thermistor was chosen for; a board that says nothing gets none + * of this, because biasing a pin with no thermistor on it measures the pull-up + * and a charger regulating against that would throttle a connector that is + * perfectly cool. + * + * The die-temperature half of the same register is deliberately left alone: it + * describes the PMIC rather than the board, and changing it here would change + * what every other board using this driver does. + * + * Returns: 0, or negative on error. + */ +static int smb_init_connector_therm(struct smb_chip *chip) +{ + u32 pull_kohm, pull; + int rc; + + if (!chip->var->thermreg_src_reg) + return 0; + + if (device_property_read_u32(chip->dev, + "qcom,connector-internal-pull-kohm", + &pull_kohm)) + return 0; + + switch (pull_kohm) { + case 0: + pull = PULL_UP_NONE; + break; + case 30: + pull = PULL_UP_30K; + break; + case 100: + pull = PULL_UP_100K; + break; + case 400: + pull = PULL_UP_400K; + break; + default: + dev_warn(chip->dev, + "connector pull-up %u kohm is not one the PMIC can switch in, ignoring\n", + pull_kohm); + return 0; + } + + rc = regmap_update_bits(chip->regmap, + chip->base + BATIF_ADC_INTERNAL_PULL_UP, + CONN_THM_PULL_UP_MASK, + pull << CONN_THM_PULL_UP_SHIFT); + if (!rc) + rc = regmap_update_bits(chip->regmap, + chip->base + BATIF_ADC_CHANNEL_EN, + CONN_THM_CHANNEL_EN_BIT, + CONN_THM_CHANNEL_EN_BIT); + if (!rc) + rc = regmap_update_bits(chip->regmap, + chip->base + chip->var->thermreg_src_reg, + THERMREG_CONNECTOR_ADC_SRC_EN_BIT, + THERMREG_CONNECTOR_ADC_SRC_EN_BIT); + if (rc < 0) + return dev_err_probe(chip->dev, rc, + "Couldn't enable connector thermal regulation\n"); + + dev_dbg(chip->dev, + "connector thermistor on a %u kohm pull-up, input current regulated against it\n", + pull_kohm); + + return 0; +} + /* * A board that can be fitted with more than one battery tells them apart by a * resistor in the pack, read through a divider against the ADC's reference. @@ -2540,6 +2854,10 @@ static int smb_probe(struct platform_device *pdev) "Couldn't set the fast-charge current\n"); } + rc = smb_set_term_current(chip); + if (rc < 0) + return rc; + rc = smb_init_jeita(chip); if (rc < 0) return rc; @@ -2549,6 +2867,10 @@ static int smb_probe(struct platform_device *pdev) if (rc < 0) return rc; + rc = smb_init_connector_therm(chip); + if (rc < 0) + return rc; + rc = smb_init_irq(chip, &irq, "bat-ov", smb_handle_batt_overvoltage); if (rc < 0) return rc; From 30184377b47fadb6602e62d1cd8a8d2329359d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 12 Aug 2026 21:09:02 +0200 Subject: [PATCH 23/33] power: supply: qcom_smbx: let the charger terminate a charge on SMB5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pmi632 init sequence cleared I_TERM_BIT, on the strength of a comment this driver inherited from the SMB2 half of itself: I_TERM_BIT - Current termination ?? 0 = enabled The two question marks are the author saying they did not know, and on SMB5 the answer is the other way round. Downstream leaves the bit set; clearing it is what stopped the charger ever leaving taper. Measured on a Fairphone 3 against the vendor stack running on the same phone, same pack, same supply. Everything the decision depends on was already identical between the two - termination threshold 99.9 mA in CHGR_ADC_ITERM_UP_THD, the ADC comparator selected in CHGR_ENG_CHARGING_CFG, the same count in NO_SAMPLE_TERM_RCHG_CFG - and CHGR_CFG2 was 0x05 here against 0x2c there. With the bit cleared the current sat below the threshold for one hour forty-nine minutes and the charger stayed in taper, eight hours twenty-six in all, never once reaching BATTERY_CHARGER_STATUS_1 code 5. With it set the charger terminates within a minute of the current crossing - from 59% on a 2 A supply and from 97% on a 500 mA one - ending at 0x45, the value the vendor stack shows in the same state. Left as it was, a charge that never terminates holds the pack at the float voltage indefinitely instead of finishing and letting it relax to the recharge threshold, so this was not only a reporting problem. Only this bit changes. CHGR_CFG2 also differs from downstream in CHARGER_INHIBIT and PRETOFAST_TRANSITION, and those are left alone: with three bits moved at once, a charger that then worked would not say which one had been the problem. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 3ccf929225e08e..f86459a15a3d1b 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -72,6 +72,17 @@ #define CHG_EN_POLARITY_BIT BIT(6) #define PRETOFAST_TRANSITION_CFG_BIT BIT(5) #define BAT_OV_ECC_BIT BIT(4) +/* + * ☠️ On SMB5 this does not mean what the SMB2 comment below says. Clearing it + * there is described as enabling current termination; on a pmi632 it is what + * stops the charger ever leaving taper. Measured on a Fairphone 3 against the + * vendor stack running on the same phone, same pack, same supply: downstream + * leaves the bit SET and terminated within a minute of the current crossing the + * threshold, while this driver cleared it and sat below the same threshold for + * one hour forty-nine without terminating once. Every other input to the + * decision was identical between the two - threshold 99.9 mA, ADC comparator + * selected, same sample count. + */ #define I_TERM_BIT BIT(3) #define AUTO_RECHG_BIT BIT(2) #define EN_ANALOG_DROP_IN_VBATT_BIT BIT(1) @@ -2016,7 +2027,8 @@ static const struct smb_init_register pmi632_init_seq[] = { .mask = CHG_EN_SRC_BIT | CHG_EN_POLARITY_BIT | PRETOFAST_TRANSITION_CFG_BIT | BAT_OV_ECC_BIT | I_TERM_BIT | SMB5_RECHG_MASK | CHARGER_INHIBIT_BIT, - .val = SMB5_VBAT_BASED_RECHG_BIT | CHARGER_INHIBIT_BIT }, + .val = SMB5_VBAT_BASED_RECHG_BIT | CHARGER_INHIBIT_BIT | + I_TERM_BIT }, /* * Take three samples before acting on the voltage comparator, which is * what the vendor driver asks for whenever it selects VBAT recharge. From 9af02eb38ef68545a3cebdc074b8c2e0ca35f97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 12 Aug 2026 21:09:29 +0200 Subject: [PATCH 24/33] power: supply: qcom_smbx: do not forget a charge because the input blinked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full pack stops being full once charge starts leaving it, so losing that flag when the input goes away is right. Forgetting that a charge happened at all is not: that is a fact about what the charger did, and it does not unhappen because the cable flickered. The input does flicker. Re-running APSD on a source change drops online briefly, and so does anything at the other end of the cable. Clearing the history there costs a full pack its full reading, because the charger settles back into inhibit, inhibit without a charge behind it is not completion, and the rested open-circuit voltage then pulls a genuinely full battery about six percent down the table - which is the exact drop smb_fg_track_completion() exists to prevent, described in its own comment. Seen three times on a Fairphone 3 before it was understood: twice from the cable being handled, once from APSD alone, each time leaving a terminated pack reporting 96% and "not charging" while sitting at 4.33 V with nothing drawn from it. Reproduced deliberately afterwards by suspending and unsuspending USBIN, which now returns to full instead of dropping. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index f86459a15a3d1b..60d29e4975917f 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -715,9 +715,11 @@ struct smb_variant { * @fg_full: The charger has finished a charge on the input that is * still attached, so the pack is full whatever the curve * says, see smb_fg_track_completion() - * @fg_charge_seen: A charge has actually been in progress since that input - * appeared, which is what makes a later inhibit mean - * completion rather than a top-up that was never needed + * @fg_charge_seen: A charge has actually been in progress on this cable, + * which is what makes a later inhibit mean completion + * rather than a top-up that was never needed. Survives a + * momentary loss of the input, which is a fact about the + * cable and not about the pack * @fg_charging: The charger is driving the pack right now, so its terminal * voltage is imposed rather than chosen and says nothing * the OCV table can answer @@ -1393,8 +1395,25 @@ static bool smb_fg_track_completion(struct smb_chip *chip) smb_get_prop_usb_online(chip, &online); if (!online) { + /* + * The pack stops being full once charge starts leaving it, so + * that flag goes. Whether a charge was seen does not: it is a + * fact about what happened, and it does not unhappen because + * the input went away for a moment. + * + * That distinction matters because the input does go away for + * a moment. Re-running APSD on a source change drops online + * briefly, and so does anything at the other end of the cable. + * Clearing the history there costs a full pack its full + * reading: the charger settles back into inhibit, inhibit + * without a charge behind it is not full, and the rested OCV + * then pulls a genuinely full battery some six percent down + * the table - exactly the drop the comment above this function + * exists to prevent. Seen three times on a Fairphone 3 before + * it was understood, twice from a cable being handled and once + * from APSD alone. + */ chip->fg_full = false; - chip->fg_charge_seen = false; chip->fg_charging = false; return false; } From 7e69898fb91fa6115a1cfae254f5fea99187ea5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 12 Aug 2026 21:09:51 +0200 Subject: [PATCH 25/33] power: supply: qcom_smbx: report how much charge the pack holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The percentage this driver reported says nothing about size, and a percentage was all it reported, which leaves anything wanting to say how long a charge has left with no way to work it out. UPower derives its estimate from the charge remaining and the power going in, so without a capacity it can only show a number and no time. Report CHARGE_FULL_DESIGN, CHARGE_FULL and CHARGE_NOW. There is no learned capacity here - this gauge does not measure how the pack has aged - so full and full-design are the same value, the one the device tree states. Reporting a learned figure it has not learned would be worse than reporting the design one. Measured on a Fairphone 3: before this there was no time estimate at all, after it UPower reports one, and its arithmetic checks out against the sysfs values - (13.4334 - 9.6707) Wh / 4.09352 W = 55 minutes, where the energy comes from these properties and the rate from current_now times voltage_now. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 48 ++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 60d29e4975917f..3053152c615ae0 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -1244,6 +1244,9 @@ static enum power_supply_property smb_batt_properties[] = { POWER_SUPPLY_PROP_PRESENT, POWER_SUPPLY_PROP_TECHNOLOGY, POWER_SUPPLY_PROP_CAPACITY, + POWER_SUPPLY_PROP_CHARGE_FULL_DESIGN, + POWER_SUPPLY_PROP_CHARGE_FULL, + POWER_SUPPLY_PROP_CHARGE_NOW, POWER_SUPPLY_PROP_VOLTAGE_NOW, POWER_SUPPLY_PROP_VOLTAGE_OCV, POWER_SUPPLY_PROP_CURRENT_NOW, @@ -1783,6 +1786,46 @@ static int smb_fg_start(struct smb_chip *chip) return 0; } +/* + * How much charge the pack holds, and how much is in it. The percentage above + * says nothing about size, and a percentage is all this driver used to report - + * which leaves anything wanting to say how long a charge has left with no way + * to work it out. UPower derives its estimate from the charge remaining and the + * power going in, so without these it can only show a number and no time. + * + * There is no learned capacity here: this gauge does not measure how the pack + * has aged, so full and full-design are the same value, the one the device tree + * states. Reporting a learned figure it has not learned would be worse than + * reporting the design one. + */ +static int smb_get_batt_charge_full(struct smb_chip *chip, int *val) +{ + if (!chip->batt_info || chip->batt_info->charge_full_design_uah <= 0) + return -ENODATA; + + *val = chip->batt_info->charge_full_design_uah; + + return 0; +} + +static int smb_get_batt_charge_now(struct smb_chip *chip, int *val) +{ + if (!smb_fg_available(chip)) + return -ENODATA; + + guard(mutex)(&chip->fg_lock); + + if (!chip->fg_ready) + return -EAGAIN; + + /* soc is in hundredths of a percent, so the divisor is 100 * 100 */ + *val = div_u64((u64)chip->soc_permyriad * + chip->batt_info->charge_full_design_uah, + 100 * 100); + + return 0; +} + static int smb_get_batt_capacity(struct smb_chip *chip, int *val) { int v_uv, cap, rc; @@ -1872,6 +1915,11 @@ static int smb_batt_get_property(struct power_supply *psy, return 0; case POWER_SUPPLY_PROP_CAPACITY: return smb_get_batt_capacity(chip, &val->intval); + case POWER_SUPPLY_PROP_CHARGE_FULL_DESIGN: + case POWER_SUPPLY_PROP_CHARGE_FULL: + return smb_get_batt_charge_full(chip, &val->intval); + case POWER_SUPPLY_PROP_CHARGE_NOW: + return smb_get_batt_charge_now(chip, &val->intval); case POWER_SUPPLY_PROP_VOLTAGE_NOW: return smb_get_batt_voltage(chip, false, &val->intval); case POWER_SUPPLY_PROP_VOLTAGE_OCV: From 2bbd76575858f4798e3e6369c6f82d2a65e61dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 12 Aug 2026 22:30:39 +0200 Subject: [PATCH 26/33] power: supply: qcom_smbx: read the charging policy from the charger node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the bindings: the JEITA thresholds, the soft-zone currents, the recharge voltage and the battery-ID tolerance are now properties of this device, and the pack's identification resistor is the generic id-resistor-ohms rather than a vendor-prefixed name the battery schema rejects. Three of the property readers lose their monitored-battery lookup entirely, which is why smb_init_jeita() no longer gives up when there is no battery node: none of what it programs comes from one any more. No behavioural change is intended - the same values reach the same registers, from a different node. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 51 ++++++++++++++------------------ 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index 3053152c615ae0..d180ccc5d479fb 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -2225,19 +2225,18 @@ static int smb_init_hw(struct smb_chip *chip) } /* - * Write one four-byte JEITA comparator block from a property on the battery - * node holding the pair of raw BAT_THERM ADC codes { cold, hot }. Absent - * property leaves the PMIC's power-on defaults in place. + * Write one four-byte JEITA comparator block from a property on this device + * holding the pair of raw BAT_THERM ADC codes { cold, hot }. Absent property + * leaves the PMIC's power-on defaults in place. */ -static int smb_set_jeita_thresholds(struct smb_chip *chip, - struct fwnode_handle *batt, - const char *prop, unsigned int reg) +static int smb_set_jeita_thresholds(struct smb_chip *chip, const char *prop, + unsigned int reg) { u32 thresh[2]; u8 buf[JEITA_THRESHOLDS_LEN]; int rc; - rc = fwnode_property_read_u32_array(batt, prop, thresh, + rc = device_property_read_u32_array(chip->dev, prop, thresh, ARRAY_SIZE(thresh)); if (rc == -EINVAL) return 0; @@ -2277,17 +2276,14 @@ static int smb_set_jeita_thresholds(struct smb_chip *chip, */ static int smb_set_recharge_threshold(struct smb_chip *chip) { - struct fwnode_handle *batt __free(fwnode_handle) = - fwnode_find_reference(dev_fwnode(chip->dev), - "monitored-battery", 0); u32 uv, raw; int rc; if (!chip->var->rechg_thresh_reg) return 0; - if (IS_ERR(batt) || - fwnode_property_read_u32(batt, "qcom,auto-recharge-microvolt", &uv)) + if (device_property_read_u32(chip->dev, "qcom,auto-recharge-microvolt", + &uv)) return 0; if (uv >= chip->batt_info->voltage_max_design_uv) { @@ -2484,13 +2480,13 @@ static int smb_verify_battery_id(struct smb_chip *chip) return 1; /* All three have to be described before there is anything to check. */ - if (fwnode_property_read_u32(batt, "qcom,batt-id-ohm", &expect_ohm) || - device_property_read_u32(chip->dev, "qcom,batt-id-pullup-ohm", + if (fwnode_property_read_u32(batt, "id-resistor-ohms", &expect_ohm) || + device_property_read_u32(chip->dev, "qcom,batt-id-pullup-ohms", &pullup_ohm) || !chip->bat_id_chan) return 1; - fwnode_property_read_u32(batt, "qcom,batt-id-tolerance-percent", + device_property_read_u32(chip->dev, "qcom,batt-id-tolerance-percent", &tol_pct); rc = iio_read_channel_processed(chip->bat_id_chan, &uv); @@ -2524,36 +2520,33 @@ static int smb_verify_battery_id(struct smb_chip *chip) * enabling; the soft ones only do something once the corresponding * compensation bit is set, and then they subtract a fixed offset from the * fast-charge current. Express that offset as the current we want to be left - * with in each soft zone, so the battery node carries a charge current rather - * than a register delta. + * with in each soft zone, so the board carries a charge current rather than a + * register delta. * - * All of it is read from the monitored battery, not from this device: which - * temperatures a cell may be charged at, and how hard, is a property of the - * cell. A board that fits more than one pack describes each of them. + * All of it is read from this device rather than from the monitored battery, + * because a threshold here is a raw BAT_THERM ADC code: what code a given + * temperature produces depends on this PMIC's ADC full scale and on the + * board's pull-up as much as on the cell, so it is not a property of the pack + * and cannot travel with one. The soft-zone currents follow the thresholds + * they belong to. */ static int smb_init_jeita(struct smb_chip *chip) { - struct fwnode_handle *batt __free(fwnode_handle) = - fwnode_find_reference(dev_fwnode(chip->dev), - "monitored-battery", 0); unsigned int fcc_ua, comp_hot, comp_cold; u32 soft_fcc_ua[2]; int rc; - if (IS_ERR(batt)) - return 0; - - rc = smb_set_jeita_thresholds(chip, batt, "qcom,jeita-hard-thresholds", + rc = smb_set_jeita_thresholds(chip, "qcom,jeita-hard-thresholds", JEITA_HARD_THRESHOLDS); if (rc < 0) return rc; - rc = smb_set_jeita_thresholds(chip, batt, "qcom,jeita-soft-thresholds", + rc = smb_set_jeita_thresholds(chip, "qcom,jeita-soft-thresholds", JEITA_SOFT_THRESHOLDS); if (rc < 0) return rc; - rc = fwnode_property_read_u32_array(batt, + rc = device_property_read_u32_array(chip->dev, "qcom,jeita-soft-fcc-microamp", soft_fcc_ua, ARRAY_SIZE(soft_fcc_ua)); From 4789b6b82412906329ada8d818e25a783d22e35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 29 Aug 2026 10:37:30 +0200 Subject: [PATCH 27/33] power: supply: qcom_smbx: learn what the pack actually holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gauge divides counted charge by charge_full_design_uah and reports the same value as CHARGE_FULL. That value is a nameplate for a new cell. Measured on this device, a full sweep of the OCV table from 4.3756 V to 3.000 V delivered 2175 mAh against a declared 3060 mAh, and 2076 mAh down to the declared 3.400 V cutoff - so one reported percent was worth 21.8 mAh rather than 30.6, every percentage the gauge derived was optimistic by the difference, and a discharge to the hardware's own shutdown stopped at 35 % with the pack empty. Fixing that by editing the device tree's design value would be wrong twice over: it is a nameplate the OCV table was characterised against, and it is a statement about the hardware rather than about one aged pack. So learn it instead. The gauge already has points it trusts enough to overwrite the count outright - charge termination, an S3_GOOD_OCV re-anchor, and a wake sample taken at rest - and the charge that flowed between two such points is the size of the pack. Accumulate the counted charge between consecutive anchors and, when the span is at least half the pack and the charge agrees with it about which way the pack went, blend a quarter of the way toward what it says. Spans with a hole in them are thrown away rather than used: a suspend gap counts no charge, so it ends the span instead of shortening the answer. CHARGE_FULL now reports the learned value and CHARGE_FULL_DESIGN keeps reporting the device tree's, which is what the two properties are for. The learned value is persisted beside the state of charge in the gauge's scratch SRAM behind the same magic, so a warm reboot does not throw away hours of learning, and a battery swap clears the magic and correctly falls back to design. Expected convergence on the pack this was developed against: about 2076 mAh. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- drivers/power/supply/qcom_smbx.c | 194 +++++++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 10 deletions(-) diff --git a/drivers/power/supply/qcom_smbx.c b/drivers/power/supply/qcom_smbx.c index d180ccc5d479fb..37946cc08c1fce 100644 --- a/drivers/power/supply/qcom_smbx.c +++ b/drivers/power/supply/qcom_smbx.c @@ -489,6 +489,7 @@ #define QG_SDAM_SOC (QG_SDAM_BASE + 0x47) /* 1 byte, percent */ #define QG_SDAM_MAGIC (QG_SDAM_BASE + 0x80) /* 4 bytes */ #define QG_SDAM_MAGIC_VALUE 0x736d6278 /* "smbx" */ +#define QG_SDAM_FULL (QG_SDAM_BASE + 0x84) /* 2 bytes, mAh */ /* * How often the gauge integrates, and the current below which a wake sample is @@ -518,6 +519,21 @@ */ #define SMB_FG_SEED_DISAGREE (10 * 100) +/* + * Learning the pack's real capacity. A span has to be long enough that the + * error in its two endpoints is small beside it - half the pack keeps a + * percent of endpoint error under two percent of the answer - and the result + * is blended rather than taken, so one bad span moves the number a quarter of + * the way and the next good one pulls it back. The clamp is not a refinement + * but a guard: a pack that measures outside it is a measurement fault, not an + * aged battery. + */ +#define SMB_FG_LEARN_MIN_SPAN (50 * 100) +#define SMB_FG_LEARN_BLEND_NUM 1 +#define SMB_FG_LEARN_BLEND_DEN 4 +#define SMB_FG_LEARN_MIN_PCT 50 +#define SMB_FG_LEARN_MAX_PCT 110 + #define SDP_CURRENT_UA 500000 #define CDP_CURRENT_UA 1500000 #define DCP_CURRENT_UA 1500000 @@ -723,6 +739,16 @@ struct smb_variant { * @fg_charging: The charger is driving the pack right now, so its terminal * voltage is imposed rather than chosen and says nothing * the OCV table can answer + * @charge_full_uah: What the pack actually holds, learned from spans between + * trusted anchors. Seeded from the device tree's design + * value, which is a nameplate for a new cell and not a + * statement about the one that is fitted + * @fg_learn_ua_ms: Charge counted since the last anchor, in microamp- + * milliseconds and signed, so that its sign says which way + * the span ran + * @fg_learn_soc: State of charge at the last anchor, or -1 when there is + * no span in progress - which is also how a span that + * cannot be trusted is thrown away */ struct smb_chip { struct device *dev; @@ -761,6 +787,9 @@ struct smb_chip { bool fg_charging; /* Last hardware rest-OCV harvested, to tell a fresh capture from a stale one */ int fg_good_ocv_uv; + int charge_full_uah; + s64 fg_learn_ua_ms; + int fg_learn_soc; }; static enum power_supply_property smb_properties[] = { @@ -1445,6 +1474,76 @@ static bool smb_fg_track_completion(struct smb_chip *chip) return chip->fg_full; } +/** + * smb_fg_anchor() - close the span that ended here, and learn from it + * @chip: the charger + * @soc: the state of charge this anchor establishes, in hundredths of a percent + * + * The gauge counts charge between points it trusts and is corrected at them. + * Those same points bound a measurement nothing else in this driver can make: + * the charge that flowed between two known states of charge is the size of the + * pack, and the pack is not the size the device tree states. That value is a + * nameplate for a new cell; measured here, an aged one delivered barely seventy + * percent of it, and every percentage the gauge reported was wrong by the + * difference. + * + * Only spans that are actually accountable are used. A span is thrown away - + * @fg_learn_soc set to -1 - wherever charge moved without being counted, which + * is what a suspend gap is, so a short measurement is preferred to a long one + * with a hole in it. + * + * Caller holds @fg_lock. + */ +static void smb_fg_anchor(struct smb_chip *chip, int soc) +{ + int span, learned, full, lo, hi; + s64 uah; + + if (chip->fg_learn_soc >= 0) { + span = soc - chip->fg_learn_soc; + uah = div_s64(chip->fg_learn_ua_ms, 3600 * 1000); + + /* + * The span and the charge have to agree about which way the + * pack went. When they do not, something moved that was not + * counted, and the arithmetic below would divide a partial + * charge by a full span and learn a pack that is too small. + */ + if (abs(span) >= SMB_FG_LEARN_MIN_SPAN && + ((span > 0) == (uah > 0))) { + learned = div_s64(abs(uah) * 100 * 100, abs(span)); + + /* + * Blend rather than jump. A learned capacity that + * follows the last span is as noisy as that span; one + * that moves a quarter of the way each time settles on + * the pack and rides out a bad measurement. + */ + full = chip->charge_full_uah + + (learned - chip->charge_full_uah) * + SMB_FG_LEARN_BLEND_NUM / + SMB_FG_LEARN_BLEND_DEN; + + lo = chip->batt_info->charge_full_design_uah / + 100 * SMB_FG_LEARN_MIN_PCT; + hi = chip->batt_info->charge_full_design_uah / + 100 * SMB_FG_LEARN_MAX_PCT; + full = clamp(full, lo, hi); + + if (full != chip->charge_full_uah) { + dev_info(chip->dev, + "fg: span %d.%02d%% carried %lld uAh -> pack %d uAh, learned %d uAh\n", + abs(span) / 100, abs(span) % 100, + abs(uah), learned, full); + chip->charge_full_uah = full; + } + } + } + + chip->fg_learn_soc = soc; + chip->fg_learn_ua_ms = 0; +} + /** * smb_fg_take_good_ocv() - anchor to the gauge's own rested OCV when it is fresh * @chip: the charger @@ -1480,6 +1579,7 @@ static bool smb_fg_take_good_ocv(struct smb_chip *chip, bool *changed) return false; was = chip->fg_ready ? chip->soc_permyriad : -1; + smb_fg_anchor(chip, gsoc); chip->soc_permyriad = gsoc; chip->fg_residue = 0; chip->fg_ready = true; @@ -1538,6 +1638,7 @@ static bool smb_fg_update(struct smb_chip *chip) guard(mutex)(&chip->fg_lock); was = chip->fg_ready ? chip->soc_permyriad : -1; + smb_fg_anchor(chip, 100 * 100); chip->soc_permyriad = 100 * 100; chip->fg_residue = 0; chip->fg_ready = true; @@ -1578,6 +1679,7 @@ static bool smb_fg_update(struct smb_chip *chip) * since there is no better number to start from; the first rest * correction puts it right. */ + smb_fg_anchor(chip, soc_ocv); chip->soc_permyriad = soc_ocv; chip->fg_residue = 0; chip->fg_ready = true; @@ -1595,10 +1697,19 @@ static bool smb_fg_update(struct smb_chip *chip) if (abs(i_ua) <= SMB_FG_OCV_QUIET_UA) chip->soc_permyriad = soc_ocv; chip->fg_residue = 0; + /* + * Nothing was counted across the gap, so whatever span was in + * progress has a hole in it. A quiet wake sample is a rest OCV + * and starts a new span; anything else leaves none. + */ + if (abs(i_ua) <= SMB_FG_OCV_QUIET_UA) + smb_fg_anchor(chip, chip->soc_permyriad); + else + chip->fg_learn_soc = -1; } else { /* * charge (uAh) = i_ua * elapsed / 3600, and one percent of the - * pack is charge_full_design_uah / 100, so a hundredth of a + * pack is charge_full_uah / 100, so a hundredth of a * percent per millisecond is i_ua * elapsed_ms / (360 * full). * * At a tenth of an amp that quotient is under one per poll, so @@ -1606,10 +1717,11 @@ static bool smb_fg_update(struct smb_chip *chip) * discarded it would be a standing error in the rate itself, * always in the direction of counting too little. */ - s64 per_permyriad = 360LL * chip->batt_info->charge_full_design_uah; + s64 per_permyriad = 360LL * chip->charge_full_uah; s64 step; chip->fg_residue += (s64)i_ua * elapsed_ms; + chip->fg_learn_ua_ms += (s64)i_ua * elapsed_ms; step = div_s64(chip->fg_residue, per_permyriad); chip->fg_residue -= step * per_permyriad; @@ -1636,11 +1748,21 @@ static bool smb_fg_update(struct smb_chip *chip) static void smb_fg_sdam_store(struct smb_chip *chip) { unsigned int soc; + __le16 full; - scoped_guard(mutex, &chip->fg_lock) + scoped_guard(mutex, &chip->fg_lock) { soc = clamp(DIV_ROUND_CLOSEST(chip->soc_permyriad, 100), 0, 100); + full = cpu_to_le16(chip->charge_full_uah / 1000); + } regmap_write(chip->regmap, QG_SDAM_SOC, soc); + /* + * The learned capacity goes with it, in mAh, which is finer than the + * learning ever resolves and fits two bytes. Without this a pack has to + * be relearned from scratch after every reboot, and the spans this + * gauge can trust are hours long. + */ + regmap_bulk_write(chip->regmap, QG_SDAM_FULL, &full, sizeof(full)); } /* @@ -1664,6 +1786,36 @@ static bool smb_fg_sdam_restore(struct smb_chip *chip, int *soc_permyriad) return true; } +/* + * Restore the learned capacity, guarded by the same magic - so a battery swap, + * which clears it, correctly falls back to the design value for a pack this + * driver has never measured. The clamp is applied again on the way in: a stored + * value outside it was not written by any span this driver would have accepted. + */ +static void smb_fg_sdam_restore_full(struct smb_chip *chip) +{ + __le32 magic; + __le16 stored; + int full, lo, hi; + + if (regmap_bulk_read(chip->regmap, QG_SDAM_MAGIC, &magic, sizeof(magic))) + return; + if (le32_to_cpu(magic) != QG_SDAM_MAGIC_VALUE) + return; + if (regmap_bulk_read(chip->regmap, QG_SDAM_FULL, &stored, sizeof(stored))) + return; + + full = le16_to_cpu(stored) * 1000; + lo = chip->batt_info->charge_full_design_uah / 100 * SMB_FG_LEARN_MIN_PCT; + hi = chip->batt_info->charge_full_design_uah / 100 * SMB_FG_LEARN_MAX_PCT; + if (full < lo || full > hi) + return; + + chip->charge_full_uah = full; + dev_dbg(chip->dev, "fg: restored learned capacity %d uAh from sdam\n", + full); +} + static void smb_fg_work(struct work_struct *work) { struct smb_chip *chip = container_of(work, struct smb_chip, @@ -1703,6 +1855,14 @@ static int smb_fg_start(struct smb_chip *chip) if (rc) return rc; + /* + * Start from the nameplate and let the pack correct it. A driver that + * has measured nothing yet has nothing better to say than what the + * device tree states. + */ + chip->charge_full_uah = chip->batt_info->charge_full_design_uah; + chip->fg_learn_soc = -1; + /* The gauge's freshest rested capture, where it holds one. */ if (smb_qg_read_sample(chip, QG_S3_GOOD_OCV_V_DATA0, &v_uv, &i_ua) == 0) { rest_soc = smb_ocv_to_permyriad(chip, smb_batt_ocv(chip, v_uv, i_ua)); @@ -1724,6 +1884,8 @@ static int smb_fg_start(struct smb_chip *chip) * now - so take it, and say so, because a seed being overruled is worth * knowing about. */ + smb_fg_sdam_restore_full(chip); + if (smb_fg_sdam_restore(chip, &soc)) { if (rest_soc >= 0 && abs(rest_soc - soc) > SMB_FG_SEED_DISAGREE) { dev_info(chip->dev, @@ -1793,12 +1955,14 @@ static int smb_fg_start(struct smb_chip *chip) * to work it out. UPower derives its estimate from the charge remaining and the * power going in, so without these it can only show a number and no time. * - * There is no learned capacity here: this gauge does not measure how the pack - * has aged, so full and full-design are the same value, the one the device tree - * states. Reporting a learned figure it has not learned would be worse than - * reporting the design one. + * Full and full-design are not the same number. The design value is what the + * device tree states, which is a nameplate for a new cell; full is what the + * spans between trusted anchors say the pack fitted here actually holds. On the + * pack this was developed against those differ by nearly thirty percent, and + * reporting the nameplate as the capacity made every percentage the gauge + * derived from it wrong by that much. */ -static int smb_get_batt_charge_full(struct smb_chip *chip, int *val) +static int smb_get_batt_charge_full_design(struct smb_chip *chip, int *val) { if (!chip->batt_info || chip->batt_info->charge_full_design_uah <= 0) return -ENODATA; @@ -1808,6 +1972,16 @@ static int smb_get_batt_charge_full(struct smb_chip *chip, int *val) return 0; } +static int smb_get_batt_charge_full(struct smb_chip *chip, int *val) +{ + if (!chip->batt_info || chip->batt_info->charge_full_design_uah <= 0) + return -ENODATA; + + *val = chip->charge_full_uah ?: chip->batt_info->charge_full_design_uah; + + return 0; +} + static int smb_get_batt_charge_now(struct smb_chip *chip, int *val) { if (!smb_fg_available(chip)) @@ -1819,8 +1993,7 @@ static int smb_get_batt_charge_now(struct smb_chip *chip, int *val) return -EAGAIN; /* soc is in hundredths of a percent, so the divisor is 100 * 100 */ - *val = div_u64((u64)chip->soc_permyriad * - chip->batt_info->charge_full_design_uah, + *val = div_u64((u64)chip->soc_permyriad * chip->charge_full_uah, 100 * 100); return 0; @@ -1916,6 +2089,7 @@ static int smb_batt_get_property(struct power_supply *psy, case POWER_SUPPLY_PROP_CAPACITY: return smb_get_batt_capacity(chip, &val->intval); case POWER_SUPPLY_PROP_CHARGE_FULL_DESIGN: + return smb_get_batt_charge_full_design(chip, &val->intval); case POWER_SUPPLY_PROP_CHARGE_FULL: return smb_get_batt_charge_full(chip, &val->intval); case POWER_SUPPLY_PROP_CHARGE_NOW: From dfe96ab0dffa21194403ed8389a2e548d161948c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Sat, 25 Jul 2026 21:01:57 +0200 Subject: [PATCH 28/33] arm64: dts: qcom: pmi632: add the charger node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the SMB5 charger block and the ADC channels it reads (USB input current and voltage, battery sense). Left disabled; boards that wire a battery enable it and point it at their simple-battery node. Interrupt numbers and the ADC channel assignment follow the Qualcomm downstream pmi632 device tree from the Fairphone 3 kernel source release. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-4-8 Signed-off-by: lostsignal-502 --- arch/arm64/boot/dts/qcom/pmi632.dtsi | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/arch/arm64/boot/dts/qcom/pmi632.dtsi b/arch/arm64/boot/dts/qcom/pmi632.dtsi index 8c899d148e461c..6230d2e6627e19 100644 --- a/arch/arm64/boot/dts/qcom/pmi632.dtsi +++ b/arch/arm64/boot/dts/qcom/pmi632.dtsi @@ -50,6 +50,27 @@ status = "disabled"; }; + pmi632_charger: charger@1000 { + compatible = "qcom,pmi632-charger"; + reg = <0x1000>; + + interrupts = <0x2 0x13 0x4 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x12 0x2 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x16 0x1 IRQ_TYPE_EDGE_RISING>, + <0x2 0x13 0x6 IRQ_TYPE_EDGE_RISING>; + interrupt-names = "usb-plugin", + "bat-ov", + "wdog-bark", + "usbin-icl-change"; + + io-channels = <&pmi632_adc ADC5_USB_IN_I>, + <&pmi632_adc ADC5_USB_IN_V_16>, + <&pmi632_adc ADC5_VBAT_SNS>; + io-channel-names = "usbin_i", "usbin_v", "vbat"; + + status = "disabled"; + }; + pmi632_typec: typec@1500 { compatible = "qcom,pmi632-typec"; reg = <0x1500>; From ec0ea261db5d16b1b3e13ccdb51f147739493a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 14:13:51 +0200 Subject: [PATCH 29/33] iio: adc: qcom-spmi-adc5: add the BAT_THERM channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADC5_BAT_THERM_100K_PU is the battery pack thermistor, biased from the PMIC's 100k pull-up. The channel exists on every ADC5 PMIC and Qualcomm's own device trees describe it, but the driver's channel table never listed it, so a device tree that referenced it was rejected at probe. Provenance: - the channel number (0x4a), the 100k pull-up variant and the ratiometric calibration are Qualcomm's, read off the downstream pmi632 device tree in Fairphone's published Fairphone 3 kernel source release, where the same channel appears as chan@4a "bat_therm"; - the conversion curve is pre-existing mainline code: the table entry reuses SCALE_HW_CALIB_THERM_100K_PULLUP, the 100k pull-up thermistor scaling already used by the AMUX_THM and GPIO thermistor channels in this driver; - new here is only the table entry itself. Downstream carries a per-pack thermistor table for this battery rather than the generic 100k curve. Measured against each other the two agree at 25 degC and diverge away from it, by roughly 1.5 degC at 0 degC and 2.5 degC at 60 degC. The generic curve is therefore accurate enough to report a temperature, but a board that wants to drive a JEITA profile from it should check that first. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- drivers/iio/adc/qcom-spmi-adc5.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/drivers/iio/adc/qcom-spmi-adc5.c b/drivers/iio/adc/qcom-spmi-adc5.c index af3c2f659f5e90..d6091dd7d15518 100644 --- a/drivers/iio/adc/qcom-spmi-adc5.c +++ b/drivers/iio/adc/qcom-spmi-adc5.c @@ -545,6 +545,8 @@ static const struct adc5_channels adc5_chans_pmic[ADC5_MAX_CHANNEL] = { SCALE_HW_CALIB_XOTHERM) [ADC5_BAT_ID_100K_PU] = ADC5_CHAN_TEMP("bat_id", 0, SCALE_HW_CALIB_DEFAULT) + [ADC5_BAT_THERM_100K_PU] = ADC5_CHAN_TEMP("bat_therm_100k_pu", 0, + SCALE_HW_CALIB_THERM_100K_PULLUP) [ADC5_AMUX_THM1_100K_PU] = ADC5_CHAN_TEMP("amux_thm1_100k_pu", 0, SCALE_HW_CALIB_THERM_100K_PULLUP) [ADC5_AMUX_THM2_100K_PU] = ADC5_CHAN_TEMP("amux_thm2_100k_pu", 0, From 025c83aabfac584bbeec3191b88a211f500860e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Wed, 29 Jul 2026 14:14:12 +0200 Subject: [PATCH 30/33] arm64: dts: qcom: pmi632: read the battery thermistor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe the BAT_THERM channel and hand it to the charger, so the battery reports a temperature. Provenance: taken from Qualcomm's downstream pmi632 device tree, as published in Fairphone's Fairphone 3 kernel source release, where the same channel appears as chan@4a { label = "bat_therm"; reg = <0x4a>; qcom,calibration-type = "ratiometric"; ... }; The channel number, the 100k pull-up variant of it and the ratiometric calibration are therefore Qualcomm's; the settle time follows the neighbouring bat_id channel in this file rather than downstream, which leaves it at zero. Signed-off-by: Lajosházi, László Gergely Assisted-by: Claude:claude-opus-5 Signed-off-by: lostsignal-502 --- arch/arm64/boot/dts/qcom/pmi632.dtsi | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/arch/arm64/boot/dts/qcom/pmi632.dtsi b/arch/arm64/boot/dts/qcom/pmi632.dtsi index 6230d2e6627e19..28d9d03378faa6 100644 --- a/arch/arm64/boot/dts/qcom/pmi632.dtsi +++ b/arch/arm64/boot/dts/qcom/pmi632.dtsi @@ -65,8 +65,10 @@ io-channels = <&pmi632_adc ADC5_USB_IN_I>, <&pmi632_adc ADC5_USB_IN_V_16>, - <&pmi632_adc ADC5_VBAT_SNS>; - io-channel-names = "usbin_i", "usbin_v", "vbat"; + <&pmi632_adc ADC5_VBAT_SNS>, + <&pmi632_adc ADC5_BAT_THERM_100K_PU>; + io-channel-names = "usbin_i", "usbin_v", "vbat", + "bat_therm"; status = "disabled"; }; @@ -146,6 +148,14 @@ label = "chg_temp"; }; + channel@4a { + reg = ; + qcom,hw-settle-time = <200>; + qcom,pre-scaling = <1 1>; + qcom,ratiometric; + label = "bat_therm"; + }; + channel@4b { reg = ; qcom,hw-settle-time = <200>; From 45212db7c2a86ec25adaecf4d219a2d6c952a030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Mon, 10 Aug 2026 19:33:59 +0200 Subject: [PATCH 31/33] arm64: dts: qcom: pmi632: wire the QG good-OCV interrupt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gauge raises this the moment it captures a fresh rested open-circuit voltage. The charger driver takes it as optional and uses it, when present, to re-anchor the state of charge as soon as the capture happens instead of waiting for its ten-second poll to notice. The interrupt number and name are the downstream pmi632 QG node's. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Signed-off-by: lostsignal-502 --- arch/arm64/boot/dts/qcom/pmi632.dtsi | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/arch/arm64/boot/dts/qcom/pmi632.dtsi b/arch/arm64/boot/dts/qcom/pmi632.dtsi index 28d9d03378faa6..ba2ee63f7bfb35 100644 --- a/arch/arm64/boot/dts/qcom/pmi632.dtsi +++ b/arch/arm64/boot/dts/qcom/pmi632.dtsi @@ -5,6 +5,7 @@ #include #include +#include #include / { @@ -57,11 +58,13 @@ interrupts = <0x2 0x13 0x4 IRQ_TYPE_EDGE_BOTH>, <0x2 0x12 0x2 IRQ_TYPE_EDGE_BOTH>, <0x2 0x16 0x1 IRQ_TYPE_EDGE_RISING>, - <0x2 0x13 0x6 IRQ_TYPE_EDGE_RISING>; + <0x2 0x13 0x6 IRQ_TYPE_EDGE_RISING>, + <0x2 0x48 0x4 IRQ_TYPE_EDGE_RISING>; interrupt-names = "usb-plugin", "bat-ov", "wdog-bark", - "usbin-icl-change"; + "usbin-icl-change", + "good-ocv"; io-channels = <&pmi632_adc ADC5_USB_IN_I>, <&pmi632_adc ADC5_USB_IN_V_16>, @@ -236,5 +239,11 @@ reg = <0x5700>; status = "disabled"; }; + + pmi632_flash: led-controller@d300 { + compatible = "qcom,pmi632-flash-led", "qcom,spmi-flash-led"; + reg = <0xd300>; + status = "disabled"; + }; }; }; From d065d080ed19dc21439156a66c07fe88efa1e83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lajosh=C3=A1zi=2C=20L=C3=A1szl=C3=B3=20Gergely?= Date: Tue, 11 Aug 2026 20:44:58 +0200 Subject: [PATCH 32/33] arm64: dts: qcom: pmi632: describe the charger's interrupts, and fix one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The charger node named five of the peripheral's interrupt sources. Add the rest of the ones that describe charging - the CHGR, DCDC, BATIF and MISC blocks - taking each specifier from the downstream qpnp-smb5 device tree for this PMIC. Nothing is obliged to request them; a driver takes what it uses, and describing the hardware is this file's job. Two sources are left out on purpose. BATIF's all-chnl-conv-done marks an ADC conversion rather than anything the charger decided, and CHGR's fg-fvcal-qualified marks a float-voltage calibration sample - measured on a Fairphone 3 at about one a second while merely discharging. The Type-C block is not here either: it is a separate node with its own driver, and naming its interrupts on this node would take them away from it. usbin-icl-change was also pointing at the wrong index. It was copied from pmi8998.dtsi, where the USB peripheral numbers icl-change as 6, but SMB5 inserted vashdn and revi-change into that block, which pushes src-change to 6 and icl-change to 7. So the driver's ICL-change handler has been wired to source-change on this PMIC. Both indices come from the downstream device trees, which describe the two generations side by side. Signed-off-by: Lajosházi, László Gergely Co-authored-by: Claude Opus 5 Signed-off-by: lostsignal-502 --- arch/arm64/boot/dts/qcom/pmi632.dtsi | 87 +++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/arch/arm64/boot/dts/qcom/pmi632.dtsi b/arch/arm64/boot/dts/qcom/pmi632.dtsi index ba2ee63f7bfb35..fe8896ef89cc1a 100644 --- a/arch/arm64/boot/dts/qcom/pmi632.dtsi +++ b/arch/arm64/boot/dts/qcom/pmi632.dtsi @@ -55,16 +55,99 @@ compatible = "qcom,pmi632-charger"; reg = <0x1000>; + /* + * The charger's interrupt sources, in the order the + * downstream qpnp-smb5 device tree gives them. SMB5 + * renumbered the USB peripheral against SMB2 - vashdn + * and revi-change are new, which pushes src-change to + * 6 and icl-change to 7 - so the indices here are not + * the pmi8998 ones they were first copied from. + */ interrupts = <0x2 0x13 0x4 IRQ_TYPE_EDGE_BOTH>, <0x2 0x12 0x2 IRQ_TYPE_EDGE_BOTH>, <0x2 0x16 0x1 IRQ_TYPE_EDGE_RISING>, + <0x2 0x13 0x7 IRQ_TYPE_EDGE_RISING>, + <0x2 0x48 0x4 IRQ_TYPE_EDGE_RISING>, + /* CHGR */ + <0x2 0x10 0x0 IRQ_TYPE_EDGE_RISING>, + <0x2 0x10 0x1 IRQ_TYPE_EDGE_RISING>, + <0x2 0x10 0x2 IRQ_TYPE_EDGE_RISING>, + <0x2 0x10 0x3 IRQ_TYPE_EDGE_RISING>, + <0x2 0x10 0x4 IRQ_TYPE_EDGE_RISING>, + <0x2 0x10 0x5 IRQ_TYPE_EDGE_RISING>, + <0x2 0x10 0x6 IRQ_TYPE_EDGE_RISING>, + <0x2 0x10 0x7 IRQ_TYPE_EDGE_RISING>, + /* DCDC */ + <0x2 0x11 0x0 IRQ_TYPE_EDGE_RISING>, + <0x2 0x11 0x1 IRQ_TYPE_EDGE_RISING>, + <0x2 0x11 0x2 IRQ_TYPE_EDGE_RISING>, + <0x2 0x11 0x3 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x11 0x4 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x11 0x5 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x11 0x6 IRQ_TYPE_EDGE_RISING>, + <0x2 0x11 0x7 IRQ_TYPE_EDGE_BOTH>, + /* BATIF */ + <0x2 0x12 0x0 IRQ_TYPE_EDGE_RISING>, + <0x2 0x12 0x3 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x12 0x4 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x12 0x5 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x12 0x6 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x12 0x7 IRQ_TYPE_EDGE_BOTH>, + /* USB */ + <0x2 0x13 0x0 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x13 0x1 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x13 0x2 IRQ_TYPE_EDGE_RISING>, + <0x2 0x13 0x3 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x13 0x5 IRQ_TYPE_EDGE_BOTH>, <0x2 0x13 0x6 IRQ_TYPE_EDGE_RISING>, - <0x2 0x48 0x4 IRQ_TYPE_EDGE_RISING>; + /* MISC */ + <0x2 0x16 0x0 IRQ_TYPE_EDGE_RISING>, + <0x2 0x16 0x2 IRQ_TYPE_EDGE_RISING>, + <0x2 0x16 0x3 IRQ_TYPE_EDGE_RISING>, + <0x2 0x16 0x4 IRQ_TYPE_EDGE_BOTH>, + <0x2 0x16 0x5 IRQ_TYPE_EDGE_RISING>, + <0x2 0x16 0x6 IRQ_TYPE_EDGE_RISING>, + <0x2 0x16 0x7 IRQ_TYPE_EDGE_RISING>; interrupt-names = "usb-plugin", "bat-ov", "wdog-bark", "usbin-icl-change", - "good-ocv"; + "good-ocv", + "chgr-error", + "chg-state-change", + "step-chg-state-change", + "step-chg-soc-update-fail", + "step-chg-soc-update-req", + "fg-fvcal-qualified", + "vph-alarm", + "vph-drop-prechg", + "otg-fail", + "otg-oc-disable-sw", + "otg-oc-hiccup", + "bsm-active", + "high-duty-cycle", + "input-current-limiting", + "concurrent-mode-disable", + "switcher-power-ok", + "bat-temp", + "bat-low", + "bat-therm-or-id-missing", + "bat-terminal-missing", + "buck-oc", + "vph-ov", + "usbin-collapse", + "usbin-vashdn", + "usbin-uv", + "usbin-ov", + "usbin-revi-change", + "usbin-src-change", + "wdog-snarl", + "aicl-fail", + "aicl-done", + "smb-en", + "imp-trigger", + "temp-change", + "temp-change-smb"; io-channels = <&pmi632_adc ADC5_USB_IN_I>, <&pmi632_adc ADC5_USB_IN_V_16>, From a9a20fc92b5548577c52cdd0597b0eb0cc583cbf Mon Sep 17 00:00:00 2001 From: lostsignal-502 Date: Sun, 11 Oct 2026 08:11:48 +0530 Subject: [PATCH 33/33] arm64: dts: qcom: sdm429-lenovo-tbx505x: Enable charger and fuel gauge Add the battery node from the stock LWN profile and enable the PMI632 charger. Signed-off-by: lostsignal-502 --- .../boot/dts/qcom/sdm429-lenovo-tbx505x.dts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/arch/arm64/boot/dts/qcom/sdm429-lenovo-tbx505x.dts b/arch/arm64/boot/dts/qcom/sdm429-lenovo-tbx505x.dts index 36d7e1a168597f..b68c9020ff7ea5 100644 --- a/arch/arm64/boot/dts/qcom/sdm429-lenovo-tbx505x.dts +++ b/arch/arm64/boot/dts/qcom/sdm429-lenovo-tbx505x.dts @@ -47,6 +47,34 @@ }; }; + battery: battery { + compatible = "simple-battery"; + voltage-min-design-microvolt = <3400000>; + voltage-max-design-microvolt = <4400000>; + charge-full-design-microamp-hours = <4982000>; + constant-charge-current-max-microamp = <2000000>; + constant-charge-voltage-max-microvolt = <4400000>; + charge-term-current-microamp = <170000>; + factory-internal-resistance-micro-ohms = <120000>; + id-resistor-ohms = <21000>; + + ocv-capacity-celsius = <25>; + ocv-capacity-table-0 = <4382700 100>, <4361700 98>, <4339700 96>, <4314400 94>, + <4289200 92>, <4264600 90>, <4240400 88>, <4216900 86>, + <4194100 84>, <4171800 82>, <4149400 80>, <4128600 78>, + <4111200 76>, <4094700 74>, <4072300 72>, <4040700 70>, + <4014800 68>, <3999700 66>, <3987400 64>, <3973500 62>, + <3959100 60>, <3941400 58>, <3914200 56>, <3887900 54>, + <3872900 52>, <3862200 50>, <3852900 48>, <3844900 46>, + <3837600 44>, <3830500 42>, <3824000 40>, <3818100 38>, + <3812700 36>, <3807800 34>, <3803900 32>, <3800400 30>, + <3796700 28>, <3793000 26>, <3788100 24>, <3780000 22>, + <3769700 20>, <3758500 18>, <3745600 16>, <3730200 14>, + <3715700 12>, <3706400 10>, <3704000 9>, <3702300 8>, + <3700700 7>, <3697000 6>, <3675800 5>, <3634000 4>, + <3577700 3>, <3501200 2>, <3383700 1>, <3000000 0>; + }; + usb_vbus: extcon-usb-dummy { compatible = "linux,extcon-usb-dummy"; }; @@ -145,6 +173,23 @@ status = "okay"; }; +&pmi632_charger { + #cooling-cells = <2>; + io-channels = <&pmi632_adc ADC5_USB_IN_I>, + <&pmi632_adc ADC5_USB_IN_V_16>, + <&pmi632_adc ADC5_VBAT_SNS>; + io-channel-names = "usbin_i", "usbin_v", "vbat"; + monitored-battery = <&battery>; + qcom,batt-id-pullup-ohms = <100000>; + qcom,auto-recharge-microvolt = <4350000>; + qcom,jeita-hard-thresholds = <0x36b9 0x0dbf>; + qcom,jeita-soft-thresholds = <0x2554 0x0fdc>; + qcom,jeita-soft-fcc-microamp = <975000 1000000>; + qcom,thermal-mitigation = <2000000 1500000 1000000 800000 500000 200000>; + + status = "okay"; +}; + &pmi632_lpg { status = "okay"; @@ -421,6 +466,45 @@ clock-frequency = <32768>; }; +&{/thermal-zones/pmi632-thermal} { + trips { + pmi632_charger_warm: charger-warm { + temperature = <70000>; + hysteresis = <2000>; + type = "passive"; + }; + + pmi632_charger_hot: charger-hot { + temperature = <80000>; + hysteresis = <2000>; + type = "passive"; + }; + + pmi632_charger_very_hot: charger-very-hot { + temperature = <90000>; + hysteresis = <2000>; + type = "passive"; + }; + }; + + cooling-maps { + map-charger-warm { + trip = <&pmi632_charger_warm>; + cooling-device = <&pmi632_charger 1 1>; + }; + + map-charger-hot { + trip = <&pmi632_charger_hot>; + cooling-device = <&pmi632_charger 3 3>; + }; + + map-charger-very-hot { + trip = <&pmi632_charger_very_hot>; + cooling-device = <&pmi632_charger 5 5>; + }; + }; +}; + &tlmm { gpio-reserved-ranges = <0 4>;