Skip to content
This repository was archived by the owner on Oct 24, 2025. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
47f3421
Fixing reply_regex to match current response
darek-margas Mar 31, 2023
51ba1bb
Merge branch 'modrzew:master' into master
darek-margas Apr 7, 2023
33b15a6
Update README.md
darek-margas May 6, 2023
d5d717e
Update hacs.json
darek-margas May 6, 2023
ed103bf
Delete example.png
darek-margas May 6, 2023
e8b28b7
Renamed directory
darek-margas May 21, 2023
a99dfc8
Compatibility cleanup
darek-margas May 22, 2023
dbf67ac
Missing items updated
darek-margas May 22, 2023
26c5237
Adding layers
darek-margas May 22, 2023
640e8c4
Added example screen file
darek-margas May 22, 2023
56af5cb
Update README.md
darek-margas May 22, 2023
b8934d3
Update README.md
darek-margas May 22, 2023
d269f9b
Update README.md
darek-margas May 22, 2023
1dd0842
Update README.md
darek-margas May 22, 2023
676cc03
Update README.md
darek-margas May 22, 2023
fa27ec3
Camera fix
darek-margas May 22, 2023
fb2c8cb
Update Adventurer-example-1.PNG
darek-margas May 22, 2023
b66c61f
Update manifest.json
darek-margas May 22, 2023
ac74493
Corrected IoT class
darek-margas Aug 25, 2024
c4e4109
Fixing regex handling
darek-margas Aug 25, 2024
a76e8ed
Update __init__.py
darek-margas Aug 25, 2024
6f4f298
Update __init__.py
darek-margas Aug 25, 2024
a53a648
Update protocol.py
darek-margas Aug 25, 2024
c8b8907
Added support for printer informations
darek-margas Aug 25, 2024
6194e0d
Update __init__.py
darek-margas Aug 25, 2024
6bb3a1d
Add files via upload
darek-margas Aug 25, 2024
e731e61
Delete adventurer-example-2.PNG
darek-margas Aug 25, 2024
9b15ed6
Add files via upload
darek-margas Aug 25, 2024
6e24369
Update README.md
darek-margas Aug 25, 2024
d7845b9
Update __init__.py
darek-margas Aug 25, 2024
6d3caf4
Update protocol.py
darek-margas Aug 25, 2024
e2e35ce
Create LICENSE
darek-margas Aug 25, 2024
461e478
Fix for missing item while printer disconnected
darek-margas Aug 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added Adventurer-example-1.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added Adventurer-example-3.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
504 changes: 504 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

21 changes: 7 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
# FlashForge Adventurer 3 for Home Assistant
# FlashForge Adventurer 4 for Home Assistant
[![Paypal-shield]](https://www.paypal.com/donate/?business=UZ6F4WY9P5MFY&no_recurring=0&item_name=Please+donate+if+you+like+my+work.&currency_code=AUD)

A custom Home Assistant integration for the FlashForge Adventurer 3 printer.
A fork of Adventurer 3 integration adjusted for the FlashForge Adventurer 4 printer.

It adds three entities:

- state, together with nozzle and bed temperatures available as attributes
- state, together with nozzle, bed temperatures and layers (being printed and total number) available as attributes
- current print job's progress
- camera feed

<img src="https://raw.githubusercontent.com/modrzew/hass-flashforge-adventurer-3/master/example.png" alt="Example dashboard" width="800"/>
<img src="https://raw.githubusercontent.com/darek-margas/hass-flashforge-adventurer-4/master/Adventurer-example-3.PNG" alt="Example" width="800"/>

## Installation

Expand All @@ -21,13 +21,6 @@ IP address of the printer. It might be a good idea to assign it a static IP
address in your router settings.

## Printer compatibility
Works with Adventurer 4 and possibly nothing else as Adventurer 4 provides layers which are a must to match regex.

I own the Adventurer 3 printer at the moment, so that's the model which is 100%
supported. There are reports of other users trying this integration with other
FlashForge printers:

| Printer | Notes |
| - | - |
| FlashForge Adventurer 3 | supported |
| FlashForge Adventurer 4 | seems to work ([related issue](https://github.com/modrzew/hass-flashforge-adventurer-3/issues/1)) |
| FlashForge Adventurer 3X | seems to work ([related issue](https://github.com/modrzew/hass-flashforge-adventurer-3/issues/2)) |
[Paypal-shield]: https://img.shields.io/badge/donate-paypal-blue.svg?style=flat-square&colorA=273133&colorB=b008bb "Paypal"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import asyncio
from homeassistant import config_entries, core
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN

async def async_setup_entry(
Expand All @@ -15,22 +16,19 @@ async def async_setup_entry(
hass.data[DOMAIN][entry.entry_id] = hass_data

# Forward the setup to the sensor and camera platforms.
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, 'sensor')
)
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, 'camera')
)
return True
try:
await hass.config_entries.async_forward_entry_setups(entry, ["sensor", "camera"])
except asyncio.TimeoutError as ex:
raise ConfigEntryNotReady(f"Timeout while loading config entry for sensor") from ex

return True

async def options_update_listener(
hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry
):
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)


async def async_unload_entry(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from .const import DOMAIN
from .sensor import (
FlashforgeAdventurer3CommonPropertiesMixin,
FlashforgeAdventurer4CommonPropertiesMixin,
PrinterDefinition,
)

Expand All @@ -24,12 +24,12 @@ async def async_setup_entry(
if config_entry.options:
config.update(config_entry.options)
sensors = [
FlashforgeAdventurer3Camera(config),
FlashforgeAdventurer4Camera(config),
]
async_add_entities(sensors, update_before_add=True)


class FlashforgeAdventurer3Camera(FlashforgeAdventurer3CommonPropertiesMixin, MjpegCamera):
class FlashforgeAdventurer4Camera(FlashforgeAdventurer4CommonPropertiesMixin, MjpegCamera):
def __init__(self, printer_definition: PrinterDefinition) -> None:
self.ip = printer_definition['ip_address']
self.port = printer_definition['port']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ async def async_step_user(self, user_input: Optional[Dict[str, Any]] = None):
self.data = user_input
self.data[CONF_PRINTERS] = []
# Return the form of the next step.
return self.async_create_entry(title='FlashForge Adventurer 3', data=self.data)
return self.async_create_entry(title='FlashForge Adventurer 4', data=self.data)
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
DOMAIN = 'flashforge_adventurer_3'
DOMAIN = 'flashforge_adventurer_4'
CONF_PRINTERS = '3dprinters'
DEFAULT_PORT = 8899
12 changes: 12 additions & 0 deletions custom_components/flashforge_adventurer/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"domain": "flashforge_adventurer_4",
"name": "FlashForge Adventurer 4",
"codeowners": ["@modrzew","@darek-margas"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/darek-margas/hass-flashforge-adventurer-4/",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/darek-margas/hass-flashforge-adventurer-4/issues",
"requirements": [],
"version": "1.0.0"
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
STATUS_COMMAND = '~M601 S1'
PRINT_JOB_INFO_COMMAND = '~M27'
TEMPERATURE_COMMAND = '~M105'
PRINTER_INFO_COMMAND = '~M115'

STATUS_REPLY_REGEX = re.compile('CMD M27 Received.\r\n\w+ printing byte (\d+)/100\r\nok\r\n')
TEMPERATURE_REPLY_REGEX = re.compile('CMD M105 Received.\r\nT0:(\d+)\W*/(\d+) B:(\d+)\W*/(\d+)\r\nok\r\n')
STATUS_REPLY_REGEX = re.compile(r'CMD M27 Received.\r\n\w+ printing byte (\d+)/100\r\nLayer: (\d+)/(\d+)\r\nok\r\n')
TEMPERATURE_REPLY_REGEX = re.compile(r'CMD M105 Received.\r\nT0:(\d+)\W*/(\d+) B:(\d+)\W*/(\d+)\r\nok\r\n')
PRINTER_INFO_REGEX = re.compile(r'CMD M115 Received.\r\nMachine Type: (.*?)\r\nMachine Name: (.*?)\r\nFirmware: (.*?)\r\nSN: (.*?)\r\nX: (\d+) Y: (\d+) Z: (\d+)\r\nTool Count: (\d+)\r\nMac Address:(.*?)\n \r\nok\r\n')

class PrinterStatus(TypedDict):
online: bool
Expand All @@ -24,7 +26,15 @@ class PrinterStatus(TypedDict):
desired_bed_temperature: Optional[int]
nozzle_temperature: Optional[int]
desired_nozzle_temperature: Optional[int]

type: Optional[str]
name: Optional[str]
fw: Optional[str]
sn: Optional[str]
max_x: Optional[int]
max_y: Optional[int]
max_z: Optional[int]
extruder_count: Optional[str]
mac: Optional[str]

async def send_msg(reader: StreamReader, writer: StreamWriter, payload: str):
msg = f'{payload}\r\n'
Expand All @@ -35,26 +45,28 @@ async def send_msg(reader: StreamReader, writer: StreamWriter, payload: str):
logger.debug(f'Response from the printer: {result}')
return result.decode()


async def collect_data(ip: str, port: int) -> Tuple[PrinterStatus, Optional[str], Optional[str]]:
async def collect_data(ip: str, port: int) -> Tuple[PrinterStatus, Optional[str], Optional[str], Optional[str]]:
future = asyncio.open_connection(ip, port)
try:
reader, writer = await asyncio.wait_for(future, timeout=TIMEOUT_SECONDS)
except (asyncio.TimeoutError, OSError):
return { 'online': False }, None, None
return { 'online': False }, None, None, None
response: PrinterStatus = { 'online': True }
await send_msg(reader, writer, STATUS_COMMAND)
print_job_info = await send_msg(reader, writer, PRINT_JOB_INFO_COMMAND)
temperature_info = await send_msg(reader, writer, TEMPERATURE_COMMAND)
printer_info = await send_msg(reader, writer, PRINTER_INFO_COMMAND)
writer.close()
await writer.wait_closed()
return response, print_job_info, temperature_info
return response, print_job_info, temperature_info, printer_info


def parse_data(response: PrinterStatus, print_job_info: str, temperature_info: str) -> PrinterStatus:
def parse_data(response: PrinterStatus, print_job_info: str, temperature_info: str, printer_info: str) -> PrinterStatus:
print_job_info_match = STATUS_REPLY_REGEX.match(print_job_info)
if print_job_info_match:
response['progress'] = int(print_job_info_match.group(1))
response['printing_layer'] = int(print_job_info_match.group(2))
response['total_layers'] = int(print_job_info_match.group(3))

temperature_match = TEMPERATURE_REPLY_REGEX.match(temperature_info)
if temperature_match:
# Printer is printing if desired temperatures are greater than zero. If not, it's paused.
Expand All @@ -65,15 +77,26 @@ def parse_data(response: PrinterStatus, print_job_info: str, temperature_info: s
response['desired_nozzle_temperature'] = desired_nozzle_temperature
response['bed_temperature'] = int(temperature_match.group(3))
response['desired_bed_temperature'] = desired_bed_temperature
return response

printer_info_match = PRINTER_INFO_REGEX.match(printer_info)
if printer_info_match:
response['type'] = str(printer_info_match.group(1))
response['name'] = str(printer_info_match.group(2))
response['fw'] = str(printer_info_match.group(3))
response['sn'] = str(printer_info_match.group(4))
response['max_x'] = int(printer_info_match.group(5))
response['max_y'] = int(printer_info_match.group(6))
response['max_z'] = int(printer_info_match.group(7))
response['extruder_count'] = str(printer_info_match.group(8))
response['mac'] = str(printer_info_match.group(9))

return response

async def get_print_job_status(ip: str, port: int) -> PrinterStatus:
response, print_job_info, temperature_info = await collect_data(ip, port)
response, print_job_info, temperature_info, printer_info = await collect_data(ip, port)
if not response['online']:
return response
return parse_data(response, print_job_info, temperature_info)

return parse_data(response, print_job_info, temperature_info, printer_info)

if __name__ == '__main__':
status = asyncio.run(get_print_job_status(os.environ['PRINTER_IP'], 8899))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ async def async_setup_entry(
config = hass.data[DOMAIN][config_entry.entry_id]
if config_entry.options:
config.update(config_entry.options)
coordinator = FlashforgeAdventurer3Coordinator(hass, config)
coordinator = FlashforgeAdventurer4Coordinator(hass, config)
await coordinator.async_config_entry_first_refresh()
sensors = [
FlashforgeAdventurer3StateSensor(coordinator, config),
FlashforgeAdventurer3ProgressSensor(coordinator, config),
FlashforgeAdventurer4StateSensor(coordinator, config),
FlashforgeAdventurer4ProgressSensor(coordinator, config),
]
async_add_entities(sensors, update_before_add=True)


class FlashforgeAdventurer3Coordinator(DataUpdateCoordinator):
class FlashforgeAdventurer4Coordinator(DataUpdateCoordinator):
def __init__(self, hass, printer_definition: PrinterDefinition):
super().__init__(
hass,
Expand All @@ -64,17 +64,17 @@ async def _async_update_data(self):
return await get_print_job_status(self.ip, self.port)


class FlashforgeAdventurer3CommonPropertiesMixin:
class FlashforgeAdventurer4CommonPropertiesMixin:
@property
def name(self) -> str:
return f'FlashForge Adventurer 3'
return f'FlashForge Adventurer 4'

@property
def unique_id(self) -> str:
return f'flashforge_adventurer_3_{self.ip}'
return f'flashforge_adventurer_4_{self.ip}'


class BaseFlashforgeAdventurer3Sensor(FlashforgeAdventurer3CommonPropertiesMixin, CoordinatorEntity, Entity):
class BaseFlashforgeAdventurer4Sensor(FlashforgeAdventurer4CommonPropertiesMixin, CoordinatorEntity, Entity):
def __init__(self, coordinator: DataUpdateCoordinator, printer_definition: PrinterDefinition) -> None:
super().__init__(coordinator)
self.ip = printer_definition['ip_address']
Expand All @@ -100,7 +100,7 @@ def _handle_coordinator_update(self) -> None:
self.async_write_ha_state()


class FlashforgeAdventurer3StateSensor(BaseFlashforgeAdventurer3Sensor):
class FlashforgeAdventurer4StateSensor(BaseFlashforgeAdventurer4Sensor):
@property
def name(self) -> str:
return f'{super().name} state'
Expand Down Expand Up @@ -128,7 +128,7 @@ def icon(self) -> str:
return 'mdi:printer-3d'


class FlashforgeAdventurer3ProgressSensor(BaseFlashforgeAdventurer3Sensor):
class FlashforgeAdventurer4ProgressSensor(BaseFlashforgeAdventurer4Sensor):
@property
def name(self) -> str:
return f'{super().name} progress'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"step": {
"user": {
"description": "Please enter your printer's IP address and port.",
"title": "FlashForge Adventurer 3 setup",
"title": "FlashForge Adventurer 4 setup",
"data": {
"ip_address": "IP Address",
"port": "Port (default: 8899)"
Expand Down
12 changes: 0 additions & 12 deletions custom_components/flashforge_adventurer_3/manifest.json

This file was deleted.

Binary file removed example.png
Binary file not shown.
2 changes: 1 addition & 1 deletion hacs.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "FlashForge Adventurer 3",
"name": "FlashForge Adventurer 4",
"render_readme": true,
"homeassistant": "2022.6",
"hacs": "1.26"
Expand Down