├── custom_components └── anycubic_cloud │ ├── scripts │ ├── anycubic_cached_sig_token.token.sample │ ├── user_store_info.py │ ├── README.md │ ├── delete_cloud_file.py │ ├── list_cloud_files.py │ ├── script_base.py │ ├── delete_local_file.py │ ├── gcode_metadata_parser.py │ ├── cloud_direct_print_with_ace.py │ ├── upload_to_cloud_and_print_with_ace.py │ ├── debug_dump_raw.py │ └── build_translations.py │ ├── anycubic_cloud_api │ ├── anycubic_api.py │ ├── const │ │ ├── mqtt.py │ │ ├── const.py │ │ ├── enums.py │ │ └── api_endpoints.py │ ├── models │ │ └── http.py │ ├── resources │ │ ├── anycubic_mqqt_tls_ca.crt │ │ ├── anycubic_mqqt_tls_client.crt │ │ └── anycubic_mqqt_tls_client.key │ ├── data_models │ │ ├── print_speed_mode.py │ │ ├── print_response.py │ │ ├── consumable.py │ │ ├── gcode_file.py │ │ └── printing_settings.py │ ├── exceptions │ │ └── exceptions.py │ └── helpers │ │ └── helpers.py │ ├── frontend_panel │ ├── src │ │ ├── const.ts │ │ ├── views │ │ │ ├── print │ │ │ │ ├── view-print-no_cloud_save.ts │ │ │ │ ├── view-print-save_in_cloud.ts │ │ │ │ ├── styles.ts │ │ │ │ └── view-print-base.ts │ │ │ ├── files │ │ │ │ ├── styles.ts │ │ │ │ ├── view-files_local.ts │ │ │ │ ├── view-files_udisk.ts │ │ │ │ ├── view-files_cloud.ts │ │ │ │ └── view-files_base.ts │ │ │ └── debug │ │ │ │ └── view-debug.ts │ │ ├── fire_haptic.ts │ │ ├── load-ha-elements.ts │ │ ├── components │ │ │ ├── ui │ │ │ │ ├── modal-styles.ts │ │ │ │ └── select-dropdown.ts │ │ │ └── printer_card │ │ │ │ ├── stats │ │ │ │ ├── temperature_stat.ts │ │ │ │ ├── stat_line.ts │ │ │ │ ├── progress_line.ts │ │ │ │ └── time_stat.ts │ │ │ │ ├── printer_view │ │ │ │ ├── printer_view.ts │ │ │ │ └── utils.ts │ │ │ │ └── camera_view │ │ │ │ └── camera_view.ts │ │ ├── lib │ │ │ └── colorpicker │ │ │ │ ├── lib.js │ │ │ │ ├── HueBar.js │ │ │ │ ├── HSLCanvas.js │ │ │ │ └── ColorInputChannel.js │ │ ├── fire_event.ts │ │ └── internal │ │ │ └── register-custom-element.ts │ ├── .prettierrc │ ├── tsconfig.json │ ├── rollup.config.mjs │ ├── rollup.config-card.mjs │ ├── localize │ │ ├── localize.ts │ │ └── languages │ │ │ └── en.json │ ├── package.json │ └── .eslintrc.js │ ├── manifest.json │ ├── entity.py │ ├── panel.py │ ├── __init__.py │ ├── const.py │ ├── image.py │ ├── switch.py │ ├── update.py │ ├── binary_sensor.py │ ├── button.py │ ├── services.yaml │ └── diagnostics.py ├── screenshots ├── kobra2-1.png ├── kobra2-2.png ├── kobra3-1.png ├── kobra3-print.png ├── anycubic-ace-ui.gif ├── anycubic_api_token.png └── auth_slicer_token.png ├── hacs.json ├── requirements.txt ├── .flake8 ├── .github └── workflows │ ├── hassfest.yml │ ├── validate.yml │ └── lint_and_build.yml ├── .gitignore ├── pyproject.toml ├── mypy.ini ├── DEVELOPMENT.md ├── Version ├── .pre-commit-config.yaml └── README.md /custom_components/anycubic_cloud/scripts/anycubic_cached_sig_token.token.sample: -------------------------------------------------------------------------------- 1 | longtokenstringinthisfile -------------------------------------------------------------------------------- /screenshots/kobra2-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WaresWichall/hass-anycubic_cloud/HEAD/screenshots/kobra2-1.png -------------------------------------------------------------------------------- /screenshots/kobra2-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WaresWichall/hass-anycubic_cloud/HEAD/screenshots/kobra2-2.png -------------------------------------------------------------------------------- /screenshots/kobra3-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WaresWichall/hass-anycubic_cloud/HEAD/screenshots/kobra3-1.png -------------------------------------------------------------------------------- /screenshots/kobra3-print.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WaresWichall/hass-anycubic_cloud/HEAD/screenshots/kobra3-print.png -------------------------------------------------------------------------------- /screenshots/anycubic-ace-ui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WaresWichall/hass-anycubic_cloud/HEAD/screenshots/anycubic-ace-ui.gif -------------------------------------------------------------------------------- /screenshots/anycubic_api_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WaresWichall/hass-anycubic_cloud/HEAD/screenshots/anycubic_api_token.png -------------------------------------------------------------------------------- /screenshots/auth_slicer_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WaresWichall/hass-anycubic_cloud/HEAD/screenshots/auth_slicer_token.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Anycubic Cloud", 3 | "content_in_root": false, 4 | "render_readme": true, 5 | "homeassistant": "2024.9.0" 6 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles==24.1.0 2 | homeassistant==2024.9.3 3 | pydantic==2.9.2 4 | types-aiofiles==24.1.0.20240626 5 | types-paho-mqtt==1.6.0.20240321 -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | filename = *.py, *.pyx, *.pxd 3 | max-line-length = 135 4 | exclude = example/* 5 | in-place = true 6 | recursive = true 7 | aggressive = 3 8 | pep8-passes = 4 9 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/anycubic_api.py: -------------------------------------------------------------------------------- 1 | from .api.functions import AnycubicAPIFunctions as AnycubicAPI 2 | from .api.mqtt import AnycubicMQTTAPI 3 | 4 | __all__ = [ 5 | "AnycubicAPI", 6 | "AnycubicMQTTAPI", 7 | ] 8 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/const.ts: -------------------------------------------------------------------------------- 1 | export const platform = "anycubic_cloud"; 2 | 3 | export const DEBUG = false; 4 | 5 | export const LIGHT_ENTITY_DOMAINS = ["light"]; 6 | export const SWITCH_ENTITY_DOMAINS = ["switch"]; 7 | export const CAMERA_ENTITY_DOMAINS = ["camera"]; 8 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __pycache__ 3 | custom_components/anycubic_cloud/frontend_panel/node_modules/ 4 | custom_components/anycubic_cloud/scripts/*.cache 5 | custom_components/anycubic_cloud/scripts/*.json 6 | custom_components/anycubic_cloud/scripts/*.token 7 | custom_components/anycubic_cloud/scripts/anycubic_credentials.py 8 | debug_dump.json 9 | test_api 10 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - name: HACS validation 16 | uses: "hacs/action@main" 17 | with: 18 | category: "integration" 19 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/print/view-print-no_cloud_save.ts: -------------------------------------------------------------------------------- 1 | import { customElement, state } from "lit/decorators.js"; 2 | 3 | import { AnycubicViewPrintBase } from "./view-print-base"; 4 | 5 | @customElement("anycubic-view-print-no_cloud_save") 6 | export class AnycubicViewPrintNoCloudSave extends AnycubicViewPrintBase { 7 | @state() 8 | protected _serviceName: string = "print_and_upload_no_cloud_save"; 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/print/view-print-save_in_cloud.ts: -------------------------------------------------------------------------------- 1 | import { customElement, state } from "lit/decorators.js"; 2 | 3 | import { AnycubicViewPrintBase } from "./view-print-base"; 4 | 5 | @customElement("anycubic-view-print-save_in_cloud") 6 | export class AnycubicViewPrintSaveInCloud extends AnycubicViewPrintBase { 7 | @state() 8 | protected _serviceName: string = "print_and_upload_save_in_cloud"; 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "overrides": [ 4 | { 5 | "files": "*.ts,*.js", 6 | "options": { 7 | "htmlWhitespaceSensitivity": "strict" 8 | } 9 | }, 10 | { 11 | "files": "*.scss", 12 | "options": { 13 | "parser": "scss", 14 | "singleQuote": true, 15 | "printWidth": 200 16 | } 17 | }, 18 | { 19 | "files": "*.md", 20 | "options": { 21 | "printWidth": 200 22 | } 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "anycubic_cloud", 3 | "name": "Anycubic Cloud", 4 | "codeowners": [ 5 | "@WaresWichall" 6 | ], 7 | "config_flow": true, 8 | "dependencies": [ 9 | "file_upload", 10 | "http", 11 | "image", 12 | "panel_custom" 13 | ], 14 | "documentation": "https://www.home-assistant.io/integrations/anycubic_cloud", 15 | "iot_class": "cloud_polling", 16 | "issue_tracker": "https://github.com/WaresWichall/anycubic_cloud/issues", 17 | "loggers": [], 18 | "requirements": ["paho-mqtt==1.6.1"], 19 | "version": "0.2.2" 20 | } 21 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/print/styles.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, css } from "lit"; 2 | 3 | export const commonPrintStyle: CSSResult = css` 4 | :host { 5 | padding: 16px; 6 | display: block; 7 | } 8 | ac-print-view { 9 | padding: 16px; 10 | display: block; 11 | font-size: 18px; 12 | max-width: 1024px; 13 | margin: 0 auto; 14 | } 15 | 16 | ha-alert { 17 | margin-top: 10px; 18 | margin-bottom: 10px; 19 | } 20 | 21 | .print-button { 22 | margin: auto; 23 | width: 100px; 24 | height: 40px; 25 | display: block; 26 | margin-top: 20px; 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/const/mqtt.py: -------------------------------------------------------------------------------- 1 | MQTT_HOST = "mqtt-universe.anycubic.com" 2 | MQTT_PORT = 8883 3 | 4 | MQTT_TOPIC_PREFIX = "anycubic/anycubicCloud/v1" 5 | MQTT_ROOT_TOPIC_PLUS = f"{MQTT_TOPIC_PREFIX}/+/public/" 6 | MQTT_ROOT_TOPIC_PRINTER = f"{MQTT_TOPIC_PREFIX}/printer/app/" 7 | MQTT_ROOT_TOPIC_PUBLISH = f"{MQTT_TOPIC_PREFIX}/app/" 8 | MQTT_ROOT_TOPIC_PUBLISH_PRINTER = f"{MQTT_TOPIC_PREFIX}/printer/public/" 9 | MQTT_ROOT_TOPIC_SERVER = f"{MQTT_TOPIC_PREFIX}/server/app/" 10 | MQTT_ROOT_TOPIC_SLICER = f"{MQTT_TOPIC_PREFIX}/pc/printer/" 11 | # Slicer MQTT topics seem to use /pc/ instead of /app/ 12 | 13 | MQTT_TIMEOUT = 60 * 20 14 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/fire_haptic.ts: -------------------------------------------------------------------------------- 1 | enum HapticStrength { 2 | Light = "light", 3 | Medium = "medium", 4 | Heavy = "heavy", 5 | } 6 | 7 | interface HapticEvent extends Event { 8 | detail: HapticStrength; 9 | } 10 | 11 | const fireHaptic = ( 12 | hapticStrength: HapticStrength = HapticStrength.Medium, 13 | ): void => { 14 | const event: HapticEvent = new Event("haptic") as HapticEvent; 15 | event.detail = hapticStrength; 16 | 17 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 18 | if (window) { 19 | window.dispatchEvent(event); 20 | } 21 | }; 22 | 23 | export { fireHaptic, HapticStrength }; 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 135 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.hg 8 | | \.mypy_cache 9 | | \.tox 10 | | \.venv 11 | | _build 12 | | buck-out 13 | | build 14 | | dist 15 | | example 16 | )/ 17 | ''' 18 | 19 | [tool.isort] 20 | line_length = 135 21 | multi_line_output = 3 22 | force_grid_wrap = 3 23 | include_trailing_comma = true 24 | use_parentheses = true 25 | ensure_newline_before_comments = true 26 | combine_as_imports = true 27 | filter_files = true 28 | conda_env = "base" 29 | skip = [] 30 | group_by_package = true 31 | sections = ['FUTURE', 'STDLIB', 'THIRDPARTY', 'FIRSTPARTY', 'LOCALFOLDER'] 32 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/models/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import Enum 4 | 5 | 6 | class HTTP_METHODS(Enum): 7 | GET = 1 8 | POST = 2 9 | PUT = 3 10 | 11 | 12 | class AnycubicAPIEndpoint: 13 | __slots__ = ("_method", "_endpoint") 14 | 15 | def __init__( 16 | self, 17 | method: HTTP_METHODS, 18 | endpoint: str, 19 | ) -> None: 20 | self._method: HTTP_METHODS = method 21 | self._endpoint: str = endpoint 22 | 23 | @property 24 | def method(self) -> HTTP_METHODS: 25 | return self._method 26 | 27 | @property 28 | def endpoint(self) -> str: 29 | return self._endpoint 30 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2017", 8 | "dom", 9 | "dom.iterable" 10 | ], 11 | "noEmit": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "strict": true, 16 | "noImplicitAny": false, 17 | "skipLibCheck": true, 18 | "resolveJsonModule": true, 19 | "allowImportingTsExtensions": true, 20 | "experimentalDecorators": true, 21 | "useDefineForClassFields": false, 22 | "strictPropertyInitialization": false, 23 | "allowSyntheticDefaultImports": true 24 | } 25 | } -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/user_store_info.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | from typing import Any 6 | 7 | from . import script_base 8 | 9 | 10 | def get_sys_args() -> dict[str, Any]: 11 | parser = argparse.ArgumentParser(description='Anycubic Cloud User Store Info') 12 | return vars(parser.parse_args()) 13 | 14 | 15 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 16 | 17 | async def script_runner(self) -> None: 18 | await self.check_api_tokens() 19 | 20 | user_store = await self.get_user_cloud_store() 21 | 22 | self._log_to_debug(str(user_store)) 23 | 24 | 25 | def main() -> None: 26 | sys_args = get_sys_args() 27 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 28 | 29 | 30 | main() 31 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import terser from '@rollup/plugin-terser'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import babel from '@rollup/plugin-babel'; 6 | import json from '@rollup/plugin-json'; 7 | 8 | export default { 9 | plugins: [ 10 | resolve(), 11 | commonjs({ 12 | include: 'node_modules/**' 13 | }), 14 | typescript(), 15 | json(), 16 | babel(), 17 | terser({ 18 | ecma: 2021, 19 | module: true, 20 | warnings: true, 21 | }), 22 | ], 23 | input: 'src/anycubic-cloud-panel.ts', 24 | output: { 25 | dir: 'dist', 26 | format: 'iife', 27 | sourcemap: false 28 | }, 29 | context: 'window', 30 | preserveEntrySignatures: 'strict', 31 | }; -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/rollup.config-card.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import terser from '@rollup/plugin-terser'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import babel from '@rollup/plugin-babel'; 6 | import json from '@rollup/plugin-json'; 7 | 8 | export default { 9 | plugins: [ 10 | resolve(), 11 | commonjs({ 12 | include: 'node_modules/**' 13 | }), 14 | typescript(), 15 | json(), 16 | babel(), 17 | terser({ 18 | ecma: 2021, 19 | module: true, 20 | warnings: true, 21 | }), 22 | ], 23 | input: 'src/components/printer_card/printer_card.ts', 24 | output: { 25 | file: 'dist/anycubic-card.js', 26 | format: 'iife', 27 | sourcemap: false 28 | }, 29 | context: 'window', 30 | preserveEntrySignatures: 'strict', 31 | }; -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/README.md: -------------------------------------------------------------------------------- 1 | # Anycubic Cloud Helper Scripts 2 | 3 | ## WORK IN PROGRESS 4 | 5 | 6 | Install all requirements with 7 | 8 | ```bash 9 | pip install homeassistant paho-mqtt aiofiles 10 | ``` 11 | 12 | Copy `anycubic_cached_sig_token.token.sample` to `anycubic_cached_sig_token.token` and replace the contents with your user token. 13 | 14 | Open a terminal or command prompt inside the `custom_components` directory and run scripts with e.g. 15 | 16 | ### Gcode Metadata Dump 17 | 18 | ```bash 19 | python -m anycubic_cloud.scripts.gcode_metadata_parser --filepath ~/some_gcode_file.gcode 20 | ``` 21 | 22 | ### File Dump 23 | 24 | ```bash 25 | python -m anycubic_cloud.scripts.debug_dump_raw --dump-file ../debug_dump.json 26 | ``` 27 | 28 | ### Cloud upload and print 29 | 30 | ```bash 31 | python -m anycubic_cloud.scripts.upload_to_cloud_and_print_with_ace --help 32 | ``` 33 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy 3 | show_error_codes = true 4 | follow_imports = normal 5 | enable_incomplete_feature = NewGenericSyntax 6 | local_partial_types = true 7 | strict_equality = true 8 | no_implicit_optional = true 9 | warn_incomplete_stub = true 10 | warn_redundant_casts = true 11 | warn_unused_configs = true 12 | warn_unused_ignores = true 13 | enable_error_code = ignore-without-code, redundant-self, truthy-iterable 14 | check_untyped_defs = true 15 | disallow_incomplete_defs = true 16 | disallow_subclassing_any = true 17 | disallow_untyped_calls = true 18 | disallow_untyped_decorators = true 19 | disallow_untyped_defs = true 20 | warn_return_any = true 21 | warn_unreachable = true 22 | exclude = (?x)( 23 | ^custom_components/anycubic_cloud/test_api/.* 24 | | ^custom_components/anycubic_cloud/scripts_old/.* 25 | ) 26 | 27 | [pydantic-mypy] 28 | init_forbid_extra = true 29 | init_typed = true 30 | warn_required_dynamic_aliases = true 31 | warn_untyped_fields = true -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/delete_cloud_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | from typing import Any 6 | 7 | from . import script_base 8 | 9 | 10 | def get_sys_args() -> dict[str, Any]: 11 | parser = argparse.ArgumentParser(description='Anycubic Delete Cloud File') 12 | parser.add_argument( 13 | '--file-id', 14 | help='File ID.', 15 | type=int, 16 | required=True, 17 | ) 18 | return vars(parser.parse_args()) 19 | 20 | 21 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 22 | 23 | async def script_runner(self) -> None: 24 | await self.check_api_tokens() 25 | 26 | if not self._args['file_id'] or self._args['file_id'] < 1: 27 | raise Exception('Invalid file ID.') 28 | 29 | response = await self.delete_file_from_cloud(self._args['file_id']) 30 | self._log_to_debug(f"Success: {response}") 31 | 32 | 33 | def main() -> None: 34 | sys_args = get_sys_args() 35 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 36 | 37 | 38 | main() 39 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/load-ha-elements.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 5 | export const loadHaServiceControl = async (): Promise => { 6 | if (customElements.get("ha-service-control")) { 7 | return; 8 | } 9 | 10 | // Load in ha-service-control from developer-tools-service 11 | const ppResolver = document.createElement("partial-panel-resolver"); 12 | const routes = (ppResolver as any).getRoutes([ 13 | { 14 | component_name: "developer-tools", 15 | url_path: "a", 16 | }, 17 | ]); 18 | await routes?.routes?.a?.load?.(); 19 | const devToolsRouter = document.createElement("developer-tools-router"); 20 | const devToolsRoutes = (devToolsRouter as any)?.routerOptions?.routes; 21 | if (devToolsRoutes?.service) { 22 | await devToolsRoutes?.service?.load?.(); 23 | } 24 | if (devToolsRoutes?.action) { 25 | await devToolsRoutes?.action?.load?.(); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/list_cloud_files.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | from typing import Any 6 | 7 | from . import script_base 8 | 9 | 10 | def get_sys_args() -> dict[str, Any]: 11 | parser = argparse.ArgumentParser(description='Anycubic List Cloud Files') 12 | return vars(parser.parse_args()) 13 | 14 | 15 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 16 | 17 | async def script_runner(self) -> None: 18 | await self.check_api_tokens() 19 | 20 | cloud_files = await self.get_user_cloud_files( 21 | printable=True, 22 | machine_type=0, 23 | ) 24 | 25 | if not cloud_files or len(cloud_files) < 1: 26 | self._log_to_debug("No files.") 27 | return 28 | 29 | for file in cloud_files: 30 | self._log_to_debug(f"File ID: {file.id}, Name: {file.old_filename}, Size: {file.size_mb:.2f}MB") 31 | 32 | 33 | def main() -> None: 34 | sys_args = get_sys_args() 35 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 36 | 37 | 38 | main() 39 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/localize/localize.ts: -------------------------------------------------------------------------------- 1 | import * as en from './languages/en.json'; 2 | 3 | import IntlMessageFormat from 'intl-messageformat'; 4 | 5 | var languages: any = { 6 | en: en, 7 | }; 8 | 9 | export function localize(string: string, language: string, ...args: any[]): string { 10 | const lang = language.replace(/['"]+/g, ''); 11 | 12 | var translated: string; 13 | 14 | try { 15 | translated = string.split('.').reduce((o, i) => o[i], languages[lang]); 16 | } catch (e) { 17 | translated = string.split('.').reduce((o, i) => o[i], languages['en']); 18 | } 19 | 20 | if (translated === undefined) translated = string.split('.').reduce((o, i) => o[i], languages['en']); 21 | 22 | if (!args.length) return translated; 23 | 24 | const argObject = {}; 25 | for (let i = 0; i < args.length; i += 2) { 26 | let key = args[i]; 27 | key = key.replace(/^{([^}]+)?}$/, '$1'); 28 | argObject[key] = args[i + 1]; 29 | } 30 | 31 | try { 32 | const message = new IntlMessageFormat(translated, language); 33 | return message.format(argObject) as string; 34 | } catch (err) { 35 | return 'Translation ' + err; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/ui/modal-styles.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, css } from "lit"; 2 | 3 | export const commonModalStyle: CSSResult = css` 4 | :host { 5 | display: none; 6 | position: fixed; 7 | z-index: 10; 8 | left: 0; 9 | top: 0; 10 | width: 100%; 11 | height: 100%; 12 | overflow: auto; 13 | background-color: rgb(0, 0, 0); 14 | background-color: rgba(0, 0, 0, 0.2); 15 | backdrop-filter: blur(3px); 16 | } 17 | 18 | .ac-modal-container { 19 | border-radius: 16px; 20 | background-color: var(--primary-background-color); 21 | margin: auto; 22 | padding: 50px; 23 | width: 80%; 24 | min-height: 150px; 25 | max-width: 600px; 26 | margin-top: 50px; 27 | box-shadow: 0px 0px 15px 5px rgba(0, 0, 0, 0.3); 28 | } 29 | 30 | .ac-modal-card { 31 | padding: 20px; 32 | } 33 | .ac-modal-close { 34 | color: #aaa; 35 | float: right; 36 | font-size: 28px; 37 | font-weight: bold; 38 | } 39 | 40 | .ac-modal-close:hover, 41 | .ac-modal-close:focus { 42 | color: black; 43 | text-decoration: none; 44 | cursor: pointer; 45 | } 46 | 47 | .ac-modal-label { 48 | } 49 | 50 | @media (max-width: 599px) { 51 | .ac-modal-container { 52 | width: 95%; 53 | padding: 6px; 54 | } 55 | } 56 | `; 57 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Frontend 2 | 3 | Currently building using node version v18.20.4 4 | 5 | Open a terminal inside the `custom_components/anycubic_cloud/frontend_panel` directory. 6 | 7 | Run: 8 | ```bash 9 | npm install 10 | ``` 11 | 12 | Build both the panel and the card using the command: 13 | ```bash 14 | npm run build && npm run build_card 15 | ``` 16 | 17 | To build just the panel: 18 | ```bash 19 | npm run build 20 | ``` 21 | 22 | To build just the card: 23 | ```bash 24 | npm run build_card 25 | ``` 26 | 27 | 28 | # Translations 29 | 30 | ## Component 31 | 32 | Edit source translation files in `custom_components/anycubic_cloud/translations/input_translation_files/` 33 | 34 | Build output json files with: 35 | 36 | ```bash 37 | python custom_components/anycubic_cloud/scripts/build_translations.py 38 | ``` 39 | 40 | All service strings are built from the `common` section. 41 | 42 | ## Frontend 43 | 44 | Edit source translation files in `custom_components/anycubic_cloud/frontend_panel/localize/languages` 45 | 46 | Add new languages to `custom_components/anycubic_cloud/frontend_panel/localize/localize.ts` following the below edits, using German as an example: 47 | 48 | 49 | ```ts 50 | import * as en from './languages/en.json'; 51 | import * as de from './languages/de.json'; 52 | ```` 53 | 54 | ```ts 55 | var languages: any = { 56 | en: en, 57 | de: de, 58 | }; 59 | ```` 60 | 61 | Rebuild the frontend. 62 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/printer_card/stats/temperature_stat.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, LitElement, css, html } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | 4 | import { customElementIfUndef } from "../../../internal/register-custom-element"; 5 | 6 | import { getEntityTemperature } from "../../../helpers"; 7 | import { HassEntity, LitTemplateResult, TemperatureUnit } from "../../../types"; 8 | 9 | import "./stat_line.ts"; 10 | 11 | @customElementIfUndef("anycubic-printercard-stat-temperature") 12 | export class AnycubicPrintercardStatTemperature extends LitElement { 13 | @property({ type: String }) 14 | public name: string; 15 | 16 | @property({ attribute: "temperature-entity" }) 17 | public temperatureEntity: HassEntity; 18 | 19 | @property({ type: Boolean }) 20 | public round?: boolean; 21 | 22 | @property({ attribute: "temperature-unit", type: String }) 23 | public temperatureUnit: TemperatureUnit; 24 | 25 | render(): LitTemplateResult { 26 | return html``; 34 | } 35 | 36 | static get styles(): CSSResult { 37 | return css` 38 | :host { 39 | box-sizing: border-box; 40 | width: 100%; 41 | } 42 | `; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Version: -------------------------------------------------------------------------------- 1 | Version: 0.0.1 20240617 - Initial Release 2 | Version: 0.0.5 20240801 - Various updates + frontend 3 | Version: 0.0.6 20240803 - Various updates + frontend 4 | Version: 0.0.7 20240805 - Various updates, add print settings services/UI control. Breaking entity ID changes. 5 | Version: 0.0.8 20240806 - Various fixes/updates again - frontend and integration. Using update entities for firmware now. 6 | Version: 0.0.9 20240807 - Minor bug fixes 7 | Version: 0.0.10 20240811 - Bug fix for config flow, add more frontend buttons / option modals 8 | Version: 0.0.11 20240813 - Frontend improvements to panel print pages, data refactor, work on Resin printer bugs 9 | Version: 0.0.12 20240816 - Minor bug fix in MQTT reconnect, untested support for multiple ACE units, frontend refactoring 10 | Version: 0.0.13 20240825 - Bug fixes for multiple printers, HomeAssistant 2024.8 and Resin printers 11 | Version: 0.0.14 20240829 - Minor bug fixes 12 | Version: 0.0.15 20240831 - Minor bug fixes 13 | Version: 0.1.0 20240831 - Breaking changes, removed several raw sensors, add project image preview. 14 | Version: 0.1.1 20240912 - Fix cached image preview, clean quotes from user token in config flow 15 | Version: 0.1.2 20240919 - Fix user token validation 16 | Version: 0.1.3 20240925 - Fix user token validation again 17 | Version: 0.2.0 20241023 - Various improvements to integration + card 18 | Version: 0.2.1 20241108 - Various improvements to authentication + config 19 | Version: 0.2.2 20241201 - Optimisations for frontend, bug fixes throughout, better error handling 20 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/script_base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import sys 5 | from os import path 6 | from typing import Any 7 | 8 | import aiohttp 9 | 10 | from ..anycubic_cloud_api.anycubic_api import AnycubicMQTTAPI 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | LOGGER.setLevel(logging.DEBUG) 14 | handler = logging.StreamHandler(sys.stdout) 15 | handler.setLevel(logging.DEBUG) 16 | LOGGER.addHandler(handler) 17 | 18 | 19 | class anycubic_api_with_script(AnycubicMQTTAPI): 20 | def __init__( 21 | self, 22 | sys_args: dict[str, Any], 23 | *args: Any, 24 | **kwargs: Any, 25 | ) -> None: 26 | super().__init__(*args, **kwargs) 27 | self._debug_logger = LOGGER 28 | self._args = sys_args 29 | script_dir_path = path.dirname(path.realpath(__file__)) 30 | self._cached_web_auth_token_path = path.join(script_dir_path, "anycubic_cached_sig_token.token") 31 | 32 | async def script_runner(self) -> None: 33 | """Execute script.""" 34 | raise NotImplementedError 35 | 36 | 37 | async def main_mqtt_async( 38 | api_class: type[anycubic_api_with_script], 39 | sys_args: dict[str, Any], 40 | ) -> None: 41 | cookie_jar = aiohttp.CookieJar(unsafe=True) 42 | async with aiohttp.ClientSession(cookie_jar=cookie_jar) as session: 43 | ac = api_class( 44 | sys_args, 45 | session=session, 46 | cookie_jar=cookie_jar, 47 | ) 48 | await ac.script_runner() 49 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/resources/anycubic_mqqt_tls_ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIECDCCAvCgAwIBAgIBATANBgkqhkiG9w0BAQsFADCBmzELMAkGA1UEBhMCQ04x 3 | EjAQBgNVBAgMCUd1YW5nZG9uZzERMA8GA1UEBwwIU2hlbnpoZW4xETAPBgNVBAoM 4 | CEFueWN1YmljMREwDwYDVQQLDAhBbnljdWJpYzETMBEGA1UEAwwKQUMgUm9vdCBD 5 | QTEqMCgGCSqGSIb3DQEJARYbYW55Y3ViaWNfY2xvdWRAYW55Y3ViaWMuY29tMCAX 6 | DTIzMDcxMjA2NDgwNVoYDzIxMjMwNzEzMDY0ODA1WjCBmzELMAkGA1UEBhMCQ04x 7 | EjAQBgNVBAgMCUd1YW5nZG9uZzERMA8GA1UEBwwIU2hlbnpoZW4xETAPBgNVBAoM 8 | CEFueWN1YmljMREwDwYDVQQLDAhBbnljdWJpYzETMBEGA1UEAwwKQUMgUm9vdCBD 9 | QTEqMCgGCSqGSIb3DQEJARYbYW55Y3ViaWNfY2xvdWRAYW55Y3ViaWMuY29tMIIB 10 | IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn2Kpx1AzT2Df1kf1MImTdqCg 11 | q5JDGdFTMw/Mx4TN6+oYUQyVUcSs8j+EPFlaCja7t9nXoNLrfS26PwdDzJEt+wVD 12 | CdoWvNxuie+0w2Zk8lpRHn76z+waTONO3OLaneWe0xINNMtAqmuV0w2HSoNmvrLq 13 | J7iO8moNeUO92fHUAnwkrNCtKeWpB80DLdVJBZRUeiL7KKH+7ud343KcfWLpC5cj 14 | 9RyZYVCEShwY9/tPN40Jg+jk43JiFbu5ruAMPuUWn5Iy/6ENnyBKk21uYxED3y/O 15 | KNVJyl8+fZMgZriCzIP2YsU52O7nbr2iWytA1a1ZTIyiUPpGYQSYY8PSV7vwxwID 16 | AQABo1MwUTAdBgNVHQ4EFgQUJZA6qRREX697ta0fYDWzSrYKxyIwHwYDVR0jBBgw 17 | FoAUJZA6qRREX697ta0fYDWzSrYKxyIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG 18 | 9w0BAQsFAAOCAQEALPq1pVM6KjsJJx2Dk12JEN3AdYjj6oBKTBOYm1GvuuY+zN/a 19 | ZTCuNADJQHg/UUypx5ZygJudIkDcLLmYPv99C+kNacbPIl6BqUQUEo4qNuxHqvCT 20 | d48HMO6dngGY98pjBzHfHUH25LLAb0I/BqIx++XgZK/u7hea7xOkcBi11gpSplnj 21 | Bzf3h7gl9iGr+FEAWmZAKVyNXZ9tMsf5AEJVgfaDG6UXyPKeMhO6zVC58quArL5r 22 | LXVIJknBKbtnQ9n3qqExNyBWsvkQxgD+o07rZzdehnExZ5+LjeF6ktyP0e7BgFqW 23 | mqBFWhzKUShFFw+6tjt9piDil2agec2b2JEEuA== 24 | -----END CERTIFICATE----- 25 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEDTCCAvWgAwIBAgICAZAwDQYJKoZIhvcNAQEFBQAwgZsxCzAJBgNVBAYTAkNO 3 | MRIwEAYDVQQIDAlHdWFuZ2RvbmcxETAPBgNVBAcMCFNoZW56aGVuMREwDwYDVQQK 4 | DAhBbnljdWJpYzERMA8GA1UECwwIQW55Y3ViaWMxEzARBgNVBAMMCkFDIFJvb3Qg 5 | Q0ExKjAoBgkqhkiG9w0BCQEWG2FueWN1YmljX2Nsb3VkQGFueWN1YmljLmNvbTAg 6 | Fw0yMzA3MjAwMzI3NTFaGA8yMTIzMDcyMTAzMjc1MVowgZ8xCzAJBgNVBAYTAkNO 7 | MRIwEAYDVQQIDAlHdWFuZ2RvbmcxETAPBgNVBAcMCFNoZW56aGVuMREwDwYDVQQK 8 | DAhBbnljdWJpYzERMA8GA1UECwwIQW55Y3ViaWMxFzAVBgNVBAMMDkFueWN1Ymlj 9 | U2xpY2VyMSowKAYJKoZIhvcNAQkBFhthbnljdWJpY19jbG91ZEBhbnljdWJpYy5j 10 | b20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDdoQ7g2F/yecfpdlqT 11 | b8W/84r3vQ4ZEWx2PbSTBcGD55HmzJp2lwABHFHbn4CltT9YzoJWpOiVMHYnyPep 12 | 43tkNUIcGm7z0jrTD5djyYjVAzEitkNzJspKK/xcVmZe/V7Q3IAWXtzgWCd0YpVk 13 | K3J0HqoqJvcTSnYe4VXxbIGwbpeYyji9W/DuG1M4Z+sFiPDWeR9xo5IXRU5ZwaTP 14 | 8OiCCLSBbeKgf0UFWTIZdJ1JXJ7efbbstZOjf5L9LhBIC0hLdL4jlMpF7r0ThecJ 15 | cTx9Bnw/hhy+i32rJTRzZDIaLhKg/bka9ZrORZdxxQRiPoMjLjoxtr4+AUaeLWkI 16 | ajSJAgMBAAGjUzBRMB0GA1UdDgQWBBRI4P3/uKdYYFPEcFIwYxdv1p9gETAfBgNV 17 | HSMEGDAWgBQlkDqpFERfr3u1rR9gNbNKtgrHIjAPBgNVHRMBAf8EBTADAQH/MA0G 18 | CSqGSIb3DQEBBQUAA4IBAQBP3ws80Y9eBR2lpjYP3rVvH8kA6+LnEXT4PpHj+fSw 19 | jciaNskzpiwNvBy00m32ACR5YKlMUjevlQuyyw+LQbTUwAEOwyy9SDQpiXdjL6q3 20 | SPQ4aB4A57nFXOGrthc/nb9yFcteWrZrKbwvVUu2vqU7U8n7lJKjhVuFRWSXS3SV 21 | sPc9JZ21kpPYWKbGtfD6jUlW0Ip+PurLw9FrbVwnEcOMf/ezSlrH5c8mfJyo8pVk 22 | aC/6PpReqijusOSRZ5oLyhPvtgddXseJFByun1Ud0CDlFA05nGGPmnVcXD+GMnHH 23 | i6baCTeifwp5Jpdzv4imcCPvayKUNuX32vYNfNkWC/R5 24 | -----END CERTIFICATE----- 25 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/delete_local_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | from typing import Any 6 | 7 | from . import script_base 8 | 9 | 10 | def get_sys_args() -> dict[str, Any]: 11 | parser = argparse.ArgumentParser(description='Anycubic Delete Local File') 12 | parser.add_argument( 13 | '--printer-id', 14 | help='Printer ID.', 15 | type=int, 16 | required=True, 17 | ) 18 | parser.add_argument( 19 | '--filename', 20 | help='Local filename to delete.', 21 | type=str, 22 | required=True, 23 | ) 24 | return vars(parser.parse_args()) 25 | 26 | 27 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 28 | 29 | async def script_runner(self) -> None: 30 | await self.check_api_tokens() 31 | 32 | if not self._args['filename'] or len(self._args['filename']) < 1: 33 | raise Exception('Invalid file name.') 34 | 35 | if not self._args['printer_id'] or self._args['printer_id'] < 1: 36 | raise Exception('Invalid printer ID.') 37 | 38 | printer = await self.printer_info_for_id(self._args['printer_id']) 39 | 40 | if not printer: 41 | raise Exception('No printer loaded from API.') 42 | 43 | response = await printer.delete_local_file(self._args['filename']) 44 | 45 | self._log_to_debug(f"Success: {response}") 46 | 47 | 48 | def main() -> None: 49 | sys_args = get_sys_args() 50 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 51 | 52 | 53 | main() 54 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/data_models/print_speed_mode.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class AnycubicPrintSpeedMode: 7 | __slots__ = ( 8 | "_title", 9 | "_mode", 10 | ) 11 | 12 | def __init__( 13 | self, 14 | title: str, 15 | mode: int, 16 | ) -> None: 17 | self._title = str(title) 18 | self._mode = int(mode) 19 | 20 | @classmethod 21 | def from_json(cls, data: dict[str, Any] | None) -> AnycubicPrintSpeedMode | None: 22 | if data is None: 23 | return None 24 | 25 | return cls( 26 | title=data['title'], 27 | mode=data['print_speed_mode'], 28 | ) 29 | 30 | @property 31 | def title(self) -> str: 32 | return self._title 33 | 34 | @property 35 | def mode(self) -> int: 36 | return self._mode 37 | 38 | @property 39 | def data_object(self) -> dict[str, str | int]: 40 | return { 41 | "description": self._title, 42 | "mode": self._mode, 43 | } 44 | 45 | def __repr__(self) -> str: 46 | return ( 47 | f"AnycubicPrintSpeedMode(" 48 | f"title={self._title}, " 49 | f"mode={self._mode})" 50 | ) 51 | 52 | def __eq__(self, other: object) -> bool: 53 | if isinstance(other, AnycubicPrintSpeedMode): 54 | return self.mode == other.mode 55 | else: 56 | try: 57 | return bool(self.mode == int(other)) # type: ignore[call-overload] 58 | except Exception: 59 | pass 60 | 61 | return False 62 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/mirrors-autopep8 3 | rev: v2.0.4 4 | hooks: 5 | - id: autopep8 6 | args: 7 | - "--global-config" 8 | - ".flake8" 9 | types: ['file'] 10 | files: \.(py|pyx|pxd)$ 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v2.3.0 13 | hooks: 14 | - id: flake8 15 | types: ['file'] 16 | files: \.(py|pyx|pxd)$ 17 | - repo: https://github.com/pre-commit/mirrors-mypy 18 | rev: 'v1.11.2' 19 | hooks: 20 | - id: mypy 21 | exclude: ^custom_components/anycubic_cloud/test_api/ 22 | files: custom_components/ 23 | args: 24 | - "--strict" 25 | - "--config-file=mypy.ini" 26 | additional_dependencies: 27 | - "homeassistant==2024.9.3" 28 | - "pydantic==2.9.2" 29 | - "aiofiles==24.1.0" 30 | - "types-aiofiles==24.1.0.20240626" 31 | - "types-paho-mqtt==1.6.0.20240321" 32 | - repo: https://github.com/pycqa/isort 33 | rev: 5.13.2 34 | hooks: 35 | - id: isort 36 | files: "\\.(py)$" 37 | args: [--settings-path=pyproject.toml] 38 | - repo: https://github.com/pre-commit/mirrors-eslint 39 | rev: v8.56.0 40 | hooks: 41 | - id: eslint 42 | always_run: true 43 | pass_filenames: false 44 | files: \.(ts|js)$ 45 | args: 46 | - "--fix" 47 | - "custom_components/anycubic_cloud/frontend_panel/src" 48 | - "-c" 49 | - "custom_components/anycubic_cloud/frontend_panel/.eslintrc.js" 50 | - "--ignore-pattern" 51 | - "custom_components/anycubic_cloud/frontend_panel/src/lib" 52 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/const/const.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | BASE_DOMAIN = "cloud-universe.anycubic.com" 4 | APP_REDIRECT_URI = "anycubic-i18n://cloud.anycubic.com:8088" 5 | AUTH_DOMAIN = "uc.makeronline.com" 6 | 7 | PUBLIC_API_ENDPOINT = "p/p/workbench/api" 8 | 9 | PROJECT_IMAGE_URL_BASE = "https://workbentch.s3.us-east-2.amazonaws.com/" 10 | 11 | REX_JS_FILE = re.compile(r'src="(/js/app\.[^.]+\.js)"') 12 | # REX_CLIENT_ID = re.compile(r',clientId:"([^"]+)",') 13 | REX_CLIENT_ID = re.compile(r'\'(?!getEl)([a-zA-Z0-9]{20})\'') 14 | REX_APP_ID_BASIC = re.compile(r'appid["\']?:["\']([^"\']+)["\'],') 15 | REX_APP_ID_OBF = re.compile(r'(? 21 |

${this.name}

22 |

${this.value}${this.unit}

23 | 24 | `; 25 | } 26 | 27 | static get styles(): CSSResult { 28 | return css` 29 | :host { 30 | box-sizing: border-box; 31 | width: 100%; 32 | } 33 | 34 | .ac-stat-line { 35 | box-sizing: border-box; 36 | display: flex; 37 | width: 100%; 38 | flex-direction: row; 39 | justify-content: space-between; 40 | align-items: center; 41 | margin: 2px 0; 42 | } 43 | 44 | .ac-stat-text { 45 | margin: 0; 46 | font-size: 16px; 47 | display: inline-block; 48 | max-width: calc(100% - 120px); 49 | text-align: right; 50 | word-wrap: break-word; 51 | } 52 | 53 | .ac-stat-heading { 54 | font-weight: bold; 55 | max-width: unset; 56 | overflow: unset; 57 | } 58 | `; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/entity.py: -------------------------------------------------------------------------------- 1 | """Base class for anycubic_cloud entity.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | from homeassistant.helpers.entity import Entity, EntityDescription 8 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 9 | 10 | from .const import PrinterEntityType 11 | from .coordinator import AnycubicCloudDataUpdateCoordinator 12 | from .helpers import build_printer_device_info, printer_entity_unique_id 13 | 14 | if TYPE_CHECKING: 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.device_registry import DeviceInfo 17 | 18 | 19 | @dataclass(frozen=True, kw_only=True) 20 | class AnycubicCloudEntityDescription(EntityDescription): 21 | """Generic Anycubic Cloud entity description.""" 22 | 23 | printer_entity_type: PrinterEntityType | None = None 24 | 25 | 26 | class AnycubicCloudEntity(CoordinatorEntity[AnycubicCloudDataUpdateCoordinator], Entity): 27 | """Base implementation for Anycubic Printer device.""" 28 | 29 | _attr_has_entity_name = True 30 | 31 | def __init__( 32 | self, 33 | hass: HomeAssistant, 34 | coordinator: AnycubicCloudDataUpdateCoordinator, 35 | printer_id: int, 36 | entity_description: AnycubicCloudEntityDescription, 37 | ) -> None: 38 | """Initialize an Anycubic device.""" 39 | super().__init__(coordinator) 40 | self._printer_id = int(printer_id) 41 | self._attr_device_info: DeviceInfo = build_printer_device_info( 42 | coordinator.data, 43 | self._printer_id, 44 | ) 45 | self.entity_description = entity_description 46 | self._attr_unique_id = printer_entity_unique_id(coordinator, self._printer_id, entity_description.key) 47 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/lib/colorpicker/lib.js: -------------------------------------------------------------------------------- 1 | import { Color } from "modern-color"; 2 | import { html } from "lit"; 3 | 4 | export const colorEvent = (target, color, name = "color-update") => { 5 | const detail = name.includes("color") ? { color } : color; 6 | const event = new CustomEvent(name, { 7 | bubbles: true, 8 | composed: true, 9 | detail, 10 | }); 11 | target.dispatchEvent(event); 12 | }; 13 | export const hueGradient = (gran = 3, hsx) => { 14 | //todo: update to take optional hsx(v/l) vals and compose 15 | let h = 0; 16 | let s = 100; 17 | let l = 50; 18 | let v = null; 19 | let isHsv = false; 20 | if (hsx) { 21 | s = hsx.s; 22 | if (hsx.hasOwnProperty("v")) { 23 | v = hsx.v; 24 | l = null; 25 | isHsv = true; 26 | } else { 27 | l = hsx.l; 28 | } 29 | } 30 | const stops = []; 31 | let color, pos; 32 | const cs = (color, pos) => `${color.css} ${(pos * 100).toFixed(1)}%`; 33 | while (h < 360) { 34 | color = Color.parse(isHsv ? { h, s, v } : { h, s, l }); 35 | pos = h / 360; 36 | stops.push(cs(color, pos)); 37 | h += gran; 38 | } 39 | h = 359; 40 | color = Color.parse(isHsv ? { h, s, v } : { h, s, l }); 41 | pos = 1; 42 | stops.push(cs(color, pos)); 43 | return stops.join(", "); 44 | }; 45 | 46 | export const copy = html` 52 | 53 | 54 | 55 | 61 | `; 62 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/resources/anycubic_mqqt_tls_client.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdoQ7g2F/yecfp 3 | dlqTb8W/84r3vQ4ZEWx2PbSTBcGD55HmzJp2lwABHFHbn4CltT9YzoJWpOiVMHYn 4 | yPep43tkNUIcGm7z0jrTD5djyYjVAzEitkNzJspKK/xcVmZe/V7Q3IAWXtzgWCd0 5 | YpVkK3J0HqoqJvcTSnYe4VXxbIGwbpeYyji9W/DuG1M4Z+sFiPDWeR9xo5IXRU5Z 6 | waTP8OiCCLSBbeKgf0UFWTIZdJ1JXJ7efbbstZOjf5L9LhBIC0hLdL4jlMpF7r0T 7 | hecJcTx9Bnw/hhy+i32rJTRzZDIaLhKg/bka9ZrORZdxxQRiPoMjLjoxtr4+AUae 8 | LWkIajSJAgMBAAECggEASwRkC9lRiLqN30kvWW5g6hsec8KrTfLm2pMCVy2AlgxB 9 | B3VD51YvKzERyBwSKITT/1RPK9K/4xe3NrpAkmGsd3vLd8W+vorvXFePr7gct7VP 10 | 4Wb+J7D+keKXlg2sswRiHqI0PN45Nzq/iBaCaJiIMiPbB0+PHBl9J/Cv7XsD3tq+ 11 | 9WKhvXf2g1g9GMrLaCCcWXWCqcu0LlbqJnw3yMnJLSltmyFTmlVLjDHM75bMVz97 12 | 4emQzOlnRN2yA5cWWCaM+mgjNM2aWwUsXBZzCgwSqSaj1QD4B/epCuDBORWHS9D6 13 | jL15w8xjly9q8OS+4d6beR5h9GiPyMK4Ff2wXImCXQKBgQDwXxtrL+kVZrQ/qftj 14 | 24F3+QDN0j5Z3lUMTfZPn6ng/E/aBfn8KcWJHj2vYkKZdB5wOXJr56BYe3Hukzfp 15 | QF0E2+g1WAGskF1mb/vVab54geox5Y6CA+ionRn2kcCwybVkktR/0JK2UV9Qjb/z 16 | k1WU+RUhNrW/GDBqYulaadnR+wKBgQDsCf2/yKGPxj4pIvAtn5RFSlfscddgkSnc 17 | ouBkDXEp5ta+5PGrlrdzS/F0vFhvBPbfbVJxVwRnM/Oqj8c0/bj7oc5RpPxirciO 18 | AaovKVPTiORaviytnB2HgkflkJfy5vdXv4ZQahAV/UwtSmLwBshe+Ya68MAFrQRa 19 | 7M4z6k4QSwKBgQCm7OVVoofzXMeADsONrTpT3pA4XvD95/CYAuwyj2ah35Z0igH4 20 | o+mSN3YO/eXSO1mIBdz4Inqv98o/K+2ABjqSzUSNBvjipb63DL2Oj0i+1zmUPR6i 21 | G6TOs4r8OGvgWbOmjHEV8fpwskHG5ymONZsRQYjy79N3SY0V1GrJZwjlUQKBgD0x 22 | AeWcP7YkMK09b4KEYk3sTgrwIGPafj3Cw+VsTrAMNhPbCoPvWLO9NmWLBmoRoWae 23 | 0sarRmry3vKSv5QPSsuBURl9aiiy4NFfwRzk2+R1Eq4rqy1+0XD152muKJZCJlFL 24 | R6jFNlJdDkiXhjqvp3ZnvfPswfs2tXBU/8gZsA8tAoGBALXfc5m9I5R1l1zN7tpa 25 | ncA0S3EKzqmuCc3KzlS6OS0e9Lz1MsmfEsvxvW3w4SrdfTbwQpEy9RNg89dlgPtc 26 | rdId1QdN2eWPY5M4lz9n9EYdzi9ufoKAEYu2a0lP+qz690JwmL1Jx49bvQEn5Nu0 27 | 4swn72uwBRlhjAw46MF77SBQ 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/files/styles.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, css } from "lit"; 2 | 3 | export const commonFilesStyle: CSSResult = css` 4 | :host { 5 | padding: 16px; 6 | display: block; 7 | } 8 | 9 | .files-card { 10 | padding: 16px; 11 | display: block; 12 | font-size: 18px; 13 | margin: 0 auto; 14 | text-align: center; 15 | } 16 | 17 | .files-container { 18 | display: flex; 19 | flex-direction: row; 20 | align-items: center; 21 | justify-content: flex-start; 22 | flex-wrap: wrap; 23 | padding: 0; 24 | margin: 0; 25 | } 26 | 27 | .file-info { 28 | display: flex; 29 | min-height: 20px; 30 | min-width: 250px; 31 | border: 2px solid #ccc3; 32 | border-radius: 16px; 33 | padding: 16px 32px; 34 | line-height: 20px; 35 | text-align: center; 36 | font-weight: 900; 37 | margin: 6px; 38 | width: 100%; 39 | justify-content: space-between; 40 | } 41 | 42 | .file-name { 43 | display: block; 44 | line-height: 20px; 45 | text-align: center; 46 | font-weight: 900; 47 | margin: 6px; 48 | word-wrap: break-word; 49 | max-width: calc(100% - 58px); 50 | } 51 | 52 | .file-info:hover { 53 | background-color: #ccc3; 54 | border-color: #ccc9; 55 | } 56 | 57 | .file-refresh-button { 58 | padding: 10px; 59 | margin-bottom: 20px; 60 | } 61 | 62 | .file-refresh-icon { 63 | --mdc-icon-size: 50px; 64 | } 65 | 66 | .file-delete-button { 67 | padding: 4px; 68 | margin-left: 10px; 69 | } 70 | 71 | .file-delete-icon { 72 | } 73 | 74 | .no-mqtt-msg { 75 | } 76 | 77 | @media (max-width: 599px) { 78 | :host { 79 | padding: 6px; 80 | } 81 | 82 | .files-card { 83 | padding: 0px; 84 | } 85 | 86 | .file-info { 87 | padding: 6px 6px; 88 | margin: 6px 0px; 89 | } 90 | } 91 | `; 92 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/gcode_metadata_parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | import json 6 | from os import path 7 | from typing import Any 8 | 9 | from aiofiles import open as aio_file_open 10 | 11 | from ..anycubic_cloud_api.data_models.gcode_file import AnycubicGcodeFile 12 | from . import script_base 13 | 14 | 15 | def get_sys_args() -> dict[str, Any]: 16 | parser = argparse.ArgumentParser(description='Anycubic GCode metadata parser') 17 | parser.add_argument( 18 | '--filepath', 19 | help='Path to gcode file to parse.', 20 | type=str, 21 | required=True, 22 | ) 23 | return vars(parser.parse_args()) 24 | 25 | 26 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 27 | 28 | async def script_runner(self) -> None: 29 | if not self._args['filepath'] or len(self._args['filepath']) < 1: 30 | raise Exception('Invalid file path.') 31 | 32 | file_path = path.expanduser(self._args['filepath']) 33 | 34 | path_parts = file_path.rsplit(".", 1) 35 | 36 | if not len(path_parts) == 2 or path_parts[1] != "gcode": 37 | raise Exception('Must be a .gcode file.') 38 | 39 | gcode_file = await AnycubicGcodeFile.async_read_from_file( 40 | full_file_path=file_path, 41 | file_bytes=None, 42 | ) 43 | 44 | output_file = f"{path_parts[0]}.json" 45 | 46 | json_dump = json.dumps(gcode_file.data, indent=2) 47 | 48 | async with aio_file_open(output_file, mode='w') as f: 49 | await f.write(json_dump) 50 | 51 | self._log_to_debug( 52 | f"\nDumped gcode metadata to {output_file}\n" 53 | ) 54 | 55 | 56 | def main() -> None: 57 | sys_args = get_sys_args() 58 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 59 | 60 | 61 | main() 62 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/printer_card/printer_view/printer_view.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, LitElement, css, html } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | 4 | import { printerConfigAnycubic } from "./utils"; 5 | import { customElementIfUndef } from "../../../internal/register-custom-element"; 6 | 7 | import { 8 | HassEntityInfos, 9 | HomeAssistant, 10 | LitTemplateResult, 11 | } from "../../../types"; 12 | 13 | import "./animated_printer.ts"; 14 | 15 | @customElementIfUndef("anycubic-printercard-printer_view") 16 | export class AnycubicPrintercardPrinterview extends LitElement { 17 | @property() 18 | public hass!: HomeAssistant; 19 | 20 | @property({ attribute: "toggle-video", type: Function }) 21 | public toggleVideo?: () => void; 22 | 23 | @property({ attribute: "printer-entities" }) 24 | public printerEntities: HassEntityInfos; 25 | 26 | @property({ attribute: "printer-entity-id-part" }) 27 | public printerEntityIdPart: string | undefined; 28 | 29 | @property({ attribute: "scale-factor" }) 30 | public scaleFactor?: number; 31 | 32 | render(): LitTemplateResult { 33 | return html` 34 |
35 | 42 |
43 | `; 44 | } 45 | 46 | private _viewClick = (): void => { 47 | if (this.toggleVideo) { 48 | this.toggleVideo(); 49 | } 50 | }; 51 | 52 | static get styles(): CSSResult { 53 | return css` 54 | :host { 55 | box-sizing: border-box; 56 | width: 100%; 57 | } 58 | 59 | .ac-printercard-printerview { 60 | height: 100%; 61 | box-sizing: border-box; 62 | } 63 | `; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_build.yml: -------------------------------------------------------------------------------- 1 | name: "Lint and Build" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | lint-python: 10 | runs-on: ubuntu-latest 11 | name: "Lint: Python" 12 | steps: 13 | - name: Check out source repository 14 | uses: actions/checkout@v3 15 | - name: Set up Python environment 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.12" 19 | - name: flake8 Lint 20 | uses: py-actions/flake8@v2 21 | with: 22 | path: "custom_components" 23 | - name: Run mypy 24 | uses: jpetrucciani/mypy-check@master 25 | with: 26 | path: './custom_components' 27 | mypy_flags: '--config-file=mypy.ini' 28 | requirements_file: requirements.txt 29 | python_version: "3.12" 30 | - name: Run mypy 31 | uses: isort/isort-action@v1 32 | with: 33 | configuration: "--settings-path=pyproject.toml" 34 | - name: Check translation diff 35 | run: | 36 | python custom_components/anycubic_cloud/scripts/build_translations.py 37 | git diff --exit-code 38 | lint-ts: 39 | runs-on: ubuntu-latest 40 | name: "Lint & Build: Typescript" 41 | steps: 42 | - name: Check out source repository 43 | uses: actions/checkout@v3 44 | - name: Setup Node 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: 18 48 | cache-dependency-path: custom_components/anycubic_cloud/frontend_panel/package-lock.json 49 | - name: Install dependencies 50 | run: npm ci 51 | working-directory: custom_components/anycubic_cloud/frontend_panel 52 | - name: Run lint 53 | run: npm run lint 54 | working-directory: custom_components/anycubic_cloud/frontend_panel 55 | - name: Build and check diff 56 | run: | 57 | npm run build && npm run build_card 58 | git diff --exit-code 59 | working-directory: custom_components/anycubic_cloud/frontend_panel 60 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/files/view-files_local.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValues } from "lit"; 2 | import { customElement } from "lit/decorators.js"; 3 | 4 | import { AnycubicViewFilesBase } from "./view-files_base"; 5 | import { platform } from "../../const"; 6 | import { 7 | getEntityState, 8 | getFileListLocalFilesEntity, 9 | getFileListLocalRefreshEntity, 10 | } from "../../helpers"; 11 | import { 12 | AnycubicFileListEntity, 13 | AnycubicFileLocal, 14 | DomClickEvent, 15 | EvtTargFileInfo, 16 | } from "../../types"; 17 | 18 | @customElement("anycubic-view-files_local") 19 | export class AnycubicViewFilesLocal extends AnycubicViewFilesBase { 20 | protected willUpdate(changedProperties: PropertyValues): void { 21 | super.willUpdate(changedProperties); 22 | 23 | if ( 24 | changedProperties.has("hass") || 25 | changedProperties.has("selectedPrinterID") 26 | ) { 27 | const fileListState: AnycubicFileListEntity | undefined = getEntityState( 28 | this.hass, 29 | getFileListLocalFilesEntity(this.printerEntities), 30 | ); 31 | this._fileArray = fileListState 32 | ? fileListState.attributes.file_info 33 | : undefined; 34 | this._listRefreshEntity = getFileListLocalRefreshEntity( 35 | this.printerEntities, 36 | ); 37 | } 38 | } 39 | 40 | deleteFile = (ev: DomClickEvent): void => { 41 | const fileInfo: AnycubicFileLocal = ev.currentTarget 42 | .file_info as AnycubicFileLocal; 43 | if (this.selectedPrinterDevice && fileInfo.name) { 44 | this._isDeleting = true; 45 | this.hass 46 | .callService(platform, "delete_file_local", { 47 | config_entry: this.selectedPrinterDevice.primary_config_entry, 48 | device_id: this.selectedPrinterDevice.id, 49 | filename: fileInfo.name, 50 | }) 51 | .then(() => { 52 | this._isDeleting = false; 53 | }) 54 | .catch((_e: unknown) => { 55 | this._isDeleting = false; 56 | }); 57 | } 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/files/view-files_udisk.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValues } from "lit"; 2 | import { customElement } from "lit/decorators.js"; 3 | 4 | import { AnycubicViewFilesBase } from "./view-files_base"; 5 | import { platform } from "../../const"; 6 | import { 7 | getEntityState, 8 | getFileListUdiskFilesEntity, 9 | getFileListUdiskRefreshEntity, 10 | } from "../../helpers"; 11 | import { 12 | AnycubicFileListEntity, 13 | AnycubicFileLocal, 14 | DomClickEvent, 15 | EvtTargFileInfo, 16 | } from "../../types"; 17 | 18 | @customElement("anycubic-view-files_udisk") 19 | export class AnycubicViewFilesUdisk extends AnycubicViewFilesBase { 20 | protected willUpdate(changedProperties: PropertyValues): void { 21 | super.willUpdate(changedProperties); 22 | 23 | if ( 24 | changedProperties.has("hass") || 25 | changedProperties.has("selectedPrinterID") 26 | ) { 27 | const fileListState: AnycubicFileListEntity | undefined = getEntityState( 28 | this.hass, 29 | getFileListUdiskFilesEntity(this.printerEntities), 30 | ); 31 | this._fileArray = fileListState 32 | ? fileListState.attributes.file_info 33 | : undefined; 34 | this._listRefreshEntity = getFileListUdiskRefreshEntity( 35 | this.printerEntities, 36 | ); 37 | } 38 | } 39 | 40 | deleteFile = (ev: DomClickEvent): void => { 41 | const fileInfo: AnycubicFileLocal = ev.currentTarget 42 | .file_info as AnycubicFileLocal; 43 | if (this.selectedPrinterDevice && fileInfo.name) { 44 | this._isDeleting = true; 45 | this.hass 46 | .callService(platform, "delete_file_udisk", { 47 | config_entry: this.selectedPrinterDevice.primary_config_entry, 48 | device_id: this.selectedPrinterDevice.id, 49 | filename: fileInfo.name, 50 | }) 51 | .then(() => { 52 | this._isDeleting = false; 53 | }) 54 | .catch((_e: unknown) => { 55 | this._isDeleting = false; 56 | }); 57 | } 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/files/view-files_cloud.ts: -------------------------------------------------------------------------------- 1 | import { PropertyValues } from "lit"; 2 | import { customElement, state } from "lit/decorators.js"; 3 | 4 | import { AnycubicViewFilesBase } from "./view-files_base"; 5 | import { platform } from "../../const"; 6 | import { 7 | getEntityState, 8 | getFileListCloudFilesEntity, 9 | getFileListCloudRefreshEntity, 10 | } from "../../helpers"; 11 | import { 12 | AnycubicCloudFileListEntity, 13 | AnycubicFileCloud, 14 | DomClickEvent, 15 | EvtTargFileInfo, 16 | } from "../../types"; 17 | 18 | @customElement("anycubic-view-files_cloud") 19 | export class AnycubicViewFilesCloud extends AnycubicViewFilesBase { 20 | @state() 21 | protected _fileArray: AnycubicFileCloud[] | undefined; 22 | 23 | @state() 24 | protected _httpResponse: boolean = true; 25 | 26 | protected willUpdate(changedProperties: PropertyValues): void { 27 | super.willUpdate(changedProperties); 28 | 29 | if ( 30 | changedProperties.has("hass") || 31 | changedProperties.has("selectedPrinterID") 32 | ) { 33 | const fileListState: AnycubicCloudFileListEntity | undefined = 34 | getEntityState( 35 | this.hass, 36 | getFileListCloudFilesEntity(this.printerEntities), 37 | ); 38 | this._fileArray = fileListState 39 | ? fileListState.attributes.file_info 40 | : undefined; 41 | this._listRefreshEntity = getFileListCloudRefreshEntity( 42 | this.printerEntities, 43 | ); 44 | } 45 | } 46 | 47 | deleteFile = (ev: DomClickEvent): void => { 48 | const fileInfo: AnycubicFileCloud = ev.currentTarget 49 | .file_info as AnycubicFileCloud; 50 | if (this.selectedPrinterDevice && fileInfo.id) { 51 | this._isDeleting = true; 52 | this.hass 53 | .callService(platform, "delete_file_cloud", { 54 | config_entry: this.selectedPrinterDevice.primary_config_entry, 55 | device_id: this.selectedPrinterDevice.id, 56 | file_id: fileInfo.id, 57 | }) 58 | .then(() => { 59 | this._isDeleting = false; 60 | }) 61 | .catch((_e: unknown) => { 62 | this._isDeleting = false; 63 | }); 64 | } 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/cloud_direct_print_with_ace.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | from os import path 6 | from typing import Any 7 | 8 | from . import script_base 9 | 10 | 11 | def get_sys_args() -> dict[str, Any]: 12 | parser = argparse.ArgumentParser(description='Anycubic Cloud Direct Print with ACE') 13 | parser.add_argument( 14 | '--printer-id', 15 | help='Printer ID.', 16 | type=int, 17 | required=True, 18 | ) 19 | parser.add_argument( 20 | '--filepath', 21 | help='Path to file for upload & print.', 22 | type=str, 23 | required=True, 24 | ) 25 | parser.add_argument( 26 | '--slots', 27 | nargs='+', 28 | type=int, 29 | help='ACE Slot Numbers to map to print colours.', 30 | required=True, 31 | ) 32 | return vars(parser.parse_args()) 33 | 34 | 35 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 36 | 37 | async def script_runner(self) -> None: 38 | await self.check_api_tokens() 39 | 40 | if not self._args['filepath'] or len(self._args['filepath']) < 1: 41 | raise Exception('Invalid file path.') 42 | 43 | file_path = path.expanduser(self._args['filepath']) 44 | 45 | if not self._args['printer_id'] or self._args['printer_id'] < 1: 46 | raise Exception('Invalid printer ID.') 47 | 48 | printer = await self.printer_info_for_id(self._args['printer_id']) 49 | 50 | if not printer: 51 | raise Exception('No printer loaded from API.') 52 | 53 | if not self._args['slots'] or len(self._args['slots']) < 1: 54 | raise Exception('No ACE Slots mapped to print.') 55 | 56 | slot_indexes = list([x - 1 for x in self._args['slots']]) 57 | 58 | response = await self.print_and_upload_no_cloud_save( 59 | printer=printer, 60 | full_file_path=file_path, 61 | slot_index_list=slot_indexes, 62 | ) 63 | self._log_to_debug(f"Success: {response}") 64 | 65 | 66 | def main() -> None: 67 | sys_args = get_sys_args() 68 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 69 | 70 | 71 | main() 72 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/upload_to_cloud_and_print_with_ace.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | from os import path 6 | from typing import Any 7 | 8 | from . import script_base 9 | 10 | 11 | def get_sys_args() -> dict[str, Any]: 12 | parser = argparse.ArgumentParser(description='Anycubic Cloud Upload & Print') 13 | parser.add_argument( 14 | '--printer-id', 15 | help='Printer ID.', 16 | type=int, 17 | required=True, 18 | ) 19 | parser.add_argument( 20 | '--filepath', 21 | help='Path to file for upload & print.', 22 | type=str, 23 | required=True, 24 | ) 25 | parser.add_argument( 26 | '--slots', 27 | nargs='+', 28 | type=int, 29 | help='ACE Slot Numbers to map to print colours.', 30 | required=True, 31 | ) 32 | return vars(parser.parse_args()) 33 | 34 | 35 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 36 | 37 | async def script_runner(self) -> None: 38 | await self.check_api_tokens() 39 | 40 | if not self._args['filepath'] or len(self._args['filepath']) < 1: 41 | raise Exception('Invalid file path.') 42 | 43 | file_path = path.expanduser(self._args['filepath']) 44 | 45 | if not self._args['printer_id'] or self._args['printer_id'] < 1: 46 | raise Exception('Invalid printer ID.') 47 | 48 | printer = await self.printer_info_for_id(self._args['printer_id']) 49 | 50 | if not printer: 51 | raise Exception('No printer loaded from API.') 52 | 53 | if not self._args['slots'] or len(self._args['slots']) < 1: 54 | raise Exception('No ACE Slots mapped to print.') 55 | 56 | slot_indexes = list([x - 1 for x in self._args['slots']]) 57 | 58 | response = await self.print_and_upload_save_in_cloud( 59 | printer=printer, 60 | full_file_path=file_path, 61 | slot_index_list=slot_indexes, 62 | ) 63 | self._log_to_debug(f"Success: {response}") 64 | 65 | 66 | def main() -> None: 67 | sys_args = get_sys_args() 68 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 69 | 70 | 71 | main() 72 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/debug_dump_raw.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import asyncio 5 | import json 6 | from os import path 7 | from typing import Any 8 | 9 | from aiofiles import open as aio_file_open 10 | 11 | from . import script_base 12 | 13 | 14 | def get_sys_args() -> dict[str, Any]: 15 | parser = argparse.ArgumentParser(description='Anycubic Debug Dump') 16 | parser.add_argument( 17 | '--dump-file', 18 | help='Path to dump output to.', 19 | type=str, 20 | required=True, 21 | ) 22 | return vars(parser.parse_args()) 23 | 24 | 25 | class anycubic_api_with_script(script_base.anycubic_api_with_script): 26 | 27 | async def script_runner(self) -> None: 28 | await self.check_api_tokens() 29 | 30 | if not self._args['dump_file'] or len(self._args['dump_file']) < 1: 31 | raise Exception('Invalid file path.') 32 | 33 | output_file = path.expanduser(self._args['dump_file']) 34 | 35 | user_info = await self.get_user_info(raw_data=True) 36 | printer_info = await self.list_my_printers(raw_data=True) 37 | projects_info = await self.list_all_projects(raw_data=True) 38 | detailed_printer_info = list() 39 | if printer_info.get('data') is not None: 40 | for printer in printer_info['data']: 41 | printer_id = printer['id'] 42 | detailed_printer_info.append( 43 | await self.printer_info_for_id( 44 | printer_id, 45 | raw_data=True, 46 | ) 47 | ) 48 | 49 | dump = { 50 | "user_info": user_info, 51 | "printer_info": printer_info, 52 | "projects_info": projects_info, 53 | "detailed_printer_info": detailed_printer_info, 54 | } 55 | 56 | json_dump = json.dumps(dump, indent=2) 57 | 58 | async with aio_file_open(output_file, mode='w') as f: 59 | await f.write(json_dump) 60 | 61 | self._log_to_debug(f"Dumped to {output_file}") 62 | 63 | 64 | def main() -> None: 65 | sys_args = get_sys_args() 66 | asyncio.run(script_base.main_mqtt_async(anycubic_api_with_script, sys_args)) 67 | 68 | 69 | main() 70 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/panel.py: -------------------------------------------------------------------------------- 1 | """Anycubic Cloud frontend panel.""" 2 | from __future__ import annotations 3 | 4 | import os 5 | from typing import Any 6 | 7 | from homeassistant.components import frontend, panel_custom 8 | from homeassistant.components.http import StaticPathConfig 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .const import ( 12 | CUSTOM_COMPONENTS, 13 | DOMAIN, 14 | INTEGRATION_FOLDER, 15 | LOGGER, 16 | PANEL_FILENAME, 17 | PANEL_FOLDER, 18 | PANEL_ICON, 19 | PANEL_NAME, 20 | PANEL_TITLE, 21 | ) 22 | from .helpers import extract_panel_card_config 23 | 24 | PANEL_URL = "/anycubic-cloud-panel-static" 25 | 26 | 27 | def process_card_config( 28 | conf_object: Any, 29 | ) -> dict[str, Any]: 30 | if isinstance(conf_object, dict): 31 | return extract_panel_card_config(conf_object) 32 | else: 33 | return {} 34 | 35 | 36 | async def async_register_panel( 37 | hass: HomeAssistant, 38 | conf_object: Any, 39 | ) -> None: 40 | """Register the Anycubic Cloud frontend panel.""" 41 | if DOMAIN not in hass.data.get("frontend_panels", {}): 42 | root_dir = os.path.join(hass.config.path(CUSTOM_COMPONENTS), INTEGRATION_FOLDER) 43 | panel_dir = os.path.join(root_dir, PANEL_FOLDER) 44 | view_url = os.path.join(panel_dir, PANEL_FILENAME) 45 | 46 | try: 47 | await hass.http.async_register_static_paths( 48 | [StaticPathConfig(PANEL_URL, view_url, cache_headers=False)] 49 | ) 50 | except RuntimeError as e: 51 | if "already registered" not in str(e): 52 | raise e 53 | 54 | conf = process_card_config(conf_object) 55 | 56 | LOGGER.debug(f"Processed panel config: {conf}") 57 | 58 | await panel_custom.async_register_panel( 59 | hass, 60 | webcomponent_name=PANEL_NAME, 61 | frontend_url_path=DOMAIN, 62 | module_url=PANEL_URL, 63 | sidebar_title=PANEL_TITLE, 64 | sidebar_icon=PANEL_ICON, 65 | require_admin=False, 66 | config=conf, 67 | ) 68 | 69 | 70 | def async_unregister_panel(hass: HomeAssistant) -> None: 71 | frontend.async_remove_panel(hass, DOMAIN) 72 | LOGGER.debug("Removing panel") 73 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/exceptions/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | 6 | class AnycubicAPIError(Exception): 7 | pass 8 | 9 | 10 | class AnycubicAuthTokensExpired(AnycubicAPIError): 11 | pass 12 | 13 | 14 | class AnycubicAPIParsingError(AnycubicAPIError): 15 | pass 16 | 17 | 18 | class AnycubicFileNotFoundError(AnycubicAPIError): 19 | pass 20 | 21 | 22 | class AnycubicPropertiesNotLoaded(AnycubicAPIError): 23 | pass 24 | 25 | 26 | class AnycubicInvalidValue(AnycubicAPIError): 27 | pass 28 | 29 | 30 | class AnycubicAuthError(AnycubicAPIError): 31 | pass 32 | 33 | 34 | class AnycubicMQTTClientError(AnycubicAPIError): 35 | pass 36 | 37 | 38 | class AnycubicCloudUploadError(AnycubicAPIError): 39 | pass 40 | 41 | 42 | class AnycubicDataParsingError(AnycubicAPIError): 43 | pass 44 | 45 | 46 | class AnycubicGcodeParsingError(AnycubicDataParsingError): 47 | pass 48 | 49 | 50 | class AnycubicMQTTUnknownUpdate(AnycubicDataParsingError): 51 | pass 52 | 53 | 54 | class AnycubicMQTTUnhandledData(AnycubicDataParsingError): 55 | __slots__ = ( 56 | "_unhandled_mqtt_data", 57 | "_unhandled_mqtt_type", 58 | "_unhandled_mqtt_action", 59 | "_unhandled_mqtt_state", 60 | ) 61 | 62 | def __init__( 63 | self, 64 | *args: Any, 65 | unhandled_mqtt_data: dict[Any, Any] | None = None, 66 | unhandled_mqtt_type: str | None = None, 67 | unhandled_mqtt_action: str | None = None, 68 | unhandled_mqtt_state: str | None = None, 69 | **kwargs: Any, 70 | ): 71 | super().__init__(*args, **kwargs) 72 | self._unhandled_mqtt_data = unhandled_mqtt_data 73 | self._unhandled_mqtt_type = unhandled_mqtt_type 74 | self._unhandled_mqtt_action = unhandled_mqtt_action 75 | self._unhandled_mqtt_state = unhandled_mqtt_state 76 | 77 | @property 78 | def unhandled_mqtt_data(self) -> dict[Any, Any] | None: 79 | return self._unhandled_mqtt_data 80 | 81 | @property 82 | def unhandled_mqtt_type(self) -> str | None: 83 | return self._unhandled_mqtt_type 84 | 85 | @property 86 | def unhandled_mqtt_action(self) -> str | None: 87 | return self._unhandled_mqtt_action 88 | 89 | @property 90 | def unhandled_mqtt_state(self) -> str | None: 91 | return self._unhandled_mqtt_state 92 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hass-anycubic-cloud-panel", 3 | "version": "0.2.2", 4 | "description": "anycubic panel for hass", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "check-types": "tsc --noemit", 8 | "build": "npm run lint && npm run rollup && npm run babel", 9 | "build:quick": "npm run rollup && npm run babel", 10 | "build_card": "npm run lint && npm run rollup_card && npm run babel_card", 11 | "build_card:quick": "npm run rollup_card && npm run babel_card", 12 | "rollup": "rollup -c", 13 | "rollup_card": "rollup -c rollup.config-card.mjs", 14 | "babel": "./node_modules/.bin/babel dist/anycubic-cloud-panel.js --out-file dist/anycubic-cloud-panel.js", 15 | "babel_card": "./node_modules/.bin/babel dist/anycubic-card.js --out-file dist/anycubic-card.js", 16 | "eslint": "eslint src --fix -c .eslintrc.js --ignore-pattern src/lib", 17 | "lint": "npm run eslint && npm run check-types", 18 | "prettier": "prettier src/components/**/*.ts --write", 19 | "start": "rollup -c --watch" 20 | }, 21 | "author": "", 22 | "license": "ISC", 23 | "dependencies": { 24 | "@babel/cli": "^7.24.8", 25 | "@babel/core": "^7.24.9", 26 | "@babel/plugin-proposal-decorators": "^7.24.7", 27 | "@babel/plugin-transform-class-properties": "^7.24.7", 28 | "@date-fns/utc": "^2.1.0", 29 | "@eslint/js": "^9.7.0", 30 | "@lit-labs/motion": "^1.0.7", 31 | "@lit-labs/observers": "^2.0.2", 32 | "@mdi/js": "^7.4.47", 33 | "@rollup/plugin-babel": "^6.0.4", 34 | "@rollup/plugin-commonjs": "^26.0.1", 35 | "@rollup/plugin-json": "^6.1.0", 36 | "@rollup/plugin-node-resolve": "^15.2.3", 37 | "@rollup/plugin-terser": "^0.4.4", 38 | "@rollup/plugin-typescript": "^11.1.6", 39 | "@typescript-eslint/eslint-plugin": "^7.17.0", 40 | "@typescript-eslint/parser": "^7.17.0", 41 | "date-fns": "^4.1.0", 42 | "eslint": "^8.56.0", 43 | "eslint-config-prettier": "^9.1.0", 44 | "eslint-import-resolver-typescript": "^3.6.3", 45 | "eslint-plugin-import": "^2.31.0", 46 | "eslint-plugin-lit": "^1.14.0", 47 | "eslint-plugin-prettier": "^5.2.1", 48 | "home-assistant-js-websocket": "^9.4.0", 49 | "intl-messageformat": "^10.5.14", 50 | "lit": "^3.1.4", 51 | "modern-color": "^1.1.3", 52 | "prettier": "^3.3.3", 53 | "rollup": "^2.79.2", 54 | "typescript": "^5.5.4", 55 | "typescript-eslint": "^7.17.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/__init__.py: -------------------------------------------------------------------------------- 1 | """The anycubic_cloud component.""" 2 | from __future__ import annotations 3 | 4 | from homeassistant.config_entries import ConfigEntry 5 | from homeassistant.core import HomeAssistant 6 | 7 | from .const import ( 8 | CONF_CARD_CONFIG, 9 | COORDINATOR, 10 | DOMAIN, 11 | PLATFORMS, 12 | ) 13 | from .coordinator import AnycubicCloudDataUpdateCoordinator 14 | from .panel import async_register_panel, async_unregister_panel 15 | from .services import SERVICES 16 | 17 | 18 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 19 | """Set up Anycubic Cloud from a config entry.""" 20 | 21 | coordinator = AnycubicCloudDataUpdateCoordinator(hass, entry) 22 | 23 | await coordinator.async_config_entry_first_refresh() 24 | hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { 25 | COORDINATOR: coordinator, 26 | } 27 | 28 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 29 | entry.async_on_unload(entry.add_update_listener(update_listener)) 30 | 31 | # register service calls 32 | for service_name, service in SERVICES: 33 | if not hass.services.has_service(DOMAIN, service_name): 34 | hass.services.async_register( 35 | DOMAIN, 36 | service_name, 37 | service(hass).async_call_service, 38 | service.schema, 39 | ) 40 | 41 | # register panel 42 | await async_register_panel( 43 | hass, 44 | entry.options.get(CONF_CARD_CONFIG) 45 | ) 46 | 47 | return True 48 | 49 | 50 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 51 | """Handle options update.""" 52 | await hass.config_entries.async_reload(entry.entry_id) 53 | 54 | 55 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 56 | """Unload a config entry.""" 57 | 58 | unload_ok = await hass.config_entries.async_unload_platforms( 59 | entry, PLATFORMS 60 | ) 61 | 62 | if unload_ok and entry.entry_id in hass.data[DOMAIN]: 63 | host = hass.data[DOMAIN].pop(entry.entry_id) 64 | await host[COORDINATOR].stop_anycubic_mqtt_connection_if_started() 65 | 66 | # unregister service calls 67 | if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload 68 | for service_name, _ in SERVICES: 69 | hass.services.async_remove(DOMAIN, service_name) 70 | 71 | # unregister panel 72 | async_unregister_panel(hass) 73 | 74 | return unload_ok 75 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/debug/view-debug.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, LitElement, PropertyValues, css, html } from "lit"; 2 | import { customElement, property, state } from "lit/decorators.js"; 3 | 4 | import { getPrinterEntities } from "../../helpers"; 5 | import { 6 | HassDevice, 7 | HassDeviceList, 8 | HassEntityInfos, 9 | HassPanel, 10 | HassRoute, 11 | HomeAssistant, 12 | LitTemplateResult, 13 | } from "../../types"; 14 | 15 | @customElement("anycubic-view-debug") 16 | export class AnycubicViewDebug extends LitElement { 17 | @property() 18 | public hass!: HomeAssistant; 19 | 20 | @property() 21 | public language!: string; 22 | 23 | @property({ type: Boolean, reflect: true }) 24 | public narrow!: boolean; 25 | 26 | @property() 27 | public route!: HassRoute; 28 | 29 | @property() 30 | public panel!: HassPanel; 31 | 32 | @property() 33 | public printers?: HassDeviceList; 34 | 35 | @property({ attribute: "selected-printer-id" }) 36 | public selectedPrinterID: string | undefined; 37 | 38 | @property({ attribute: "selected-printer-device" }) 39 | public selectedPrinterDevice: HassDevice | undefined; 40 | 41 | @state() 42 | private printerEntities: HassEntityInfos; 43 | 44 | protected willUpdate(changedProperties: PropertyValues): void { 45 | super.willUpdate(changedProperties); 46 | 47 | if (!changedProperties.has("selectedPrinterID")) { 48 | return; 49 | } 50 | 51 | this.printerEntities = getPrinterEntities( 52 | this.hass, 53 | this.selectedPrinterID, 54 | ); 55 | } 56 | 57 | render(): LitTemplateResult { 58 | return html` 59 | 60 |

There are ${Object.keys(this.hass.states).length} entities.

61 |

The screen is${this.narrow ? "" : " not"} narrow.

62 | Configured panel config 63 |
${JSON.stringify(this.panel, undefined, 2)}
64 | Current route 65 |
${JSON.stringify(this.route, undefined, 2)}
66 | Printers 67 |
${JSON.stringify(this.printers, undefined, 2)}
68 | Printer Entities 69 |
${JSON.stringify(this.printerEntities, undefined, 2)}
70 | Selected Printer 71 |
${JSON.stringify(this.selectedPrinterDevice, undefined, 2)}
72 |
73 | `; 74 | } 75 | 76 | static get styles(): CSSResult { 77 | return css` 78 | :host { 79 | padding: 16px; 80 | display: block; 81 | } 82 | debug-data { 83 | padding: 16px; 84 | display: block; 85 | font-size: 18px; 86 | max-width: 600px; 87 | margin: 0 auto; 88 | } 89 | `; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/data_models/print_response.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | if TYPE_CHECKING: 6 | from .printer_properties import AnycubicMaterialMapping 7 | 8 | 9 | class AnycubicPrintResponse: 10 | __slots__ = ( 11 | "_order_msg_id", 12 | "_printer_id", 13 | "_saved_in_cloud", 14 | "_file_name", 15 | "_cloud_file_id", 16 | "_gcode_id", 17 | "_material_list", 18 | "_ams_box_mapping", 19 | ) 20 | 21 | def __init__( 22 | self, 23 | order_msg_id: str | None = None, 24 | printer_id: int | None = None, 25 | saved_in_cloud: bool = False, 26 | file_name: str | None = None, 27 | cloud_file_id: int | None = None, 28 | gcode_id: int | None = None, 29 | material_list: list[dict[str, Any]] | None = None, 30 | ams_box_mapping: list[AnycubicMaterialMapping] | None = None, 31 | ) -> None: 32 | self._order_msg_id: str | None = order_msg_id 33 | self._printer_id: int | None = printer_id 34 | self._saved_in_cloud: bool = bool(saved_in_cloud) 35 | self._file_name: str | None = file_name 36 | self._cloud_file_id: int | None = cloud_file_id 37 | self._gcode_id: int | None = gcode_id 38 | self._material_list: list[dict[str, Any]] | None = material_list 39 | self._ams_box_mapping: list[AnycubicMaterialMapping] | None = ams_box_mapping 40 | 41 | @property 42 | def event_dict(self) -> dict[str, Any]: 43 | ams_box_mapping = None 44 | 45 | if self._ams_box_mapping: 46 | ams_box_mapping = list([ 47 | x.as_box_mapping_data() for x in self._ams_box_mapping 48 | ]) 49 | return { 50 | 'order_msg_id': self._order_msg_id, 51 | 'printer_id': self._printer_id, 52 | 'saved_in_cloud': self._saved_in_cloud, 53 | 'file_name': self._file_name, 54 | 'cloud_file_id': self._cloud_file_id, 55 | 'gcode_id': self._gcode_id, 56 | 'material_list': self._material_list, 57 | 'ams_box_mapping': ams_box_mapping, 58 | } 59 | 60 | def __repr__(self) -> str: 61 | return ( 62 | f"AnycubicPrintResponse(" 63 | f"order_msg_id={self._order_msg_id}, " 64 | f"printer_id={self._printer_id}, " 65 | f"saved_in_cloud={self._saved_in_cloud}, " 66 | f"file_name={self._file_name}, " 67 | f"cloud_file_id={self._cloud_file_id}, " 68 | f"gcode_id={self._gcode_id}, " 69 | f"material_list={self._material_list}, " 70 | f"ams_box_mapping={self._ams_box_mapping})" 71 | ) 72 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/fire_event.ts: -------------------------------------------------------------------------------- 1 | // Polymer legacy event helpers used courtesy of the Polymer project. 2 | // 3 | // Copyright (c) 2017 The Polymer Authors. All rights reserved. 4 | // 5 | // Redistribution and use in source and binary forms, with or without 6 | // modification, are permitted provided that the following conditions are 7 | // met: 8 | // 9 | // * Redistributions of source code must retain the above copyright 10 | // notice, this list of conditions and the following disclaimer. 11 | // * Redistributions in binary form must reproduce the above 12 | // copyright notice, this list of conditions and the following disclaimer 13 | // in the documentation and/or other materials provided with the 14 | // distribution. 15 | // * Neither the name of Google Inc. nor the names of its 16 | // contributors may be used to endorse or promote products derived from 17 | // this software without specific prior written permission. 18 | // 19 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | /* global HASSDomEvents */ 32 | 33 | declare global { 34 | // tslint:disable-next-line 35 | interface HASSDomEvents {} 36 | } 37 | 38 | export type ValidHassDomEvent = keyof HASSDomEvents; 39 | 40 | export interface HASSDomEvent extends Event { 41 | detail: T; 42 | } 43 | 44 | export const fireEvent = ( 45 | node: HTMLElement | Window, 46 | type: string, 47 | evt_detail?: object | null, 48 | evt_options?: { 49 | bubbles?: boolean; 50 | cancelable?: boolean; 51 | composed?: boolean; 52 | }, 53 | ): Event => { 54 | const options = evt_options || {}; 55 | const detail = 56 | evt_detail === null || evt_detail === undefined ? {} : evt_detail; 57 | const event = new Event(type, { 58 | bubbles: options.bubbles === undefined ? true : options.bubbles, 59 | cancelable: Boolean(options.cancelable), 60 | composed: options.composed === undefined ? true : options.composed, 61 | }); 62 | (event as HASSDomEvent).detail = detail; 63 | node.dispatchEvent(event); 64 | return event; 65 | }; 66 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/const.py: -------------------------------------------------------------------------------- 1 | """Anycubic Cloud integration constants.""" 2 | import logging 3 | from enum import IntEnum 4 | 5 | from homeassistant.const import Platform 6 | 7 | DEFAULT_NAME = "Anycubic Cloud Printer" 8 | 9 | MANUFACTURER = "Anycubic" 10 | MODEL = "main" 11 | 12 | DOMAIN = "anycubic_cloud" 13 | COORDINATOR = "coordinator" 14 | 15 | CUSTOM_COMPONENTS = "custom_components" 16 | INTEGRATION_FOLDER = DOMAIN 17 | PANEL_FOLDER = "frontend_panel" 18 | PANEL_FILENAME = "dist/anycubic-cloud-panel.js" 19 | PANEL_NAME = "anycubic-cloud-panel" 20 | PANEL_TITLE = "Anycubic Cloud" 21 | PANEL_ICON = "mdi:printer-3d" 22 | 23 | ATTR_CONFIG_ENTRY = "config_entry" 24 | ATTR_ANYCUBIC_EVENT = "anycubic_cloud" 25 | 26 | ENTITY_ID_DRYING_START_PRESET_ = "drying_start_preset_" 27 | 28 | CONF_USER_AUTH_MODE = "user_auth_mode" 29 | CONF_USER_DEVICE_ID = "user_device_id" 30 | CONF_USER_TOKEN = "user_token" 31 | CONF_PRINTER_ID = "printer_id" 32 | CONF_PRINTER_ID_LIST = "printer_ids" 33 | CONF_PRINTER_NAME = "printer_name" 34 | CONF_DRYING_PRESET_DURATION_ = "drying_preset_duration_" 35 | CONF_DRYING_PRESET_TEMPERATURE_ = "drying_preset_temperature_" 36 | CONF_SLOT_NUMBER = "slot_number" 37 | CONF_SLOT_COLOR_RED = "slot_color_red" 38 | CONF_SLOT_COLOR_GREEN = "slot_color_green" 39 | CONF_SLOT_COLOR_BLUE = "slot_color_blue" 40 | CONF_BOX_ID = "box_id" 41 | CONF_FINISHED = "finished" 42 | CONF_MQTT_CONNECT_MODE = "mqtt_connect_mode" 43 | CONF_CARD_CONFIG = "card_config" 44 | CONF_DEBUG_DEPRECATED = "debug" 45 | CONF_DEBUG_MQTT_MSG = "debug_mqtt_msg" 46 | CONF_DEBUG_API_CALLS = "debug_api_calls" 47 | CONF_UPLOADED_GCODE_FILE = "uploaded_gcode_file" 48 | CONF_FILE_ID = "file_id" 49 | CONF_SPEED_MODE = "speed_mode" 50 | CONF_SPEED = "speed" 51 | CONF_TEMPERATURE = "temperature" 52 | CONF_LAYERS = "layers" 53 | CONF_TIME = "time" 54 | 55 | AC_EVENT_PRINT_CLOUD_START = "print_cloud_start" 56 | 57 | UNIT_LAYERS = "Layers" 58 | 59 | STORAGE_KEY = DOMAIN 60 | STORAGE_VERSION = 1 61 | 62 | API_SETUP_RETRIES = 3 63 | API_SETUP_RETRY_INTERVAL_SECONDS = 10 64 | DEFAULT_SCAN_INTERVAL = 60 65 | MQTT_SCAN_INTERVAL = 15 66 | FAILED_UPDATE_DELAY = DEFAULT_SCAN_INTERVAL * 4 67 | MAX_FAILED_UPDATES = 3 68 | MQTT_IDLE_DISCONNECT_SECONDS = 60 * 15 69 | MQTT_ACTION_RESPONSE_ALIVE_SECONDS = 60 * 5 70 | MQTT_REFRESH_INTERVAL = 60 * 5 71 | MAX_FILE_UPLOAD_RETRIES = 3 72 | PRINT_JOB_STARTED_UPDATE_DELAY = 5 73 | 74 | MAX_DRYING_PRESETS = 4 75 | 76 | 77 | class PrinterEntityType(IntEnum): 78 | GLOBAL = 1 79 | PRINTER = 2 80 | FDM = 3 81 | LCD = 4 82 | ACE_PRIMARY = 5 83 | ACE_SECONDARY = 6 84 | DRY_PRESET_PRIMARY = 7 85 | DRY_PRESET_SECONDARY = 8 86 | 87 | 88 | LOGGER = logging.getLogger(__package__) 89 | 90 | PLATFORMS = [ 91 | Platform.BINARY_SENSOR, 92 | Platform.BUTTON, 93 | Platform.IMAGE, 94 | Platform.SENSOR, 95 | Platform.SWITCH, 96 | Platform.UPDATE, 97 | ] 98 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/printer_card/camera_view/camera_view.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, LitElement, PropertyValues, css, html, nothing } from "lit"; 2 | import { property, state } from "lit/decorators.js"; 3 | import { styleMap } from "lit/directives/style-map.js"; 4 | 5 | import { customElementIfUndef } from "../../../internal/register-custom-element"; 6 | import { buildCameraUrlFromEntity } from "../../../helpers"; 7 | import { HassEntity, LitTemplateResult } from "../../../types"; 8 | 9 | @customElementIfUndef("anycubic-printercard-camera_view") 10 | export class AnycubicPrintercardCameraview extends LitElement { 11 | @property({ attribute: "show-video" }) 12 | public showVideo?: boolean | undefined; 13 | 14 | @property({ attribute: "toggle-video" }) 15 | public toggleVideo?: () => void; 16 | 17 | @property({ attribute: "camera-entity" }) 18 | public cameraEntity: HassEntity | undefined; 19 | 20 | @state() 21 | private camImgString: string = "none"; 22 | 23 | protected willUpdate(changedProperties: PropertyValues): void { 24 | super.willUpdate(changedProperties); 25 | 26 | if ( 27 | changedProperties.has("showVideo") || 28 | changedProperties.has("cameraEntity") 29 | ) { 30 | this.camImgString = 31 | this.showVideo && !!this.cameraEntity 32 | ? `url('${buildCameraUrlFromEntity(this.cameraEntity)}')` 33 | : "none"; 34 | } 35 | } 36 | 37 | render(): LitTemplateResult { 38 | const stylesView = { 39 | display: this.showVideo ? "block" : "none", 40 | }; 41 | return html` 42 |
47 | ${this.showVideo ? this._renderInner() : nothing} 48 |
49 | `; 50 | } 51 | 52 | private _renderInner(): LitTemplateResult { 53 | const stylesCamera = { 54 | "background-image": this.camImgString, 55 | }; 56 | 57 | return html`
`; 61 | } 62 | 63 | private _handleToggleClick = (): void => { 64 | if (this.toggleVideo) { 65 | this.toggleVideo(); 66 | } 67 | }; 68 | 69 | static get styles(): CSSResult { 70 | return css` 71 | :host { 72 | box-sizing: border-box; 73 | display: block; 74 | position: absolute; 75 | top: 0px; 76 | left: 0px; 77 | width: 100%; 78 | height: 100%; 79 | } 80 | 81 | .ac-printercard-cameraview { 82 | background-color: black; 83 | cursor: pointer; 84 | width: 100%; 85 | height: 100%; 86 | } 87 | 88 | .ac-camera-wrapper { 89 | width: 100%; 90 | height: 100%; 91 | position: relative; 92 | background-size: cover; 93 | background-position: center; 94 | } 95 | `; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/printer_card/stats/progress_line.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, LitElement, css, html } from "lit"; 2 | import { property } from "lit/decorators.js"; 3 | import { styleMap } from "lit/directives/style-map.js"; 4 | 5 | import { customElementIfUndef } from "../../../internal/register-custom-element"; 6 | import { LitTemplateResult } from "../../../types"; 7 | 8 | @customElementIfUndef("anycubic-printercard-progress-line") 9 | export class AnycubicPrintercardProgressLine extends LitElement { 10 | @property({ type: String }) 11 | public name: string; 12 | 13 | @property({ type: Number }) 14 | public value: string; 15 | 16 | @property({ type: Number }) 17 | public progress: number; 18 | 19 | render(): LitTemplateResult { 20 | const progressStyle = { 21 | width: String(this.progress) + "%", 22 | }; 23 | return html` 24 |
25 |

${this.name}

26 |
27 |
28 |
${this.value}
29 |
33 |
34 |
35 |
36 | `; 37 | } 38 | 39 | static get styles(): CSSResult { 40 | return css` 41 | :host { 42 | box-sizing: border-box; 43 | width: 100%; 44 | } 45 | 46 | .ac-stat-line { 47 | box-sizing: border-box; 48 | display: flex; 49 | width: 100%; 50 | flex-direction: row; 51 | justify-content: space-between; 52 | align-items: center; 53 | margin: 2px 0; 54 | } 55 | 56 | .ac-stat-value { 57 | margin: 0; 58 | display: inline-block; 59 | max-width: calc(100% - 120px); 60 | width: 100%; 61 | position: relative; 62 | } 63 | 64 | .ac-stat-text { 65 | margin: 0; 66 | font-size: 16px; 67 | display: block; 68 | position: relative; 69 | top: 3px; 70 | left: 0px; 71 | z-index: 1; 72 | text-align: center; 73 | } 74 | 75 | .ac-stat-heading { 76 | margin: 0; 77 | font-size: 16px; 78 | display: block; 79 | font-weight: bold; 80 | } 81 | 82 | .ac-progress-bar { 83 | display: block; 84 | width: 100%; 85 | height: 30px; 86 | background-color: #8b8b8b6e; 87 | position: relative; 88 | } 89 | 90 | .ac-progress-line { 91 | position: absolute; 92 | top: 0px; 93 | left: 0px; 94 | display: block; 95 | height: 100%; 96 | background-color: #ee8f36e6; 97 | border-right: 2px solid #ffd151e6; 98 | box-shadow: 4px 0px 6px 0px rgb(255 245 126 / 25%); 99 | } 100 | `; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/internal/register-custom-element.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 4 | /* 5 | * This was pulled AND MODIFIED from the URL below as 6 | * LitElements does not prevent the same element from 7 | * being registered more than once causing errors. 8 | * https://github.com/lit/lit-element/blob/master/src/lib/decorators.ts 9 | * 10 | * Idea: https://github.com/lit/lit-element/issues/207#issuecomment-1150057355 11 | */ 12 | 13 | interface Constructor { 14 | // tslint:disable-next-line:no-any 15 | new (...args: any[]): T; 16 | } 17 | 18 | // From the TC39 Decorators proposal 19 | interface ClassElement { 20 | kind: "field" | "method"; 21 | key: PropertyKey; 22 | placement: "static" | "prototype" | "own"; 23 | initializer?: Function; 24 | extras?: ClassElement[]; 25 | finisher?: (clazz: Constructor) => undefined | Constructor; 26 | descriptor?: PropertyDescriptor; 27 | } 28 | 29 | // From the TC39 Decorators proposal 30 | interface ClassDescriptor { 31 | kind: "class"; 32 | elements: ClassElement[]; 33 | finisher?: (clazz: Constructor) => undefined | Constructor; 34 | } 35 | 36 | const legacyCustomElement = ( 37 | tagName: string, 38 | clazz: Constructor, 39 | ): any => { 40 | if (window.customElements.get(tagName)) { 41 | return clazz as any; 42 | } 43 | 44 | window.customElements.define(tagName, clazz); 45 | // Cast as any because TS doesn't recognize the return type as being a 46 | // subtype of the decorated class when clazz is typed as 47 | // `Constructor` for some reason. 48 | // `Constructor` is helpful to make sure the decorator is 49 | // applied to elements however. 50 | return clazz as any; 51 | }; 52 | 53 | const standardCustomElement = ( 54 | tagName: string, 55 | descriptor: ClassDescriptor, 56 | ): any => { 57 | const { kind, elements } = descriptor; 58 | return { 59 | kind, 60 | elements, 61 | // This callback is called once the class is otherwise fully defined 62 | finisher(clazz: Constructor): void { 63 | if (window.customElements.get(tagName)) { 64 | return; 65 | } 66 | window.customElements.define(tagName, clazz); 67 | }, 68 | }; 69 | }; 70 | 71 | /** 72 | * Class decorator factory that defines the decorated class as a custom element. 73 | * 74 | * ``` 75 | * @customElement('my-element') 76 | * class MyElement { 77 | * render() { 78 | * return html``; 79 | * } 80 | * } 81 | * ``` 82 | * @category Decorator 83 | * @param tagName The name of the custom element to define. 84 | */ 85 | export const customElementIfUndef = 86 | (tagName: string): any => 87 | (classOrDescriptor: Constructor | ClassDescriptor): any => 88 | typeof classOrDescriptor === "function" 89 | ? legacyCustomElement(tagName, classOrDescriptor) 90 | : standardCustomElement(tagName, classOrDescriptor); 91 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/printer_card/stats/time_stat.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, LitElement, PropertyValues, css, html } from "lit"; 2 | import { property, state } from "lit/decorators.js"; 3 | 4 | import { customElementIfUndef } from "../../../internal/register-custom-element"; 5 | 6 | import { calculateTimeStat, getEntityTotalSeconds } from "../../../helpers"; 7 | import { 8 | CalculatedTimeType, 9 | HassEntity, 10 | LitTemplateResult, 11 | } from "../../../types"; 12 | 13 | import "./stat_line.ts"; 14 | 15 | @customElementIfUndef("anycubic-printercard-stat-time") 16 | export class AnycubicPrintercardStatTime extends LitElement { 17 | @property({ attribute: "time-entity" }) 18 | public timeEntity: HassEntity; 19 | 20 | @property({ attribute: "time-type" }) 21 | public timeType: CalculatedTimeType; 22 | 23 | @property({ type: String }) 24 | public name: string; 25 | 26 | @property({ type: Number }) 27 | public direction: number; 28 | 29 | @property({ type: Boolean }) 30 | public round?: boolean; 31 | 32 | @property({ type: Boolean }) 33 | public use_24hr?: boolean; 34 | 35 | @property({ attribute: "is-seconds", type: Boolean }) 36 | public isSeconds?: boolean; 37 | 38 | @state() 39 | private currentTime: number | string | undefined = 0; 40 | 41 | @state() 42 | private lastIntervalId: number = -1; 43 | 44 | protected override willUpdate(changedProperties: PropertyValues): void { 45 | super.willUpdate(changedProperties); 46 | 47 | if (!changedProperties.has("timeEntity")) { 48 | return; 49 | } 50 | 51 | if (this.lastIntervalId !== -1) { 52 | clearInterval(this.lastIntervalId); 53 | } 54 | 55 | this.currentTime = getEntityTotalSeconds(this.timeEntity); 56 | 57 | this.lastIntervalId = setInterval(() => { 58 | this._incTime(); 59 | }, 1000); 60 | } 61 | 62 | public connectedCallback(): void { 63 | super.connectedCallback(); 64 | if (this.lastIntervalId === -1) { 65 | this.lastIntervalId = setInterval(() => { 66 | this._incTime(); 67 | }, 1000); 68 | } 69 | } 70 | 71 | public disconnectedCallback(): void { 72 | super.disconnectedCallback(); 73 | if (this.lastIntervalId !== -1) { 74 | clearInterval(this.lastIntervalId); 75 | this.lastIntervalId = -1; 76 | } 77 | } 78 | 79 | render(): LitTemplateResult { 80 | return html``; 89 | } 90 | 91 | private _incTime(): void { 92 | if ( 93 | this.currentTime === 0 || 94 | (this.currentTime && !isNaN(this.currentTime as number)) 95 | ) { 96 | this.currentTime = Number(this.currentTime) + this.direction; 97 | } 98 | } 99 | 100 | static get styles(): CSSResult { 101 | return css` 102 | :host { 103 | box-sizing: border-box; 104 | width: 100%; 105 | } 106 | `; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/const/enums.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum, StrEnum 2 | 3 | 4 | class AnycubicFeedType(IntEnum): 5 | Feed = 1 6 | Retract = 2 7 | Finish = 3 8 | 9 | 10 | class AnycubicPrintStatus(IntEnum): 11 | Printing = 1 12 | Complete = 2 13 | Cancelled = 3 14 | Downloading = 4 15 | Checking = 5 16 | Preheating = 6 17 | Slicing = 7 18 | 19 | 20 | class AnycubicOrderID(IntEnum): 21 | START_PRINT = 1 22 | PAUSE_PRINT = 2 23 | RESUME_PRINT = 3 24 | STOP_PRINT = 4 25 | PRINT_SETTINGS = 6 26 | IGNORE = 11 # Not handled 27 | DETECT = 12 # Not handled, appears un-used. 28 | STOP_PRINT_FORCE = 44 29 | LIST_UDISK_FILES = 101 30 | DELETE_UDISK_FILE = 102 31 | LIST_LOCAL_FILES = 103 32 | DELETE_LOCAL_FILE = 104 33 | MOVE_AXLE = 201 # Not handled 34 | MOVE_AXLE_TO_COORDINATES = 202 # Not handled, appears un-used. 35 | START_EXPOSURE = 301 # Not handled, appears un-used. 36 | CANCEL_EXPOSURE = 302 # Not handled, appears un-used. 37 | START_RESIDUAL = 501 # Not handled, appears un-used. 38 | CANCEL_RESIDUAL = 502 # Not handled 39 | SET_DEVICE_SELF_TEST = 601 # Not handled, appears un-used. 40 | GET_DEVICE_SELF_TEST = 602 # Not handled 41 | SET_AUTO_OPERATION = 701 # Not handled, appears un-used. 42 | GET_AUTO_OPERATION = 702 # Not handled 43 | RESET_RELEASE_FILM = 801 # Not handled 44 | GET_RELEASE_FILM = 802 # Not handled 45 | SET_PRINT_STATUS_FREE = 901 # Not handled, appears un-used. 46 | CAMERA_OPEN = 1001 47 | CAMERA_CLOSE = 1002 # Not handled, appears un-used. 48 | MULTI_COLOR_BOX_GET_INFO = 1206 49 | MULTI_COLOR_BOX_DRY = 1207 50 | FEED_FILAMENT = 1208 51 | FEED_FILAMENT_FINISH = 1209 # Not handled, appears un-used. 52 | MULTI_COLOR_BOX_REFRESH_SLOT = 1210 # Not handled 53 | MULTI_COLOR_BOX_SET_SLOT = 1211 54 | MULTI_COLOR_BOX_AUTO_FEED = 1212 55 | MOVE_AXLE_TURN_OFF = 1213 # Not handled 56 | FILAMENT_CONTROL = 1215 # Not handled 57 | FEED_RESIN = 1224 # Not handled 58 | M7_AUTO_OPERATION = 1225 # Not handled 59 | CYCLIC_CLEANING = 1226 # Not handled 60 | SET_AUTO_FEED_INFO = 1227 # Not handled, appears un-used. 61 | GET_M7_AUTO_OPERATION = 1228 # Not handled 62 | EXTFILBOX = 1229 # Not handled 63 | GET_EXTFILBOX_INFO = 1230 # Not handled 64 | QUERY_PERIPHERALS = 1231 65 | GET_LIGHT_STATUS = 1232 66 | SET_LIGHT_STATUS = 1233 67 | 68 | 69 | class AnycubicFunctionID(IntEnum): 70 | AXLE_MOVEMENT = 1 71 | FILE_MANAGER = 2 72 | EXPOSURE_TEST = 3 73 | LCD_PEER_VIDEO = 7 74 | FDM_AXIS_MOVE = 13 75 | FDM_PEER_VIDEO = 22 76 | DEVICE_STARTUP_SELF_TEST = 26 77 | PRINT_STARTUP_SELF_TEST = 27 78 | AUTOMATIC_OPERATION = 28 79 | RESIDUE_CLEAN = 29 80 | NOVICE_GUIDE = 30 81 | RELEASE_FILM = 31 82 | TASK_MODE = 32 83 | LCD_INTELLIGENT_MATERIALS_BOX = 33 84 | LCD_AUTO_OUT_IN_MATERIALS = 34 85 | M7PRO_AUTOMATIC_OPERATION = 35 86 | AI_DETECTION = 36 87 | AUTO_LEVELER = 37 88 | VIBRATION_COMPENSATION = 38 89 | TIME_LAPSE = 39 90 | VIDEO_LIGHT = 40 91 | BOX_LIGHT = 41 92 | MULTI_COLOR_BOX = 2006 93 | 94 | 95 | class AnycubicPrinterMaterialType(StrEnum): 96 | FILAMENT = "Filament" 97 | RESIN = "Resin" 98 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/const/api_endpoints.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ..models.http import HTTP_METHODS, AnycubicAPIEndpoint 4 | 5 | 6 | class API_ENDPOINT: 7 | oauth_token_url = AnycubicAPIEndpoint( 8 | HTTP_METHODS.GET, 9 | '/v3/public/getoauthToken', 10 | ) 11 | auth_sig_token = AnycubicAPIEndpoint( 12 | HTTP_METHODS.POST, 13 | '/v3/public/loginWithAccessToken', 14 | ) 15 | user_store = AnycubicAPIEndpoint( 16 | HTTP_METHODS.POST, 17 | '/work/index/getUserStore', 18 | ) 19 | lock_storage_space = AnycubicAPIEndpoint( 20 | HTTP_METHODS.POST, 21 | '/v2/cloud_storage/lockStorageSpace', 22 | ) 23 | unlock_storage_space = AnycubicAPIEndpoint( 24 | HTTP_METHODS.POST, 25 | '/v2/cloud_storage/unlockStorageSpace', 26 | ) 27 | new_file_upload = AnycubicAPIEndpoint( 28 | HTTP_METHODS.POST, 29 | '/v2/profile/newUploadFile', 30 | ) 31 | delete_cloud_file = AnycubicAPIEndpoint( 32 | HTTP_METHODS.POST, 33 | '/work/index/delFiles', 34 | ) 35 | user_files = AnycubicAPIEndpoint( 36 | HTTP_METHODS.POST, 37 | '/work/index/files', 38 | ) 39 | user_info = AnycubicAPIEndpoint( 40 | HTTP_METHODS.GET, 41 | '/user/profile/userInfo', 42 | ) 43 | printer_status = AnycubicAPIEndpoint( 44 | HTTP_METHODS.GET, 45 | '/v2/Printer/status', 46 | ) 47 | printer_info = AnycubicAPIEndpoint( 48 | HTTP_METHODS.GET, 49 | '/v2/printer/info', 50 | ) 51 | printer_tool = AnycubicAPIEndpoint( 52 | HTTP_METHODS.GET, 53 | '/v2/printer/tool', 54 | ) 55 | printer_functions = AnycubicAPIEndpoint( 56 | HTTP_METHODS.GET, 57 | '/v2/printer/functions', 58 | ) 59 | printer_all = AnycubicAPIEndpoint( 60 | HTTP_METHODS.GET, 61 | '/v2/printer/all', 62 | ) 63 | printers_status = AnycubicAPIEndpoint( 64 | HTTP_METHODS.GET, 65 | '/work/printer/printersStatus', 66 | ) 67 | printer_get_printers = AnycubicAPIEndpoint( 68 | HTTP_METHODS.GET, 69 | '/work/printer/getPrinters', 70 | ) 71 | print_history = AnycubicAPIEndpoint( 72 | HTTP_METHODS.GET, 73 | '/v2/project/printHistory', 74 | ) 75 | project_info = AnycubicAPIEndpoint( 76 | HTTP_METHODS.GET, 77 | '/v2/project/info', 78 | ) 79 | project_monitor = AnycubicAPIEndpoint( 80 | HTTP_METHODS.GET, 81 | '/v2/project/monitor', 82 | ) 83 | project_get_projects = AnycubicAPIEndpoint( 84 | HTTP_METHODS.GET, 85 | '/work/project/getProjects', 86 | ) 87 | project_gcode_info_fdm = AnycubicAPIEndpoint( 88 | HTTP_METHODS.GET, 89 | '/work/gcode/infoFdm', 90 | ) 91 | send_order = AnycubicAPIEndpoint( 92 | HTTP_METHODS.POST, 93 | '/work/operation/sendOrder', 94 | ) 95 | printer_update_name = AnycubicAPIEndpoint( 96 | HTTP_METHODS.POST, 97 | '/work/printer/Info', 98 | ) 99 | printer_firmware_update = AnycubicAPIEndpoint( 100 | HTTP_METHODS.GET, 101 | '/work/printer/update_version', 102 | ) 103 | printer_multi_color_box_firmware_update = AnycubicAPIEndpoint( 104 | HTTP_METHODS.POST, 105 | '/v2/printer/update_multi_color_box_version' 106 | ) 107 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/lib/colorpicker/HueBar.js: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, unsafeCSS } from "lit"; 2 | import { Color } from "modern-color"; 3 | import { styleMap } from "lit/directives/style-map.js"; 4 | import { colorEvent, hueGradient } from "./lib.js"; 5 | 6 | export class HueBar extends LitElement { 7 | static properties = { 8 | hue: { type: Number }, 9 | color: { type: Object }, 10 | gradient: { type: String, attribute: false }, 11 | sliderStyle: { type: String, attribute: false }, 12 | sliderBounds: { type: Object }, 13 | width: { type: Number, attribute: false }, 14 | }; 15 | static styles = css` 16 | :host > div { 17 | display: block; 18 | width: ${unsafeCSS(this.width)}px; 19 | height: 15px; 20 | cursor: pointer; 21 | position: relative; 22 | } 23 | 24 | :host .slider { 25 | position: absolute; 26 | top: -1px; 27 | height: 17px; 28 | width: 8px; 29 | margin-left: -4px; 30 | box-shadow: 31 | 0 0 3px #111, 32 | inset 0 0 2px white; 33 | } 34 | `; 35 | 36 | constructor() { 37 | super(); 38 | this.gradient = { 39 | backgroundImage: `linear-gradient(90deg, ${hueGradient(24)})`, 40 | }; 41 | this.width = 400; 42 | this.sliderStyle = { display: "none" }; 43 | } 44 | 45 | firstUpdated() { 46 | const me = this.renderRoot.querySelector("lit-movable"); 47 | me.onmovestart = () => { 48 | colorEvent(this.renderRoot, { sliding: true }, "sliding-hue"); 49 | }; 50 | me.onmoveend = () => { 51 | colorEvent(this.renderRoot, { sliding: false }, "sliding-hue"); 52 | }; 53 | me.onmove = ({ posLeft }) => this.selectHue({ offsetX: posLeft }); 54 | this.sliderStyle = this.sliderCss(this.hue); 55 | } 56 | 57 | get sliderBounds() { 58 | const r = this.width / 360; 59 | const posLeft = Number(this.hue) * r; 60 | const min = 0 - posLeft; 61 | const max = this.width - posLeft; 62 | return { min, max, posLeft }; 63 | } 64 | get sliderCss() { 65 | return (h) => { 66 | if (this.color.hsx) { 67 | h = this.color.hsx.h; 68 | } 69 | if (h === undefined) { 70 | h = this.color.hsl.h; 71 | } 72 | const color = Color.parse({ h, s: 100, l: 50 }); 73 | 74 | return { backgroundColor: color.css }; 75 | }; 76 | } 77 | 78 | willUpdate(props) { 79 | const h = props.get("hue"); 80 | if (h && isFinite(this.hue)) { 81 | if (this.color?.hsx) { 82 | return; // console.log({hueBarIgnored: this.color.hsx}); 83 | } 84 | const hue = this.hue; 85 | this.sliderStyle = this.sliderCss(hue); 86 | } 87 | } 88 | 89 | selectHue(e) { 90 | const r = 360 / this.width; 91 | const l = e.offsetX; 92 | const h = Math.max(0, Math.min(359, Math.round(l * r))); 93 | const target = this.renderRoot.querySelector("a"); 94 | const event = new CustomEvent("hue-update", { 95 | bubbles: true, 96 | composed: true, 97 | detail: { h }, 98 | }); 99 | 100 | target.dispatchEvent(event); 101 | this.sliderStyle = this.sliderCss(h); 102 | } 103 | 104 | render() { 105 | return html`
110 | 114 | 115 | 116 |
`; 117 | } 118 | } 119 | 120 | if (!customElements.get("hue-bar")) { 121 | customElements.define("hue-bar", HueBar); 122 | } 123 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/data_models/consumable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import UserDict 4 | from typing import Any 5 | 6 | 7 | class AnycubicConsumableData(UserDict[Any, Any]): 8 | __slots__ = ( 9 | "data", 10 | "_consumed_data", 11 | ) 12 | 13 | def __init__( 14 | self, 15 | *args: Any, 16 | **kwargs: Any, 17 | ) -> None: 18 | super().__init__(*args, **kwargs) 19 | self.data: dict[Any, Any] = self._encode_items() 20 | self._consumed_data: dict[Any, Any] = dict() 21 | 22 | @property 23 | def is_empty(self) -> bool: 24 | return len(self.data) == 0 25 | 26 | @property 27 | def remaining_data(self) -> dict[Any, Any]: 28 | return self.data 29 | 30 | def force_empty(self) -> None: 31 | for key in list(self.data.keys()): 32 | self._consumed_data[key] = self.data.pop(key) 33 | 34 | def _encode_list( 35 | self, 36 | data: list[Any], 37 | ) -> list[None | str | int | float | bool | AnycubicConsumableData | list[Any]]: 38 | new_data: list[Any] = list() 39 | for v in data: 40 | if isinstance(v, AnycubicConsumableData): 41 | new_data.append(v) 42 | elif isinstance(v, dict): 43 | new_data.append(AnycubicConsumableData(v)) 44 | elif isinstance(v, list): 45 | new_data.append(self._encode_list(v)) 46 | elif ( 47 | isinstance(v, str) or 48 | isinstance(v, int) or 49 | isinstance(v, float) or 50 | isinstance(v, bool) or 51 | v is None 52 | ): 53 | new_data.append(v) 54 | else: 55 | raise TypeError( 56 | f"Invalid type for AnycubicConsumableData: {type(v)}" 57 | ) 58 | 59 | return new_data 60 | 61 | def _encode_items( 62 | self, 63 | ) -> dict[Any, None | str | int | float | bool | AnycubicConsumableData | list[Any]]: 64 | new_data: dict[Any, Any] = dict() 65 | for k, v in self.data.items(): 66 | if isinstance(v, AnycubicConsumableData): 67 | new_data[k] = v 68 | elif isinstance(v, dict): 69 | new_data[k] = AnycubicConsumableData(v) 70 | elif isinstance(v, list): 71 | new_data[k] = self._encode_list(v) 72 | elif ( 73 | isinstance(v, str) or 74 | isinstance(v, int) or 75 | isinstance(v, float) or 76 | isinstance(v, bool) or 77 | v is None 78 | ): 79 | new_data[k] = v 80 | else: 81 | raise TypeError( 82 | f"Invalid type for AnycubicConsumableData: {type(v)}" 83 | ) 84 | 85 | return new_data 86 | 87 | def __getitem__( 88 | self, 89 | key: Any, 90 | ) -> Any: 91 | try: 92 | return self._consumed_data[key] 93 | except KeyError: 94 | pass 95 | 96 | try: 97 | value = self.data[key] 98 | if ( 99 | not isinstance(value, AnycubicConsumableData) or 100 | value.is_empty 101 | ): 102 | self._consumed_data[key] = self.data.pop(key) 103 | except KeyError: 104 | raise 105 | 106 | return value 107 | 108 | def get( 109 | self, 110 | key: Any, 111 | default: Any = None, 112 | ) -> Any: 113 | try: 114 | return self[key] 115 | except KeyError: 116 | return default 117 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/image.py: -------------------------------------------------------------------------------- 1 | """Support for Anycubic Cloud image.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from datetime import datetime 6 | from typing import TYPE_CHECKING 7 | 8 | from homeassistant.components.image import ( 9 | Image, 10 | ImageEntity, 11 | ImageEntityDescription, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import Platform 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from homeassistant.util import dt as dt_util 18 | 19 | from .const import ( 20 | COORDINATOR, 21 | DOMAIN, 22 | PrinterEntityType, 23 | ) 24 | from .entity import AnycubicCloudEntity, AnycubicCloudEntityDescription 25 | from .helpers import printer_state_for_key 26 | 27 | if TYPE_CHECKING: 28 | from .coordinator import AnycubicCloudDataUpdateCoordinator 29 | 30 | 31 | @dataclass(frozen=True) 32 | class AnycubicImageEntityDescription( 33 | ImageEntityDescription, AnycubicCloudEntityDescription 34 | ): 35 | """Describes Anycubic Cloud image entity.""" 36 | 37 | 38 | IMAGE_TYPES: list[AnycubicImageEntityDescription] = list([ 39 | AnycubicImageEntityDescription( 40 | key="job_image_url", 41 | translation_key="job_image_url", 42 | printer_entity_type=PrinterEntityType.PRINTER, 43 | ), 44 | ]) 45 | 46 | GLOBAL_IMAGE_TYPES: list[AnycubicImageEntityDescription] = list([ 47 | ]) 48 | 49 | 50 | async def async_setup_entry( 51 | hass: HomeAssistant, 52 | entry: ConfigEntry, 53 | async_add_entities: AddEntitiesCallback, 54 | ) -> None: 55 | """Set up the image from a config entry.""" 56 | 57 | coordinator: AnycubicCloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 58 | COORDINATOR 59 | ] 60 | 61 | coordinator.add_entities_for_seen_printers( 62 | async_add_entities=async_add_entities, 63 | entity_constructor=AnycubicCloudImage, 64 | platform=Platform.IMAGE, 65 | available_descriptors=list( 66 | IMAGE_TYPES 67 | + GLOBAL_IMAGE_TYPES 68 | ), 69 | ) 70 | 71 | 72 | class AnycubicCloudImage(AnycubicCloudEntity, ImageEntity): 73 | """An image for Anycubic Cloud.""" 74 | 75 | entity_description: AnycubicImageEntityDescription 76 | 77 | def __init__( 78 | self, 79 | hass: HomeAssistant, 80 | coordinator: AnycubicCloudDataUpdateCoordinator, 81 | printer_id: int, 82 | entity_description: AnycubicImageEntityDescription, 83 | ) -> None: 84 | """Initialize.""" 85 | super().__init__(hass, coordinator, printer_id, entity_description) 86 | ImageEntity.__init__(self, hass) 87 | self._known_image_url = None 88 | 89 | def _reset_cached_image(self) -> None: 90 | self._cached_image = None 91 | self._attr_image_last_updated = dt_util.utcnow() 92 | 93 | def _check_image_url(self) -> None: 94 | image_url = printer_state_for_key(self.coordinator, self._printer_id, self.entity_description.key) 95 | if self._known_image_url != image_url: 96 | self._reset_cached_image() 97 | 98 | self._known_image_url = image_url 99 | 100 | @property 101 | def image_url(self) -> str | None: 102 | return self._known_image_url 103 | 104 | @property 105 | def image_last_updated(self) -> datetime | None: 106 | return self._attr_image_last_updated 107 | 108 | async def _async_load_image_from_url(self, url: str) -> Image | None: 109 | """Load an image by url.""" 110 | if response := await self._fetch_url(url): 111 | return Image( 112 | content=response.content, 113 | content_type="image/png", 114 | ) 115 | return None 116 | 117 | async def async_image(self) -> bytes | None: 118 | """Return bytes of image.""" 119 | 120 | self._check_image_url() 121 | 122 | return await ImageEntity.async_image(self) 123 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/switch.py: -------------------------------------------------------------------------------- 1 | """Switches for Anycubic Cloud.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import EntityCategory, Platform 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | 13 | from .const import ( 14 | COORDINATOR, 15 | DOMAIN, 16 | PrinterEntityType, 17 | ) 18 | from .entity import AnycubicCloudEntity, AnycubicCloudEntityDescription 19 | from .helpers import printer_state_for_key 20 | 21 | if TYPE_CHECKING: 22 | from .coordinator import AnycubicCloudDataUpdateCoordinator 23 | 24 | 25 | @dataclass(frozen=True) 26 | class AnycubicSwitchEntityDescription( 27 | SwitchEntityDescription, AnycubicCloudEntityDescription 28 | ): 29 | """Describes Anycubic Cloud switch entity.""" 30 | 31 | 32 | PRIMARY_MULTI_COLOR_BOX_SWITCH_TYPES: list[AnycubicSwitchEntityDescription] = list([ 33 | AnycubicSwitchEntityDescription( 34 | key="multi_color_box_runout_refill", 35 | translation_key="multi_color_box_runout_refill", 36 | printer_entity_type=PrinterEntityType.ACE_PRIMARY, 37 | ), 38 | ]) 39 | 40 | SECONDARY_MULTI_COLOR_BOX_SWITCH_TYPES: list[AnycubicSwitchEntityDescription] = list([ 41 | AnycubicSwitchEntityDescription( 42 | key="secondary_multi_color_box_runout_refill", 43 | translation_key="secondary_multi_color_box_runout_refill", 44 | printer_entity_type=PrinterEntityType.ACE_SECONDARY, 45 | ), 46 | ]) 47 | 48 | SWITCH_TYPES: list[AnycubicSwitchEntityDescription] = list([ 49 | ]) 50 | 51 | GLOBAL_SWITCH_TYPES: list[AnycubicSwitchEntityDescription] = list([ 52 | AnycubicSwitchEntityDescription( 53 | key="manual_mqtt_connection_enabled", 54 | translation_key="manual_mqtt_connection_enabled", 55 | entity_category=EntityCategory.DIAGNOSTIC, 56 | printer_entity_type=PrinterEntityType.GLOBAL, 57 | ), 58 | ]) 59 | 60 | 61 | async def async_setup_entry( 62 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 63 | ) -> None: 64 | """Set up the Anycubic Cloud switch entry.""" 65 | 66 | coordinator: AnycubicCloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 67 | COORDINATOR 68 | ] 69 | coordinator.add_entities_for_seen_printers( 70 | async_add_entities=async_add_entities, 71 | entity_constructor=AnycubicSwitch, 72 | platform=Platform.SWITCH, 73 | available_descriptors=list( 74 | SWITCH_TYPES 75 | + PRIMARY_MULTI_COLOR_BOX_SWITCH_TYPES 76 | + SECONDARY_MULTI_COLOR_BOX_SWITCH_TYPES 77 | + GLOBAL_SWITCH_TYPES 78 | ), 79 | ) 80 | 81 | 82 | class AnycubicSwitch(AnycubicCloudEntity, SwitchEntity): 83 | """Representation of a Anycubic switch.""" 84 | 85 | entity_description: AnycubicSwitchEntityDescription 86 | 87 | def __init__( 88 | self, 89 | hass: HomeAssistant, 90 | coordinator: AnycubicCloudDataUpdateCoordinator, 91 | printer_id: int, 92 | entity_description: AnycubicSwitchEntityDescription, 93 | ) -> None: 94 | """Initiate Anycubic Switch.""" 95 | super().__init__(hass, coordinator, printer_id, entity_description) 96 | 97 | @property 98 | def is_on(self) -> bool: 99 | """Return true if the switch is on.""" 100 | return bool( 101 | printer_state_for_key(self.coordinator, self._printer_id, self.entity_description.key) 102 | ) 103 | 104 | async def async_turn_on(self, **kwargs: Any) -> None: 105 | """Turn the device on.""" 106 | 107 | await self.coordinator.switch_on_event(self._printer_id, self.entity_description.key) 108 | 109 | async def async_turn_off(self, **kwargs: Any) -> None: 110 | """Turn the device off.""" 111 | 112 | await self.coordinator.switch_off_event(self._printer_id, self.entity_description.key) 113 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/data_models/gcode_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import UserDict 4 | from typing import Any 5 | 6 | from aiofiles import open as aio_file_open 7 | 8 | from ..exceptions.error_strings import ErrorsGcodeParsing 9 | from ..exceptions.exceptions import AnycubicGcodeParsingError 10 | from ..helpers.helpers import ( 11 | GCODE_STRING_FIRST_ATTR_LINE, 12 | REX_GCODE_DATA_KEY_VALUE, 13 | gcode_key_value_pair_to_dict, 14 | ) 15 | 16 | 17 | class AnycubicGcodeFile(UserDict[str, Any]): 18 | __slots__ = ( 19 | "_material_list", 20 | ) 21 | 22 | def __init__( 23 | self, 24 | *args: Any, 25 | **kwargs: Any, 26 | ) -> None: 27 | super().__init__(*args, **kwargs) 28 | self._material_list: list[dict[str, Any]] | None = None 29 | 30 | try: 31 | self.material_list 32 | except AnycubicGcodeParsingError: 33 | pass 34 | 35 | @classmethod 36 | async def async_read_from_file( 37 | cls, 38 | full_file_path: str | None = None, 39 | file_bytes: bytes | None = None, 40 | ) -> AnycubicGcodeFile: 41 | data_found = False 42 | file_lines = list() 43 | slicer_data = dict() 44 | 45 | if full_file_path is not None: 46 | try: 47 | async with aio_file_open(full_file_path, mode='r') as f: 48 | file_lines = await f.readlines() 49 | except Exception as error: 50 | raise AnycubicGcodeParsingError(ErrorsGcodeParsing.read_fail.format(error)) 51 | elif file_bytes is not None: 52 | try: 53 | file_lines = file_bytes.decode('utf-8').split('\n') 54 | except Exception as error: 55 | raise AnycubicGcodeParsingError(ErrorsGcodeParsing.byte_decode_fail.format(error)) 56 | else: 57 | raise AnycubicGcodeParsingError(ErrorsGcodeParsing.invalid_path_and_bytes) 58 | 59 | for line in file_lines: 60 | if not data_found and line.startswith(GCODE_STRING_FIRST_ATTR_LINE): 61 | data_found = True 62 | if not data_found: 63 | continue 64 | try: 65 | slicer_data.update(gcode_key_value_pair_to_dict(REX_GCODE_DATA_KEY_VALUE, line)) 66 | except Exception as error: 67 | raise AnycubicGcodeParsingError(ErrorsGcodeParsing.parse_meta_fail.format(error)) 68 | 69 | return cls(slicer_data) 70 | 71 | @property 72 | def material_list(self) -> list[dict[str, Any]]: 73 | if self._material_list is not None: 74 | return self._material_list 75 | 76 | filament_used_g = self.data.get('filament_used_g') 77 | filament_used_mm = self.data.get('filament_used_mm') 78 | filament_used_cm3 = self.data.get('filament_used_cm3') 79 | ams_data = self.data.get('paint_info') 80 | 81 | if not ams_data: 82 | raise AnycubicGcodeParsingError(ErrorsGcodeParsing.empty_paint_info) 83 | 84 | if not filament_used_g or len(filament_used_g) < 1: 85 | raise AnycubicGcodeParsingError(ErrorsGcodeParsing.empty_used_filament) 86 | 87 | if len(filament_used_g) < len(ams_data): 88 | raise AnycubicGcodeParsingError(ErrorsGcodeParsing.invalid_used_filament) 89 | 90 | if not filament_used_mm or len(filament_used_mm) < len(ams_data): 91 | filament_used_mm = list([None for x in range(len(ams_data))]) 92 | 93 | if not filament_used_cm3 or len(filament_used_cm3) < len(ams_data): 94 | filament_used_cm3 = list([None for x in range(len(ams_data))]) 95 | 96 | self._material_list = list([ 97 | { 98 | **paint_info, 99 | 'filament_used': filament_used_g[paint_info['paint_index']], 100 | 'filament_used_mm': filament_used_mm[paint_info['paint_index']], 101 | 'filament_used_cm3': filament_used_cm3[paint_info['paint_index']], 102 | } for paint_info in ams_data 103 | ]) 104 | 105 | self.data['material_list'] = self._material_list 106 | 107 | return self._material_list 108 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended-type-checked", 6 | "plugin:@typescript-eslint/strict-type-checked", 7 | "prettier", 8 | "plugin:prettier/recommended", 9 | "plugin:lit/recommended", 10 | "plugin:import/recommended", 11 | ], 12 | plugins: ["prettier", "lit", "import"], 13 | parserOptions: { 14 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 15 | sourceType: "module", // Allows for the use of imports 16 | experimentalDecorators: true, 17 | emitDecoratorMetadata: true, 18 | projectService: true, 19 | tsconfigRootDir: __dirname, 20 | project: "./tsconfig.json", 21 | }, 22 | settings: { 23 | "import/resolver": { 24 | "typescript": { 25 | "alwaysTryTypes": true, 26 | "project": "./tsconfig.json" 27 | } 28 | }, 29 | "import/parsers": { 30 | "@typescript-eslint/parser": [".ts", ".tsx"] 31 | } 32 | }, 33 | rules: { 34 | "@typescript-eslint/array-type": "error", 35 | "@typescript-eslint/camelcase": 0, 36 | "@typescript-eslint/consistent-generic-constructors": "error", 37 | "@typescript-eslint/consistent-type-exports": "error", 38 | "@typescript-eslint/explicit-function-return-type": "error", 39 | "@typescript-eslint/explicit-module-boundary-types": "error", 40 | "@typescript-eslint/no-confusing-non-null-assertion": "error", 41 | "@typescript-eslint/no-dupe-class-members": "error", 42 | "@typescript-eslint/no-shadow": "error", 43 | "@typescript-eslint/no-unnecessary-parameter-property-assignment": "error", 44 | "@typescript-eslint/no-unused-expressions": "error", 45 | "@typescript-eslint/no-unused-vars": [ 46 | "error", 47 | { 48 | "argsIgnorePattern": "^_" 49 | } 50 | ], 51 | "@typescript-eslint/no-use-before-define": "error", 52 | "@typescript-eslint/parameter-properties": "error", 53 | "@typescript-eslint/restrict-template-expressions": [ 54 | "error", 55 | { 56 | "allowNumber": true 57 | } 58 | ], 59 | "@typescript-eslint/typedef": "error", 60 | "curly": "error", 61 | "eqeqeq": "error", 62 | "lit/attribute-names": "warn", 63 | "lit/ban-attributes": "error", 64 | "lit/lifecycle-super": "error", 65 | "lit/no-classfield-shadowing": "error", 66 | "lit/no-invalid-escape-sequences": "error", 67 | "lit/no-legacy-imports": "error", 68 | "lit/no-native-attributes": "error", 69 | "lit/no-private-properties": "error", 70 | "lit/no-template-arrow": "error", 71 | "lit/no-template-bind": "error", 72 | "lit/no-template-map": "error", 73 | "lit/no-this-assign-in-render": "error", 74 | "lit/no-useless-template-literals": "error", 75 | "lit/no-value-attribute": "error", 76 | "lit/prefer-nothing": "error", 77 | "lit/prefer-static-styles": "error", 78 | "lit/quoted-expressions": "error", 79 | "lit/value-after-constraints": "error", 80 | "import/order": [ 81 | "error", 82 | { 83 | "groups": [ 84 | "external", 85 | "builtin", 86 | "internal", 87 | "sibling", 88 | "parent", 89 | "index" 90 | ] 91 | } 92 | ], 93 | "no-console": "warn", 94 | "no-duplicate-imports": "error", 95 | "no-empty-function": "warn", 96 | "no-undef": "error", 97 | "no-unneeded-ternary": "warn", 98 | "no-var": "error", 99 | "operator-assignment": "warn", 100 | "prefer-const": "error", 101 | "sort-imports": [ 102 | "error", 103 | { 104 | "ignoreCase": false, 105 | "ignoreDeclarationSort": true, 106 | "ignoreMemberSort": false 107 | } 108 | ] 109 | }, 110 | overrides: [ 111 | { 112 | files: [ 113 | "*.ts", 114 | "**/*.ts" 115 | ], 116 | excludedFiles: [ 117 | "./src/lib", 118 | "./src/lib/**/*.js", 119 | ] 120 | }, 121 | ], 122 | globals: { 123 | customElements: "writable", 124 | document: "writable", 125 | history: "writable", 126 | window: "writable", 127 | clearInterval: "readonly", 128 | setInterval: "readonly", 129 | clearTimeout: "readonly", 130 | setTimeout: "readonly", 131 | CustomEvent: "readonly", 132 | HTMLElement: "readonly", 133 | Window: "readonly", 134 | Event: "readonly", 135 | FillMode: "readonly", 136 | scrollTo: "readonly" 137 | } 138 | }; -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/print/view-print-base.ts: -------------------------------------------------------------------------------- 1 | import { mdiPlay } from "@mdi/js"; 2 | import { CSSResult, LitElement, PropertyValues, css, html, nothing } from "lit"; 3 | import { property, state } from "lit/decorators.js"; 4 | 5 | import { commonPrintStyle } from "./styles"; 6 | import { localize } from "../../../localize/localize"; 7 | 8 | import { platform } from "../../const"; 9 | import { HASSDomEvent } from "../../fire_event"; 10 | import { fireHaptic } from "../../fire_haptic"; 11 | import { loadHaServiceControl } from "../../load-ha-elements"; 12 | import { 13 | FormChangeDetail, 14 | HassDevice, 15 | HassPanel, 16 | HassProgressButton, 17 | HassRoute, 18 | HassServiceError, 19 | HomeAssistant, 20 | LitTemplateResult, 21 | } from "../../types"; 22 | 23 | export class AnycubicViewPrintBase extends LitElement { 24 | @property({ attribute: false }) public hass!: HomeAssistant; 25 | 26 | @property() 27 | public language!: string; 28 | 29 | @property({ type: Boolean, reflect: true }) 30 | public narrow!: boolean; 31 | 32 | @property() 33 | public route!: HassRoute; 34 | 35 | @property() 36 | public panel!: HassPanel; 37 | 38 | @property({ attribute: "selected-printer-id" }) 39 | public selectedPrinterID: string | undefined; 40 | 41 | @property({ attribute: "selected-printer-device" }) 42 | public selectedPrinterDevice: HassDevice | undefined; 43 | 44 | @state() private _scriptData: Record< 45 | string, 46 | string | Record | undefined 47 | > = {}; 48 | 49 | @state() 50 | private _error: string | undefined; 51 | 52 | @state() 53 | protected _serviceName: string = ""; 54 | 55 | @state() 56 | private _buttonPrint: string; 57 | 58 | @state() 59 | private _buttonProgress: boolean = false; 60 | 61 | async firstUpdated(): Promise { 62 | await loadHaServiceControl(); 63 | } 64 | 65 | protected override willUpdate(changedProperties: PropertyValues): void { 66 | super.willUpdate(changedProperties); 67 | 68 | if (changedProperties.has("language")) { 69 | this._buttonPrint = localize("common.actions.print", this.language); 70 | } 71 | 72 | if (changedProperties.has("selectedPrinterDevice")) { 73 | if (this.selectedPrinterDevice) { 74 | const srvName = `${platform}.${this._serviceName}`; 75 | this._scriptData = { 76 | ...this._scriptData, 77 | action: srvName, 78 | service: srvName, 79 | data: { 80 | ...((this._scriptData.data as object | undefined) || 81 | ({} as object)), 82 | config_entry: this.selectedPrinterDevice.primary_config_entry, 83 | device_id: this.selectedPrinterDevice.id, 84 | }, 85 | }; 86 | } 87 | } 88 | } 89 | 90 | render(): LitTemplateResult { 91 | return html` 92 | 93 | 101 | ${this._error !== undefined 102 | ? html`${this._error}` 103 | : nothing} 104 | 110 | 111 | ${this._buttonPrint} 112 | 113 | 114 | `; 115 | } 116 | 117 | private _scriptDataChanged = (ev: HASSDomEvent): void => { 118 | this._scriptData = { ...this._scriptData, ...ev.detail.value }; 119 | this._error = undefined; 120 | }; 121 | 122 | private _runScript = (ev: Event): void => { 123 | const button = ev.currentTarget as unknown as HassProgressButton; 124 | this._error = undefined; 125 | ev.stopPropagation(); 126 | this._buttonProgress = true; 127 | fireHaptic(); 128 | this.hass 129 | .callService(platform, this._serviceName, this._scriptData.data as object) 130 | .then(() => { 131 | button.actionSuccess(); 132 | this._buttonProgress = false; 133 | }) 134 | .catch((e: unknown) => { 135 | this._error = (e as HassServiceError).message; 136 | button.actionError(); 137 | this._buttonProgress = false; 138 | }); 139 | }; 140 | 141 | static get styles(): CSSResult { 142 | return css` 143 | ${commonPrintStyle} 144 | `; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/update.py: -------------------------------------------------------------------------------- 1 | """Platform for update integration.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from homeassistant.components.update import ( 8 | UpdateDeviceClass, 9 | UpdateEntity, 10 | UpdateEntityDescription, 11 | UpdateEntityFeature, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import EntityCategory, Platform 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | 18 | from .const import ( 19 | COORDINATOR, 20 | DOMAIN, 21 | PrinterEntityType, 22 | ) 23 | from .entity import AnycubicCloudEntity, AnycubicCloudEntityDescription 24 | from .helpers import printer_attributes_for_key, printer_state_for_key 25 | 26 | if TYPE_CHECKING: 27 | from .coordinator import AnycubicCloudDataUpdateCoordinator 28 | 29 | 30 | @dataclass(frozen=True) 31 | class AnycubicUpdateEntityDescription( 32 | UpdateEntityDescription, AnycubicCloudEntityDescription 33 | ): 34 | """Describes Anycubic Cloud update entity.""" 35 | 36 | 37 | PRIMARY_MULTI_COLOR_BOX_UPDATE_TYPES: list[AnycubicUpdateEntityDescription] = list([ 38 | AnycubicUpdateEntityDescription( 39 | key="multi_color_box_fw_version", 40 | translation_key="multi_color_box_fw_version", 41 | device_class=UpdateDeviceClass.FIRMWARE, 42 | entity_category=EntityCategory.CONFIG, 43 | printer_entity_type=PrinterEntityType.ACE_PRIMARY, 44 | ), 45 | ]) 46 | 47 | SECONDARY_MULTI_COLOR_BOX_UPDATE_TYPES: list[AnycubicUpdateEntityDescription] = list([ 48 | AnycubicUpdateEntityDescription( 49 | key="secondary_multi_color_box_fw_version", 50 | translation_key="secondary_multi_color_box_fw_version", 51 | device_class=UpdateDeviceClass.FIRMWARE, 52 | entity_category=EntityCategory.CONFIG, 53 | printer_entity_type=PrinterEntityType.ACE_SECONDARY, 54 | ), 55 | ]) 56 | 57 | UPDATE_TYPES: list[AnycubicUpdateEntityDescription] = list([ 58 | AnycubicUpdateEntityDescription( 59 | key="fw_version", 60 | translation_key="fw_version", 61 | device_class=UpdateDeviceClass.FIRMWARE, 62 | entity_category=EntityCategory.CONFIG, 63 | printer_entity_type=PrinterEntityType.PRINTER, 64 | ), 65 | ]) 66 | 67 | GLOBAL_UPDATE_TYPES: list[AnycubicUpdateEntityDescription] = list([ 68 | ]) 69 | 70 | 71 | async def async_setup_entry( 72 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 73 | ) -> None: 74 | """Set up the Anycubic Cloud sensor entry.""" 75 | 76 | coordinator: AnycubicCloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 77 | COORDINATOR 78 | ] 79 | coordinator.add_entities_for_seen_printers( 80 | async_add_entities=async_add_entities, 81 | entity_constructor=AnycubicUpdateEntity, 82 | platform=Platform.UPDATE, 83 | available_descriptors=list( 84 | UPDATE_TYPES 85 | + PRIMARY_MULTI_COLOR_BOX_UPDATE_TYPES 86 | + SECONDARY_MULTI_COLOR_BOX_UPDATE_TYPES 87 | + GLOBAL_UPDATE_TYPES 88 | ), 89 | ) 90 | 91 | 92 | class AnycubicUpdateEntity(AnycubicCloudEntity, UpdateEntity): 93 | """Representation of a Anycubic Cloud sensor.""" 94 | 95 | entity_description: AnycubicUpdateEntityDescription 96 | _attr_supported_features = ( 97 | UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS 98 | ) 99 | 100 | def __init__( 101 | self, 102 | hass: HomeAssistant, 103 | coordinator: AnycubicCloudDataUpdateCoordinator, 104 | printer_id: int, 105 | entity_description: AnycubicUpdateEntityDescription, 106 | ) -> None: 107 | """Initiate Anycubic Sensor.""" 108 | super().__init__(hass, coordinator, printer_id, entity_description) 109 | 110 | @property 111 | def installed_version(self) -> str: 112 | """Version currently in use.""" 113 | return str(printer_state_for_key(self.coordinator, self._printer_id, self.entity_description.key)) 114 | 115 | @property 116 | def latest_version(self) -> str: 117 | """Latest version available for install.""" 118 | fw_attr = printer_attributes_for_key(self.coordinator, self._printer_id, self.entity_description.key) 119 | return str(fw_attr['latest_version']) if fw_attr else "error" 120 | 121 | @property 122 | def in_progress(self) -> bool: 123 | """Update installation in progress.""" 124 | fw_attr = printer_attributes_for_key(self.coordinator, self._printer_id, self.entity_description.key) 125 | return bool(fw_attr['in_progress']) if fw_attr else False 126 | 127 | async def async_install( 128 | self, version: str | None, backup: bool, **kwargs: Any 129 | ) -> None: 130 | await self.coordinator.fw_update_event(self._printer_id, self.entity_description.key) 131 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/views/files/view-files_base.ts: -------------------------------------------------------------------------------- 1 | import { CSSResult, LitElement, PropertyValues, css, html, nothing } from "lit"; 2 | import { property, state } from "lit/decorators.js"; 3 | 4 | import { commonFilesStyle } from "./styles"; 5 | import { localize } from "../../../localize/localize"; 6 | import { 7 | getPrinterEntities, 8 | getPrinterEntityIdPart, 9 | getPrinterSupportsMQTT, 10 | } from "../../helpers"; 11 | import { 12 | AnycubicFileLocal, 13 | DomClickEvent, 14 | EvtTargFileInfo, 15 | HassDevice, 16 | HassEntityInfo, 17 | HassEntityInfos, 18 | HassPanel, 19 | HassRoute, 20 | HomeAssistant, 21 | LitTemplateResult, 22 | } from "../../types"; 23 | 24 | export class AnycubicViewFilesBase extends LitElement { 25 | @property() 26 | public hass!: HomeAssistant; 27 | 28 | @property() 29 | public language!: string; 30 | 31 | @property({ type: Boolean, reflect: true }) 32 | public narrow!: boolean; 33 | 34 | @property() 35 | public route!: HassRoute; 36 | 37 | @property() 38 | public panel!: HassPanel; 39 | 40 | @property({ attribute: "selected-printer-id" }) 41 | public selectedPrinterID: string | undefined; 42 | 43 | @property({ attribute: "selected-printer-device" }) 44 | public selectedPrinterDevice: HassDevice | undefined; 45 | 46 | @state() 47 | protected printerEntities: HassEntityInfos; 48 | 49 | @state() 50 | private printerEntityIdPart: string | undefined; 51 | 52 | @state() 53 | protected _fileArray: AnycubicFileLocal[] | undefined; 54 | 55 | @state() 56 | protected _listRefreshEntity: HassEntityInfo | undefined; 57 | 58 | @state() 59 | private _isRefreshing: boolean = false; 60 | 61 | @state() 62 | protected _isDeleting: boolean; 63 | 64 | @state() 65 | private _noMqttMessage: string; 66 | 67 | @state() 68 | private _supportsMQTT: boolean = false; 69 | 70 | @state() 71 | protected _httpResponse: boolean = false; 72 | 73 | protected willUpdate(changedProperties: PropertyValues): void { 74 | super.willUpdate(changedProperties); 75 | 76 | if (changedProperties.has("language")) { 77 | this._noMqttMessage = localize( 78 | "common.messages.mqtt_unsupported", 79 | this.language, 80 | ); 81 | } 82 | 83 | if ( 84 | changedProperties.has("hass") || 85 | changedProperties.has("selectedPrinterID") 86 | ) { 87 | this.printerEntities = getPrinterEntities( 88 | this.hass, 89 | this.selectedPrinterID, 90 | ); 91 | this.printerEntityIdPart = getPrinterEntityIdPart(this.printerEntities); 92 | this._supportsMQTT = getPrinterSupportsMQTT( 93 | this.hass, 94 | this.printerEntities, 95 | this.printerEntityIdPart, 96 | ); 97 | } 98 | } 99 | 100 | render(): LitTemplateResult { 101 | return html` 102 |
103 | 114 | ${ 115 | !this._httpResponse && !this._supportsMQTT 116 | ? html`
${this._noMqttMessage}
` 117 | : nothing 118 | } 119 |
    120 | ${ 121 | this._fileArray 122 | ? this._fileArray.map( 123 | (fileInfo) => html` 124 |
  • 125 |
    ${fileInfo.name}
    126 | 137 |
  • 138 | `, 139 | ) 140 | : null 141 | } 142 |
143 | `; 144 | } 145 | 146 | refreshList = (): void => { 147 | if (this._listRefreshEntity) { 148 | this._isRefreshing = true; 149 | this.hass 150 | .callService("button", "press", { 151 | entity_id: this._listRefreshEntity.entity_id, 152 | }) 153 | .then(() => { 154 | this._isRefreshing = false; 155 | }) 156 | .catch((_e: unknown) => { 157 | this._isRefreshing = false; 158 | }); 159 | } 160 | }; 161 | 162 | // eslint-disable-next-line no-empty-function 163 | deleteFile = (_ev: DomClickEvent): void => {}; 164 | 165 | static get styles(): CSSResult { 166 | return css` 167 | ${commonFilesStyle} 168 | `; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/printer_card/printer_view/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AnimatedPrinterBasicDimension, 3 | AnimatedPrinterConfig, 4 | AnimatedPrinterDimensions, 5 | } from "../../../types"; 6 | 7 | class Scale { 8 | scale_factor: number; 9 | 10 | constructor(scale_factor: number) { 11 | this.scale_factor = scale_factor; 12 | } 13 | 14 | val(value): number { 15 | return this.scale_factor * value; 16 | } 17 | 18 | og(value): number { 19 | return value / this.scale_factor; 20 | } 21 | 22 | scaleFactor(): number { 23 | return this.scale_factor; 24 | } 25 | } 26 | 27 | export function getDimensions( 28 | config: AnimatedPrinterConfig, 29 | bounds: AnimatedPrinterBasicDimension, 30 | haScaleFactor: number, 31 | ): AnimatedPrinterDimensions { 32 | /* We estimate the initial scale factor based on the height + width of the frame, then compound with set factor */ 33 | const scaledBoundsHeight = 34 | bounds.height / 35 | (config.top.height + config.bottom.height + config.left.height); 36 | 37 | const scaledBoundsWidth = 38 | bounds.width / (config.top.width + config.left.width + config.right.width); 39 | 40 | const scale = new Scale( 41 | Math.min(scaledBoundsHeight, scaledBoundsWidth) * haScaleFactor, 42 | ); 43 | 44 | /* Frame */ 45 | const F_W = scale.val(config.top.width); // Width 46 | const F_H = scale.val( 47 | config.top.height + config.bottom.height + config.left.height, 48 | ); // Height 49 | 50 | /* Scalable */ 51 | // const S_ML = (bounds.width - F_W) / 2; // Margin Left 52 | // const S_MT = (bounds.height - F_H) / 2; // Margin Top 53 | 54 | /* Hole */ 55 | const H_W = scale.val( 56 | config.top.width - (config.left.width + config.right.width), 57 | ); // Width 58 | const H_H = scale.val(config.left.height); // Height 59 | const H_L = scale.val(config.left.width); // Left 60 | const H_T = scale.val(config.top.height); // Top 61 | 62 | /* Basis */ 63 | const BASIS_Y = 64 | scale.val(config.top.height - config.buildplate.verticalOffset) + H_H; 65 | const BASIS_X = 66 | BASIS_Y + 67 | scale.val( 68 | (config.xAxis.extruder.height - config.xAxis.height) / 2 - 69 | (config.xAxis.extruder.height + 12), 70 | ); 71 | 72 | /* Build Area */ 73 | const B_W = scale.val(config.buildplate.maxWidth); // Width 74 | const B_H = scale.val(config.buildplate.maxHeight); // Height 75 | const B_L = scale.val( 76 | config.left.width + (scale.og(H_W) - config.buildplate.maxWidth) / 2, 77 | ); // Left 78 | const B_T = BASIS_Y - scale.val(config.buildplate.maxHeight); // Top 79 | 80 | /* Build Plate */ 81 | const P_W = B_W; // Width 82 | const P_L = B_L; // Left 83 | const P_T = BASIS_Y; // Top 84 | 85 | /* X Axis */ 86 | const X_W = scale.val(config.xAxis.width); 87 | const X_H = scale.val(config.xAxis.height); 88 | const X_L = scale.val(config.xAxis.offsetLeft); 89 | 90 | /* Track */ 91 | const T_W = X_W; 92 | const T_H = X_H; 93 | 94 | /* Extruder */ 95 | const E_W = scale.val(config.xAxis.extruder.width); 96 | const E_H = scale.val(config.xAxis.extruder.height); 97 | const E_L = P_L - E_W / 2; 98 | const E_M = E_L + B_W; 99 | 100 | /* Nozzle */ 101 | const N_W = scale.val(12); 102 | const N_H = scale.val(12); 103 | const N_L = (E_W - N_W) / 2; 104 | const N_T = E_H; 105 | 106 | const E_T = P_T - E_H - N_H; 107 | const X_T = E_T + E_H * 0.7 - X_H / 2; 108 | 109 | return { 110 | Scalable: { 111 | width: F_W, 112 | height: F_H, 113 | }, 114 | Frame: { 115 | width: F_W, 116 | height: F_H, 117 | }, 118 | Hole: { 119 | width: H_W, 120 | height: H_H, 121 | left: H_L, 122 | top: H_T, 123 | }, 124 | BuildArea: { 125 | width: B_W, 126 | height: B_H, 127 | left: B_L, 128 | top: B_T, 129 | }, 130 | BuildPlate: { 131 | width: P_W, 132 | left: P_L, 133 | top: P_T, 134 | }, 135 | XAxis: { 136 | width: X_W, 137 | height: X_H, 138 | left: X_L, 139 | top: X_T, 140 | }, 141 | Track: { 142 | width: T_W, 143 | height: T_H, 144 | }, 145 | Basis: { 146 | Y: BASIS_Y, 147 | X: BASIS_X, 148 | }, 149 | Gantry: { 150 | width: E_W, 151 | height: E_H, 152 | left: E_L, 153 | top: E_T, 154 | }, 155 | Nozzle: { 156 | width: N_W, 157 | height: N_H, 158 | left: N_L, 159 | top: N_T, 160 | }, 161 | GantryMaxLeft: E_M, 162 | }; 163 | } 164 | 165 | export const printerConfigAnycubic: AnimatedPrinterConfig = { 166 | top: { 167 | width: 340, 168 | height: 20, 169 | }, 170 | bottom: { 171 | width: 340, 172 | height: 52.3, 173 | }, 174 | left: { 175 | width: 30, 176 | height: 400, 177 | }, 178 | right: { 179 | width: 30, 180 | height: 380, 181 | }, 182 | 183 | buildplate: { 184 | maxWidth: 250, 185 | maxHeight: 260, 186 | verticalOffset: 55, 187 | }, 188 | 189 | xAxis: { 190 | stepper: true, 191 | width: 400, 192 | offsetLeft: -30, 193 | height: 30, 194 | extruder: { 195 | width: 60, 196 | height: 100, 197 | }, 198 | }, 199 | }; 200 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/data_models/printing_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class AnycubicPrintingSettings: 5 | __slots__ = ( 6 | "_print_speed_mode", 7 | "_target_nozzle_temp", 8 | "_target_hotbed_temp", 9 | "_fan_speed_pct", 10 | "_aux_fan_speed_pct", 11 | "_box_fan_level", 12 | "_bottom_layers", 13 | "_bottom_time", 14 | "_off_time", 15 | "_on_time", 16 | ) 17 | 18 | def __init__( 19 | self, 20 | print_speed_mode: int | None = None, 21 | target_nozzle_temp: int | None = None, 22 | target_hotbed_temp: int | None = None, 23 | fan_speed_pct: int | None = None, 24 | aux_fan_speed_pct: int | None = None, 25 | box_fan_level: int | None = None, 26 | bottom_layers: int | None = None, 27 | bottom_time: float | int | None = None, 28 | off_time: float | int | None = None, 29 | on_time: float | int | None = None, 30 | ): 31 | self._print_speed_mode: int | None = int(print_speed_mode) if print_speed_mode is not None else None 32 | self._target_nozzle_temp: int | None = int(target_nozzle_temp) if target_nozzle_temp is not None else None 33 | self._target_hotbed_temp: int | None = int(target_hotbed_temp) if target_hotbed_temp is not None else None 34 | self._fan_speed_pct: int | None = int(fan_speed_pct) if fan_speed_pct is not None else None 35 | self._aux_fan_speed_pct: int | None = int(aux_fan_speed_pct) if aux_fan_speed_pct is not None else None 36 | self._box_fan_level: int | None = int(box_fan_level) if box_fan_level is not None else None 37 | self._bottom_layers: int | None = int(bottom_layers) if bottom_layers is not None else None 38 | self._bottom_time: float | None = float(bottom_time) if bottom_time is not None else None 39 | self._off_time: float | None = float(off_time) if off_time is not None else None 40 | self._on_time: float | None = float(on_time) if on_time is not None else None 41 | 42 | @property 43 | def print_speed_mode(self) -> int | None: 44 | return self._print_speed_mode 45 | 46 | @property 47 | def target_nozzle_temp(self) -> int | None: 48 | return self._target_nozzle_temp 49 | 50 | @property 51 | def target_hotbed_temp(self) -> int | None: 52 | return self._target_hotbed_temp 53 | 54 | @property 55 | def fan_speed_pct(self) -> int | None: 56 | return self._fan_speed_pct 57 | 58 | @property 59 | def aux_fan_speed_pct(self) -> int | None: 60 | return self._aux_fan_speed_pct 61 | 62 | @property 63 | def box_fan_level(self) -> int | None: 64 | return self._box_fan_level 65 | 66 | @property 67 | def bottom_layers(self) -> int | None: 68 | return self._bottom_layers 69 | 70 | @property 71 | def bottom_time(self) -> float | None: 72 | return self._bottom_time 73 | 74 | @property 75 | def off_time(self) -> float | None: 76 | return self._off_time 77 | 78 | @property 79 | def on_time(self) -> float | None: 80 | return self._on_time 81 | 82 | @property 83 | def settings_data(self) -> dict[str, int | float]: 84 | print_settings: dict[str, int | float] = dict() 85 | if self._print_speed_mode is not None: 86 | print_settings['print_speed_mode'] = self._print_speed_mode 87 | 88 | if self._target_nozzle_temp is not None: 89 | print_settings['target_nozzle_temp'] = self._target_nozzle_temp 90 | 91 | if self._target_hotbed_temp is not None: 92 | print_settings['target_hotbed_temp'] = self._target_hotbed_temp 93 | 94 | if self._fan_speed_pct is not None: 95 | print_settings['fan_speed_pct'] = self._fan_speed_pct 96 | 97 | if self._aux_fan_speed_pct is not None: 98 | print_settings['aux_fan_speed_pct'] = self._aux_fan_speed_pct 99 | 100 | if self._box_fan_level is not None: 101 | print_settings['box_fan_level'] = self._box_fan_level 102 | 103 | if self._bottom_layers is not None: 104 | print_settings['bottom_layers'] = self._bottom_layers 105 | 106 | if self._bottom_time is not None: 107 | print_settings['bottom_time'] = self._bottom_time 108 | 109 | if self._off_time is not None: 110 | print_settings['off_time'] = self._off_time 111 | 112 | if self._on_time is not None: 113 | print_settings['on_time'] = self._on_time 114 | 115 | return print_settings 116 | 117 | def __repr__(self) -> str: 118 | return ( 119 | f"AnycubicPrintingSettings(" 120 | f"print_speed_mode={self._print_speed_mode}, " 121 | f"target_nozzle_temp={self._target_nozzle_temp}, " 122 | f"target_hotbed_temp={self._target_hotbed_temp}, " 123 | f"fan_speed_pct={self._fan_speed_pct}, " 124 | f"aux_fan_speed_pct={self._aux_fan_speed_pct}, " 125 | f"box_fan_level={self._box_fan_level}, " 126 | f"bottom_layers={self._bottom_layers}, " 127 | f"bottom_time={self._bottom_time}, " 128 | f"off_time={self._off_time}, " 129 | f"on_time={self._on_time})" 130 | ) 131 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/lib/colorpicker/HSLCanvas.js: -------------------------------------------------------------------------------- 1 | import { styleMap } from "lit/directives/style-map.js"; 2 | import { LitElement, html, css } from "lit"; 3 | import { Color } from "modern-color"; 4 | import { colorEvent } from "./lib.js"; 5 | 6 | export class HSLCanvas extends LitElement { 7 | static properties = { 8 | color: { type: Object }, 9 | isHsl: { type: Boolean }, 10 | size: { type: Number }, 11 | debounceMode: { type: Boolean }, 12 | ctx: { type: Object, state: true, attribute: false }, 13 | hsw: { type: Object, state: true, attribute: false }, 14 | circlePos: { type: Object, state: true, attribute: false }, 15 | }; 16 | static styles = css` 17 | :host .outer { 18 | position: absolute; 19 | top: 0; 20 | right: 0; 21 | } 22 | 23 | :host .outer canvas { 24 | height: inherit; 25 | width: inherit; 26 | cursor: pointer; 27 | } 28 | 29 | :host .circle { 30 | height: 12px; 31 | width: 12px; 32 | border: solid 2px #eee; 33 | border-radius: 50%; 34 | box-shadow: 35 | 0 0 3px #000, 36 | inset 0 0 1px #fff; 37 | position: absolute; 38 | margin: -8px; 39 | mix-blend-mode: difference; 40 | } 41 | `; 42 | 43 | constructor() { 44 | super(); 45 | this.isHsl = true; 46 | this.circlePos = { top: 0, left: 0, bounds: { x: "", y: "" } }; 47 | this.size = 160; 48 | } 49 | 50 | setColor(c) { 51 | //this.color = c; 52 | colorEvent(this.renderRoot, c); 53 | } 54 | 55 | setCircleCss(x, y) { 56 | const left = `${x}`; 57 | const top = `${y}`; 58 | const bounds = { x: `0, ${this.size}`, y: `0,${this.size}` }; 59 | //let bounds = {x: `${-x}, ${this.size-x}`,y:`${-y},${this.size-y}`} 60 | this.circlePos = { top, left, bounds }; 61 | } 62 | 63 | pickCoord({ offsetX, offsetY }) { 64 | const x = offsetX; 65 | const y = offsetY; 66 | const { size, hsw, isHsl, color } = this; 67 | 68 | let w = (size - y) / size; 69 | w = Math.round(w * 100); 70 | const sat = Math.round((x / size) * 100); 71 | const hsx = { h: hsw.h, s: sat, [isHsl ? "l" : "v"]: w }; 72 | 73 | const c = isHsl ? Color.fromHsl(hsx) : Color.fromHsv(hsx); 74 | this.setCircleCss(x, y); 75 | c.a = color.alpha; 76 | c.hsx = hsx; 77 | c.fromHSLCanvas = true; 78 | this.setColor(c); 79 | } 80 | 81 | debouncePaintDetail(hsx) { 82 | clearTimeout(this.bouncer); 83 | this.bouncer = setTimeout(() => this.paintHSL(hsx, true), 50); 84 | this.paintHSL(hsx, false); 85 | } 86 | 87 | // todo: test assumption that this perf lag (lit warning) 88 | // is ok due to rendering canvas post update 89 | paintHSL(hsx, detail = null) { 90 | if (this.debounceMode && detail === null) { 91 | // enable rapid painting in lower res 92 | return this.debouncePaintDetail(hsx); 93 | } 94 | const { ctx, color, isHsl, size } = this; 95 | if (!ctx) { 96 | return; 97 | } 98 | //console.time('paint'+detail) 99 | 100 | const clr = color; 101 | hsx = (hsx ?? isHsl) ? clr.hsl : clr.hsv; // hue-sat-whatever 102 | hsx.w = isHsl ? hsx.l : hsx.v; 103 | const { h, s, w } = hsx; 104 | const hsw = (this.hsw = { h, s, w }); 105 | const scale = size / 100; 106 | const fillHsl = (h, s, l) => `hsl(${h}, ${s}%, ${100 - l}%)`; 107 | const fillHsv = (h, s, v) => Color.fromHsv({ h, s, v: 100 - v }).hex; 108 | const fill = isHsl ? fillHsl : fillHsv; 109 | 110 | const incr = detail === false ? 4 : 1; //rapid painting during hue slider ops 111 | for (let s = 0; s < 100; s += incr) { 112 | for (let w = 0; w < 100; w += incr) { 113 | ctx.fillStyle = fill(h, s, w); 114 | ctx.fillRect(s, w, s + incr, w + incr); 115 | } 116 | } 117 | 118 | this.setCircleCss(hsw.s * scale, size - hsx.w * scale); 119 | //console.timeEnd('paint'+detail) 120 | } 121 | 122 | willUpdate(props) { 123 | if (props.has("color") || props.has("isHsl")) { 124 | if (this.color?.hsx) { 125 | if (this.color.fromHSLCanvas) { 126 | delete this.color.fromHSLCanvas; //avoid extra paint job 127 | return; 128 | } 129 | return this.paintHSL(this.color.hsx); 130 | } 131 | this.paintHSL(); 132 | } 133 | } 134 | 135 | firstUpdated(props) { 136 | const canvas = this.renderRoot.querySelector("canvas"); 137 | this.ctx = canvas.getContext("2d"); 138 | this.paintHSL(); 139 | } 140 | 141 | circleMove({ posTop: offsetY, posLeft: offsetX }) { 142 | this.pickCoord({ offsetX, offsetY }); 143 | } 144 | 145 | render() { 146 | const hw = { height: this.size + "p", width: this.size + "px" }; 147 | const { top, left, bounds } = this.circlePos; 148 | return html`
153 | 154 | 161 |
162 |
163 |
`; 164 | } 165 | } 166 | 167 | if (!customElements.get("hsl-canvas")) { 168 | customElements.define("hsl-canvas", HSLCanvas); 169 | } 170 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/lib/colorpicker/ColorInputChannel.js: -------------------------------------------------------------------------------- 1 | import { html, LitElement } from "lit"; 2 | import { Color } from "modern-color"; 3 | import { hueGradient } from "./lib.js"; 4 | import { styleMap } from "lit/directives/style-map.js"; 5 | import { classMap } from "lit/directives/class-map.js"; 6 | import { inputChannelRules } from "./css.js"; 7 | import { colorEvent } from "./lib.js"; 8 | 9 | const labelDictionary = { 10 | r: "R (red) channel", 11 | g: "G (green) channel", 12 | b: "B (blue) channel", 13 | h: "H (hue) channel", 14 | s: "S (saturation) channel", 15 | v: "V (value / brightness) channel", 16 | l: "L (luminosity) channel", 17 | a: "A (alpha / opacity) channel", 18 | }; 19 | 20 | export class ColorInputChannel extends LitElement { 21 | static properties = { 22 | group: { type: String }, 23 | channel: { type: String }, 24 | color: { type: Object }, 25 | isHsl: { type: Boolean }, 26 | c: { type: Object, state: true, attribute: false }, 27 | previewGradient: { type: Object, state: true, attribute: false }, 28 | active: { type: Boolean, state: true, attribute: false }, 29 | max: { type: Number, state: true, attribute: false }, 30 | v: { type: Number, state: true, attribute: false }, 31 | }; 32 | 33 | static styles = inputChannelRules; 34 | 35 | clickPreview(e) { 36 | const w = 128; 37 | const x = Math.max(0, Math.min(e.offsetX, w)); 38 | let v = Math.round((x / 128) * this.max); 39 | if (this.channel === "a") { 40 | v = Number((x / 127).toFixed(2)); 41 | } 42 | this.valueChange(null, v); 43 | this.setActive(false); 44 | } 45 | 46 | valueChange = (e, val = null) => { 47 | val = val ?? Number(this.renderRoot.querySelector("input").value); 48 | if (this.channel === "a") { 49 | val /= 100; 50 | } 51 | this.c[this.channel] = val; 52 | const c = Color.parse(this.c); 53 | if (this.group !== "rgb") { 54 | c.hsx = this.c; 55 | } 56 | this.c = 57 | this.group === "rgb" 58 | ? this.color.rgbObj 59 | : this.isHsl 60 | ? this.color.hsl 61 | : this.color.hsv; 62 | colorEvent(this.renderRoot, c); 63 | }; 64 | 65 | setActive(active) { 66 | this.active = active; 67 | if (active) { 68 | this.renderRoot.querySelector("input").select(); 69 | } 70 | } 71 | 72 | constructor() { 73 | super(); 74 | } 75 | 76 | setPreviewGradient() { 77 | let c; 78 | if (this.group === "rgb") { 79 | c = this.color.rgbObj; 80 | } else { 81 | if (this.color.hsx) { 82 | c = this.color.hsx; 83 | } else { 84 | c = this.isHsl ? this.color.hsl : this.color.hsv; 85 | } 86 | } 87 | this.c = c; 88 | const g = this.group; 89 | const ch = this.channel; 90 | const isAlpha = ch === "a"; 91 | this.v = c[ch]; 92 | if (isAlpha) { 93 | this.v *= 100; 94 | } 95 | let max = 255; 96 | let minC, maxC; 97 | if (g !== "rgb" || ch === "a") { 98 | if (ch === "h") { 99 | max = this.max = 359; 100 | this.previewGradient = { 101 | "--preview": `linear-gradient(90deg, ${hueGradient(24, c)})`, 102 | "--pct": `${100 * (c.h / max)}%`, 103 | }; 104 | return; 105 | } else if (isAlpha) { 106 | max = 1; 107 | } else { 108 | max = 100; 109 | } 110 | } 111 | this.max = max; 112 | minC = { ...c }; 113 | maxC = minC; 114 | minC[this.channel] = 0; 115 | minC = Color.parse(minC); 116 | maxC[this.channel] = max; 117 | maxC = Color.parse(maxC); 118 | if (this.channel === "l") { 119 | const midC = { ...c }; 120 | midC.l = 50; 121 | this.previewGradient = { 122 | "--preview": `linear-gradient(90deg, ${minC.hex}, ${Color.parse(midC).hex}, ${maxC.hex})`, 123 | "--pct": `${100 * (c[this.channel] / max)}%`, 124 | }; 125 | } else { 126 | this.previewGradient = { 127 | "--preview": `linear-gradient(90deg, ${isAlpha ? minC.css : minC.hex}, ${isAlpha ? maxC.css : maxC.hex})`, 128 | "--pct": `${100 * (c[this.channel] / max)}%`, 129 | }; 130 | } 131 | } 132 | 133 | willUpdate(props) { 134 | this.setPreviewGradient(); 135 | } 136 | 137 | render() { 138 | const chex = 139 | this.channel === "a" 140 | ? html`
` 141 | : null; 142 | const max = this.channel === "a" ? 100 : this.max; 143 | return html`
144 | 145 | 157 |
162 |
163 | ${chex} 164 |
165 |
`; 166 | } 167 | } 168 | 169 | if (!customElements.get("color-input-channel")) { 170 | customElements.define("color-input-channel", ColorInputChannel); 171 | } 172 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/localize/languages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Anycubic Cloud", 3 | "common": { 4 | "actions": { 5 | "cancel": "Cancel", 6 | "pause": "Pause", 7 | "print": "Print", 8 | "resume": "Resume", 9 | "yes": "Yes", 10 | "no": "No", 11 | "save": "Save" 12 | }, 13 | "messages": { 14 | "mqtt_unsupported": "This feature requires MQTT to retrieve data but unfortunately MQTT is not supported with the configured authentication mode." 15 | } 16 | }, 17 | "card": { 18 | "buttons": { 19 | "print_settings": "Print Settings", 20 | "dry": "Dry", 21 | "runout_refill": "Refill" 22 | }, 23 | "configure": { 24 | "tabs": { 25 | "main": "Main", 26 | "stats": "Stats", 27 | "colours": "ACE Colour Presets" 28 | }, 29 | "labels": { 30 | "printer_id": "Select Printer", 31 | "vertical": "Vertical Layout?", 32 | "round": "Round Stats?", 33 | "use_24hr": "Use 24hr Time?", 34 | "show_settings_button": "Always show print settings button?", 35 | "always_show": "Always show card?", 36 | "temperature_unit": "Temperature Unit", 37 | "light_entity_id": "Light Entity", 38 | "power_entity_id": "Power Entity", 39 | "camera_entity_id": "Camera Entity", 40 | "scale_factor": "Scale Factor", 41 | "slot_colors": "Slot Colour Presets" 42 | } 43 | }, 44 | "print_settings": { 45 | "confirm_message": "Are you sure you want to {action} the print?", 46 | "label_nozzle_temp": "Nozzle Temperature", 47 | "label_hotbed_temp": "Hotbed Temperature", 48 | "label_fan_speed": "Fan Speed", 49 | "label_aux_fan_speed": "AUX Fan Speed", 50 | "label_box_fan_speed": "Box Fan Speed", 51 | "print_pause": "Pause Print", 52 | "print_resume": "Resume Print", 53 | "print_cancel": "Cancel Print", 54 | "save_speed_mode": "Save Speed Mode", 55 | "save_target_nozzle": "Save Target Nozzle", 56 | "save_target_hotbed": "Save Target Hotbed", 57 | "save_fan_speed": "Save Fan Speed", 58 | "save_aux_fan_speed": "Save AUX Fan Speed", 59 | "save_box_fan_speed": "Save Box Fan Speed" 60 | }, 61 | "drying_settings": { 62 | "heading": "Drying Options", 63 | "button_preset": "Preset", 64 | "button_stop_drying": "Stop Drying", 65 | "button_minutes": "Mins" 66 | }, 67 | "spool_settings": { 68 | "heading": "Editing Slot", 69 | "label_select_material": "Select Material", 70 | "label_select_colour": "Manually select colour" 71 | }, 72 | "monitored_stats": { 73 | "ETA": "ETA", 74 | "Elapsed": "Elapsed", 75 | "Remaining": "Remaining", 76 | "Status": "Status", 77 | "Online": "Online", 78 | "Availability": "Availability", 79 | "Project": "Project", 80 | "Layer": "Layer", 81 | "Hotend": "Hotend", 82 | "Bed": "Bed", 83 | "T Hotend": "T Hotend", 84 | "T Bed": "T Bed", 85 | "Dry Status": "Dry Status", 86 | "Dry Time": "Dry Time", 87 | "Speed Mode": "Speed Mode", 88 | "Fan Speed": "Fan Speed", 89 | "Dry Status": "Dry Status", 90 | "Dry Time": "Dry Time", 91 | "On Time": "On Time", 92 | "Off Time": "Off Time", 93 | "Bottom Time": "Bottom Time", 94 | "Model Height": "Model Height", 95 | "Bottom Layers": "Bottom Layers", 96 | "Z Up Height": "Z Up Height", 97 | "Z Up Speed": "Z Up Speed", 98 | "Z Down Speed": "Z Down Speed" 99 | } 100 | }, 101 | "panels": { 102 | "initial": { 103 | "printer_select": "Select a printer." 104 | }, 105 | "main": { 106 | "title": "Main", 107 | "cards": { 108 | "main": { 109 | "description": "General information about the printer.", 110 | "fields": { 111 | "printer_name": "Name", 112 | "printer_id": "ID", 113 | "printer_mac": "MAC", 114 | "printer_model": "Model", 115 | "printer_fw_version": "FW Version", 116 | "printer_fw_update_available": "FW Status", 117 | "printer_online": "Online", 118 | "printer_available": "Available", 119 | "curr_nozzle_temp": "Current Nozzle Temperature", 120 | "curr_hotbed_temp": "Current Hotbed Temperature", 121 | "target_nozzle_temp": "Target Nozzle Temperature", 122 | "target_hotbed_temp": "Target Hotbed Temperature", 123 | "job_state": "Job State", 124 | "job_progress": "Job Progress", 125 | "ace_fw_version": "ACE FW Version", 126 | "ace_fw_update_available": "ACE FW Status", 127 | "drying_active": "ACE Drying Status", 128 | "drying_progress": "ACE Drying Progress" 129 | } 130 | } 131 | } 132 | }, 133 | "files_cloud": { 134 | "title": "Cloud Files", 135 | "cards": {} 136 | }, 137 | "files_local": { 138 | "title": "Local Files", 139 | "cards": {} 140 | }, 141 | "files_udisk": { 142 | "title": "USB Files", 143 | "cards": {} 144 | }, 145 | "print_save_in_cloud": { 146 | "title": "Print (Save in user cloud)", 147 | "cards": {} 148 | }, 149 | "print_no_cloud_save": { 150 | "title": "Print (No Cloud Save)", 151 | "cards": {} 152 | }, 153 | "debug": { 154 | "title": "Debug", 155 | "cards": {} 156 | } 157 | } 158 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > Anycubic have been attempting to block MQTT access, see [here](https://github.com/WaresWichall/hass-anycubic_cloud/issues/33) 3 | > 4 | > I'm moving on to another printer brand and won't be active on this project as much but will still be fixing issues. 5 | > 6 | > MQTT access is now available using Slicer Next (windows version) tokens. 7 | > 8 | > Still works as of 01/12/2024. 9 | 10 | --- 11 | 12 | # Anycubic Cloud Home Assistant Integration 13 | 14 | Component is working very well so far with: 15 | - Kobra 3 Combo 16 | - Kobra 2 17 | - Kobra 2 Max 18 | - Kobra 2 Pro 19 | - Photon Mono M5s (Basic support still) 20 | - M7 Pro (Basic support still) 21 | 22 | If you have success with other printers please report it, or if you don't please report that too :) 23 | 24 | Anycubic Cloud is polled for data updates every 1 minute, whilst MQTT updates can be received multiple times per second. 25 | 26 | If you find updates for any sensors are only received every minute, please open an issue. 27 | 28 | 29 | ## Frontend Card 30 | 31 | This integration couples with my [Anycubic card for Home Assistant](https://github.com/WaresWichall/hass-anycubic_card) 32 | 33 | 34 | ## Gallery 35 | 36 | 37 | 38 | 39 | 40 | 41 | ## Features 42 | 43 | - Supports multiple printers 44 | - Start print services / UI panel 45 | - Pause/Resume/Cancel print buttons 46 | - Edit ACE slot colours/settings via services / UI panel 47 | - File manager via services / UI panel 48 | - Retraction/Extrude services 49 | - Printer sensors e.g. temperature, fan, print speed etc 50 | - Job sensors e.g. name, progress, image preview, time, print params 51 | - ACE sensors 52 | - Firmware Update entities 53 | - ACE drying management with customisable presets 54 | - ACE spool management with customisable colour presets 55 | - Configurable MQTT Connection Mode (Defaults to Printing Only) 56 | - And more ... 57 | 58 | 59 | ## Panel 60 | 61 | It also comes with a frontend panel which will be added to your sidebar. 62 | Current features: 63 | - Basic printer info (+ the printer card above) 64 | - File manager (requires MQTT connection to be active) 65 | - Start print services 66 | 67 | 68 | ## How to Install 69 | 70 | 1. Pick a method of authentication and grab your logon token. 71 | 2. Add this repository to HACS under ... (menu) > Custom Repositories as an **Integration** 72 | 3. Restart Home Assistant 73 | 4. Go to Settings > Integrations > Add New and search Anycubic 74 | 5. Select your chosen authentication mode 75 | 6. Paste your **token** into the `User Token` or `Slicer Access Token` box. 76 | 7. Select your printer, then you're good to go! 77 | 8. Optionally configure more options in the home assistant integration `configure` menus. 78 | 79 | 80 | ### Slicer authentication 81 | 82 | > [!IMPORTANT] 83 | > Only tested / supported with Slicer Next for Windows 84 | 85 | 1. Make sure your installation of Slicer Next is logged in, then close it. 86 | 2. Locate your `AnycubicSlicerNext` config directory. 87 | > [!NOTE] 88 | > This is found in: 89 | > ``` 90 | > %AppData%\AnycubicSlicerNext\AnycubicSlicerNext.conf 91 | > ``` 92 | > or 93 | > ``` 94 | > C:\Users\\AppData\Roaming\AnycubicSlicerNext\AnycubicSlicerNext.conf 95 | > ``` 96 | 3. Copy/save the whole `access_token` string without the quotes, it should be a 344 character string. 97 | 4. Ideally you should now clear your login config for the slicer to prevent it logging in at the same time as Home Assistant. 98 | This can be done setting your `access_token` to an empty string in the config file, e.g. `"access_token": "",` 99 | 100 | 101 | 102 | 103 | ### Web authentication 104 | 105 | > [!IMPORTANT] 106 | > Unfortunately web authentication no longer supports MQTT updates. 107 | 108 | 1. Go to the [Anycubic Cloud Website](https://cloud-universe.anycubic.com/file) 109 | 2. Log in 110 | 3. Open Developer Tools in your browser 111 | 4. Paste `window.localStorage["XX-Token"]` into the **console** 112 | 5. Copy/save the long string of numbers and letters without the `''` - this is your token. 113 | 114 | 115 | 116 | ### Re-Authentication 117 | 118 | If you log yourself out or your token expires you'll get a re-authentication warning in Home Assistant, just grab a new token as above. 119 | 120 | 121 | ## Donations 122 | 123 | 124 | 125 | 126 | ## Thanks 127 | 128 | Thanks to @dangreco for his original work on threedy which I first modded and then completely rewrote with Lit instead of React. 129 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Binary sensors for Anycubic Cloud.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import EntityCategory, Platform 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | 13 | from .const import ( 14 | COORDINATOR, 15 | DOMAIN, 16 | PrinterEntityType, 17 | ) 18 | from .entity import AnycubicCloudEntity, AnycubicCloudEntityDescription 19 | from .helpers import printer_attributes_for_key, printer_state_for_key 20 | 21 | if TYPE_CHECKING: 22 | from .coordinator import AnycubicCloudDataUpdateCoordinator 23 | 24 | 25 | @dataclass(frozen=True) 26 | class AnycubicBinarySensorEntityDescription( 27 | BinarySensorEntityDescription, AnycubicCloudEntityDescription 28 | ): 29 | """Describes Anycubic Cloud binary sensor entity.""" 30 | 31 | 32 | PRIMARY_MULTI_COLOR_BOX_SENSOR_TYPES: list[AnycubicBinarySensorEntityDescription] = list([ 33 | AnycubicBinarySensorEntityDescription( 34 | key="dry_status_is_drying", 35 | translation_key="dry_status_is_drying", 36 | printer_entity_type=PrinterEntityType.ACE_PRIMARY, 37 | ), 38 | ]) 39 | 40 | SECONDARY_MULTI_COLOR_BOX_SENSOR_TYPES: list[AnycubicBinarySensorEntityDescription] = list([ 41 | AnycubicBinarySensorEntityDescription( 42 | key="secondary_dry_status_is_drying", 43 | translation_key="secondary_dry_status_is_drying", 44 | printer_entity_type=PrinterEntityType.ACE_SECONDARY, 45 | ), 46 | ]) 47 | 48 | SENSOR_TYPES: list[AnycubicBinarySensorEntityDescription] = list([ 49 | AnycubicBinarySensorEntityDescription( 50 | key="job_in_progress", 51 | translation_key="job_in_progress", 52 | printer_entity_type=PrinterEntityType.PRINTER, 53 | ), 54 | AnycubicBinarySensorEntityDescription( 55 | key="job_complete", 56 | translation_key="job_complete", 57 | printer_entity_type=PrinterEntityType.PRINTER, 58 | ), 59 | AnycubicBinarySensorEntityDescription( 60 | key="job_failed", 61 | translation_key="job_failed", 62 | printer_entity_type=PrinterEntityType.PRINTER, 63 | ), 64 | AnycubicBinarySensorEntityDescription( 65 | key="job_is_paused", 66 | translation_key="job_is_paused", 67 | printer_entity_type=PrinterEntityType.PRINTER, 68 | ), 69 | AnycubicBinarySensorEntityDescription( 70 | key="printer_online", 71 | translation_key="printer_online", 72 | printer_entity_type=PrinterEntityType.PRINTER, 73 | ), 74 | AnycubicBinarySensorEntityDescription( 75 | key="is_busy", 76 | translation_key="is_busy", 77 | printer_entity_type=PrinterEntityType.PRINTER, 78 | ), 79 | AnycubicBinarySensorEntityDescription( 80 | key="is_available", 81 | translation_key="is_available", 82 | printer_entity_type=PrinterEntityType.PRINTER, 83 | ), 84 | AnycubicBinarySensorEntityDescription( 85 | key="mqtt_connection_active", 86 | translation_key="mqtt_connection_active", 87 | entity_category=EntityCategory.DIAGNOSTIC, 88 | printer_entity_type=PrinterEntityType.PRINTER, 89 | ), 90 | ]) 91 | 92 | GLOBAL_SENSOR_TYPES: list[AnycubicBinarySensorEntityDescription] = list([ 93 | ]) 94 | 95 | 96 | async def async_setup_entry( 97 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 98 | ) -> None: 99 | """Set up the Anycubic Cloud binary sensor entry.""" 100 | 101 | coordinator: AnycubicCloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 102 | COORDINATOR 103 | ] 104 | coordinator.add_entities_for_seen_printers( 105 | async_add_entities=async_add_entities, 106 | entity_constructor=AnycubicBinarySensor, 107 | platform=Platform.BINARY_SENSOR, 108 | available_descriptors=list( 109 | SENSOR_TYPES 110 | + PRIMARY_MULTI_COLOR_BOX_SENSOR_TYPES 111 | + SECONDARY_MULTI_COLOR_BOX_SENSOR_TYPES 112 | + GLOBAL_SENSOR_TYPES 113 | ), 114 | ) 115 | 116 | 117 | class AnycubicBinarySensor(AnycubicCloudEntity, BinarySensorEntity): 118 | """Representation of a Anycubic binary sensor.""" 119 | 120 | entity_description: AnycubicBinarySensorEntityDescription 121 | 122 | def __init__( 123 | self, 124 | hass: HomeAssistant, 125 | coordinator: AnycubicCloudDataUpdateCoordinator, 126 | printer_id: int, 127 | entity_description: AnycubicBinarySensorEntityDescription, 128 | ) -> None: 129 | """Initiate Anycubic Binary Sensor.""" 130 | super().__init__(hass, coordinator, printer_id, entity_description) 131 | 132 | @property 133 | def is_on(self) -> bool: 134 | """Return true if the binary sensor is on.""" 135 | return bool( 136 | printer_state_for_key(self.coordinator, self._printer_id, self.entity_description.key) 137 | ) 138 | 139 | @property 140 | def extra_state_attributes(self) -> dict[str, Any] | None: 141 | """Return extra state attributes.""" 142 | attrib = printer_attributes_for_key(self.coordinator, self._printer_id, self.entity_description.key) 143 | if attrib is not None: 144 | return attrib 145 | else: 146 | return None 147 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/scripts/build_translations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | import re 6 | import sys 7 | from pathlib import Path 8 | from typing import Any 9 | 10 | ROOT_DIR = Path(os.path.dirname(os.path.dirname(__file__))) 11 | OUT_STRINGS_PATH = ROOT_DIR / "strings.json" 12 | TRANSLATIONS_DIR = ROOT_DIR / "translations" 13 | INPUT_TRANSLATIONS_DIR = TRANSLATIONS_DIR / "input_translation_files" 14 | IN_STRINGS_PATH = INPUT_TRANSLATIONS_DIR / "en.json" 15 | 16 | REX_TRANSLATION_KEY = r"\[\%key:([a-z0-9_]+(?:::(?:[a-z0-9-_])+)+(?:{[\w\d\-_ ]+})*)\%\]" 17 | REX_DICT_TRANSLATION_KEY = r"\[\%dictkey:([a-z0-9_]+(?:::(?:[a-z0-9-_#])+)+)\%\]" 18 | REX_VAR_SUB = r"{([\w\d\-_ ]+)}" 19 | 20 | 21 | def substitute_translation_references( 22 | integration_strings: dict[str, Any], 23 | shared_translations: dict[str, Any], 24 | ) -> dict[str, Any]: 25 | result: dict[str, Any] = {} 26 | for key, value in integration_strings.items(): 27 | ref_key, ref_dict = substitute_dict_reference(key, shared_translations) 28 | if ref_key and ref_dict: 29 | result[ref_key] = substitute_translation_references(ref_dict, shared_translations) 30 | else: 31 | if isinstance(value, dict): 32 | sub_dict = substitute_translation_references(value, shared_translations) 33 | result[key] = sub_dict 34 | elif isinstance(value, str): 35 | result[key] = substitute_reference(value, shared_translations) 36 | 37 | return result 38 | 39 | 40 | def get_key_parts(key: str) -> list[str]: 41 | return key.replace("common::", "").split("::") 42 | 43 | 44 | def substitute_dict_reference( 45 | translation_key: str, 46 | shared_translations: dict[str, Any], 47 | ) -> tuple[str | None, dict[str, Any] | None]: 48 | matches = re.findall(REX_DICT_TRANSLATION_KEY, translation_key) 49 | if not matches: 50 | return None, None 51 | 52 | for matched_reference in matches: 53 | key_parts = get_key_parts(matched_reference) 54 | 55 | found_translations = shared_translations 56 | 57 | for idx, key in enumerate(key_parts): 58 | if key in found_translations: 59 | if idx == len(key_parts) - 1: 60 | cleaned_key = key.split("#")[0] 61 | return cleaned_key, found_translations[key] 62 | else: 63 | found_translations = found_translations[key] 64 | 65 | print(f"Invalid substitution key '{translation_key}'") 66 | sys.exit(1) 67 | 68 | 69 | def get_key_without_vars(key: str) -> str: 70 | l_split = key.split("{") 71 | if len(l_split) > 1: 72 | r_split = key.rsplit("}", 1) 73 | if len(r_split) > 1: 74 | return l_split[0] + r_split[-1] 75 | 76 | return key 77 | 78 | 79 | def substitute_reference( 80 | value: str, 81 | shared_translations: dict[str, Any], 82 | ) -> str: 83 | matches = re.findall(REX_TRANSLATION_KEY, value) 84 | if not matches: 85 | return value 86 | 87 | new = value 88 | for matched_reference in matches: 89 | key_parts = get_key_parts(matched_reference) 90 | 91 | found_translations = shared_translations 92 | 93 | for idx, key in enumerate(key_parts): 94 | stripped_key = get_key_without_vars(key) 95 | 96 | if stripped_key in found_translations: 97 | if idx == len(key_parts) - 1: 98 | if stripped_key in found_translations: 99 | matching_translation = found_translations[stripped_key] 100 | var_matches = re.findall(REX_VAR_SUB, key) 101 | if var_matches: 102 | matching_translation = matching_translation.format(*var_matches) 103 | new = new.replace( 104 | f"[%key:{matched_reference}%]", 105 | # New value can also be a substitution reference 106 | substitute_reference( 107 | matching_translation, shared_translations 108 | ), 109 | ) 110 | else: 111 | print(f"Invalid substitution key '{key}' found in string '{value}'") 112 | sys.exit(1) 113 | else: 114 | found_translations = found_translations[stripped_key] 115 | 116 | return new 117 | 118 | 119 | def read_input_strings(file_path: Path) -> dict[str, Any]: 120 | input_string_data = file_path.read_text() 121 | input_string_dict: dict[str, Any] = json.loads(input_string_data) 122 | 123 | return input_string_dict 124 | 125 | 126 | def run_single( 127 | file_path: Path 128 | ) -> None: 129 | print(f"Generating translations for {file_path.name}.") 130 | 131 | input_strings = read_input_strings(file_path) 132 | 133 | shared_translations = input_strings['common'] 134 | 135 | output_strings = substitute_translation_references( 136 | input_strings, shared_translations 137 | ) 138 | 139 | output_strings.pop("common") 140 | 141 | translated_dump = json.dumps( 142 | output_strings, 143 | indent=2, 144 | ) 145 | 146 | (TRANSLATIONS_DIR / file_path.name).write_text( 147 | translated_dump 148 | ) 149 | 150 | if file_path.name == "en.json": 151 | OUT_STRINGS_PATH.write_text( 152 | translated_dump 153 | ) 154 | 155 | 156 | def translate_all() -> None: 157 | for lang_file in INPUT_TRANSLATIONS_DIR.iterdir(): 158 | run_single(lang_file) 159 | 160 | 161 | translate_all() 162 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/button.py: -------------------------------------------------------------------------------- 1 | """Support for Anycubic Cloud button.""" 2 | from __future__ import annotations 3 | 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from homeassistant.components.button import ButtonEntity, ButtonEntityDescription 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.const import EntityCategory, Platform 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 12 | 13 | from .const import ( 14 | COORDINATOR, 15 | DOMAIN, 16 | ENTITY_ID_DRYING_START_PRESET_, 17 | MAX_DRYING_PRESETS, 18 | PrinterEntityType, 19 | ) 20 | from .entity import AnycubicCloudEntity, AnycubicCloudEntityDescription 21 | from .helpers import printer_attributes_for_key 22 | 23 | if TYPE_CHECKING: 24 | from .coordinator import AnycubicCloudDataUpdateCoordinator 25 | 26 | 27 | @dataclass(frozen=True) 28 | class AnycubicButtonEntityDescription( 29 | ButtonEntityDescription, AnycubicCloudEntityDescription 30 | ): 31 | """Describes Anycubic Cloud button entity.""" 32 | 33 | 34 | PRIMARY_DRYING_PRESET_BUTTON_TYPES: list[AnycubicButtonEntityDescription] = list([ 35 | AnycubicButtonEntityDescription( 36 | key=f"{ENTITY_ID_DRYING_START_PRESET_}{x + 1}", 37 | translation_key=f"{ENTITY_ID_DRYING_START_PRESET_}{x + 1}", 38 | printer_entity_type=PrinterEntityType.DRY_PRESET_PRIMARY, 39 | ) for x in range(MAX_DRYING_PRESETS) 40 | ]) 41 | 42 | SECONDARY_DRYING_PRESET_BUTTON_TYPES: list[AnycubicButtonEntityDescription] = list([ 43 | AnycubicButtonEntityDescription( 44 | key=f"secondary_{ENTITY_ID_DRYING_START_PRESET_}{x + 1}", 45 | translation_key=f"secondary_{ENTITY_ID_DRYING_START_PRESET_}{x + 1}", 46 | printer_entity_type=PrinterEntityType.DRY_PRESET_SECONDARY, 47 | ) for x in range(MAX_DRYING_PRESETS) 48 | ]) 49 | 50 | PRIMARY_MULTI_COLOR_BOX_BUTTON_TYPES: list[AnycubicButtonEntityDescription] = list([ 51 | AnycubicButtonEntityDescription( 52 | key="drying_stop", 53 | translation_key="drying_stop", 54 | printer_entity_type=PrinterEntityType.ACE_PRIMARY, 55 | ), 56 | ]) 57 | 58 | SECONDARY_MULTI_COLOR_BOX_BUTTON_TYPES: list[AnycubicButtonEntityDescription] = list([ 59 | AnycubicButtonEntityDescription( 60 | key="secondary_drying_stop", 61 | translation_key="secondary_drying_stop", 62 | printer_entity_type=PrinterEntityType.ACE_SECONDARY, 63 | ), 64 | ]) 65 | 66 | BUTTON_TYPES: list[AnycubicButtonEntityDescription] = list([ 67 | AnycubicButtonEntityDescription( 68 | key="pause_print", 69 | translation_key="pause_print", 70 | printer_entity_type=PrinterEntityType.PRINTER, 71 | ), 72 | AnycubicButtonEntityDescription( 73 | key="resume_print", 74 | translation_key="resume_print", 75 | printer_entity_type=PrinterEntityType.PRINTER, 76 | ), 77 | AnycubicButtonEntityDescription( 78 | key="cancel_print", 79 | translation_key="cancel_print", 80 | printer_entity_type=PrinterEntityType.PRINTER, 81 | ), 82 | AnycubicButtonEntityDescription( 83 | key="request_file_list_local", 84 | translation_key="request_file_list_local", 85 | printer_entity_type=PrinterEntityType.PRINTER, 86 | ), 87 | AnycubicButtonEntityDescription( 88 | key="request_file_list_udisk", 89 | translation_key="request_file_list_udisk", 90 | printer_entity_type=PrinterEntityType.PRINTER, 91 | ), 92 | ]) 93 | 94 | GLOBAL_BUTTON_TYPES: list[AnycubicButtonEntityDescription] = list([ 95 | AnycubicButtonEntityDescription( 96 | key="request_file_list_cloud", 97 | translation_key="request_file_list_cloud", 98 | printer_entity_type=PrinterEntityType.GLOBAL, 99 | ), 100 | AnycubicButtonEntityDescription( 101 | key="refresh_mqtt_connection", 102 | translation_key="refresh_mqtt_connection", 103 | entity_category=EntityCategory.DIAGNOSTIC, 104 | printer_entity_type=PrinterEntityType.GLOBAL, 105 | ), 106 | ]) 107 | 108 | 109 | async def async_setup_entry( 110 | hass: HomeAssistant, 111 | entry: ConfigEntry, 112 | async_add_entities: AddEntitiesCallback, 113 | ) -> None: 114 | """Set up the button from a config entry.""" 115 | 116 | coordinator: AnycubicCloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 117 | COORDINATOR 118 | ] 119 | 120 | coordinator.add_entities_for_seen_printers( 121 | async_add_entities=async_add_entities, 122 | entity_constructor=AnycubicCloudButton, 123 | platform=Platform.BUTTON, 124 | available_descriptors=list( 125 | BUTTON_TYPES 126 | + PRIMARY_MULTI_COLOR_BOX_BUTTON_TYPES 127 | + SECONDARY_MULTI_COLOR_BOX_BUTTON_TYPES 128 | + PRIMARY_DRYING_PRESET_BUTTON_TYPES 129 | + SECONDARY_DRYING_PRESET_BUTTON_TYPES 130 | + GLOBAL_BUTTON_TYPES 131 | ), 132 | ) 133 | 134 | 135 | class AnycubicCloudButton(AnycubicCloudEntity, ButtonEntity): 136 | """A button for Anycubic Cloud.""" 137 | 138 | entity_description: AnycubicButtonEntityDescription 139 | 140 | def __init__( 141 | self, 142 | hass: HomeAssistant, 143 | coordinator: AnycubicCloudDataUpdateCoordinator, 144 | printer_id: int, 145 | entity_description: AnycubicButtonEntityDescription, 146 | ) -> None: 147 | """Initialize.""" 148 | super().__init__(hass, coordinator, printer_id, entity_description) 149 | 150 | async def async_press(self) -> None: 151 | """Press the button.""" 152 | if TYPE_CHECKING: 153 | assert self.coordinator.anycubic_api, "Connection to API is missing" 154 | 155 | await self.coordinator.button_press_event(self._printer_id, self.entity_description.key) 156 | 157 | @property 158 | def extra_state_attributes(self) -> dict[str, Any] | None: 159 | """Return extra state attributes.""" 160 | attrib = printer_attributes_for_key(self.coordinator, self._printer_id, self.entity_description.key) 161 | if attrib is not None: 162 | return attrib 163 | else: 164 | return None 165 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available Anycubic Cloud services 2 | 3 | multi_color_box_set_slot_pla: &multi_color_box_set_slot 4 | fields: 5 | config_entry: &anycubic_config_entry 6 | required: true 7 | selector: 8 | config_entry: 9 | integration: anycubic_cloud 10 | device_id: &device_id 11 | required: false 12 | selector: 13 | device: 14 | integration: anycubic_cloud 15 | printer_id: &printer_id 16 | required: false 17 | selector: &bignum_selector 18 | number: 19 | min: 0 20 | max: 9999999999999999999 21 | step: 1 22 | mode: box 23 | box_id: &box_id 24 | required: false 25 | selector: 26 | number: 27 | min: 0 28 | max: 7 29 | step: 1 30 | mode: box 31 | slot_number: &slot_number 32 | required: true 33 | selector: 34 | number: 35 | min: 1 36 | max: 4 37 | step: 1 38 | mode: box 39 | slot_color_red: 40 | required: true 41 | selector: 42 | number: 43 | min: 0 44 | max: 255 45 | step: 1 46 | mode: box 47 | slot_color_green: 48 | required: true 49 | selector: 50 | number: 51 | min: 0 52 | max: 255 53 | step: 1 54 | mode: box 55 | slot_color_blue: 56 | required: true 57 | selector: 58 | number: 59 | min: 0 60 | max: 255 61 | step: 1 62 | mode: box 63 | multi_color_box_set_slot_petg: *multi_color_box_set_slot 64 | multi_color_box_set_slot_abs: *multi_color_box_set_slot 65 | multi_color_box_set_slot_pacf: *multi_color_box_set_slot 66 | multi_color_box_set_slot_pc: *multi_color_box_set_slot 67 | multi_color_box_set_slot_asa: *multi_color_box_set_slot 68 | multi_color_box_set_slot_hips: *multi_color_box_set_slot 69 | multi_color_box_set_slot_pa: *multi_color_box_set_slot 70 | multi_color_box_set_slot_pla_se: *multi_color_box_set_slot 71 | multi_color_box_filament_extrude: 72 | fields: 73 | config_entry: *anycubic_config_entry 74 | device_id: *device_id 75 | printer_id: *printer_id 76 | box_id: *box_id 77 | slot_number: *slot_number 78 | finished: 79 | required: false 80 | selector: 81 | boolean: 82 | multi_color_box_filament_retract: 83 | fields: 84 | config_entry: *anycubic_config_entry 85 | device_id: *device_id 86 | printer_id: *printer_id 87 | box_id: *box_id 88 | print_and_upload_save_in_cloud: 89 | fields: 90 | config_entry: *anycubic_config_entry 91 | device_id: *device_id 92 | printer_id: *printer_id 93 | slot_number: &slot_number_list 94 | required: false 95 | example: [1, 2] 96 | selector: 97 | object: 98 | uploaded_gcode_file: &uploaded_gcode_file 99 | required: true 100 | selector: 101 | file: 102 | print_and_upload_no_cloud_save: 103 | fields: 104 | config_entry: *anycubic_config_entry 105 | device_id: *device_id 106 | printer_id: *printer_id 107 | slot_number: *slot_number_list 108 | uploaded_gcode_file: *uploaded_gcode_file 109 | delete_file_local: &delete_file_printer 110 | fields: 111 | config_entry: *anycubic_config_entry 112 | device_id: *device_id 113 | printer_id: *printer_id 114 | filename: 115 | required: true 116 | selector: 117 | text: 118 | delete_file_udisk: *delete_file_printer 119 | delete_file_cloud: 120 | fields: 121 | config_entry: *anycubic_config_entry 122 | device_id: *device_id 123 | printer_id: *printer_id 124 | file_id: 125 | required: true 126 | selector: *bignum_selector 127 | change_print_speed_mode: 128 | fields: 129 | config_entry: *anycubic_config_entry 130 | device_id: *device_id 131 | printer_id: *printer_id 132 | speed_mode: &pct_selector 133 | required: true 134 | selector: 135 | number: 136 | min: 0 137 | max: 100 138 | step: 1 139 | mode: box 140 | change_print_target_nozzle_temperature: 141 | fields: 142 | config_entry: *anycubic_config_entry 143 | device_id: *device_id 144 | printer_id: *printer_id 145 | temperature: &temp_selector 146 | required: true 147 | selector: 148 | number: 149 | min: 0 150 | max: 400 151 | step: 1 152 | mode: box 153 | change_print_target_hotbed_temperature: 154 | fields: 155 | config_entry: *anycubic_config_entry 156 | device_id: *device_id 157 | printer_id: *printer_id 158 | temperature: *temp_selector 159 | change_print_fan_speed: 160 | fields: 161 | config_entry: *anycubic_config_entry 162 | device_id: *device_id 163 | printer_id: *printer_id 164 | speed: *pct_selector 165 | change_print_aux_fan_speed: 166 | fields: 167 | config_entry: *anycubic_config_entry 168 | device_id: *device_id 169 | printer_id: *printer_id 170 | speed: *pct_selector 171 | change_print_box_fan_speed: 172 | fields: 173 | config_entry: *anycubic_config_entry 174 | device_id: *device_id 175 | printer_id: *printer_id 176 | speed: *pct_selector 177 | change_print_bottom_layers: 178 | fields: 179 | config_entry: *anycubic_config_entry 180 | device_id: *device_id 181 | printer_id: *printer_id 182 | layers: 183 | required: true 184 | selector: *bignum_selector 185 | change_print_bottom_time: 186 | fields: 187 | config_entry: *anycubic_config_entry 188 | device_id: *device_id 189 | printer_id: *printer_id 190 | time: 191 | required: true 192 | selector: &bigfloat_selector 193 | number: 194 | min: 0 195 | max: 9999999999999999999 196 | step: 0.001 197 | mode: box 198 | change_print_off_time: 199 | fields: 200 | config_entry: *anycubic_config_entry 201 | device_id: *device_id 202 | printer_id: *printer_id 203 | time: 204 | required: true 205 | selector: *bigfloat_selector 206 | change_print_on_time: 207 | fields: 208 | config_entry: *anycubic_config_entry 209 | device_id: *device_id 210 | printer_id: *printer_id 211 | time: 212 | required: true 213 | selector: *bigfloat_selector 214 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/anycubic_cloud_api/helpers/helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import json 5 | import re 6 | import struct 7 | import uuid 8 | from datetime import timedelta 9 | from os import path 10 | from typing import Any 11 | 12 | ALPHANUMERIC_CHARS: str = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 13 | GCODE_STRING_FIRST_ATTR_LINE: str = '; filament used' 14 | 15 | REX_GCODE_DATA_KEY_VALUE: re.Pattern[Any] = re.compile(r'; ([a-zA-Z0-9_\[\] ]+) = (.*)$') 16 | 17 | REX_PRINT_TOTAL_TIME: re.Pattern[Any] = re.compile(r'^([\d]+)hour([\d]+)min$') 18 | 19 | 20 | def timedelta_to_total_minutes( 21 | delta: timedelta, 22 | ) -> float: 23 | return delta.total_seconds() / 60.0 24 | 25 | 26 | def timedelta_to_total_hours( 27 | delta: timedelta, 28 | ) -> float: 29 | return delta.total_seconds() / 3600.0 30 | 31 | 32 | def timedelta_to_dhm_string( 33 | delta: timedelta, 34 | ) -> str: 35 | days = delta.days 36 | hours, remain_sec = divmod(delta.seconds, 3600) 37 | mins = int(remain_sec / 60) 38 | 39 | return f"{days}:{hours}:{mins}" 40 | 41 | 42 | def hour_min_time_string_to_delta( 43 | time_string: str, 44 | ) -> timedelta: 45 | match = REX_PRINT_TOTAL_TIME.match(time_string) 46 | 47 | if match: 48 | hours = int(match.group(1)) 49 | mins = int(match.group(2)) 50 | 51 | return timedelta( 52 | minutes=mins, 53 | hours=hours, 54 | ) 55 | 56 | raise ValueError("No hour min regex match.") 57 | 58 | 59 | def int_seconds_string_to_delta( 60 | time_string: str, 61 | ) -> timedelta: 62 | try: 63 | total_seconds = int(time_string) 64 | return timedelta( 65 | seconds=total_seconds 66 | ) 67 | except ValueError: 68 | raise 69 | 70 | 71 | def float_minutes_string_to_delta( 72 | time_string: str, 73 | ) -> timedelta: 74 | try: 75 | minutes = float(time_string) 76 | total_seconds = int(minutes * 60) 77 | return timedelta( 78 | seconds=total_seconds 79 | ) 80 | except ValueError: 81 | raise 82 | 83 | 84 | def time_duration_string_to_delta( 85 | time_string: str | None, 86 | ) -> timedelta: 87 | if isinstance(time_string, str): 88 | # try: 89 | # return int_seconds_string_to_delta(time_string) 90 | # except ValueError: 91 | # pass 92 | 93 | try: 94 | return float_minutes_string_to_delta(time_string) 95 | except ValueError: 96 | pass 97 | 98 | try: 99 | return hour_min_time_string_to_delta(time_string) 100 | except ValueError: 101 | pass 102 | 103 | return timedelta() 104 | 105 | 106 | def get_part_from_mqtt_topic(topic: str, part: int) -> str | None: 107 | split_topic = topic.split("/") 108 | if len(split_topic) < part + 1: 109 | return None 110 | 111 | return split_topic[part] 112 | 113 | 114 | def redact_part_from_mqtt_topic(topic: str, part: int) -> str: 115 | split_topic = topic.split("/") 116 | new_chunk = list() 117 | if len(split_topic) < part + 1: 118 | return topic 119 | 120 | for idx, chunk in enumerate(split_topic): 121 | if idx != part: 122 | new_chunk.append(chunk) 123 | else: 124 | new_chunk.append("**REDACTED**") 125 | 126 | return "/".join(new_chunk) 127 | 128 | 129 | def base_62_encode_int(num: int) -> str: 130 | rounds = 11 131 | enc_arr = list(['0' for x in range(rounds)]) 132 | while num != -1 and num != 0: 133 | rounds -= 1 134 | enc_arr[rounds] = ALPHANUMERIC_CHARS[(61 & num)] 135 | num = num >> 6 136 | return "".join(enc_arr) 137 | 138 | 139 | def generate_fake_device_id() -> str: 140 | return (uuid.uuid1().hex + uuid.uuid1().hex)[:33] 141 | 142 | 143 | def generate_cookie_state() -> str: 144 | return str(uuid.uuid4())[-11:] 145 | 146 | 147 | def get_msb_and_lsb_from_bytes(input_bytes: bytes) -> tuple[int, int]: 148 | return struct.unpack(">qq", input_bytes) 149 | 150 | 151 | def generate_android_app_nonce() -> str: 152 | nonce = uuid.uuid1() 153 | msb, lsb = get_msb_and_lsb_from_bytes(nonce.bytes) 154 | return base_62_encode_int(msb) + base_62_encode_int(lsb) 155 | 156 | 157 | def generate_web_nonce() -> str: 158 | return str(uuid.uuid1()) 159 | 160 | 161 | def string_to_int_float(value: str) -> int | float | str: 162 | if value.isdigit(): 163 | return int(value) 164 | else: 165 | try: 166 | return float(value) 167 | except ValueError: 168 | pass 169 | 170 | return value 171 | 172 | 173 | def gcode_key_value_pair_to_dict( 174 | rex: re.Pattern[Any], 175 | data_string: str, 176 | ) -> dict[str, Any]: 177 | data_key = rex.findall(data_string) 178 | 179 | if not data_key or len(data_key) < 1: 180 | return {} 181 | 182 | key = data_key[0][0] 183 | value = data_key[0][1] 184 | 185 | if value in ['begin', 'end']: 186 | return {} 187 | 188 | for repl in [' ', '[', ']', '(', ')', '__']: 189 | key = key.replace(repl, '_') 190 | 191 | if key[-1] == '_': 192 | key = key[:-1] 193 | 194 | try: 195 | value = json.loads(value) 196 | except json.decoder.JSONDecodeError: 197 | if "," in value: 198 | value = list([string_to_int_float(x.strip()) for x in value.split(',')]) 199 | 200 | else: 201 | value = string_to_int_float(value) 202 | 203 | return { 204 | key: value 205 | } 206 | 207 | 208 | def md5_hex_of_string( 209 | input_string: str, 210 | ) -> str: 211 | return hashlib.md5(input_string.encode('utf-8')).hexdigest().lower() 212 | 213 | 214 | def get_ssl_cert_directory() -> str: 215 | library_root = path.dirname(path.dirname(__file__)) 216 | 217 | if 'anycubic_cloud_api' not in library_root: 218 | library_root = path.join(library_root, 'anycubic_cloud_api') 219 | 220 | ssl_root = path.join(library_root, 'resources') 221 | 222 | return ssl_root 223 | 224 | 225 | def get_mqtt_ssl_path_ca( 226 | ssl_root: str, 227 | ) -> str: 228 | return path.join(ssl_root, 'anycubic_mqqt_tls_ca.crt') 229 | 230 | 231 | def get_mqtt_ssl_path_cert( 232 | ssl_root: str, 233 | ) -> str: 234 | return path.join(ssl_root, 'anycubic_mqqt_tls_client.crt') 235 | 236 | 237 | def get_mqtt_ssl_path_key( 238 | ssl_root: str, 239 | ) -> str: 240 | return path.join(ssl_root, 'anycubic_mqqt_tls_client.key') 241 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for Anycubic Cloud.""" 2 | from __future__ import annotations 3 | 4 | import json 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from homeassistant.components.diagnostics import async_redact_data 8 | from homeassistant.config_entries import ConfigEntry 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .const import COORDINATOR, DOMAIN 12 | 13 | if TYPE_CHECKING: 14 | from .coordinator import AnycubicCloudDataUpdateCoordinator 15 | 16 | USER_TO_REDACT = { 17 | "birthday", 18 | "user_email", 19 | "password", 20 | "message_key", 21 | "last_login_ip", 22 | "casdoor_user_id", 23 | "casdoor_user", 24 | "user_nickname", 25 | "ip_country", 26 | "ip_province", 27 | "ip_city", 28 | "create_time", 29 | "create_day_time", 30 | "last_login_time", 31 | } 32 | PRINTER_TO_REDACT = { 33 | "machine_mac", 34 | } 35 | PROJECT_TO_REDACT = { 36 | "model", 37 | } 38 | 39 | TO_TAGGED_REDACT = { 40 | "id", 41 | "taskid", 42 | "user_id", 43 | "printer_id", 44 | "gcode_id", 45 | "key", 46 | } 47 | 48 | 49 | class TaggedRedacter: 50 | def __init__(self) -> None: 51 | self.redacted_values: dict[str, str] = dict() 52 | 53 | def _get_redacted_name( 54 | self, 55 | value: Any, 56 | ) -> str: 57 | if value not in self.redacted_values: 58 | num = len(self.redacted_values) + 1 59 | self.redacted_values[value] = f"**REDACTED_{num}**" 60 | 61 | return self.redacted_values[value] 62 | 63 | def redact_data( 64 | self, 65 | data: Any, 66 | to_redact: set[str], 67 | ) -> Any: 68 | if not isinstance(data, (dict, list)): 69 | return data 70 | 71 | if isinstance(data, list): 72 | return list([self.redact_data(val, to_redact) for val in data]) 73 | 74 | redacted = {**data} 75 | 76 | for key, value in redacted.items(): 77 | if value is None: 78 | continue 79 | if isinstance(value, str) and not value: 80 | continue 81 | if key in to_redact: 82 | redacted[key] = self._get_redacted_name(value) 83 | elif isinstance(value, dict): 84 | redacted[key] = self.redact_data(value, to_redact) 85 | elif isinstance(value, list): 86 | redacted[key] = list([self.redact_data(item, to_redact) for item in value]) 87 | 88 | return redacted 89 | 90 | 91 | def json_dict_or_value(value: str) -> dict[Any, Any] | str: 92 | try: 93 | parsed_value: Any = json.loads(value) 94 | if not isinstance(parsed_value, dict): 95 | return value 96 | 97 | parsed_value["__JSON_STRING_PARSED__"] = True 98 | return parsed_value 99 | except json.decoder.JSONDecodeError: 100 | return value 101 | 102 | 103 | def parse_all_json_data( 104 | input_data: Any 105 | ) -> Any: 106 | if isinstance(input_data, str): 107 | return json_dict_or_value(input_data) 108 | 109 | if not isinstance(input_data, (dict, list)): 110 | return input_data 111 | 112 | if isinstance(input_data, list): 113 | return list([parse_all_json_data(item) for item in input_data]) 114 | 115 | output_dict: dict[Any, Any] = dict() 116 | for key, value in input_data.items(): 117 | if isinstance(value, dict): 118 | output_dict[key] = parse_all_json_data(value) 119 | elif isinstance(value, list): 120 | output_dict[key] = list([parse_all_json_data(item) for item in value]) 121 | elif isinstance(value, str): 122 | output_dict[key] = json_dict_or_value(value) 123 | else: 124 | output_dict[key] = value 125 | 126 | return output_dict 127 | 128 | 129 | async def async_get_config_entry_diagnostics( 130 | hass: HomeAssistant, entry: ConfigEntry 131 | ) -> dict[str, Any]: 132 | """Return diagnostics for a config entry.""" 133 | coordinator: AnycubicCloudDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ 134 | COORDINATOR 135 | ] 136 | 137 | tRedacter = TaggedRedacter() 138 | 139 | assert coordinator.anycubic_api 140 | user_info: dict[str, Any] = await coordinator.anycubic_api.get_user_info(raw_data=True) 141 | printer_info: dict[str, Any] = await coordinator.anycubic_api.list_my_printers(raw_data=True) 142 | projects_info: dict[str, Any] = await coordinator.anycubic_api.list_all_projects(raw_data=True) 143 | latest_project_info = {} 144 | 145 | if projects_info['data'] and len(projects_info['data']) > 0: 146 | latest_project_info = await coordinator.anycubic_api.project_info_for_id( 147 | project_id=projects_info['data'][0]['id'], 148 | ) 149 | 150 | detailed_printer_info = list() 151 | if printer_info.get('data') is not None: 152 | for printer in printer_info['data']: 153 | printer_id = printer['id'] 154 | detailed_printer_info.append( 155 | await coordinator.anycubic_api.printer_info_for_id( 156 | printer_id, 157 | raw_data=True, 158 | ) 159 | ) 160 | return { 161 | "user_info": tRedacter.redact_data( 162 | async_redact_data( 163 | parse_all_json_data(user_info), 164 | USER_TO_REDACT, 165 | ), 166 | TO_TAGGED_REDACT 167 | ), 168 | "printer_info": { 169 | **printer_info, 170 | 'data': tRedacter.redact_data( 171 | async_redact_data( 172 | parse_all_json_data(printer_info['data']), 173 | PRINTER_TO_REDACT, 174 | ), 175 | TO_TAGGED_REDACT 176 | ), 177 | }, 178 | "projects_info": { 179 | **projects_info, 180 | 'data': [ 181 | tRedacter.redact_data( 182 | async_redact_data( 183 | parse_all_json_data(x), 184 | PROJECT_TO_REDACT, 185 | ), 186 | TO_TAGGED_REDACT 187 | ) for x in projects_info['data'] 188 | ], 189 | }, 190 | "detailed_printer_info": tRedacter.redact_data( 191 | async_redact_data( 192 | parse_all_json_data(detailed_printer_info), 193 | PRINTER_TO_REDACT, 194 | ), 195 | TO_TAGGED_REDACT 196 | ), 197 | "latest_project_info": tRedacter.redact_data( 198 | async_redact_data( 199 | parse_all_json_data(latest_project_info), 200 | PROJECT_TO_REDACT, 201 | ), 202 | TO_TAGGED_REDACT 203 | ), 204 | } 205 | -------------------------------------------------------------------------------- /custom_components/anycubic_cloud/frontend_panel/src/components/ui/select-dropdown.ts: -------------------------------------------------------------------------------- 1 | import { mdiChevronDown } from "@mdi/js"; 2 | 3 | import { CSSResult, LitElement, css, html, nothing } from "lit"; 4 | import { property, state } from "lit/decorators.js"; 5 | import { map } from "lit/directives/map.js"; 6 | import { styleMap } from "lit/directives/style-map.js"; 7 | 8 | import { customElementIfUndef } from "../../internal/register-custom-element"; 9 | 10 | import { fireEvent } from "../../fire_event"; 11 | import { DomClickEvent, EvtTargItemKey, LitTemplateResult } from "../../types"; 12 | 13 | @customElementIfUndef("anycubic-ui-select-dropdown-item") 14 | export class AnycubicUISelectDropdownItem extends LitElement { 15 | @property() 16 | public item: string; 17 | 18 | @state() 19 | private _isActive: boolean = false; 20 | 21 | render(): LitTemplateResult { 22 | const stylesOption = { 23 | filter: this._isActive ? "brightness(80%)" : "brightness(100%)", 24 | }; 25 | return html` 26 | 36 | `; 37 | } 38 | 39 | private _setActive = (): void => { 40 | this._isActive = true; 41 | }; 42 | 43 | private _setInactive = (): void => { 44 | this._isActive = false; 45 | }; 46 | 47 | static get styles(): CSSResult { 48 | return css` 49 | :host { 50 | box-sizing: border-box; 51 | width: 100%; 52 | } 53 | 54 | .ac-ui-seld-select { 55 | width: 100%; 56 | border: none; 57 | outline: none; 58 | background: var( 59 | --ha-card-background, 60 | var(--card-background-color, white) 61 | ); 62 | padding: 0 16px; 63 | box-sizing: border-box; 64 | font-size: 16px; 65 | font-weight: bold; 66 | line-height: 48px; 67 | text-align: left; 68 | cursor: pointer; 69 | color: var(--primary-text-color); 70 | } 71 | `; 72 | } 73 | } 74 | 75 | @customElementIfUndef("anycubic-ui-select-dropdown") 76 | export class AnycubicUISelectDropdown extends LitElement { 77 | @property({ attribute: "available-options" }) 78 | public availableOptions?: object; 79 | 80 | @property() 81 | public placeholder: string; 82 | 83 | @property({ attribute: "initial-item" }) 84 | public initialItem: string | undefined; 85 | 86 | @state() 87 | private _selectedItem: string | undefined; 88 | 89 | @state() 90 | private _active: boolean = false; 91 | 92 | @state() 93 | private _hidden: boolean = false; 94 | 95 | // eslint-disable-next-line @typescript-eslint/require-await 96 | async firstUpdated(): Promise { 97 | this._selectedItem = this.initialItem; 98 | this._hidden = true; 99 | this._active = false; 100 | this.requestUpdate(); 101 | } 102 | 103 | render(): LitTemplateResult { 104 | const stylesButton = { 105 | backgroundColor: this._active ? "rgba(0,0,0,0.3)" : "rgba(0,0,0,0.15)", 106 | }; 107 | const stylesOptions = { 108 | opacity: this._hidden ? 0.0 : 1.0, 109 | transform: this._hidden ? "scaleY(0.0)" : "scaleY(1.0)", 110 | }; 111 | return this.availableOptions 112 | ? html` 113 | 123 |
124 | ${this._renderOptions()} 125 |
126 | ` 127 | : nothing; 128 | } 129 | 130 | private _renderOptions(): Generator { 131 | return map( 132 | Object.keys(this.availableOptions as object), 133 | (key: string | number, _index: number): LitTemplateResult => { 134 | return html` 135 | 140 | `; 141 | }, 142 | ); 143 | } 144 | 145 | private _showOptions = (): void => { 146 | this._hidden = false; 147 | }; 148 | 149 | private _hideOptions = (): void => { 150 | this._hidden = true; 151 | }; 152 | 153 | private _setActive = (): void => { 154 | this._active = true; 155 | }; 156 | 157 | private _setInactive = (): void => { 158 | this._active = false; 159 | }; 160 | 161 | private _selectItem = (ev: DomClickEvent): void => { 162 | if (!this.availableOptions) { 163 | return; 164 | } 165 | const key = ev.currentTarget.item_key; 166 | this._selectedItem = this.availableOptions[key] as string | undefined; 167 | fireEvent(this, "ac-select-dropdown", { 168 | key: key, 169 | value: this.availableOptions[key] as string | undefined, 170 | }); 171 | this._hidden = true; 172 | }; 173 | 174 | static get styles(): CSSResult { 175 | return css` 176 | :host { 177 | box-sizing: border-box; 178 | width: 100%; 179 | position: relative; 180 | background: var( 181 | --ha-card-background, 182 | var(--card-background-color, white) 183 | ); 184 | border-radius: 8px; 185 | } 186 | 187 | .ac-ui-select-button { 188 | width: 100%; 189 | border: none; 190 | outline: none; 191 | padding: 0 16px; 192 | box-sizing: border-box; 193 | font-size: 16px; 194 | font-weight: bold; 195 | line-height: 48px; 196 | border-radius: 8px; 197 | text-align: left; 198 | cursor: pointer; 199 | display: flex; 200 | flex-direction: row; 201 | justify-content: space-between; 202 | background-color: rgba(0, 0, 0, 0.05); 203 | align-items: center; 204 | color: var(--primary-text-color); 205 | } 206 | 207 | .ac-ui-select-options { 208 | width: 100%; 209 | position: absolute; 210 | top: 0px; 211 | left: 0px; 212 | box-sizing: border-box; 213 | display: flex; 214 | flex-direction: column; 215 | border-radius: 8px; 216 | overflow: hidden; 217 | box-shadow: 218 | 0px 10px 20px rgba(0, 0, 0, 0.19), 219 | 0px 6px 6px rgba(0, 0, 0, 0.23); 220 | z-index: 11; 221 | opacity: 0; 222 | transform: scaleY(0); 223 | transform-origin: top center; 224 | } 225 | `; 226 | } 227 | } 228 | --------------------------------------------------------------------------------