Skip to content

Commit

Permalink
Merge pull request #7 from sondregronas/dev
Browse files Browse the repository at this point in the history
0.5.0 Add API Status, Readme, Bugfix with unique IDs, placeholder gcode image
  • Loading branch information
sondregronas authored Jul 10, 2024
2 parents 70949e6 + aa8fed8 commit 2d4aa68
Show file tree
Hide file tree
Showing 12 changed files with 273 additions and 27 deletions.
51 changes: 51 additions & 0 deletions .github/workflows/release-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Release-Dev

on:
push:
branches:
- dev

permissions:
contents: write

jobs:
hacs:
name: HACS Action
runs-on: "ubuntu-latest"
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: dev

- name: Get tag from commit summary with abbreviated tag
run: |
tag=$(git describe --tags --abbrev=0)
if [[ $tag != *"-0" ]]; then
tag=$(echo $tag | grep -oP '\d+\.\d+\.\d+')
tag=$(echo $tag | awk -F. '{print $1"."$2+1"."$3"-0"}')
fi
echo "tag=$tag" >> $GITHUB_ENV
- name: Set version to release tag
run: |
sed -i "s/GITHUB_RELEASE_VERSION/${{ env.tag }}/g" custom_components/ankermake/manifest.json
sed -i "s/GITHUB_RELEASE_VERSION/${{ env.tag }}/g" custom_components/ankermake/const.py
- name: Zip ankermake
run: |
cd custom_components/ankermake
zip -r ankermake.zip .
- name: Upload release asset to the existing release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: custom_components/ankermake/ankermake.zip
asset_name: ankermake.zip
tag: ${{ env.tag }}
overwrite: true
body: "Autogenerated prerelease (dev) version of the component. See the GitHub repository for details."
prerelease: true
target_commit: dev
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ on:
release:
types:
- published
branches:
- main

permissions:
contents: write
Expand Down
94 changes: 93 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,105 @@ Home Assistant UI by searching for "AnkerMake" or click the button below.

[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=ankermake)

> Note: You can add as many instances as you'd like (but you will need an ankerctl instance running for each one).
> Note: You can add as many instances as you'd like (but you will need an ankerctl instance configured for each
> printer).
## Adding a camera (WIP)

<details>

<summary>Click to expand!</summary>

> NOTE: This might not work for you YET! Also it isn't the most reliable feed. See PR/Draft in
> ankerctl: [here](https://github.com/Ankermgmt/ankermake-m5-protocol/pull/162)
## Using go2rtc

`go2rtc.yaml` (https://github.com/AlexxIT/go2rtc?tab=readme-ov-file#go2rtc-home-assistant-add-on)

```yaml
streams:
Anker:
- ffmpeg:http://ankerctl-ip:4470/video
```
<details>
<summary>Alt: Frigate config</summary>
Note: Frigate just runs go2rtc
`config.yml`

```yaml
go2rtc:
streams:
Anker:
- "ffmpeg:http://ankerctl-ip:4470/video"
```

</details>

## Lovelace Card (Home Assistant)

Add WebRTC integration from HACS (https://github.com/AlexxIT/WebRTC?tab=readme-ov-file#installation)

Use either `http://<go2rtc_ip>:1984` or `http://<frigate_ip>:1984` when configuring the integration, reboot and add
a `Custom: WebRTC Camera` card to the dashboard:

```yaml
type: custom:webrtc-camera
url: Anker
```

</details>

## Dependencies

For this component to work, you will need an instance of [ankerctl](https://github.com/Ankermgmt/ankermake-m5-protocol)
running and working. Please refer to the ankerctl documentation for installation instructions. (They do have a Home
Assistant add-on in their organization, but I have not tested it with this component).

(The branch of ankerctl I'm
using: https://github.com/sondregronas/ankermake-m5-protocol/tree/patch-exiles-1.1-auto-restart-on-failure)

<details>
<summary>Click here for a docker-compose setup</summary>

You can use this `docker-compose.yml` file to start an instance of my fork of ankerctl. Note that the container is set
to restart every 2 hours as a workaround for some socket issues I've encountered, but isn't strictly necessary.

```yaml
services:
ankerctl:
container_name: ankerctl
restart: unless-stopped
build:
context: https://github.com/sondregronas/ankermake-m5-protocol.git#patch-exiles-1.1-auto-restart-on-failure
privileged: true
# host-mode networking is required for pppp communication with the
# printer, since it is an asymmetrical udp protocol.
network_mode: host
environment:
- FLASK_HOST=0.0.0.0
- FLASK_PORT=4470
volumes:
- ankerctl_vol:/root/.config/ankerctl
- ./ankermake-m5-protocol/web/:/app/web
# This container will restart the ankerctl container every 2 hours
# as a temporary workaround for some socket issues.
ankerctl_restarter:
image: docker
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
# 2 hours = 7200 seconds
command: ["/bin/sh", "-c", "while true; do sleep 7200; docker restart ankerctl; done"]
restart: unless-stopped
volumes:
ankerctl_vol:
```

</details>

## Known issues

There are probably many issues to list...
Expand All @@ -51,6 +142,7 @@ There are probably many issues to list...
- There are (almost) no unit tests :(
- Logging is pretty much non-existent, documentation is a bit lacking
- ankerctl can crash sometimes, hindering the integration from working until it's restarted
- The API isn't added to ankerctl yet (showing the service statuses, etc)

## Testing

Expand Down
24 changes: 22 additions & 2 deletions custom_components/ankermake/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, CoordinatorEntity

from .anker_models import AnkerException
from .ankerctl_util import get_api_status
from .ankermake_mqtt_adapter import AnkerData
from .const import DOMAIN, STARTUP, UPDATE_FREQUENCY_SECONDS

Expand Down Expand Up @@ -101,10 +102,13 @@ def on_message(message):
await session.close()

async def _async_update_data(self):
try:
self.ankerdata._api_status = await get_api_status(self.config['host'])
except AnkerException as e:
_LOGGER.debug(f"[AnkerMake] Error updating API data: {e}")
# Ensure task is still running
if self._listen_to_ws_task.done():
self._listen_to_ws_task = asyncio.create_task(self._listen_to_ws())
await asyncio.sleep(5) # If the task dies, wait 5 seconds before trying again


class AnkerMakeBaseEntity(CoordinatorEntity[AnkerMakeUpdateCoordinator]):
Expand All @@ -115,7 +119,7 @@ def __init__(self, coordinator: AnkerMakeUpdateCoordinator,

self._attr_name = f"{device_info['name']} {description.name}"
self.entity_description = description
self._attr_unique_id = description.key
self._attr_unique_id = f"{device_info['name']}_{description.key}"
self._attr_device_info = device_info

@property
Expand All @@ -129,3 +133,19 @@ def _handle_coordinator_update(self) -> None:

def _update_from_anker(self) -> None:
"""Update the entity. (Used by sensor.py)"""

def _filter_handler(self, key: str):
def td_convert(seconds):
return str(timedelta(seconds=seconds))

if key.startswith('%%TD='):
val = getattr(self.coordinator.ankerdata, key.split('=')[1])
return td_convert(val)
elif key.startswith('='):
return key[1:]
elif key.startswith('%SVC_ONLINE='):
return self.coordinator.ankerdata.get_api_service_online(key.split('=')[1])
elif key.startswith('%SVC_STATE='):
return self.coordinator.ankerdata.get_api_service_status(key.split('=')[1])

return getattr(self.coordinator.ankerdata, key)
1 change: 1 addition & 0 deletions custom_components/ankermake/anker_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class CommandTypes(Enum):
ZZ_MQTT_CMD_AI_INFO_CHECK = 1051 # Not used
ZZ_MQTT_CMD_MODEL_LAYER = 1052
TEMP_MAX_PRINT_SPEED = 1055 # max_print_speed: 500
TEMP_PRINT_STOPPED = 1068 # {'name': 'name', 'img': 'url', 'totalTime': 0, 'filamentUsed': 0, 'filamentUnit': 'mm', 'saveTime': 0, 'trigger': 2})
UNKNOWN_1081 = 1081 # Not used
UNKNOWN_1084 = 1084 # Not used
TEMP_IS_LEVELED = 1072 # isLeveled: 1
Expand Down
16 changes: 16 additions & 0 deletions custom_components/ankermake/ankerctl_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,19 @@ async def reload_ankerctl(host: str):
raise AnkerUtilException(f"Failed to reload ankerctl: {response.status}")
except Exception as e:
raise AnkerUtilException(f"Failed to reload ankerctl: {e}")


async def get_api_status(host: str):
"""Gets the status of the ankerctl api."""
url = host.replace("ws://", "http://").replace("wss://", "https://")
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{url}/api/ankerctl/status") as response:
# TODO: Temporary if on the main branch of ankerctl
if response.status == 404:
raise AnkerUtilException("Ankerctl API not found (not present in ankerctl yet)")
if response.status != 200:
raise AnkerUtilException(f"Failed to get api status: {response.status}")
return await response.json()
except Exception as e:
raise AnkerUtilException(f"Failed to get api status: {e}")
19 changes: 18 additions & 1 deletion custom_components/ankermake/ankermake_mqtt_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@

@dataclass
class AnkerData:
_timezone: datetime.tzinfo = None
_timezone: datetime.tzinfo = None # Defined in __init__.py
_api_status: dict = None # Updated via __init__.py

_last_heartbeat: datetime = None
_status: AnkerStatus = AnkerStatus.OFFLINE
_old_status: AnkerStatus = None
Expand Down Expand Up @@ -208,6 +210,16 @@ def _remove_error(self):
self.error_message = ""
self.error_level = ""

@property
def api_service_possible_states(self) -> list:
return list(self._api_status.get('possible_states', {}).keys()) + ['Unavailable']

def get_api_service_status(self, service: str) -> str:
return self._api_status.get('services', {}).get(service, {}).get('state', 'Unavailable')

def get_api_service_online(self, service: str) -> bool:
return self._api_status.get('services', {}).get(service, {}).get('online', False)

def update(self, websocket_message: dict):
"""Update the AnkerData object with a new message from the AnkerMake printer."""
command_type = websocket_message.get("commandType")
Expand Down Expand Up @@ -295,6 +307,11 @@ def update(self, websocket_message: dict):
case CommandTypes.TEMP_IS_LEVELED.value:
self.bed_leveled = websocket_message.get("isLeveled") == 1

# When the STOP button is pressed, this message is sent
case CommandTypes.TEMP_PRINT_STOPPED.value:
# Resetting for now, which will set state to IDLE
self._reset()

# Errors (?)
case CommandTypes.TEMP_ERROR_CODE.value:
self.error_level = websocket_message.get("errorLevel")
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 2 additions & 4 deletions custom_components/ankermake/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,11 @@ def __init__(self, coordinator, description, dev_info, attrs):
@callback
def _update_from_anker(self) -> None:
try:
state = getattr(self.coordinator.ankerdata, self.attrs['state'])
self._attr_is_on = state

for attr, key in self.attrs.items():
if attr == 'state':
self._attr_is_on = self._filter_handler(key)
continue
self._attr_extra_state_attributes[attr] = getattr(self.coordinator.ankerdata, key)
self._attr_extra_state_attributes[attr] = self._filter_handler(key)

if not self.coordinator.ankerdata.online:
self._attr_available = True
Expand Down
11 changes: 9 additions & 2 deletions custom_components/ankermake/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,30 @@ class AnkerMakeImageSensor(AnkerMakeBaseEntity, ImageEntity):
def __init__(self, coordinator, description, dev_info, hass: HomeAssistant):
super().__init__(coordinator, description, dev_info)
self._gcode_preview_url = ''
self._placeholder_path = hass.config.path('./custom_components/ankermake/assets/placeholder_gcode.png')
ImageEntity.__init__(self, hass=hass)
self._attr_image_last_updated = datetime.now()

@callback
def _update_from_anker(self) -> None:
gcode_preview_url = self.coordinator.ankerdata.image
if gcode_preview_url != self._gcode_preview_url:
self._attr_image_last_updated = datetime.now()
is_new_image = gcode_preview_url != self._gcode_preview_url

self._gcode_preview_url = gcode_preview_url
self._attr_image_url = self._gcode_preview_url

if is_new_image:
self._attr_image_last_updated = datetime.now()

if self.coordinator.ankerdata.online:
self._attr_available = True
else:
self._attr_available = False

async def async_image(self) -> bytes | None:
"""Return image bytes."""
if not self._gcode_preview_url:
return await self.hass.async_add_executor_job(lambda: open(self._placeholder_path, 'rb').read())
async with aiohttp.ClientSession() as session:
async with session.get(self._gcode_preview_url) as response:
return await response.read()
Expand Down
18 changes: 2 additions & 16 deletions custom_components/ankermake/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class AnkerMakeSensor(AnkerMakeBaseEntity, SensorEntity):
@callback
def _update_from_anker(self) -> None:
try:
value = getattr(self.coordinator.ankerdata, self.entity_description.key)
value = self._filter_handler(self.entity_description.key)
if self.coordinator.ankerdata.online:
self._attr_available = True
else:
Expand All @@ -39,24 +39,10 @@ def __init__(self, coordinator, description, dev_info, attrs):
self.attrs = attrs.copy()
self._attr_extra_state_attributes = dict()

@staticmethod
def td_convert(seconds):
return str(timedelta(seconds=seconds))

def _filter_handler(self, key: str):
if key.startswith('%%TD='):
val = getattr(self.coordinator.ankerdata, key[5:])
return self.td_convert(val)
elif key.startswith('='):
return key[1:]

return getattr(self.coordinator.ankerdata, key)

@callback
def _update_from_anker(self) -> None:
try:
state = getattr(self.coordinator.ankerdata, self.attrs['state'])
self._attr_native_value = state
self._attr_native_value = self._filter_handler(self.attrs['state'])

for attr, key in self.attrs.items():
if attr == 'state':
Expand Down
Loading

0 comments on commit 2d4aa68

Please sign in to comment.