<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>nondescriptpointer</title>
    <link>https://nondescriptpointer.com</link>
    <description>Software freedom for XR, mobile and robotics</description>
    <lastBuildDate>Tue, 21 Jul 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://nondescriptpointer.com/rss.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Experimental Linux support for the Fairphone 6 wide camera</title>
      <link>https://nondescriptpointer.com/articles/fairphone-6-wide-camera-linux</link>
      <guid isPermaLink="true">https://nondescriptpointer.com/articles/fairphone-6-wide-camera-linux</guid>
      <pubDate>Tue, 21 Jul 2026 00:00:00 +0000</pubDate>
      <description><![CDATA[<figure class="video" style="max-width: 250px;">
    <video autoplay muted loop playsinline>
        <source src="/media/fp6-cam.mp4" type="video/mp4">
    </video>
    <figcaption>Fairphone 6 ultra-wide working in GNOME Snapshot.</figcaption>
</figure>
<h2>Background</h2>
<p>The <strong>Fairphone 6</strong> is an interesting target for mainline Linux and postmarketOS for a few reasons:</p>
<ul>
<li>It has relatively modern hardware compared with most other supported postmarketOS devices.</li>
<li>Fairphone invests in mainline Linux support, with Luca Weiss driving much of the development.</li>
<li>Those efforts have already provided promising initial support for the hardware.</li>
<li>Fairphone deliberately allows alternative operating systems.</li>
<li>Fairphone aims to support its devices for a long time, so the phone could provide a stable base for future work.</li>
</ul>
<p>There are still two main blockers to making the device generally usable: <strong>onboard audio and camera support</strong>. I am working on a project that needs QR scanning, so I used that as an opportunity to see whether I could get at least one camera working. Disclaimer: I heavily relied on LLM assistance to make this work.</p>
<h2>Fairphone 6 cameras</h2>
<p>The Fairphone 6 has three cameras:</p>
<ul>
<li>Rear: Sony IMX896</li>
<li>Front: Samsung S5KKD1</li>
<li>Wide: OmniVision OV13B10</li>
</ul>
<p>The Sony and Samsung sensors have no mainline Linux drivers, but the OmniVision OV13B10 has a mainline driver. The ultra-wide camera is sufficient for QR scanning.</p>
<p>A phone camera is not a single device. On Qualcomm SoCs, the capture path is a chain:</p>
<pre><code>image sensor  →  CSI-2 D-PHY  →  CSIPHY  →  CSID  →  ISP (VFE/TFE)  →  memory
   (I2C)         (MIPI lanes)   (decode)   (demux)   (write engine)     (DMA)
</code></pre>
<p>There are also several supporting components, including a camera clock controller (camcc), the CCI (Qualcomm's dedicated camera I2C controller), power rails, and a VCM (voice-coil motor) for autofocus.</p>
<p>The downstream vendor Android kernel drives all of this with Qualcomm's substantial, proprietary CAMX/cam-kernel stack. On mainline, the equivalent is the much smaller <strong>qcom-camss</strong> driver, with <strong>libcamera</strong> providing userspace integration and image processing.</p>
<p>To get the OmniVision camera working, I had to teach qcom-camss about the SoC's specific blocks, describe the hardware in the device tree, enable the sensor driver, and configure libcamera to turn raw Bayer frames into images that applications could use.</p>
<h2>Initial exploration</h2>
<p>Before starting the implementation, I wanted to find out how much of the work was new and how much could be ported from hardware that mainline already supported. That would show whether the project was feasible without manufacturer support.</p>
<p>The initial research was encouraging:</p>
<ul>
<li>The FP6's SoC is Qualcomm <strong>milos</strong> (SM7635). The downstream board is codenamed volcano, and postmarketOS already boots it with a mainline-based kernel fork.</li>
<li>The camera hardware blocks are <strong>TFE665</strong> (a thin-front-end ISP), <strong>CSID665</strong>, and <strong>CSIPHY v2.2.1</strong>.</li>
<li>The <strong>camcc clock controller</strong> and <strong>CCI</strong> are already present in the kernel fork.</li>
<li>The three cameras are:
<ul>
<li><strong>Main:</strong> Sony IMX896, with no mainline driver.</li>
<li><strong>Ultra-wide:</strong> OmniVision <strong>OV13B10</strong>, with a mainline driver that supports ACPI/x86 only.</li>
<li><strong>Front:</strong> Samsung S5KKD1, with no mainline driver.</li>
</ul>
</li>
</ul>
<p>That made the target obvious: the <strong>OV13B10 ultra-wide</strong>. It is a 13 MP sensor with an existing mainline driver, and its wide field of view is suitable for QR scanning.</p>
<p>When I compared the downstream register headers (<code>cam_tfe665.h</code>, <code>cam_csiphy_2_2_1_hwreg.h</code>, and <code>cam_tfe_bus.c</code>) with mainline <code>qcom-camss</code>, I found that TFE665 is essentially the same IP as TFE530, which mainline already supports. The register layouts are identical. Only the base offsets of the internal blocks differ, because the write bus lives at a different address. Similarly, the CSID665 RDI registers match the supported CSID, and CSIPHY v2.2.1 belongs to the same three-phase PHY family.</p>
<p>This meant that I could <strong>port existing drivers instead of writing everything from scratch</strong>.</p>
<h2>Step 1: Port the capture subsystem into the kernel</h2>
<p><code>qcom-camss</code> was not yet available on milos, so I had to connect the existing pieces.</p>
<h3>TFE665 ISP driver</h3>
<p>I added a new <code>camss-vfe-665.c</code>, modelled directly on the mainline TFE530 driver (<code>camss-vfe-340.c</code>). Because the register contents are identical, the driver logic is the same. The main difference is that milos places the ISP control block and write-bus block at different offsets. The bus register base moved from <code>0xa00</code> to <code>0x1800</code>, and encapsulating that offset shift accounted for most of the work.</p>
<h3>CSID665 and CSIPHY v2.2.1</h3>
<ul>
<li><strong>CSID665</strong> reused the existing gen-2 CSID operations because its RDI register offsets were identical.</li>
<li><strong>CSIPHY v2.2.1</strong> needed a new lane-configuration table and D-PHY tuning values for this revision. I transcribed the lane register sequence and the approximately 1.1 Gbps/lane (&quot;500 Msps&quot;) data-rate and AFE settings from the downstream <code>cam_csiphy_2_2_1_hwreg.h</code>. The PHY's register window was at offset <code>0x1000</code>.</li>
</ul>
<h3>Resources, compatible, and device tree</h3>
<p>In <code>camss.c</code>, I described milos's capture complex: four CSIPHYs, three CSIDs, three TFEs, their clocks, interconnects, and power domains. I also registered a new <code>qcom,milos-camss</code> compatible. In the device tree, I added the <code>camss@ac13000</code> node with its registers, IRQs, clocks, interconnects, IOMMUs, GDSC, and CSI input ports, along with the MCLK and reset pinctrl states.</p>
<h3>Missing register-bus clock</h3>
<p>I ran into a problem at this point. The driver bound, but reading the TFE hardware-version register returned <code>0x0</code>, and reset timed out as if the block were not powered. The fix was a clock that was not obviously an ISP clock: <strong><code>CAM_CC_SOC_AHB_CLK</code></strong>. It gated the AHB register bus used by the whole camera complex, including the CCI. Without it, register accesses silently returned zero. I added that clock and the CAMNOC AXI clocks, then set the CAMNOC data-path clock rate. The ISP came alive with <code>hw_version = 0x30000000</code>.</p>
<p>This suggests that, on Qualcomm hardware, a block that reads as zero may be missing a clock or power domain on its access path.</p>
<h2>Step 2: Bring up the OV13B10 sensor</h2>
<p>The mainline <code>ov13b10</code> driver exists, but it is written for x86/ACPI laptops and only matches through ACPI. I made changes in two places.</p>
<h3>Making the driver usable on ARM and device tree</h3>
<ul>
<li>I added an <strong>OpenFirmware match table</strong> (<code>ovti,ov13b10</code>) so the driver could probe from the device tree.</li>
<li>I described the sensor in <code>milos-fairphone-fp6.dts</code>. It lives on the CCI I2C bus at address <code>0x36</code>, needs <strong>MCLK1 at 19.2 MHz</strong>, a reset GPIO, and power rails. For this experimental bring-up, I simply forced the regulators on instead of fully sequencing them.</li>
</ul>
<h3>2 small details</h3>
<ol>
<li><strong>Lane numbering.</strong> The downstream device tree used <code>data-lanes = &lt;1 2 3 4&gt;</code>, while mainline's convention is <strong>zero-indexed</strong>: <code>&lt;0 1 2 3&gt;</code>. With the wrong numbering, CSIPHY programmed a lane mask with lane 0 missing, and the PHY never locked.</li>
<li><strong>Which CSIPHY?</strong> The FP6 wiring routed the ultra-wide camera to <strong>CSIPHY1</strong>. Getting this from the downstream device tree instead of guessing saved a lot of trial and error.</li>
</ol>
<p>At this point, the sensor probed successfully, the chip ID was read correctly over I2C, CSIPHY reported lane activity, and the ISP produced frames that were <strong>completely black</strong>.</p>
<h2>Step 3: The all-zero frames mystery</h2>
<p>This was the main bug. Frames arrived at the right rate and size, and <code>buf_done</code> fired, but every pixel was zero. That was also true when I enabled the sensor's internal colour-bar test pattern, which was generated inside the sensor and should have appeared regardless of the scene. The data path delivered frame timing, but not pixel data.</p>
<p>The kernel log provided a clue: <code>VFE0: Bad config violation</code>. The ISP's write engine reported a <strong>consumer/config violation</strong> once per frame because the write master's configuration did not match the incoming data.</p>
<p>When I dug into the downstream <code>cam_tfe_bus.c</code>, the difference became clear. The RDI write-master's <strong>packer format</strong> depends on the ISP bus width:</p>
<ul>
<li>TFE530 (qcm2290, the target of the mainline driver) has a <strong>64-bit</strong> RDI bus, so it uses packer <code>0xa</code>.</li>
<li><strong>TFE665 (milos) has a 128-bit RDI bus, so it needs packer <code>0x0</code>.</strong></li>
</ul>
<p>The mainline driver hard-coded the 64-bit value. On milos, that was wrong, and the write engine refused to write the pixels. Changing the register value from <code>PLAIN64 (0xa)</code> to <code>0x0</code> turned the all-zero frames into <strong>real images</strong>: the maximum pixel value was 255, the full colour-bar pattern appeared, and the violations stopped.</p>
<p>That was the point at which the camera physically worked: <strong>OV13B10 → CSIPHY → CSID → TFE665 → DDR</strong>, delivering genuine Bayer frames.</p>
<h2>Step 4: Raw Bayer to libcamera</h2>
<p>Raw frames were enough for a QR script, but a usable camera needs <strong>libcamera</strong>, the userspace framework used by modern Linux camera applications. libcamera 0.7.2 already recognises the milos <code>qcom-camss</code> graph through its generic <strong>simple</strong> pipeline handler. Its <strong>software ISP</strong> debayered the raw frames to RGB, with GPU acceleration on the Adreno through EGL, at approximately 30 fps out of the box. <code>cam</code> and GStreamer's <code>libcamerasrc</code> produced clean images immediately.</p>
<p><strong>Auto-exposure</strong> was the part that did not work. Images were dark or overly bright, and the log reported <code>Failed to create camera sensor helper for ov13b10</code>. libcamera's software auto-exposure and gain loop needed a small per-sensor helper to map the sensor's gain register value to a real gain multiplier. libcamera includes helpers for several sensors, but not this one.</p>
<p>The fix was small. The OV13B10's analogue gain is linear, with <code>0x80 = 1×</code>, or <code>gain = code / 128</code>, which is the same behaviour as several other OmniVision sensors. I added a class to libcamera's sensor-helper database:</p>
<pre class="codeblock"><code><span class="hl-source hl-c++"><span class="hl-meta hl-class hl-c++"><span class="hl-storage hl-type hl-c++">class</span> <span class="hl-entity hl-name hl-class hl-c++">CameraSensorHelperOv13b10</span></span><span class="hl-meta hl-class hl-c++"> <span class="hl-punctuation hl-separator hl-c++">:</span> <span class="hl-storage hl-modifier hl-c++">public</span> <span class="hl-entity hl-other hl-inherited-class hl-c++">CameraSensorHelper</span> <span class="hl-meta hl-block hl-c++"><span class="hl-punctuation hl-section hl-block hl-begin hl-c++">{</span></span></span><span class="hl-meta hl-class hl-c++"><span class="hl-meta hl-block hl-c++">
<span class="hl-storage hl-modifier hl-c++">public</span><span class="hl-punctuation hl-section hl-class hl-c++">:</span>
    <span class="hl-meta hl-method hl-constructor hl-c++"><span class="hl-entity hl-name hl-function hl-constructor hl-c++">CameraSensorHelperOv13b10</span></span><span class="hl-meta hl-method hl-parameters hl-c++"><span class="hl-meta hl-group hl-c++"><span class="hl-punctuation hl-section hl-group hl-begin hl-c++">(</span></span></span><span class="hl-meta hl-method hl-parameters hl-c++"><span class="hl-meta hl-group hl-c++"><span class="hl-meta hl-group hl-c++"><span class="hl-punctuation hl-section hl-group hl-end hl-c++">)</span></span></span></span><span class="hl-meta hl-method hl-c++"> </span><span class="hl-meta hl-method hl-c++"><span class="hl-meta hl-block hl-c++"><span class="hl-punctuation hl-section hl-block hl-begin hl-c++">{</span></span></span><span class="hl-meta hl-method hl-c++"><span class="hl-meta hl-block hl-c++"> gain_ <span class="hl-keyword hl-operator hl-assignment hl-c">=</span> AnalogueGainLinear<span class="hl-meta hl-block hl-c++"><span class="hl-punctuation hl-section hl-block hl-begin hl-c++">{</span> <span class="hl-constant hl-numeric hl-c++">1</span><span class="hl-punctuation hl-separator hl-c++">,</span> <span class="hl-constant hl-numeric hl-c++">0</span><span class="hl-punctuation hl-separator hl-c++">,</span> <span class="hl-constant hl-numeric hl-c++">0</span><span class="hl-punctuation hl-separator hl-c++">,</span> <span class="hl-constant hl-numeric hl-c++">128</span> <span class="hl-punctuation hl-section hl-block hl-end hl-c++">}</span></span><span class="hl-punctuation hl-terminator hl-c++">;</span> </span></span><span class="hl-meta hl-method hl-c++"><span class="hl-meta hl-block hl-c++"><span class="hl-punctuation hl-section hl-block hl-end hl-c++">}</span></span></span>
</span></span><span class="hl-meta hl-class hl-c++"><span class="hl-meta hl-block hl-c++"><span class="hl-punctuation hl-section hl-block hl-end hl-c++">}</span></span></span><span class="hl-punctuation hl-terminator hl-c++">;</span>
<span class="hl-meta hl-assumed-macro hl-c"><span class="hl-variable hl-function hl-assumed-macro hl-c">REGISTER_CAMERA_SENSOR_HELPER</span><span class="hl-punctuation hl-section hl-group hl-begin hl-c">(</span><span class="hl-string hl-quoted hl-double hl-c"><span class="hl-punctuation hl-definition hl-string hl-begin hl-c">&quot;</span>ov13b10<span class="hl-punctuation hl-definition hl-string hl-end hl-c">&quot;</span></span><span class="hl-punctuation hl-separator hl-c">,</span> CameraSensorHelperOv13b10<span class="hl-punctuation hl-section hl-group hl-end hl-c">)</span></span>
</span></code></pre>
<p>The auto-exposure loop started converging immediately. I also added an entry to libcamera's sensor-properties database and backported the sensor driver's V4L2 crop and <code>get_selection</code> support, which libcamera uses for pixel-array geometry. After that, GStreamer and PipeWire received a properly exposed feed. Once I installed the PipeWire libcamera plugin, the camera appeared in desktop applications as a normal video source.</p>
<p>The images are still somewhat overexposed in some situations, so the helper might need further work.</p>
<h2>Step 5: Focus, orientation, and resolution issues</h2>
<p>I still had a few issues to fine-tune.</p>
<h3>Autofocus</h3>
<p>The ultra-wide camera has a <strong>voice-coil motor (VCM)</strong> for focus. The downstream device tree suggests that it is an <strong>Awinic AW86017</strong>. After powering the autofocus rail, which is controlled by a GPIO-driven regulator, and probing the CCI I2C bus, I found a device at address <code>0x0c</code> that spoke the de facto standard DW9714 10-bit DAC protocol. The mainline <strong><code>dw9714</code></strong> driver drives it directly.</p>
<p>A focus sweep confirmed that the lens moved physically and that sharpness peaked clearly at a middle setting. libcamera associated the lens with the sensor through a <code>lens-focus</code> device-tree link.</p>
<p>Unfortunately, libcamera's <strong>software ISP has no autofocus algorithm</strong>. Rather than leave the lens parked at infinity and produce blurry close-ups, I set a sensible fixed focus tuned for arm's-length QR scanning and documents using a small udev rule. The setting could be adjusted as needed, or the focus could be controlled from software.</p>
<h3>Orientation</h3>
<p>The sensor is mounted at an angle relative to the phone's portrait orientation, so previews initially appeared sideways. Setting the device-tree rotation and orientation properties allowed libcamera-aware applications to display the feed upright.</p>
<h3>Binned modes</h3>
<p>The OV13B10 driver advertised several resolutions, including 2×2-binned half-resolution modes. On this CAMSS path, <strong>the binned modes produced garbled, sheared output</strong>. Their readout geometry did not match the declared frame size, while full-resolution and a couple of other modes were clean.</p>
<p>Rather than ship a broken preview, I <strong>disabled the two broken modes</strong> so libcamera would select only working ones. The trade-off is that, because the software ISP crops instead of scaling, a 1080p preview is a centre crop of a larger mode. It is slightly zoomed in rather than showing the full ultra-wide field of view.</p>
<p>The result is a <strong>live, upright, auto-exposed, in-focus preview</strong> in GNOME Camera / Snapshot, and QR scanning works.</p>
<h2>Sources</h2>
<p>The patches and related files needed to reproduce these results are available in this repository:</p>
<p><a href="https://github.com/nondescriptpointer/fairphone6-wide-camera-linux">github.com/nondescriptpointer/fairphone6-wide-camera-linux</a></p>
<h2>Limitations</h2>
<p>This is an experimental start, not production camera support.</p>
<ul>
<li><strong>Only the ultra-wide (OV13B10) works.</strong> The main (IMX896) and front (S5KKD1) sensors have no mainline drivers.</li>
<li><strong>There is no autofocus.</strong> libcamera's software ISP has no contrast-AF loop, so focus is a fixed compromise position. Proper autofocus would require an AF algorithm in the software ISP or an app-side loop to drive the VCM.</li>
<li><strong>The preview field of view is cropped</strong> at common resolutions because the 2×2-binned sensor modes are broken on this path and the software ISP crops instead of scaling. Fixing the binned-mode register sequences, or adding a scaler, would restore the full ultra-wide field of view.</li>
<li><strong>Power sequencing is simplified.</strong> Regulators are forced on instead of being sequenced, and the VCM draws a little idle current while held at a fixed focus position. This is fine for experimentation, but needs proper power management for daily use.</li>
<li><strong>The ISP is software-only.</strong> All debayering, auto-exposure, and auto-white-balance processing happens in software, with GPU assistance. It works well for QR scanning and casual use, but does not provide the image quality of a tuned hardware ISP pipeline.</li>
<li>I did not spend time <strong>optimising image quality</strong>. Further optimisation is likely needed to get better photos from the sensor.</li>
</ul>
<h2>Next steps</h2>
<ul>
<li>Fix the binned modes to restore the full field of view.</li>
<li>Write proper power sequencing.</li>
<li>Prepare the parts that are ready for upstreaming.</li>
</ul>
]]></description>
    </item>
  </channel>
</rss>
