diff --git a/drivers.xml b/drivers.xml
index fc6e50a2d7..ec4cf7121f 100644
--- a/drivers.xml
+++ b/drivers.xml
@@ -615,6 +615,10 @@
indi_astromechfoc
0.2
+
+ indi_pinefeat_cef_focus
+ 1.0
+
diff --git a/drivers/focuser/CMakeLists.txt b/drivers/focuser/CMakeLists.txt
index 0d3407c3f1..6ebe06711d 100644
--- a/drivers/focuser/CMakeLists.txt
+++ b/drivers/focuser/CMakeLists.txt
@@ -89,6 +89,14 @@ add_executable(indi_astromechfoc ${astromech_focuser_SRC})
target_link_libraries(indi_astromechfoc indidriver)
install(TARGETS indi_astromechfoc RUNTIME DESTINATION bin)
+# ############### Pinefeat CEF Focuser ################
+SET(pinefeat_cef_SRC
+ pinefeat_cef.cpp)
+
+add_executable(indi_pinefeat_cef_focus ${pinefeat_cef_SRC})
+target_link_libraries(indi_pinefeat_cef_focus indidriver)
+install(TARGETS indi_pinefeat_cef_focus RUNTIME DESTINATION bin)
+
# ############### Moonlite Focuser ################
SET(moonlite_SRC
moonlite.cpp)
diff --git a/drivers/focuser/doc/pinefeat_cef/control-panel.png b/drivers/focuser/doc/pinefeat_cef/control-panel.png
new file mode 100644
index 0000000000..792a5372ee
Binary files /dev/null and b/drivers/focuser/doc/pinefeat_cef/control-panel.png differ
diff --git a/drivers/focuser/doc/pinefeat_cef/index.md b/drivers/focuser/doc/pinefeat_cef/index.md
new file mode 100644
index 0000000000..325b071bd0
--- /dev/null
+++ b/drivers/focuser/doc/pinefeat_cef/index.md
@@ -0,0 +1,96 @@
+# EF / EF-S Lens Controller & Adapter for Astronomy Camera – Canon® Lens Compatible
+
+[Pinefeat](https://www.pinefeat.co.uk) produces the adapter designed to interface between [Canon EF](https://www.canon.co.uk/store/ef-lenses/) & [EF-S lenses](https://www.canon.co.uk/store/ef-s-lenses/) and non-Canon camera bodies, incorporating features for electronic focus and aperture adjustments. The adapter has several variants: some have a built-in USB port, while others come with an external circuit control board featuring a serial connector. All variants provide a software programming interface that allows control of the lens's focus and aperture positions.
+
+_Pinefeat_CEF_ is the INDI focuser driver that allows you to use the adapter with INDI platform.
+
+The solution allows **Canon EF** and **EF-S lenses** to be used for **astrophotography**. In addition to mechanical mounting, it provides electronic control of lens focus and aperture via astronomical imaging software.
+
+
+
+## 🔗 Connecting the Adapter
+
+The adapter with a USB port supports USB CDC (Communications Device Class) and emulates serial communication over USB. When connected, the USB device appears as a serial port on the host system.
+
+If the adapter comes with an external circuit control board, the board will have a UART connector that uses 3.3V TTL logic. This can be connected to a single-board computer, such as a Raspberry Pi. When using a Raspberry Pi, the Serial Interface must be enabled in the Raspberry Pi configuration tool.
+
+**Note**: Before connecting the lens, disconnect the adapter from the USB cable.
+
+## 🧪 Quick test
+
+The adapter features a **Self-Test Mode** to verify focus and aperture communication with the lens.
+
+To activate Self-Test Mode, ensure the adapter is powered on, then toggle the **AF/MF switch** on the lens **three times within 15 seconds**. This will run a test sequence:
+- _Focus Test_: moves focus from **minimum** to **infinity**, then back to **minimum** in **four steps**.
+- _Aperture Test_: **closes** the aperture fully, then **reopens** it gradually in **four steps** to the maximum aperture.
+
+[](https://youtu.be/-aLFMjMSr5M)
+
+This sequence lasts for a few seconds and confirms that the adapter is communicating correctly with the lens.
+
+Keep in mind that STM lenses are designed to be very quiet, and their focus movements are often smooth and hard to notice. If you're unsure whether the motor is working, try placing your ear close to the lens to listen for engagement.
+
+If the lens does not complete any part of this sequence and the issue persists after repeated attempts, it indicates that the lens and adapter are not fully compatible. In such cases, some or all electronic functions (focus and/or aperture control) may not operate reliably.
+
+Note: Self-Test Mode does not affect any camera or lens settings and can be safely repeated at any time.
+
+## ⚙️ Running the driver
+
+The driver is included in the INDI core package and does not require installation.
+
+Start the INDI server with the focuser driver:
+
+```shell
+indiserver -v indi_pinefeat_cef_focus
+```
+
+## 🔧 Configuration
+
+Open the INDI Control Panel and locate the _Pinefeat EF Lens Controller_ or _Pinefeat CEF_ tab.
+
+The adapter's serial port should be detected automatically. If not, select the correct port on the _Connection_ tab.
+
+On the _Main Control_ tab, click Connect. You should see the log like in the picture below.
+
+**Important!** Upon connection, click _Calibrate_ at least once to determine total number of focus steps.
+
+
+
+**Direction**: Focus IN or Focus OUT.
+
+**Focus Speed**: If your lens supports this can set focus speed from 1 (lowest) to 4 (highest).
+
+**Relative Position**: Set the number of steps from the current absolute position to move.
+
+**Absolute Position**: Set the number of absolute steps.
+
+**Maximum Position**: The maximum possible lens positions, which is required for autofocus algorithm.
+
+**Focus Distance**: Displays approximate focus distance in meters.
+
+**Calibrate**: The calibration procedure **must be run at least once** for each lens. No need to worry about the starting lens position. The driver will automatically traverse the full focus range, from minimum to infinity, and store the total number of focus steps in the configuration file. The determined value will be displayed in the _Max position_ text field (the one that is read only) in the Main Control tab. The one that is to the right is editable and allows you to override _Max position_ if the calibration fails for whatever reason. After setting up maximum position, absolute or relative sliders will move focus correctly.
+
+**Aperture Range**: Displays lens aperture range in f-stops.
+
+**Absolute Aperture**: Sets the camera's aperture to an f-stop value. Canon lenses do not report the current aperture, so aperture controls are write-only. If the lens aperture range is from f/5.6 to f/22.6, you can set the minimum aperture by passing `22.6` value, this will close the shutter as much as possible. To fully open the shutter, pass the value `5.6`. Any value in between will partially open the shutter.
+
+**Relative Aperture**: Opens or closes the lens iris by a certain number of f-stops further. Positive values close the iris further, negative values open it further.
+
+In zoom lenses, the aperture range varies as the focal length changes. The controller will check the possible maximum and minimum aperture values before sending the value to the lens. If the values are out of range, the lens will not engage. The relative control value will take effect if the absolute one has been set at least once after the focal length has been changed.
+
+## 🔭 Using the Focuser with Ekos Focus Module
+
+Ensure that you have **completed** the one-time **calibration** for the lens.
+
+Once the focuser is configured via the INDI Control Panel, it can be used directly in the Ekos Focus Module.
+
+1. Open the **Focus** tab in Ekos.
+3. Select the _Pinefeat EF Lens Controller_ from the dropdown (it should be auto-detected if connected).
+5. Set the exposure, binning, and algorithm parameters as needed for your setup.
+7. Click Auto Focus to begin autofocus. Ekos will move the lens and calculate optimal focus based on star HFR.
+
+Refer to the [Ekos Focus Module documentation](https://docs.kde.org/trunk5/en/kstars/kstars/ekos-focus.html) for detailed information on focusing algorithms and settings.
+
+## 💡 Troubleshooting
+
+If you're experiencing issues with the setup or the adapter, please refer to our [troubleshooting guide](troubleshooting.md) for more help.
diff --git a/drivers/focuser/doc/pinefeat_cef/troubleshooting.md b/drivers/focuser/doc/pinefeat_cef/troubleshooting.md
new file mode 100644
index 0000000000..d71626d2c2
--- /dev/null
+++ b/drivers/focuser/doc/pinefeat_cef/troubleshooting.md
@@ -0,0 +1,21 @@
+# 🛠 Troubleshooting Guide
+
+A list of common problems and their solutions.
+
+## 1: Controller Is Not Detected
+
+The issue is likely caused by a poor connection of the USB cable. Ensure the cable is properly aligned and fully inserted into both the adapter and the host system.
+
+The adapter with a USB port supports USB CDC (Communications Device Class) and does not require any special drivers on most modern operating systems, including Linux, macOS, and Windows. It should appear automatically as a serial port (e.g., /dev/ttyACM0 on Linux).
+
+The device identifies itself as _Lens Controller cef135_ when connected.
+
+## 2. Lens Does Not Focus at All
+
+If the lens does not focus and the image remains static when using INDI/Ekos Focus module, it is likely because the lens has not been calibrated and does not know its full focus range.
+
+Open the INDI Control Panel and locate the _Pinefeat EF Lens Controller_ or _Pinefeat CEF_ tab. On the Main Control tab, click Connect, then click Calibrate.
+
+A valid, calibrated lens will record a positive value (e.g., 1203) in the Max position text field. If you see 0 there, the lens has not been calibrated.
+
+You only need to do this once per lens.
diff --git a/drivers/focuser/pinefeat_cef.cpp b/drivers/focuser/pinefeat_cef.cpp
new file mode 100644
index 0000000000..ef58389e87
--- /dev/null
+++ b/drivers/focuser/pinefeat_cef.cpp
@@ -0,0 +1,489 @@
+/*
+ Pinefeat EF / EF-S Lens Controller – Canon® Lens Compatible
+ Copyright (C) 2025 Pinefeat LLP (support@pinefeat.co.uk)
+
+ Based on Moonlite focuser
+ Copyright (C) 2013-2019 Jasem Mutlaq (mutlaqja@ikarustech.com)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+*/
+
+#include "pinefeat_cef.h"
+
+#include
+
+#include
+#include
+#include
+
+#define ERR_NC(res) (strcmp(res, "nc") > 0 ? "lens is not attached" : res)
+
+static std::unique_ptr pinefeatCEF(new PinefeatCEF());
+
+PinefeatCEF::PinefeatCEF()
+{
+ setVersion(1, 0);
+
+ FI::SetCapability(FOCUSER_CAN_ABS_MOVE | FOCUSER_CAN_REL_MOVE | FOCUSER_HAS_VARIABLE_SPEED);
+
+ lastUpdate = std::chrono::steady_clock::now();
+}
+
+const char * PinefeatCEF::getDefaultName()
+{
+ return "Pinefeat EF Lens Controller";
+}
+
+bool PinefeatCEF::initProperties()
+{
+ INDI::Focuser::initProperties();
+
+ FocusSpeedNP[0].setMinMax(1, 4);
+ FocusSpeedNP[0].setStep(1);
+ FocusSpeedNP[0].setValue(1);
+
+ FocusMaxPosNP[0].setMinMax(0, 32767);
+ FocusMaxPosNP[0].setStep(1);
+ FocusMaxPosNP[0].setValue(0);
+
+ FocusRelPosNP[0].setMinMax(0, 32767);
+ FocusRelPosNP[0].setStep(1);
+ FocusRelPosNP[0].setValue(0);
+
+ FocusAbsPosNP[0].setMinMax(0, 32767);
+ FocusAbsPosNP[0].setStep(1);
+ FocusAbsPosNP[0].setValue(0);
+
+ CalibrateSP[0].fill("CALIBRATE", "Calibrate", ISS_OFF);
+ CalibrateSP.fill(m_defaultDevice->getDeviceName(), "CALIBRATE", "Calibrate", MAIN_CONTROL_TAB, IP_RW, ISR_ATMOST1, 60,
+ IPS_OK);
+
+ ApertureAbsNP[0].fill("APERTURE_ABSOLUTE", "f-stop", "%.f", 0.0, 327.67, 0.0, 0.0);
+ ApertureAbsNP.fill(getDeviceName(), "ABS_APERTURE", "Absolute Aperture", MAIN_CONTROL_TAB, IP_WO,
+ 60, IPS_OK);
+
+ ApertureRelNP[0].fill("APERTURE_RELATIVE", "f-stop", "%.f", -327.68, 327.67, 0.0, 0.0);
+ ApertureRelNP.fill(getDeviceName(), "REL_APERTURE", "Relative Aperture", MAIN_CONTROL_TAB, IP_WO,
+ 60, IPS_OK);
+
+ ApertureRangeTP[0].fill("APERTURE_RANGE", "f-stop", nullptr);
+ ApertureRangeTP.fill(getDeviceName(), "RANGE_APERTURE", "Aperture range", MAIN_CONTROL_TAB, IP_RO, 60, IPS_IDLE);
+
+ FocusDistanceTP[0].fill("FOCUS_DISTANCE", "meter", nullptr);
+ FocusDistanceTP.fill(getDeviceName(), "FOCUS_DISTANCE", "Focus Distance", MAIN_CONTROL_TAB, IP_RO, 60, IPS_IDLE);
+
+ serialConnection->setDefaultBaudRate(Connection::Serial::B_115200);
+
+ setDefaultPollingPeriod(50);
+
+ return true;
+}
+
+bool PinefeatCEF::updateProperties()
+{
+ INDI::Focuser::updateProperties();
+
+ if (isConnected())
+ {
+ defineProperty(FocusDistanceTP);
+ defineProperty(CalibrateSP);
+ defineProperty(ApertureRangeTP);
+ defineProperty(ApertureAbsNP);
+ defineProperty(ApertureRelNP);
+
+ int32_t pos;
+ std::string dist, aper;
+ if (readFocusPosition(pos) &&
+ readFocusDistance(dist) &&
+ readApertureRange(aper) &&
+ updateProperties(pos, dist, aper))
+ {
+ LOG_INFO("Parameters updated, the controller is ready for use.");
+ }
+ }
+ else
+ {
+ deleteProperty(FocusDistanceTP);
+ deleteProperty(CalibrateSP);
+ deleteProperty(ApertureRangeTP);
+ deleteProperty(ApertureAbsNP);
+ deleteProperty(ApertureRelNP);
+ }
+
+ return true;
+}
+
+bool PinefeatCEF::updateProperties(const int32_t pos, const std::string dist, const std::string aper)
+{
+ FocusAbsPosNP[0].setValue(pos);
+ FocusAbsPosNP.setState(IPS_OK);
+ FocusAbsPosNP.apply();
+
+ if (FocusRelPosNP.getState() == IPS_BUSY)
+ {
+ FocusRelPosNP.setState(IPS_OK);
+ FocusRelPosNP.apply();
+ }
+
+ if (FocusMaxPosNP.getState() == IPS_BUSY)
+ {
+ FocusMaxPosNP[0].setValue(pos);
+ FocusMaxPosNP.setState(IPS_IDLE);
+ FocusMaxPosNP.apply();
+
+ double values[] = { FocusMaxPosNP[0].getValue() };
+ char *names[] = { (char*)FocusMaxPosNP[0].getName() };
+ ISNewNumber(getDeviceName(), FocusMaxPosNP.getName(), values, names, 1);
+ }
+
+ FocusDistanceTP[0].setText(dist);
+ FocusDistanceTP.apply();
+
+ ApertureRangeTP[0].setText(aper);
+ ApertureRangeTP.apply();
+
+ return true;
+}
+
+bool PinefeatCEF::Handshake()
+{
+ for (int i = 0; i < 3; i++)
+ {
+ if (readFirmwareVersion())
+ {
+ return true;
+ }
+
+ std::this_thread::sleep_for(std::chrono::milliseconds(getCurrentPollingPeriod()));
+ }
+
+ LOG_ERROR("Can't detect the controller, please ensure the device is powered and the port is correct.");
+ return false;
+}
+
+bool PinefeatCEF::readFirmwareVersion()
+{
+ char res[CEF_BUF] = {0};
+
+ if (!sendCommand("v\n", res))
+ return false;
+
+ LOGF_INFO("Detected firmware version %s.", res);
+
+ return true;
+}
+
+bool PinefeatCEF::readFocusPosition(int32_t &pos)
+{
+ char res[CEF_BUF] = {0};
+
+ if (!sendCommand("f\n", res))
+ return false;
+
+ int rc = sscanf(res, "%d", &pos);
+ if (rc <= 0)
+ {
+ LOGF_ERROR("Can't read focus position: %s.", ERR_NC(res));
+ return false;
+ }
+
+ return true;
+}
+
+bool PinefeatCEF::readFocusDistance(std::string &result)
+{
+ char res[CEF_BUF] = {0};
+
+ if (!sendCommand("d\n", res))
+ return false;
+
+ result = res;
+ return true;
+}
+
+bool PinefeatCEF::readApertureRange(std::string &result)
+{
+ char res[CEF_BUF] = {0};
+
+ if (!sendCommand("a\n", res))
+ return false;
+
+ result = res;
+ return true;
+}
+
+bool PinefeatCEF::isNotMoving()
+{
+ char res[CEF_BUF] = {0};
+ return sendCommand("e\n", res) && strstr(res, "n");
+}
+
+bool PinefeatCEF::moveFocusAbs(uint32_t position)
+{
+ char cmd[CEF_BUF] = {0};
+ char res[CEF_BUF] = {0};
+ snprintf(cmd, CEF_BUF, "f%d\n", position);
+
+ if (!sendCommand(cmd, res))
+ return false;
+
+ if (!strstr(res, "ok"))
+ {
+ LOGF_ERROR("Can't focus: %s.", ERR_NC(res));
+ return false;
+ }
+
+ return true;
+}
+
+bool PinefeatCEF::moveFocusRel(FocusDirection dir, uint32_t offset)
+{
+ char cmd[CEF_BUF] = {0};
+ char res[CEF_BUF] = {0};
+ snprintf(cmd, CEF_BUF, "f%s%d\n", (dir == FOCUS_INWARD) ? "-" : "+", offset);
+
+ if (!sendCommand(cmd, res))
+ return false;
+
+ if (!strstr(res, "ok"))
+ {
+ LOGF_ERROR("Can't focus: %s.", ERR_NC(res));
+ return false;
+ }
+
+ return true;
+}
+
+bool PinefeatCEF::setSpeed(int speed)
+{
+ char cmd[CEF_BUF] = {0};
+ char res[CEF_BUF] = {0};
+ snprintf(cmd, CEF_BUF, "s%d\n", speed);
+
+ if (!sendCommand(cmd, res))
+ return false;
+
+ if (!strstr(res, "ok"))
+ {
+ LOGF_ERROR("Can't set speed: %s.", ERR_NC(res));
+ return false;
+ }
+
+ return true;
+}
+
+bool PinefeatCEF::setApertureAbs(double value)
+{
+ char cmd[CEF_BUF] = {0};
+ char res[CEF_BUF] = {0};
+ snprintf(cmd, CEF_BUF, "a%.6g\n", value);
+
+ if (!sendCommand(cmd, res))
+ return false;
+
+ if (!strstr(res, "ok"))
+ {
+ LOGF_ERROR("Can't set aperture: %s.", ERR_NC(res));
+ return false;
+ }
+
+ LOGF_INFO("Aperture is set to f/%.6g.", value);
+
+ return true;
+}
+
+bool PinefeatCEF::setApertureRel(double value)
+{
+ char cmd[CEF_BUF] = {0};
+ char res[CEF_BUF] = {0};
+ snprintf(cmd, CEF_BUF, "a%s%.6g\n", (value < 0) ? "" : "+", value);
+
+ if (!sendCommand(cmd, res))
+ return false;
+
+ if (!strstr(res, "ok"))
+ {
+ LOGF_ERROR("Can't set aperture: %s.", ERR_NC(res));
+ return false;
+ }
+
+ LOGF_INFO("Iris is %s by f/%.6g further.", (value > 0) ? "closed" : "opened", abs(value));
+
+ return true;
+}
+
+bool PinefeatCEF::calibrate()
+{
+ char res[CEF_BUF] = {0};
+
+ if (!sendCommand("c\n", res))
+ return false;
+
+ if (!strstr(res, "ok"))
+ {
+ LOGF_ERROR("Can't calibrate: %s.", ERR_NC(res));
+ return false;
+ }
+
+ return true;
+}
+
+bool PinefeatCEF::ISNewSwitch(const char * dev, const char * name, ISState * states, char * names[], int n)
+{
+ if (dev != nullptr && strcmp(dev, getDeviceName()) == 0)
+ {
+ if (CalibrateSP.isNameMatch(name))
+ {
+ CalibrateSP.reset();
+
+ if (calibrate())
+ {
+ FocusAbsPosNP.setState(IPS_BUSY);
+ FocusAbsPosNP.apply();
+
+ FocusMaxPosNP.setState(IPS_BUSY);
+ FocusMaxPosNP.apply();
+
+ CalibrateSP.setState(IPS_OK);
+ }
+ else
+ CalibrateSP.setState(IPS_ALERT);
+
+ CalibrateSP.apply();
+ return true;
+ }
+ }
+
+ return INDI::Focuser::ISNewSwitch(dev, name, states, names, n);
+}
+
+bool PinefeatCEF::ISNewNumber(const char * dev, const char * name, double values[], char * names[], int n)
+{
+ if (dev != nullptr && strcmp(dev, getDeviceName()) == 0)
+ {
+ if (ApertureAbsNP.isNameMatch(name))
+ {
+ ApertureAbsNP.update(values, names, n);
+
+ bool res = setApertureAbs(ApertureAbsNP[0].getValue());
+ if (res)
+ ApertureAbsNP.setState(IPS_OK);
+ else
+ ApertureAbsNP.setState(IPS_ALERT);
+
+ ApertureAbsNP.apply();
+ return res;
+ }
+
+ if (ApertureRelNP.isNameMatch(name))
+ {
+ ApertureRelNP.update(values, names, n);
+
+ bool res = setApertureRel(ApertureRelNP[0].getValue());
+ if (res)
+ ApertureRelNP.setState(IPS_OK);
+ else
+ ApertureRelNP.setState(IPS_ALERT);
+
+ ApertureRelNP.apply();
+ return res;
+ }
+ }
+
+ return INDI::Focuser::ISNewNumber(dev, name, values, names, n);
+}
+
+bool PinefeatCEF::SetFocuserSpeed(int speed)
+{
+ return setSpeed(speed);
+}
+
+IPState PinefeatCEF::MoveAbsFocuser(uint32_t targetTicks)
+{
+ if (!moveFocusAbs(targetTicks))
+ return IPS_ALERT;
+
+ return IPS_BUSY;
+}
+
+IPState PinefeatCEF::MoveRelFocuser(FocusDirection dir, uint32_t ticks)
+{
+ if (!moveFocusRel(dir, ticks))
+ return IPS_ALERT;
+
+ return IPS_BUSY;
+}
+
+void PinefeatCEF::TimerHit()
+{
+ if (!isConnected())
+ return;
+
+ auto now = std::chrono::steady_clock::now();
+ auto elapsed = std::chrono::duration_cast(now - lastUpdate).count();
+
+ if ((elapsed >= 1 ||
+ FocusAbsPosNP.getState() == IPS_BUSY ||
+ FocusRelPosNP.getState() == IPS_BUSY ||
+ FocusMaxPosNP.getState() == IPS_BUSY) &&
+ isNotMoving())
+ {
+ int32_t pos;
+ std::string dist, aper;
+ if (readFocusPosition(pos)
+ && readFocusDistance(dist)
+ && readApertureRange(aper))
+ updateProperties(pos, dist, aper);
+ }
+
+ SetTimer(getCurrentPollingPeriod());
+}
+
+bool PinefeatCEF::sendCommand(const char * cmd, char * res)
+{
+ int nbytes_written = 0, nbytes_read = 0, rc = -1;
+
+ tcflush(PortFD, TCIOFLUSH);
+
+ LOGF_DEBUG("CMD <%s>", cmd);
+
+ if ((rc = tty_write_string(PortFD, cmd, &nbytes_written)) != TTY_OK)
+ {
+ char errstr[MAXRBUF] = {0};
+ tty_error_msg(rc, errstr, MAXRBUF);
+ LOGF_ERROR("Serial write error: %s.", errstr);
+ return false;
+ }
+
+ if (res == nullptr)
+ {
+ tcdrain(PortFD);
+ return true;
+ }
+
+ if ((rc = tty_nread_section(PortFD, res, CEF_BUF, CEF_DEL, CEF_TIMEOUT, &nbytes_read)) != TTY_OK)
+ {
+ char errstr[MAXRBUF] = {0};
+ tty_error_msg(rc, errstr, MAXRBUF);
+ LOGF_ERROR("Serial read error: %s.", errstr);
+ return false;
+ }
+
+ LOGF_DEBUG("RES <%s>", res);
+
+ tcflush(PortFD, TCIOFLUSH);
+
+ return true;
+}
diff --git a/drivers/focuser/pinefeat_cef.h b/drivers/focuser/pinefeat_cef.h
new file mode 100644
index 0000000000..dec98e3886
--- /dev/null
+++ b/drivers/focuser/pinefeat_cef.h
@@ -0,0 +1,90 @@
+/*
+ Pinefeat EF / EF-S Lens Controller – Canon® Lens Compatible
+ Copyright (C) 2025 Pinefeat LLP (support@pinefeat.co.uk)
+
+ Based on Moonlite focuser
+ Copyright (C) 2013-2019 Jasem Mutlaq (mutlaqja@ikarustech.com)
+
+ This library is free software; you can redistribute it and/or
+ modify it under the terms of the GNU Lesser General Public
+ License as published by the Free Software Foundation; either
+ version 2.1 of the License, or (at your option) any later version.
+
+ This library is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public
+ License along with this library; if not, write to the Free Software
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+*/
+
+#pragma once
+
+#include "indifocuser.h"
+#include
+
+class PinefeatCEF : public INDI::Focuser
+{
+ public:
+ PinefeatCEF();
+ ~PinefeatCEF() override = default;
+
+ const char * getDefaultName() override;
+ bool initProperties() override;
+ bool updateProperties() override;
+ bool ISNewNumber(const char * dev, const char * name, double values[], char * names[], int n) override;
+ bool ISNewSwitch(const char * dev, const char * name, ISState * states, char * names[], int n) override;
+
+ protected:
+ bool Handshake() override;
+ IPState MoveAbsFocuser(uint32_t targetTicks) override;
+ IPState MoveRelFocuser(FocusDirection dir, uint32_t ticks) override;
+ bool SetFocuserSpeed(int speed) override;
+ void TimerHit() override;
+
+ private:
+ bool updateProperties(const int32_t pos, const std::string dist, const std::string aper);
+ bool readFocusPosition(int32_t &pos);
+ bool readFocusDistance(std::string &result);
+ bool readApertureRange(std::string &result);
+ bool readFirmwareVersion();
+ bool isNotMoving();
+ bool moveFocusAbs(uint32_t position);
+ bool moveFocusRel(FocusDirection dir, uint32_t offset);
+ bool setSpeed(int speed);
+ bool setApertureAbs(double value);
+ bool setApertureRel(double value);
+ bool calibrate();
+
+ /**
+ * @brief sendCommand Send a string command to the controller.
+ * @param cmd Command to be sent, must already have the necessary delimiter ('\n')
+ * @param res If not nullptr, the function will read until it detects the default delimiter ('\n') up to CEF_RES length.
+ * if nullptr, no read back is done and the function returns true.
+ * @return True if successful, false otherwise.
+ */
+ bool sendCommand(const char * cmd, char * res = nullptr);
+
+ INDI::PropertySwitch CalibrateSP {1};
+
+ INDI::PropertyNumber ApertureRelNP {1};
+
+ INDI::PropertyNumber ApertureAbsNP {1};
+
+ INDI::PropertyText ApertureRangeTP {1};
+
+ INDI::PropertyText FocusDistanceTP {1};
+
+ // CEF Buffer Size
+ static const uint8_t CEF_BUF { 16 };
+
+ // CEF Command Delimiter
+ static const char CEF_DEL { '\n' };
+
+ // CEF Command Timeout
+ static const uint8_t CEF_TIMEOUT { 3 };
+
+ std::chrono::steady_clock::time_point lastUpdate;
+};