Initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
* text=auto
|
||||
|
||||
*.py text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.json text eol=lf
|
||||
Dockerfile* text eol=lf
|
||||
*.sh text eol=lf
|
||||
addon/rootfs/etc/services.d/**/run text eol=lf
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -0,0 +1,66 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Validate JSON
|
||||
run: |
|
||||
python -m json.tool hacs.json > /dev/null
|
||||
python -m json.tool repository.json > /dev/null
|
||||
python -m json.tool custom_components/elegoo_spaghetti_detection/manifest.json > /dev/null
|
||||
python -m json.tool custom_components/elegoo_spaghetti_detection/translations/en.json > /dev/null
|
||||
|
||||
- name: Validate Python syntax
|
||||
run: python -m compileall custom_components/elegoo_spaghetti_detection addon/rootfs/app
|
||||
|
||||
- name: Validate YAML
|
||||
run: |
|
||||
python -m pip install pyyaml
|
||||
python - <<'PY'
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
|
||||
class Loader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
def unknown_constructor(loader, tag_suffix, node):
|
||||
if isinstance(node, yaml.MappingNode):
|
||||
return loader.construct_mapping(node)
|
||||
if isinstance(node, yaml.SequenceNode):
|
||||
return loader.construct_sequence(node)
|
||||
return loader.construct_scalar(node)
|
||||
|
||||
Loader.add_multi_constructor("!", unknown_constructor)
|
||||
|
||||
paths = [
|
||||
Path("docker-compose.yaml"),
|
||||
Path("addon/config.yaml"),
|
||||
Path("custom_components/elegoo_spaghetti_detection/services.yaml"),
|
||||
Path(".github/workflows/ci.yaml"),
|
||||
Path(".github/workflows/hassfest.yaml"),
|
||||
Path(".github/workflows/validate.yaml"),
|
||||
Path(".github/dependabot.yml"),
|
||||
*Path("examples").glob("*.yaml"),
|
||||
]
|
||||
|
||||
for path in sorted(paths):
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
yaml.load(handle, Loader=Loader)
|
||||
PY
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Validate with hassfest
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-hacs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: HACS validation
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
|
||||
.vscode/
|
||||
@@ -0,0 +1,21 @@
|
||||
# Contributing
|
||||
|
||||
Work locally, keep changes focused, and test in Home Assistant before opening a
|
||||
pull request.
|
||||
|
||||
- Use clear commit messages.
|
||||
- Do not commit Home Assistant tokens, camera proxy tokens, SSH keys, or local
|
||||
deployment notes.
|
||||
- Keep the integration domain as `elegoo_spaghetti_detection`.
|
||||
- Validate JSON, YAML, Python syntax, HACS, and Hassfest before opening a pull
|
||||
request.
|
||||
|
||||
## Local Validation
|
||||
|
||||
```bash
|
||||
python -m json.tool hacs.json > /dev/null
|
||||
python -m json.tool custom_components/elegoo_spaghetti_detection/manifest.json > /dev/null
|
||||
python -m compileall custom_components/elegoo_spaghetti_detection addon/rootfs/app
|
||||
```
|
||||
|
||||
The GitHub workflows run the full repository checks.
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Elegoo Spaghetti Detection
|
||||
|
||||
Home Assistant spaghetti/failure detection for Elegoo FDM printers. It is
|
||||
tested with Elegoo Centauri Carbon 2 through
|
||||
[`danielcherubini/elegoo-homeassistant`](https://github.com/danielcherubini/elegoo-homeassistant),
|
||||
but the detector can use any Home Assistant camera entity.
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=hepter&repository=ha-elegoo-spaghetti-detection&category=integration)
|
||||
|
||||
This project started as an Elegoo-focused adaptation of
|
||||
[`nberktumer/ha-bambu-lab-p1-spaghetti-detection`](https://github.com/nberktumer/ha-bambu-lab-p1-spaghetti-detection).
|
||||
The original project provided the Obico ML workflow and Home Assistant
|
||||
integration foundation.
|
||||
|
||||
This repository is not affiliated with Elegoo, Home Assistant, HACS, Obico, or
|
||||
the original upstream author.
|
||||
|
||||
## Scope
|
||||
|
||||
The integration detects possible print failures and exposes Home Assistant
|
||||
entities/events. It does not directly control the printer.
|
||||
|
||||
Detection flow:
|
||||
|
||||
```text
|
||||
camera snapshot -> ML server -> confidence/result sensors -> Home Assistant events
|
||||
```
|
||||
|
||||
Printer-specific actions such as pause, resume, stop, and notifications belong
|
||||
in user automations. Ready-to-edit examples are included.
|
||||
|
||||
## Features
|
||||
|
||||
- Works with Home Assistant camera entities, including Elegoo chamber cameras.
|
||||
- Uses an Obico/TSD FDM failure model running in a local Docker/HA add-on server.
|
||||
- Validates ML server health and camera image reachability during setup.
|
||||
- Optional print status sensor gates scheduled detection to active print states.
|
||||
- Elegoo `print_status` sensors are guarded by the companion `current_status`
|
||||
sensor when it exists, avoiding scheduled checks during homing/idle states
|
||||
where `print_status` can remain `printing`.
|
||||
- Optional chamber light control can leave the light alone, turn it on and keep
|
||||
it on, or temporarily turn it on and restore the previous state after each
|
||||
snapshot.
|
||||
- Manual `Test Spaghetti Detection` button.
|
||||
- Confidence, raw score, detection count, status, last run, next run, and last
|
||||
error sensors.
|
||||
- `binary_sensor.<prefix>_spaghetti_detected`.
|
||||
- Events for every result and for detected failures.
|
||||
- ML server web dashboard, JSON status, recent request logs, and image debug
|
||||
endpoint.
|
||||
- CPU-first ML startup by default to avoid CUDA timeout failures on systems
|
||||
without a working GPU runtime.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Installation](docs/installation.md)
|
||||
- [Configuration](docs/configuration.md)
|
||||
- [Automation examples](docs/automations.md)
|
||||
- [Dashboard examples](docs/dashboard.md)
|
||||
- [ML server and logs](docs/ml-server.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [HACS publishing notes](docs/HACS_PUBLISHING.md)
|
||||
|
||||
## Screenshots
|
||||
|
||||
Integration setup:
|
||||
|
||||

|
||||
|
||||
Enhanced dashboard in idle state:
|
||||
|
||||

|
||||
|
||||
Enhanced dashboard after a detected failure:
|
||||
|
||||

|
||||
|
||||
Camera frame with an obvious spaghetti failure:
|
||||
|
||||

|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Run the ML server. See [ML server and logs](docs/ml-server.md).
|
||||
2. Install the custom integration through HACS or manually. See
|
||||
[Installation](docs/installation.md).
|
||||
3. Add `Elegoo Spaghetti Detection` from Home Assistant integrations.
|
||||
4. Select the camera and optional print status sensor. See
|
||||
[Configuration](docs/configuration.md).
|
||||
5. Press `Test Spaghetti Detection`.
|
||||
6. Add one of the [automation examples](docs/automations.md).
|
||||
7. Add one of the [dashboard examples](docs/dashboard.md).
|
||||
|
||||
## Typical Elegoo CC2 Entities
|
||||
|
||||
Your entity IDs depend on the printer/device name in Home Assistant. With a
|
||||
default-ish Elegoo Centauri Carbon 2 setup they often look like:
|
||||
|
||||
```text
|
||||
camera.elegoo_centauri_carbon2_chamber_camera
|
||||
sensor.elegoo_centauri_carbon2_print_status
|
||||
light.elegoo_centauri_carbon2_chamber_light
|
||||
button.elegoo_centauri_carbon2_pause_print
|
||||
button.elegoo_centauri_carbon2_resume_print
|
||||
button.elegoo_centauri_carbon2_stop_print
|
||||
```
|
||||
|
||||
The integration setup uses only camera, optional print status, and optional
|
||||
light. Pause/stop/resume are shown only in automation examples.
|
||||
|
||||
## Events
|
||||
|
||||
Every detection result fires:
|
||||
|
||||
```text
|
||||
elegoo_spaghetti_detection_result
|
||||
```
|
||||
|
||||
Detected failures fire:
|
||||
|
||||
```text
|
||||
elegoo_spaghetti_detection_detected
|
||||
```
|
||||
|
||||
When a print status sensor is configured, scheduled detected events are emitted
|
||||
once per active print window. If the printer leaves the configured active states
|
||||
and later returns to an active state, a new detected failure can emit one new
|
||||
event.
|
||||
|
||||
Use this event for notification, pause, and stop automations.
|
||||
|
||||
`elegoo_spaghetti_detection_result` still fires for every completed check. Use
|
||||
that event for logging, dashboards, or advanced automations only; notification
|
||||
automations based on the result event can repeat every detection interval.
|
||||
|
||||
Event data includes:
|
||||
|
||||
```text
|
||||
config_entry
|
||||
detector
|
||||
name
|
||||
camera
|
||||
manual
|
||||
printer_state
|
||||
confidence
|
||||
raw_score
|
||||
detected
|
||||
detections
|
||||
image_url
|
||||
last_error
|
||||
last_run
|
||||
next_run
|
||||
status
|
||||
```
|
||||
|
||||
Use these fields in notifications and advanced automations.
|
||||
@@ -0,0 +1,3 @@
|
||||
model/*.onnx
|
||||
model/*.darknet
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.weights filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,2 @@
|
||||
model/*.onnx
|
||||
model/*.darknet
|
||||
@@ -0,0 +1,37 @@
|
||||
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm as darknet_builder
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt update && apt install -y ca-certificates build-essential gcc g++ cmake git
|
||||
WORKDIR /
|
||||
|
||||
# Lock darknet version for reproducibility.
|
||||
RUN git clone https://github.com/AlexeyAB/darknet && cd darknet && git checkout 59c86222c5387bffd9108a21885f80e980ece234
|
||||
RUN cd darknet \
|
||||
&& sed -i 's/GPU=1/GPU=0/' Makefile \
|
||||
&& sed -i 's/CUDNN=1/CUDNN=0/' Makefile \
|
||||
&& sed -i 's/CUDNN_HALF=1/CUDNN_HALF=0/' Makefile \
|
||||
&& sed -i 's/LIBSO=0/LIBSO=1/' Makefile \
|
||||
&& make -j 4 && mv libdarknet.so libdarknet_cpu.so
|
||||
|
||||
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm
|
||||
|
||||
RUN apt update && apt install --no-install-recommends -y ca-certificates python3-pip wget python3 python3-venv
|
||||
|
||||
COPY rootfs /
|
||||
COPY --from=darknet_builder /darknet /darknet
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN python3 -m venv venv
|
||||
ENV VIRTUAL_ENV=/app/venv
|
||||
ENV PATH=/app/venv/bin:$PATH
|
||||
|
||||
RUN pip3 install --upgrade pip && \
|
||||
pip3 install opencv_python_headless && \
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
RUN echo 'Downloading the latest failure detection AI model in Darknet format...' && \
|
||||
wget -O model/model-weights.darknet $(cat model/model-weights.darknet.url | tr -d '\r') && \
|
||||
echo 'Downloading the latest failure detection AI model in ONNX format...' && \
|
||||
wget -O model/model-weights.onnx $(cat model/model-weights.onnx.url | tr -d '\r')
|
||||
|
||||
RUN chmod +x /etc/services.d/ha-elegoo-spaghetti-detection/run
|
||||
@@ -0,0 +1,44 @@
|
||||
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm as darknet_builder
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt update && apt install -y ca-certificates build-essential gcc g++ cmake git
|
||||
WORKDIR /
|
||||
# Lock darknet version for reproducibility
|
||||
RUN git clone https://github.com/AlexeyAB/darknet && cd darknet && git checkout 59c86222c5387bffd9108a21885f80e980ece234
|
||||
# compile CPU version
|
||||
RUN cd darknet \
|
||||
&& sed -i 's/GPU=1/GPU=0/' Makefile \
|
||||
&& sed -i 's/CUDNN=1/CUDNN=0/' Makefile \
|
||||
&& sed -i 's/CUDNN_HALF=1/CUDNN_HALF=0/' Makefile \
|
||||
&& sed -i 's/LIBSO=0/LIBSO=1/' Makefile \
|
||||
&& make -j 4 && mv libdarknet.so libdarknet_cpu.so
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
FROM ghcr.io/home-assistant/amd64-base-debian:bookworm as ml_api_base_amd64
|
||||
|
||||
RUN apt update && apt install --no-install-recommends -y ca-certificates python3-pip wget python3 python3-venv
|
||||
|
||||
COPY --from=darknet_builder /darknet /darknet
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p model
|
||||
COPY rootfs/app/requirements.txt /app/requirements.txt
|
||||
COPY rootfs/app/model/model-weights.darknet.url /app/model/model-weights.darknet.url
|
||||
COPY rootfs/app/model/model-weights.onnx.url /app/model/model-weights.onnx.url
|
||||
|
||||
RUN python3 -m venv venv
|
||||
ENV VIRTUAL_ENV /app/venv
|
||||
ENV PATH /app/venv/bin:$PATH
|
||||
|
||||
RUN pip3 install --upgrade pip && \
|
||||
pip3 install opencv_python_headless && \
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
RUN echo 'Downloading the latest failure detection AI model in Darknet format...' && \
|
||||
wget -O model/model-weights.darknet $(cat model/model-weights.darknet.url | tr -d '\r') && \
|
||||
echo 'Downloading the latest failure detection AI model in ONNX format...' && \
|
||||
wget -O model/model-weights.onnx $(cat model/model-weights.onnx.url | tr -d '\r')
|
||||
|
||||
COPY rootfs /
|
||||
RUN chmod +x /etc/services.d/ha-elegoo-spaghetti-detection/run
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM thespaghettidetective/ml_api_base:1.3
|
||||
WORKDIR /app
|
||||
EXPOSE 3333
|
||||
|
||||
RUN mkdir -p model
|
||||
COPY rootfs/app/requirements.txt /app/requirements.txt
|
||||
COPY rootfs/app/model/model-weights.darknet.url /app/model/model-weights.darknet.url
|
||||
COPY rootfs/app/model/model-weights.onnx.url /app/model/model-weights.onnx.url
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
RUN echo 'Downloading the latest failure detection AI model in Darknet format...'
|
||||
RUN wget -O model/model-weights.darknet $(cat model/model-weights.darknet.url | tr -d '\r')
|
||||
RUN echo 'Downloading the latest failure detection AI model in ONNX format...'
|
||||
RUN wget -O model/model-weights.onnx $(cat model/model-weights.onnx.url | tr -d '\r')
|
||||
|
||||
ADD rootfs/app /app
|
||||
ENV FLASK_APP server.py
|
||||
|
||||
CMD gunicorn --bind "0.0.0.0:3333" --workers "${GUNICORN_WORKERS:-1}" --timeout "${GUNICORN_TIMEOUT:-120}" --error-logfile - --log-level info wsgi
|
||||
@@ -0,0 +1,18 @@
|
||||
name: "Elegoo Spaghetti Detection Server"
|
||||
description: "Obico ML server for Elegoo spaghetti detection"
|
||||
version: "1.0.0"
|
||||
slug: "ha_elegoo_spaghetti_detection_addon"
|
||||
init: false
|
||||
arch:
|
||||
- amd64
|
||||
startup: services
|
||||
ports:
|
||||
3333/tcp: 3333
|
||||
options:
|
||||
obico_api_secret: "obico_api_secret"
|
||||
use_gpu: false
|
||||
gunicorn_timeout: 120
|
||||
schema:
|
||||
obico_api_secret: str
|
||||
use_gpu: bool
|
||||
gunicorn_timeout: int
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
#!python3
|
||||
import cv2
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
from addon import compare_detections, Detection
|
||||
import os
|
||||
import argparse
|
||||
import time
|
||||
|
||||
KNOWN_IMAGE_EXTENSIONS = ('.jpg', '.png')
|
||||
KNOWN_VIDEO_EXTENSIONS = ('.mp4', '.avi')
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("image", type=str, help="Image file path")
|
||||
parser.add_argument("--weights", type=str, help="Model weights file")
|
||||
parser.add_argument("--det-threshold", type=float, default=0.25, help="Detection threshold")
|
||||
parser.add_argument("--nms-threshold", type=float, default=0.4, help="NMS threshold")
|
||||
parser.add_argument("--preheat", action='store_true', help="Make a dry run of NN for initlalization")
|
||||
parser.add_argument("--cpu", action='store_true', help="Force use CPU")
|
||||
parser.add_argument("--save-detections-to", type=str, help="Save detections into this file")
|
||||
parser.add_argument("--compare-detections-with", type=str, help="Load detections from this file and compare with result")
|
||||
parser.add_argument("--render-to", type=str, help="Save detections into this file or directory")
|
||||
parser.add_argument("--print", action='store_true', help="Print detections")
|
||||
opt = parser.parse_args()
|
||||
|
||||
net_main_1 = load_net("rootfs/model/model.cfg", "rootfs/model/model.meta", weights_path=opt.weights)
|
||||
|
||||
# force use CPU, only implemented for ONNX
|
||||
if opt.cpu and onnx_ready and isinstance(net_main_1, OnnxNet):
|
||||
net_main_1.force_cpu()
|
||||
|
||||
filename = os.path.basename(opt.image)
|
||||
filename, extension = os.path.splitext(filename)
|
||||
|
||||
is_image = extension in KNOWN_IMAGE_EXTENSIONS
|
||||
is_video = extension in KNOWN_VIDEO_EXTENSIONS
|
||||
frame_number = 0
|
||||
vwr = None
|
||||
if is_video:
|
||||
cap = cv2.VideoCapture(opt.image)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
reading_success, custom_image_bgr = cap.read()
|
||||
if opt.render_to:
|
||||
fourcc = cv2.VideoWriter_fourcc("m", "p", "4", "v")
|
||||
vwr = cv2.VideoWriter(opt.render_to, fourcc, fps, (frame_w, frame_h))
|
||||
else:
|
||||
cap = None
|
||||
fps = 0.0
|
||||
custom_image_bgr = cv2.imread(opt.image)
|
||||
reading_success = True
|
||||
|
||||
|
||||
# this will make library initialize all the required resources at the first run
|
||||
# then the following runs will be much faster
|
||||
if opt.preheat:
|
||||
detections = detect(net_main_1, custom_image_bgr, thresh=opt.det_threshold, nms=opt.nms_threshold)
|
||||
|
||||
while reading_success:
|
||||
started_at = time.time()
|
||||
detections = detect(net_main_1, custom_image_bgr, thresh=opt.det_threshold, nms=opt.nms_threshold)
|
||||
finished_at = time.time()
|
||||
execution_time = finished_at - started_at
|
||||
print(f"Frame #{frame_number} execution time: {execution_time:.3} sec, detection count: {len(detections)}")
|
||||
|
||||
detections = Detection.from_tuple_list(detections)
|
||||
# dump detections into some file
|
||||
if opt.save_detections_to:
|
||||
output_filename, output_extension = os.path.splitext(opt.save_detections_to)
|
||||
if is_video and not output_extension and not os.path.exists(opt.save_detections_to):
|
||||
os.makedirs(opt.save_detections_to)
|
||||
if os.path.isdir(opt.save_detections_to):
|
||||
if is_video:
|
||||
output_file_name = f"{filename}#{frame_number:04}.json"
|
||||
else:
|
||||
output_file_name = f"{filename}.json"
|
||||
output_file_name = os.path.join(opt.save_detections_to, output_file_name)
|
||||
else:
|
||||
output_file_name = opt.save_detections_to
|
||||
|
||||
with open(output_file_name, "w") as f:
|
||||
json.dump([asdict(d) for d in detections], f)
|
||||
|
||||
# load detections from some file and compare with detection result
|
||||
if opt.compare_detections_with:
|
||||
if is_video:
|
||||
read_file_name = os.path.join(opt.compare_detections_with, f"{filename}#{frame_number:04}.json")
|
||||
else:
|
||||
read_file_name = opt.compare_detections_with
|
||||
|
||||
with open(read_file_name) as f:
|
||||
items = json.load(f)
|
||||
loaded = [Detection.from_dict(d) for d in items]
|
||||
compare_result = compare_detections(loaded, detections)
|
||||
if not compare_result:
|
||||
print(f"Frame #{frame_number} loaded detections and resulting are different")
|
||||
if opt.render_to:
|
||||
for d in detections:
|
||||
cv2.rectangle(custom_image_bgr,
|
||||
(int(d.box.left()), int(d.box.top())), (int(d.box.right()), int(d.box.bottom())),
|
||||
(0, 255, 0), 2)
|
||||
if vwr:
|
||||
vwr.write(custom_image_bgr)
|
||||
else:
|
||||
cv2.imwrite(opt.render_to, custom_image_bgr)
|
||||
|
||||
|
||||
if opt.print:
|
||||
print(detections)
|
||||
|
||||
if is_image:
|
||||
reading_success = False
|
||||
elif cap:
|
||||
reading_success, custom_image_bgr = cap.read()
|
||||
frame_number += 1
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import os
|
||||
from functools import wraps
|
||||
|
||||
from flask import Response, request
|
||||
|
||||
ML_API_TOKEN = os.environ.get("ML_API_TOKEN")
|
||||
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def check_authorization(*args, **kwargs):
|
||||
if (
|
||||
request.headers.get("Authorization") == f"Bearer {ML_API_TOKEN}"
|
||||
or request.args.get("token") == ML_API_TOKEN
|
||||
):
|
||||
return f(*args, **kwargs)
|
||||
return Response(status=401)
|
||||
|
||||
@wraps(f)
|
||||
def passthru(*args, **kwargs):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if ML_API_TOKEN:
|
||||
return check_authorization
|
||||
return passthru
|
||||
@@ -0,0 +1,254 @@
|
||||
# pylint: disable=R, W0401, W0614, W0703
|
||||
from ctypes import *
|
||||
import random
|
||||
import os
|
||||
import cv2
|
||||
import platform
|
||||
from typing import List, Tuple
|
||||
|
||||
# C-structures from Darknet lib
|
||||
|
||||
class BOX(Structure):
|
||||
_fields_ = [("x", c_float),
|
||||
("y", c_float),
|
||||
("w", c_float),
|
||||
("h", c_float)]
|
||||
|
||||
|
||||
class DETECTION(Structure):
|
||||
_fields_ = [("bbox", BOX),
|
||||
("classes", c_int),
|
||||
("best_class_idx", c_int),
|
||||
("prob", POINTER(c_float)),
|
||||
("mask", POINTER(c_float)),
|
||||
("objectness", c_float),
|
||||
("sort_class", c_int),
|
||||
("uc", POINTER(c_float)),
|
||||
("points", c_int),
|
||||
("embeddings", POINTER(c_float)),
|
||||
("embedding_size", c_int),
|
||||
("sim", c_float),
|
||||
("track_id", c_int)]
|
||||
|
||||
class IMAGE(Structure):
|
||||
_fields_ = [("w", c_int),
|
||||
("h", c_int),
|
||||
("c", c_int),
|
||||
("data", POINTER(c_float))]
|
||||
|
||||
|
||||
class METADATA(Structure):
|
||||
_fields_ = [("classes", c_int),
|
||||
("names", POINTER(c_char_p))]
|
||||
|
||||
class YoloNet:
|
||||
"""Darknet-based detector implementation"""
|
||||
net: c_void_p
|
||||
meta: METADATA
|
||||
|
||||
def __init__(self, weight_path: str, meta_path: str, config_path: str, asked_to_use_gpu: bool):
|
||||
if not os.path.exists(config_path):
|
||||
raise ValueError("Invalid config path `"+os.path.abspath(config_path)+"`")
|
||||
if not os.path.exists(weight_path):
|
||||
raise ValueError("Invalid weight path `"+os.path.abspath(weight_path)+"`")
|
||||
if not os.path.exists(meta_path):
|
||||
raise ValueError("Invalid data file path `"+os.path.abspath(meta_path)+"`")
|
||||
if not lib:
|
||||
raise ImportError(f"Unable to load darknet module.")
|
||||
|
||||
if asked_to_use_gpu and not using_gpu:
|
||||
raise Exception('I respectfully decline to load the net as I am asked to use GPU but the loaded darknet module does NOT have GPU support')
|
||||
|
||||
self.net = load_net_custom(config_path.encode("ascii"), weight_path.encode("ascii"), 0, 1) # batch size = 1
|
||||
self.meta = load_meta(meta_path.encode("ascii"))
|
||||
|
||||
def detect(self, meta, image, alt_names, thresh=.5, hier_thresh=.5, nms=.45, debug=False) -> List[Tuple[str, float, Tuple[float, float, float, float]]]:
|
||||
#pylint: disable= C0321
|
||||
custom_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
im, arr = array_to_image(custom_image) # you should comment line below: free_image(im)
|
||||
if debug:
|
||||
print("Loaded image")
|
||||
num = c_int(0)
|
||||
if debug:
|
||||
print("Assigned num")
|
||||
pnum = pointer(num)
|
||||
if debug:
|
||||
print("Assigned pnum")
|
||||
predict_image(self.net, im)
|
||||
if debug:
|
||||
print("did prediction")
|
||||
dets = get_network_boxes(self.net, custom_image.shape[1], custom_image.shape[0], thresh, hier_thresh, None, 0, pnum, 0) # OpenCV
|
||||
if debug:
|
||||
print("Got dets")
|
||||
num = pnum[0]
|
||||
if debug:
|
||||
print("got zeroth index of pnum")
|
||||
if nms:
|
||||
do_nms_sort(dets, num, meta.classes, nms)
|
||||
if debug:
|
||||
print("did sort")
|
||||
res = []
|
||||
if debug:
|
||||
print("about to range")
|
||||
for j in range(num):
|
||||
if debug:
|
||||
print("Ranging on "+str(j)+" of "+str(num))
|
||||
if debug:
|
||||
print("Classes: "+str(meta), meta.classes, meta.names)
|
||||
for i in range(meta.classes):
|
||||
if debug:
|
||||
print("Class-ranging on "+str(i)+" of "+str(meta.classes)+"= "+str(dets[j].prob[i]))
|
||||
if dets[j].prob[i] > 0:
|
||||
b = dets[j].bbox
|
||||
if alt_names is None:
|
||||
nameTag = meta.names[i]
|
||||
else:
|
||||
nameTag = alt_names[i]
|
||||
if debug:
|
||||
print("Got bbox", b)
|
||||
print(nameTag)
|
||||
print(dets[j].prob[i])
|
||||
print((b.x, b.y, b.w, b.h))
|
||||
res.append((nameTag, dets[j].prob[i], (b.x, b.y, b.w, b.h)))
|
||||
if debug:
|
||||
print("did range")
|
||||
res = sorted(res, key=lambda x: -x[1])
|
||||
if debug:
|
||||
print("did sort")
|
||||
free_detections(dets, num)
|
||||
if debug:
|
||||
print("freed detections")
|
||||
return res
|
||||
|
||||
# Loads darknet shared library. May fail if some dependencies like OpenCV not installed
|
||||
# libdarknet_gpu.so needs Cuda + Cudnn and other libraries in path, which may not exist
|
||||
# For the such case, it will try to load libdarknet.so instead
|
||||
lib = None
|
||||
using_gpu = False
|
||||
|
||||
print('\n')
|
||||
so_path = os.path.join('/darknet', "libdarknet_cpu.so")
|
||||
lib = CDLL(so_path, RTLD_GLOBAL)
|
||||
print(f" Darknet is now running on CPU.")
|
||||
print('\n')
|
||||
|
||||
if lib:
|
||||
lib.network_width.argtypes = [c_void_p]
|
||||
lib.network_width.restype = c_int
|
||||
lib.network_height.argtypes = [c_void_p]
|
||||
lib.network_height.restype = c_int
|
||||
|
||||
predict = lib.network_predict
|
||||
predict.argtypes = [c_void_p, POINTER(c_float)]
|
||||
predict.restype = POINTER(c_float)
|
||||
|
||||
if using_gpu:
|
||||
set_gpu = lib.cuda_set_device
|
||||
set_gpu.argtypes = [c_int]
|
||||
|
||||
make_image = lib.make_image
|
||||
make_image.argtypes = [c_int, c_int, c_int]
|
||||
make_image.restype = IMAGE
|
||||
|
||||
get_network_boxes = lib.get_network_boxes
|
||||
get_network_boxes.argtypes = [c_void_p, c_int, c_int, c_float, c_float, POINTER(c_int), c_int, POINTER(c_int), c_int]
|
||||
get_network_boxes.restype = POINTER(DETECTION)
|
||||
|
||||
make_network_boxes = lib.make_network_boxes
|
||||
make_network_boxes.argtypes = [c_void_p]
|
||||
make_network_boxes.restype = POINTER(DETECTION)
|
||||
|
||||
free_detections = lib.free_detections
|
||||
free_detections.argtypes = [POINTER(DETECTION), c_int]
|
||||
|
||||
free_ptrs = lib.free_ptrs
|
||||
free_ptrs.argtypes = [POINTER(c_void_p), c_int]
|
||||
|
||||
network_predict = lib.network_predict
|
||||
network_predict.argtypes = [c_void_p, POINTER(c_float)]
|
||||
|
||||
reset_rnn = lib.reset_rnn
|
||||
reset_rnn.argtypes = [c_void_p]
|
||||
|
||||
load_net = lib.load_network
|
||||
load_net.argtypes = [c_char_p, c_char_p, c_int]
|
||||
load_net.restype = c_void_p
|
||||
|
||||
load_net_custom = lib.load_network_custom
|
||||
load_net_custom.argtypes = [c_char_p, c_char_p, c_int, c_int]
|
||||
load_net_custom.restype = c_void_p
|
||||
|
||||
do_nms_obj = lib.do_nms_obj
|
||||
do_nms_obj.argtypes = [POINTER(DETECTION), c_int, c_int, c_float]
|
||||
|
||||
do_nms_sort = lib.do_nms_sort
|
||||
do_nms_sort.argtypes = [POINTER(DETECTION), c_int, c_int, c_float]
|
||||
|
||||
free_image = lib.free_image
|
||||
free_image.argtypes = [IMAGE]
|
||||
|
||||
letterbox_image = lib.letterbox_image
|
||||
letterbox_image.argtypes = [IMAGE, c_int, c_int]
|
||||
letterbox_image.restype = IMAGE
|
||||
|
||||
load_meta = lib.get_metadata
|
||||
lib.get_metadata.argtypes = [c_char_p]
|
||||
lib.get_metadata.restype = METADATA
|
||||
|
||||
load_image = lib.load_image_color
|
||||
load_image.argtypes = [c_char_p, c_int, c_int]
|
||||
load_image.restype = IMAGE
|
||||
|
||||
rgbgr_image = lib.rgbgr_image
|
||||
rgbgr_image.argtypes = [IMAGE]
|
||||
|
||||
predict_image = lib.network_predict_image
|
||||
predict_image.argtypes = [c_void_p, IMAGE]
|
||||
predict_image.restype = POINTER(c_float)
|
||||
|
||||
def sample(probs):
|
||||
s = sum(probs)
|
||||
probs = [a/s for a in probs]
|
||||
r = random.uniform(0, 1)
|
||||
for i in range(len(probs)):
|
||||
r = r - probs[i]
|
||||
if r <= 0:
|
||||
return i
|
||||
return len(probs)-1
|
||||
|
||||
|
||||
def c_array(ctype, values):
|
||||
arr = (ctype*len(values))()
|
||||
arr[:] = values
|
||||
return arr
|
||||
|
||||
def array_to_image(arr):
|
||||
import numpy as np
|
||||
# need to return old values to avoid python freeing memory
|
||||
arr = arr.transpose(2, 0, 1)
|
||||
c = arr.shape[0]
|
||||
h = arr.shape[1]
|
||||
w = arr.shape[2]
|
||||
arr = np.ascontiguousarray(arr.flat, dtype=np.float32) / 255.0
|
||||
data = arr.ctypes.data_as(POINTER(c_float))
|
||||
im = IMAGE(w, h, c, data)
|
||||
return im, arr
|
||||
|
||||
|
||||
def classify(net, meta, im):
|
||||
global alt_names
|
||||
|
||||
out = predict_image(net, im)
|
||||
res = []
|
||||
for i in range(meta.classes):
|
||||
if alt_names is None:
|
||||
nameTag = meta.names[i]
|
||||
else:
|
||||
nameTag = alt_names[i]
|
||||
res.append((nameTag, out[i]))
|
||||
res = sorted(res, key=lambda x: -x[1])
|
||||
return res
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!python3
|
||||
|
||||
# pylint: disable=R, W0401, W0614, W0703
|
||||
from lib.meta import Meta
|
||||
from os import environ, path
|
||||
|
||||
alt_names = None
|
||||
|
||||
darknet_ready = True
|
||||
try:
|
||||
from lib.darknet import YoloNet
|
||||
except Exception as e:
|
||||
print(f'Error during importing YoloNet! - {e}')
|
||||
darknet_ready = False
|
||||
|
||||
onnx_ready = True
|
||||
try:
|
||||
from lib.onnx import OnnxNet
|
||||
except Exception as e:
|
||||
print(f'Error during importing OnnxNet! - {e}')
|
||||
onnx_ready = False
|
||||
|
||||
|
||||
def load_net(config_path, meta_path, weights_path=None):
|
||||
|
||||
def try_loading_net(net_config_priority):
|
||||
for net_config in net_config_priority:
|
||||
weights = net_config['weights_path']
|
||||
use_gpu = net_config['use_gpu']
|
||||
|
||||
net_main = None
|
||||
try:
|
||||
print(f'----- Trying to load weights: {weights} - use_gpu = {use_gpu} -----')
|
||||
if weights.endswith(".onnx"):
|
||||
if not onnx_ready:
|
||||
raise Exception('Not loading ONNX net due to previous import failure. Check earlier log for errors.')
|
||||
net_main = OnnxNet(weights, meta_path, use_gpu)
|
||||
|
||||
elif weights.endswith(".darknet"):
|
||||
if not darknet_ready:
|
||||
raise Exception('Not loading darknet net due to previous import failure. Check earlier log for errors.')
|
||||
net_main = YoloNet(weights, meta_path, config_path, use_gpu)
|
||||
|
||||
else:
|
||||
raise Exception(f'Can not recognize net from weights file surfix: {weights}')
|
||||
|
||||
print('Succeeded!')
|
||||
return net_main
|
||||
except Exception as e:
|
||||
print(f'Failed! - {e}')
|
||||
|
||||
raise Exception(f'Failed to load any net after trying: {net_config_priority}')
|
||||
|
||||
global alt_names # pylint: disable=W0603
|
||||
|
||||
model_dir = path.join(path.dirname(path.realpath(__file__)), '..', 'model')
|
||||
use_gpu = environ.get('ML_USE_GPU', 'false').lower() in ('1', 'true', 'yes', 'on')
|
||||
preferred_backend = environ.get('ML_MODEL_BACKEND', 'onnx').lower()
|
||||
|
||||
cpu_priority = [
|
||||
dict(weights_path=path.join(model_dir, 'model-weights.onnx'), use_gpu=False),
|
||||
dict(weights_path=path.join(model_dir, 'model-weights.darknet'), use_gpu=False),
|
||||
]
|
||||
gpu_priority = [
|
||||
dict(weights_path=path.join(model_dir, 'model-weights.onnx'), use_gpu=True),
|
||||
dict(weights_path=path.join(model_dir, 'model-weights.darknet'), use_gpu=True),
|
||||
]
|
||||
|
||||
if preferred_backend == 'darknet':
|
||||
cpu_priority.reverse()
|
||||
gpu_priority.reverse()
|
||||
|
||||
net_config_priority = gpu_priority + cpu_priority if use_gpu else cpu_priority
|
||||
if weights_path is not None:
|
||||
net_config_priority = (
|
||||
[dict(weights_path=weights_path, use_gpu=True), dict(weights_path=weights_path, use_gpu=False)]
|
||||
if use_gpu
|
||||
else [dict(weights_path=weights_path, use_gpu=False)]
|
||||
)
|
||||
|
||||
net_main = try_loading_net(net_config_priority)
|
||||
|
||||
if alt_names is None:
|
||||
# In Python 3, the metafile default access craps out on Windows (but not Linux)
|
||||
# Read the names file and create a list to feed to detect
|
||||
try:
|
||||
meta = Meta(meta_path)
|
||||
alt_names = meta.names
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return net_main
|
||||
|
||||
def detect(net, image, thresh=.5, hier_thresh=.5, nms=.45, debug=False):
|
||||
return net.detect(net.meta, image, alt_names, thresh, hier_thresh, nms, debug)
|
||||
@@ -0,0 +1,111 @@
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
@dataclass
|
||||
class Box:
|
||||
"""Detection rect"""
|
||||
xc: float
|
||||
yc: float
|
||||
w: float
|
||||
h: float
|
||||
|
||||
@classmethod
|
||||
def from_tuple(cls, box: Tuple[float, float, float, float]) -> 'Box':
|
||||
return Box(xc=float(box[0]), yc=float(box[1]), w=float(box[2]), h=float(box[3]))
|
||||
|
||||
def left(self) -> float:
|
||||
return self.xc - self.w * 0.5
|
||||
|
||||
def right(self) -> float:
|
||||
return self.xc + self.w * 0.5
|
||||
|
||||
def top(self) -> float:
|
||||
return self.yc - self.h * 0.5
|
||||
|
||||
def bottom(self) -> float:
|
||||
return self.yc + self.h * 0.5
|
||||
|
||||
def calc_iou(self, other: 'Box') -> float:
|
||||
"""Calculates intersection over union ration which can be used to compare boxes"""
|
||||
al = self.left()
|
||||
ar = self.right()
|
||||
at = self.top()
|
||||
ab = self.bottom()
|
||||
|
||||
bl = other.left()
|
||||
br = other.right()
|
||||
bt = other.top()
|
||||
bb = other.bottom()
|
||||
|
||||
i_l = max(al, bl)
|
||||
i_r = min(ar, br)
|
||||
i_t = max(at, bt)
|
||||
i_b = min(ab, bb)
|
||||
|
||||
o_l = min(al, bl)
|
||||
o_r = max(ar, br)
|
||||
o_t = min(at, bt)
|
||||
o_b = max(ab, bb)
|
||||
|
||||
i_w = i_r - i_l
|
||||
i_h = i_b - i_t
|
||||
o_w = o_r - o_l
|
||||
o_h = o_b - o_t
|
||||
|
||||
o_a = o_w * o_h
|
||||
if o_a <= 0.0:
|
||||
return 0.0
|
||||
return i_w * i_h / o_a
|
||||
|
||||
|
||||
@dataclass
|
||||
class Detection:
|
||||
"""Detection result"""
|
||||
name: str
|
||||
confidence: float
|
||||
box: Box
|
||||
|
||||
@classmethod
|
||||
def from_tuple_list(cls, detections: List[Tuple[str, float, Tuple[float, float, float, float]]]) -> List['Detection']:
|
||||
return [Detection.from_tuple(d) for d in detections]
|
||||
|
||||
@classmethod
|
||||
def from_tuple(cls, detection: Tuple[str, float, Tuple[float, float, float, float]]) -> 'Detection':
|
||||
box = Box.from_tuple(detection[2])
|
||||
return Detection(detection[0], float(detection[1]), box)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Detection':
|
||||
return Detection(data['name'], data['confidence'], Box(**data['box']))
|
||||
|
||||
|
||||
|
||||
def compare_detections(l1: List[Detection], l2: List[Detection], threshold: float = 0.4) -> bool:
|
||||
"""Compares two lists of detections. Returns true if lists looks similar with some threshold"""
|
||||
|
||||
# Are there all boxes from l1 matching any in l2
|
||||
for a in l1:
|
||||
found = False
|
||||
for b in l2:
|
||||
iou = a.box.calc_iou(b.box)
|
||||
if iou >= threshold:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
|
||||
# are there all boxes in l2 matching any in l1
|
||||
# the list may differ and contain duplicates,
|
||||
# that's why we need two checks
|
||||
for b in l2:
|
||||
found = False
|
||||
for a in l1:
|
||||
iou = a.box.calc_iou(b.box)
|
||||
if iou >= threshold:
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from typing import List, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
import re
|
||||
|
||||
@dataclass
|
||||
class Meta:
|
||||
names: List[str] = field(default_factory=list)
|
||||
|
||||
def __init__(self, meta_path: str):
|
||||
names = None
|
||||
with open(meta_path) as f:
|
||||
meta_contents = f.read()
|
||||
match = re.search("names *= *(.*)$", meta_contents, re.IGNORECASE | re.MULTILINE)
|
||||
if match:
|
||||
names_path = match.group(1)
|
||||
try:
|
||||
if os.path.exists(names_path):
|
||||
with open(names_path) as namesFH:
|
||||
names_list = namesFH.read().strip().split("\n")
|
||||
names = [x.strip() for x in names_list]
|
||||
except TypeError:
|
||||
pass
|
||||
if names is None:
|
||||
names = ['failure']
|
||||
|
||||
self.names = names
|
||||
@@ -0,0 +1,132 @@
|
||||
from typing import List, Tuple
|
||||
import onnxruntime
|
||||
import numpy as np
|
||||
import cv2
|
||||
import os
|
||||
|
||||
from lib.meta import Meta
|
||||
|
||||
class OnnxNet:
|
||||
session: onnxruntime.InferenceSession
|
||||
meta: Meta
|
||||
|
||||
def __init__(self, onnx_path: str, meta_path: str, use_gpu: bool):
|
||||
providers = ['CUDAExecutionProvider'] if use_gpu else ['CPUExecutionProvider']
|
||||
self.session = onnxruntime.InferenceSession(onnx_path, providers=providers)
|
||||
self.meta = Meta(meta_path)
|
||||
|
||||
def detect(self, meta, image, alt_names, thresh=.5, hier_thresh=.5, nms=.45, debug=False) -> List[Tuple[str, float, Tuple[float, float, float, float]]]:
|
||||
input_h = self.session.get_inputs()[0].shape[2]
|
||||
input_w = self.session.get_inputs()[0].shape[3]
|
||||
width = image.shape[1]
|
||||
height = image.shape[0]
|
||||
|
||||
# Input
|
||||
resized = cv2.resize(image, (input_w, input_h), interpolation=cv2.INTER_LINEAR)
|
||||
img_in = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
|
||||
img_in = np.transpose(img_in, (2, 0, 1)).astype(np.float32)
|
||||
img_in = np.expand_dims(img_in, axis=0)
|
||||
img_in /= 255.0
|
||||
|
||||
input_name = self.session.get_inputs()[0].name
|
||||
outputs = self.session.run(None, {input_name: img_in})
|
||||
|
||||
detections = post_processing(outputs, width, height, thresh, nms, meta.names)
|
||||
return detections[0]
|
||||
|
||||
|
||||
def nms_cpu(boxes, confs, nms_thresh=0.5, min_mode=False):
|
||||
# print(boxes.shape)
|
||||
x1 = boxes[:, 0]
|
||||
y1 = boxes[:, 1]
|
||||
x2 = boxes[:, 2]
|
||||
y2 = boxes[:, 3]
|
||||
|
||||
areas = (x2 - x1) * (y2 - y1)
|
||||
order = confs.argsort()[::-1]
|
||||
|
||||
keep = []
|
||||
while order.size > 0:
|
||||
idx_self = order[0]
|
||||
idx_other = order[1:]
|
||||
|
||||
keep.append(idx_self)
|
||||
|
||||
xx1 = np.maximum(x1[idx_self], x1[idx_other])
|
||||
yy1 = np.maximum(y1[idx_self], y1[idx_other])
|
||||
xx2 = np.minimum(x2[idx_self], x2[idx_other])
|
||||
yy2 = np.minimum(y2[idx_self], y2[idx_other])
|
||||
|
||||
w = np.maximum(0.0, xx2 - xx1)
|
||||
h = np.maximum(0.0, yy2 - yy1)
|
||||
inter = w * h
|
||||
|
||||
if min_mode:
|
||||
over = inter / np.minimum(areas[order[0]], areas[order[1:]])
|
||||
else:
|
||||
over = inter / (areas[order[0]] + areas[order[1:]] - inter)
|
||||
|
||||
inds = np.where(over <= nms_thresh)[0]
|
||||
order = order[inds + 1]
|
||||
|
||||
return np.array(keep)
|
||||
|
||||
def post_processing(output, width, height, conf_thresh, nms_thresh, names):
|
||||
box_array = output[0]
|
||||
confs = output[1]
|
||||
|
||||
if type(box_array).__name__ != 'ndarray':
|
||||
box_array = box_array.cpu().detach().numpy()
|
||||
confs = confs.cpu().detach().numpy()
|
||||
|
||||
num_classes = confs.shape[2]
|
||||
|
||||
# [batch, num, 4]
|
||||
box_array = box_array[:, :, 0]
|
||||
|
||||
# [batch, num, num_classes] --> [batch, num]
|
||||
max_conf = np.max(confs, axis=2)
|
||||
max_id = np.argmax(confs, axis=2)
|
||||
|
||||
box_x1x1x2y2_to_xcycwh_scaled = lambda b: \
|
||||
(
|
||||
float(0.5 * width * (b[0] + b[2])),
|
||||
float(0.5 * height * (b[1] + b[3])),
|
||||
float(width * (b[2] - b[0])),
|
||||
float(width * (b[3] - b[1]))
|
||||
)
|
||||
dets_batch = []
|
||||
for i in range(box_array.shape[0]):
|
||||
|
||||
argwhere = max_conf[i] > conf_thresh
|
||||
l_box_array = box_array[i, argwhere, :]
|
||||
l_max_conf = max_conf[i, argwhere]
|
||||
l_max_id = max_id[i, argwhere]
|
||||
|
||||
bboxes = []
|
||||
# nms for each class
|
||||
for j in range(num_classes):
|
||||
|
||||
cls_argwhere = l_max_id == j
|
||||
ll_box_array = l_box_array[cls_argwhere, :]
|
||||
ll_max_conf = l_max_conf[cls_argwhere]
|
||||
ll_max_id = l_max_id[cls_argwhere]
|
||||
|
||||
keep = nms_cpu(ll_box_array, ll_max_conf, nms_thresh)
|
||||
|
||||
if (keep.size > 0):
|
||||
ll_box_array = ll_box_array[keep, :]
|
||||
ll_max_conf = ll_max_conf[keep]
|
||||
ll_max_id = ll_max_id[keep]
|
||||
|
||||
for k in range(ll_box_array.shape[0]):
|
||||
bboxes.append([ll_box_array[k, 0], ll_box_array[k, 1], ll_box_array[k, 2], ll_box_array[k, 3], ll_max_conf[k], ll_max_conf[k], ll_max_id[k]])
|
||||
|
||||
detections = [(names[b[6]], float(b[4]), box_x1x1x2y2_to_xcycwh_scaled((b[0], b[1], b[2], b[3]))) for b in bboxes]
|
||||
dets_batch.append(detections)
|
||||
|
||||
|
||||
return dets_batch
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
https://tsd-pub-static.s3.amazonaws.com/ml-models/model-weights-8be06cde4e.darknet
|
||||
@@ -0,0 +1 @@
|
||||
https://tsd-pub-static.s3.amazonaws.com/ml-models/model-weights-5a6b1be1fa.onnx
|
||||
@@ -0,0 +1,258 @@
|
||||
[net]
|
||||
# Testing
|
||||
batch=64
|
||||
subdivisions=8
|
||||
# Training
|
||||
# batch=64
|
||||
# subdivisions=8
|
||||
height=416
|
||||
width=416
|
||||
channels=3
|
||||
momentum=0.9
|
||||
decay=0.0005
|
||||
angle=0
|
||||
saturation = 1.5
|
||||
exposure = 1.5
|
||||
hue=.1
|
||||
|
||||
learning_rate=0.001
|
||||
burn_in=1000
|
||||
max_batches = 50000
|
||||
policy=steps
|
||||
steps=40000,60000
|
||||
scales=.1,.1
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=32
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[maxpool]
|
||||
size=2
|
||||
stride=2
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=64
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[maxpool]
|
||||
size=2
|
||||
stride=2
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=128
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=64
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=128
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[maxpool]
|
||||
size=2
|
||||
stride=2
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=256
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=128
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=256
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[maxpool]
|
||||
size=2
|
||||
stride=2
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=512
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=256
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=512
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=256
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=512
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[maxpool]
|
||||
size=2
|
||||
stride=2
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=1024
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=512
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=1024
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=512
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
filters=1024
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
activation=leaky
|
||||
|
||||
|
||||
#######
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
filters=1024
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
filters=1024
|
||||
activation=leaky
|
||||
|
||||
[route]
|
||||
layers=-9
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
filters=64
|
||||
activation=leaky
|
||||
|
||||
[reorg3d]
|
||||
stride=2
|
||||
|
||||
[route]
|
||||
layers=-1,-4
|
||||
|
||||
[convolutional]
|
||||
batch_normalize=1
|
||||
size=3
|
||||
stride=1
|
||||
pad=1
|
||||
filters=1024
|
||||
activation=leaky
|
||||
|
||||
[convolutional]
|
||||
size=1
|
||||
stride=1
|
||||
pad=1
|
||||
filters=30
|
||||
activation=linear
|
||||
|
||||
|
||||
[region]
|
||||
anchors = 1.3221, 1.73145, 3.19275, 4.00944, 5.05587, 8.09892, 9.47112, 4.84053, 11.2364, 10.0071
|
||||
bias_match=1
|
||||
classes=1
|
||||
coords=4
|
||||
num=5
|
||||
softmax=1
|
||||
jitter=.3
|
||||
rescore=1
|
||||
|
||||
object_scale=5
|
||||
noobject_scale=1
|
||||
class_scale=1
|
||||
coord_scale=1
|
||||
|
||||
absolute=1
|
||||
thresh = .6
|
||||
random=1
|
||||
@@ -0,0 +1,2 @@
|
||||
classes= 1
|
||||
names = /app/model/names
|
||||
@@ -0,0 +1 @@
|
||||
failure
|
||||
@@ -0,0 +1,6 @@
|
||||
ipdb
|
||||
flask>=1.0
|
||||
redis==3.0.1
|
||||
newrelic==4.12.0.113
|
||||
requests==2.21.0
|
||||
gunicorn==19.9.0
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from os import environ, path
|
||||
from time import perf_counter
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import cv2
|
||||
import flask
|
||||
from flask import Response, jsonify, request
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from auth import token_required
|
||||
from lib.detection_model import detect, load_net
|
||||
|
||||
THRESH = float(environ.get("ML_DETECTION_BOX_THRESHOLD", "0.08"))
|
||||
REQUEST_TIMEOUT = (
|
||||
float(environ.get("ML_IMAGE_CONNECT_TIMEOUT", "2")),
|
||||
float(environ.get("ML_IMAGE_READ_TIMEOUT", "10")),
|
||||
)
|
||||
MAX_RECENT_REQUESTS = int(environ.get("ML_RECENT_REQUESTS", "100"))
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
app.config["DEBUG"] = environ.get("DEBUG") == "True"
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
|
||||
STARTED_AT = datetime.now(timezone.utc)
|
||||
RECENT_REQUESTS: deque[dict] = deque(maxlen=MAX_RECENT_REQUESTS)
|
||||
|
||||
model_dir = path.join(path.dirname(path.realpath(__file__)), "model")
|
||||
net_main = load_net(path.join(model_dir, "model.cfg"), path.join(model_dir, "model.meta"))
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _redact_url(raw_url: str | None) -> str | None:
|
||||
if not raw_url:
|
||||
return None
|
||||
parsed = urlsplit(raw_url)
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
|
||||
|
||||
|
||||
def _record_request(entry: dict) -> None:
|
||||
stored_entry = dict(entry)
|
||||
if "image_url" in stored_entry:
|
||||
stored_entry["image_url"] = _redact_url(stored_entry.get("image_url"))
|
||||
RECENT_REQUESTS.appendleft({"time": _now_iso(), **stored_entry})
|
||||
app.logger.info(
|
||||
"prediction status=%s detections=%s duration_ms=%s image_host=%s error=%s",
|
||||
entry.get("status"),
|
||||
entry.get("detections", 0),
|
||||
entry.get("duration_ms"),
|
||||
urlsplit(entry.get("image_url") or "").netloc,
|
||||
entry.get("error"),
|
||||
)
|
||||
|
||||
|
||||
def _fetch_image(image_url: str) -> np.ndarray:
|
||||
response = requests.get(image_url, stream=True, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
img_array = np.array(bytearray(response.content), dtype=np.uint8)
|
||||
image = cv2.imdecode(img_array, -1)
|
||||
if image is None:
|
||||
raise ValueError("image_decode_failed")
|
||||
return image
|
||||
|
||||
|
||||
def _status_payload() -> dict:
|
||||
return {
|
||||
"ok": net_main is not None,
|
||||
"started_at": STARTED_AT.isoformat(),
|
||||
"model": {
|
||||
"classes": ["failure"],
|
||||
"box_threshold": THRESH,
|
||||
"backend": type(net_main).__name__ if net_main is not None else None,
|
||||
"use_gpu": environ.get("ML_USE_GPU", "false"),
|
||||
"model_backend_preference": environ.get("ML_MODEL_BACKEND", "onnx"),
|
||||
},
|
||||
"requests": {
|
||||
"recent_count": len(RECENT_REQUESTS),
|
||||
"max_recent": MAX_RECENT_REQUESTS,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def dashboard():
|
||||
"""Render a small operational status page."""
|
||||
status = _status_payload()
|
||||
rows = "\n".join(
|
||||
"<tr>"
|
||||
f"<td>{entry['time']}</td>"
|
||||
f"<td>{entry.get('status', '')}</td>"
|
||||
f"<td>{entry.get('detections', 0)}</td>"
|
||||
f"<td>{entry.get('duration_ms', '')}</td>"
|
||||
f"<td>{entry.get('error') or ''}</td>"
|
||||
f"<td>{_redact_url(entry.get('image_url')) or ''}</td>"
|
||||
"</tr>"
|
||||
for entry in list(RECENT_REQUESTS)[:20]
|
||||
)
|
||||
body = f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Elegoo Spaghetti Detection ML Server</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, sans-serif; margin: 24px; color: #1f2933; }}
|
||||
code {{ background: #eef2f7; padding: 2px 5px; border-radius: 4px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin-top: 16px; }}
|
||||
th, td {{ border-bottom: 1px solid #d9e2ec; padding: 8px; text-align: left; font-size: 14px; }}
|
||||
.ok {{ color: #137333; font-weight: 700; }}
|
||||
.bad {{ color: #b3261e; font-weight: 700; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Elegoo Spaghetti Detection ML Server</h1>
|
||||
<p>Status: <span class="{'ok' if status['ok'] else 'bad'}">{'ok' if status['ok'] else 'error'}</span></p>
|
||||
<p>Backend: <code>{status['model']['backend']}</code> | GPU opt-in: <code>{status['model']['use_gpu']}</code> | Box threshold: <code>{status['model']['box_threshold']}</code></p>
|
||||
<p>Health: <code>/hc/</code> | JSON status: <code>/api/status</code> | Token-protected logs: <code>/api/logs?token=<token></code></p>
|
||||
<h2>Recent Requests</h2>
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Status</th><th>Detections</th><th>ms</th><th>Error</th><th>Image URL without token</th></tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(body, mimetype="text/html")
|
||||
|
||||
|
||||
@app.route("/api/status", methods=["GET"])
|
||||
def api_status():
|
||||
"""Return JSON server status."""
|
||||
return jsonify(_status_payload())
|
||||
|
||||
|
||||
@app.route("/api/logs", methods=["GET"])
|
||||
@token_required
|
||||
def api_logs():
|
||||
"""Return recent request logs."""
|
||||
return jsonify({"requests": list(RECENT_REQUESTS)})
|
||||
|
||||
|
||||
@app.route("/debug/image", methods=["GET"])
|
||||
@token_required
|
||||
def debug_image():
|
||||
"""Check whether the server can fetch and decode an image URL."""
|
||||
image_url = request.args.get("img")
|
||||
if not image_url:
|
||||
return jsonify({"ok": False, "error": "missing_image_url"}), 400
|
||||
started = perf_counter()
|
||||
try:
|
||||
image = _fetch_image(image_url)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"duration_ms": round((perf_counter() - started) * 1000),
|
||||
"shape": list(image.shape),
|
||||
"image_url": _redact_url(image_url),
|
||||
}
|
||||
)
|
||||
except requests.RequestException as err:
|
||||
return jsonify({"ok": False, "error": "image_fetch_failed", "message": str(err)}), 502
|
||||
except ValueError as err:
|
||||
return jsonify({"ok": False, "error": str(err)}), 422
|
||||
|
||||
|
||||
@app.route("/p/", methods=["GET"])
|
||||
@token_required
|
||||
def get_p():
|
||||
"""Run prediction for the image URL in the img query parameter."""
|
||||
image_url = request.args.get("img")
|
||||
if not image_url:
|
||||
_record_request({"status": 400, "error": "missing_image_url", "detections": 0})
|
||||
return jsonify(
|
||||
{
|
||||
"detections": [],
|
||||
"error": "missing_image_url",
|
||||
"message": "Missing img query parameter.",
|
||||
}
|
||||
), 400
|
||||
|
||||
started = perf_counter()
|
||||
try:
|
||||
image = _fetch_image(image_url)
|
||||
detections = detect(net_main, image, thresh=THRESH)
|
||||
duration_ms = round((perf_counter() - started) * 1000)
|
||||
_record_request(
|
||||
{
|
||||
"status": 200,
|
||||
"detections": len(detections),
|
||||
"duration_ms": duration_ms,
|
||||
"image_url": image_url,
|
||||
}
|
||||
)
|
||||
return jsonify({"detections": detections, "duration_ms": duration_ms})
|
||||
except requests.RequestException as err:
|
||||
duration_ms = round((perf_counter() - started) * 1000)
|
||||
_record_request(
|
||||
{
|
||||
"status": 502,
|
||||
"error": "image_fetch_failed",
|
||||
"message": str(err),
|
||||
"duration_ms": duration_ms,
|
||||
"image_url": image_url,
|
||||
"detections": 0,
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"detections": [],
|
||||
"error": "image_fetch_failed",
|
||||
"message": str(err),
|
||||
}
|
||||
), 502
|
||||
except ValueError as err:
|
||||
duration_ms = round((perf_counter() - started) * 1000)
|
||||
_record_request(
|
||||
{
|
||||
"status": 422,
|
||||
"error": str(err),
|
||||
"duration_ms": duration_ms,
|
||||
"image_url": image_url,
|
||||
"detections": 0,
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"detections": [],
|
||||
"error": str(err),
|
||||
"message": "The image URL did not return a decodable image.",
|
||||
}
|
||||
), 422
|
||||
except Exception as err:
|
||||
duration_ms = round((perf_counter() - started) * 1000)
|
||||
app.logger.exception("Unable to process image")
|
||||
_record_request(
|
||||
{
|
||||
"status": 500,
|
||||
"error": "prediction_failed",
|
||||
"message": str(err),
|
||||
"duration_ms": duration_ms,
|
||||
"image_url": image_url,
|
||||
"detections": 0,
|
||||
}
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"detections": [],
|
||||
"error": "prediction_failed",
|
||||
"message": str(err),
|
||||
}
|
||||
), 500
|
||||
|
||||
|
||||
@app.route("/hc/", methods=["GET"])
|
||||
def health_check():
|
||||
"""Health check for Home Assistant and Docker."""
|
||||
if net_main is not None:
|
||||
return "ok", 200
|
||||
return "error", 503
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=3333, threaded=False)
|
||||
@@ -0,0 +1,6 @@
|
||||
import server
|
||||
|
||||
application = server.app
|
||||
|
||||
if __name__ == "__main__":
|
||||
application.run()
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
|
||||
declare ML_API_TOKEN
|
||||
|
||||
ML_API_TOKEN=$(bashio::config 'obico_api_secret')
|
||||
export ML_API_TOKEN
|
||||
export ML_USE_GPU=$(bashio::config 'use_gpu')
|
||||
export GUNICORN_TIMEOUT=$(bashio::config 'gunicorn_timeout')
|
||||
|
||||
cd /app
|
||||
FLASK_APP=server.py venv/bin/gunicorn \
|
||||
--bind "0.0.0.0:3333" \
|
||||
--workers "${GUNICORN_WORKERS:-1}" \
|
||||
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||
--error-logfile - \
|
||||
--log-level info \
|
||||
wsgi:application
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bashio
|
||||
set -e
|
||||
|
||||
ML_API_TOKEN=$(bashio::config 'obico_api_secret')
|
||||
PORT=$(bashio::addon.port 3333)
|
||||
export ML_API_TOKEN
|
||||
export ML_USE_GPU=$(bashio::config 'use_gpu')
|
||||
export GUNICORN_TIMEOUT=$(bashio::config 'gunicorn_timeout')
|
||||
|
||||
venv/bin/gunicorn \
|
||||
--bind "0.0.0.0:$PORT" \
|
||||
--workers "${GUNICORN_WORKERS:-1}" \
|
||||
--timeout "${GUNICORN_TIMEOUT:-120}" \
|
||||
--error-logfile - \
|
||||
--log-level info \
|
||||
wsgi
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Home Assistant integration for Elegoo spaghetti detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_CONFIG_ENTRY,
|
||||
CONF_DETECTOR,
|
||||
CONF_FORCE,
|
||||
CONF_IMAGE_URL,
|
||||
CONF_OBICO_AUTH_TOKEN,
|
||||
CONF_OBICO_HOST,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
REQUIRED_CONFIG_KEYS,
|
||||
RUNTIME_ML_LOCK,
|
||||
RUNTIME_BY_DETECTOR,
|
||||
RUNTIME_DATA,
|
||||
SERVICE_PREDICT,
|
||||
SERVICE_RESET_STATE,
|
||||
SERVICE_RUN_DETECTION,
|
||||
)
|
||||
from .runtime import SpaghettiDetectorRuntime
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
PREDICT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OBICO_HOST): str,
|
||||
vol.Required(CONF_OBICO_AUTH_TOKEN): str,
|
||||
vol.Required(CONF_IMAGE_URL): str,
|
||||
}
|
||||
)
|
||||
|
||||
DETECTOR_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CONFIG_ENTRY): str,
|
||||
vol.Optional(CONF_DETECTOR): str,
|
||||
vol.Optional(CONF_FORCE, default=True): bool,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
"""Set up global services for Elegoo spaghetti detection."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN].setdefault(RUNTIME_DATA, {})
|
||||
hass.data[DOMAIN].setdefault(RUNTIME_BY_DETECTOR, {})
|
||||
hass.data[DOMAIN].setdefault(RUNTIME_ML_LOCK, asyncio.Lock())
|
||||
|
||||
async def predict_handler(call: ServiceCall) -> ServiceResponse:
|
||||
"""Run the Obico ML model for a raw image URL."""
|
||||
result = await _async_predict_raw(
|
||||
hass,
|
||||
call.data[CONF_OBICO_HOST],
|
||||
call.data[CONF_OBICO_AUTH_TOKEN],
|
||||
call.data[CONF_IMAGE_URL],
|
||||
)
|
||||
return {"result": result}
|
||||
|
||||
async def run_detection_handler(call: ServiceCall) -> ServiceResponse:
|
||||
"""Run one detection against the configured detector."""
|
||||
runtime = _runtime_from_call(hass, call)
|
||||
return await runtime.async_run_detection(manual=bool(call.data[CONF_FORCE]))
|
||||
|
||||
async def reset_handler(call: ServiceCall) -> None:
|
||||
"""Reset detector state."""
|
||||
runtime = _runtime_from_call(hass, call)
|
||||
runtime.reset()
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_PREDICT,
|
||||
predict_handler,
|
||||
schema=PREDICT_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RUN_DETECTION,
|
||||
run_detection_handler,
|
||||
schema=DETECTOR_SERVICE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESET_STATE,
|
||||
reset_handler,
|
||||
schema=DETECTOR_SERVICE_SCHEMA,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up one spaghetti detector."""
|
||||
missing = sorted(
|
||||
key
|
||||
for key in REQUIRED_CONFIG_KEYS
|
||||
if key not in entry.data and key not in entry.options
|
||||
)
|
||||
if missing:
|
||||
LOGGER.error(
|
||||
"Config entry %s is incomplete and must be removed and recreated. Missing: %s",
|
||||
entry.title,
|
||||
", ".join(missing),
|
||||
)
|
||||
return False
|
||||
|
||||
runtime = SpaghettiDetectorRuntime(hass, entry)
|
||||
hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id] = runtime
|
||||
hass.data[DOMAIN][RUNTIME_BY_DETECTOR][runtime.detector_id] = runtime
|
||||
await runtime.async_setup()
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload one spaghetti detector."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
runtime = hass.data[DOMAIN][RUNTIME_DATA].pop(entry.entry_id, None)
|
||||
if runtime is not None:
|
||||
hass.data[DOMAIN][RUNTIME_BY_DETECTOR].pop(runtime.detector_id, None)
|
||||
await runtime.async_unload()
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_predict_raw(
|
||||
hass: HomeAssistant,
|
||||
obico_host: str,
|
||||
obico_auth_token: str,
|
||||
image_url: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Call Obico ML directly."""
|
||||
try:
|
||||
session = async_get_clientsession(hass)
|
||||
async with session.get(
|
||||
f"{obico_host.rstrip('/')}/p/",
|
||||
params={"img": image_url},
|
||||
headers={"Authorization": f"Bearer {obico_auth_token}"},
|
||||
timeout=aiohttp.ClientTimeout(total=60),
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
result = await response.json()
|
||||
if not isinstance(result, dict):
|
||||
return {"detections": []}
|
||||
return result
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
LOGGER.warning("Obico ML request failed: %s", err)
|
||||
return {"detections": []}
|
||||
|
||||
|
||||
def _runtime_from_call(
|
||||
hass: HomeAssistant,
|
||||
call: ServiceCall,
|
||||
) -> SpaghettiDetectorRuntime:
|
||||
"""Resolve a runtime from a service call."""
|
||||
runtime: SpaghettiDetectorRuntime | None = None
|
||||
if config_entry_id := call.data.get(CONF_CONFIG_ENTRY):
|
||||
runtime = hass.data[DOMAIN][RUNTIME_DATA].get(config_entry_id)
|
||||
elif detector := call.data.get(CONF_DETECTOR):
|
||||
runtime = hass.data[DOMAIN][RUNTIME_BY_DETECTOR].get(detector)
|
||||
|
||||
if runtime is None:
|
||||
raise HomeAssistantError("Unknown Elegoo spaghetti detector")
|
||||
return runtime
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Binary sensors for Elegoo spaghetti detection."""
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_INSTANCE_ID, DOMAIN, RUNTIME_DATA
|
||||
from .entity import SpaghettiDetectorEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities,
|
||||
) -> None:
|
||||
"""Set up binary sensors."""
|
||||
runtime = hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id]
|
||||
async_add_entities([SpaghettiDetectedBinarySensor(entry, runtime)])
|
||||
|
||||
|
||||
class SpaghettiDetectedBinarySensor(SpaghettiDetectorEntity, BinarySensorEntity):
|
||||
"""Spaghetti detected state."""
|
||||
|
||||
_attr_name = "Spaghetti Detected"
|
||||
_attr_icon = "mdi:alert-octagram"
|
||||
|
||||
def __init__(self, entry: ConfigEntry, runtime) -> None:
|
||||
super().__init__(entry, runtime, "spaghetti_detected")
|
||||
self.entity_id = (
|
||||
f"binary_sensor.{entry.data[CONF_INSTANCE_ID]}_spaghetti_detected"
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if spaghetti was detected."""
|
||||
return self.runtime.detected
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
"""Return debug attributes."""
|
||||
return {
|
||||
"confidence": self.runtime.confidence,
|
||||
"raw_score": self.runtime.raw_score,
|
||||
"warning": self.runtime.warning,
|
||||
"detections": self.runtime.detection_count,
|
||||
"last_error": self.runtime.last_error,
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,77 @@
|
||||
"""Buttons for Elegoo spaghetti detection."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_INSTANCE_ID, DOMAIN, RUNTIME_DATA
|
||||
from .entity import SpaghettiDetectorEntity
|
||||
from .runtime import SpaghettiDetectorRuntime
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DetectorButtonDescription(ButtonEntityDescription):
|
||||
"""Detector button description."""
|
||||
|
||||
press_fn: Callable[[SpaghettiDetectorRuntime], Awaitable[None]]
|
||||
|
||||
|
||||
async def _run_detection(runtime: SpaghettiDetectorRuntime) -> None:
|
||||
"""Run one manual detection."""
|
||||
await runtime.async_run_detection(manual=True)
|
||||
|
||||
|
||||
async def _reset_state(runtime: SpaghettiDetectorRuntime) -> None:
|
||||
"""Reset detection state."""
|
||||
runtime.reset()
|
||||
|
||||
|
||||
BUTTONS: tuple[DetectorButtonDescription, ...] = (
|
||||
DetectorButtonDescription(
|
||||
key="test_spaghetti_detection",
|
||||
name="Test Spaghetti Detection",
|
||||
icon="mdi:camera-iris",
|
||||
press_fn=_run_detection,
|
||||
),
|
||||
DetectorButtonDescription(
|
||||
key="reset_detection_state",
|
||||
name="Reset Detection State",
|
||||
icon="mdi:restart",
|
||||
press_fn=_reset_state,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities,
|
||||
) -> None:
|
||||
"""Set up buttons."""
|
||||
runtime = hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id]
|
||||
async_add_entities(
|
||||
DetectorButton(entry, runtime, description) for description in BUTTONS
|
||||
)
|
||||
|
||||
|
||||
class DetectorButton(SpaghettiDetectorEntity, ButtonEntity):
|
||||
"""Detector action button."""
|
||||
|
||||
entity_description: DetectorButtonDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
runtime: SpaghettiDetectorRuntime,
|
||||
description: DetectorButtonDescription,
|
||||
) -> None:
|
||||
super().__init__(entry, runtime, description.key)
|
||||
self.entity_description = description
|
||||
self.entity_id = f"button.{entry.data[CONF_INSTANCE_ID]}_{description.key}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle button press."""
|
||||
await self.entity_description.press_fn(self.runtime)
|
||||
@@ -0,0 +1,499 @@
|
||||
"""Config flow for Elegoo spaghetti detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util import slugify
|
||||
import voluptuous as vol
|
||||
|
||||
from .const import (
|
||||
CONF_ACTIVE_PRINT_STATES,
|
||||
CONF_CAMERA,
|
||||
CONF_CHAMBER_LIGHT,
|
||||
CONF_COOLDOWN_SECONDS,
|
||||
CONF_DETECTION_INTERVAL,
|
||||
CONF_FAILURE_THRESHOLD,
|
||||
CONF_HOME_ASSISTANT_HOST,
|
||||
CONF_INSTANCE_ID,
|
||||
CONF_LIGHT_CONTROL_MODE,
|
||||
CONF_LIGHT_SETTLE_SECONDS,
|
||||
CONF_OBICO_AUTH_TOKEN,
|
||||
CONF_OBICO_HOST,
|
||||
CONF_PRINT_STATUS_SENSOR,
|
||||
CONF_RUN_WITHOUT_PRINTING,
|
||||
CONF_SENSITIVITY,
|
||||
CONF_SNAPSHOT_URL,
|
||||
CONF_WARNING_THRESHOLD,
|
||||
DEFAULT_ACTIVE_PRINT_STATES,
|
||||
DEFAULT_COOLDOWN_SECONDS,
|
||||
DEFAULT_DETECTION_INTERVAL,
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
DEFAULT_HOME_ASSISTANT_HOST,
|
||||
DEFAULT_INSTANCE_ID,
|
||||
DEFAULT_LIGHT_CONTROL_MODE,
|
||||
DEFAULT_LIGHT_SETTLE_SECONDS,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_OBICO_AUTH_TOKEN,
|
||||
DEFAULT_OBICO_HOST,
|
||||
DEFAULT_SENSITIVITY,
|
||||
DEFAULT_WARNING_THRESHOLD,
|
||||
DOMAIN,
|
||||
LIGHT_CONTROL_LEAVE_ON,
|
||||
LIGHT_CONTROL_OFF,
|
||||
LIGHT_CONTROL_RESTORE,
|
||||
)
|
||||
|
||||
|
||||
OPTIONAL_ENTITY_FIELDS: tuple[tuple[str, str | list[str]], ...] = (
|
||||
(CONF_PRINT_STATUS_SENSOR, ["sensor", "binary_sensor"]),
|
||||
(CONF_CHAMBER_LIGHT, "light"),
|
||||
)
|
||||
|
||||
|
||||
def _entry_values(entry: ConfigEntry) -> dict[str, Any]:
|
||||
"""Return config entry data with options overriding editable settings."""
|
||||
return {**entry.data, **entry.options}
|
||||
|
||||
|
||||
def _default_value(defaults: dict[str, Any], key: str, fallback: Any) -> Any:
|
||||
"""Return a form default without leaking None into selectors."""
|
||||
value = defaults.get(key)
|
||||
return fallback if value is None else value
|
||||
|
||||
|
||||
def _optional_marker(key: str, defaults: dict[str, Any]) -> vol.Optional:
|
||||
"""Return an optional voluptuous marker with an existing default if present."""
|
||||
if defaults.get(key):
|
||||
return vol.Optional(key, default=defaults[key])
|
||||
return vol.Optional(key)
|
||||
|
||||
|
||||
def _light_control_mode(defaults: dict[str, Any]) -> str:
|
||||
"""Return the default light-control mode for setup/options forms."""
|
||||
mode = defaults.get(CONF_LIGHT_CONTROL_MODE)
|
||||
if mode in {LIGHT_CONTROL_OFF, LIGHT_CONTROL_LEAVE_ON, LIGHT_CONTROL_RESTORE}:
|
||||
return mode
|
||||
return DEFAULT_LIGHT_CONTROL_MODE
|
||||
|
||||
|
||||
def _schema(
|
||||
defaults: dict[str, Any] | None = None,
|
||||
*,
|
||||
include_identity: bool,
|
||||
) -> vol.Schema:
|
||||
"""Return detector setup/options schema."""
|
||||
defaults = defaults or {}
|
||||
data_schema: dict[Any, Any] = {}
|
||||
|
||||
if include_identity:
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_NAME,
|
||||
default=_default_value(defaults, CONF_NAME, DEFAULT_NAME),
|
||||
)
|
||||
] = str
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_INSTANCE_ID,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_INSTANCE_ID,
|
||||
DEFAULT_INSTANCE_ID,
|
||||
),
|
||||
)
|
||||
] = str
|
||||
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_HOME_ASSISTANT_HOST,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_HOME_ASSISTANT_HOST,
|
||||
DEFAULT_HOME_ASSISTANT_HOST,
|
||||
),
|
||||
)
|
||||
] = str
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_OBICO_HOST,
|
||||
default=_default_value(defaults, CONF_OBICO_HOST, DEFAULT_OBICO_HOST),
|
||||
)
|
||||
] = str
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_OBICO_AUTH_TOKEN,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_OBICO_AUTH_TOKEN,
|
||||
DEFAULT_OBICO_AUTH_TOKEN,
|
||||
),
|
||||
)
|
||||
] = str
|
||||
|
||||
camera_marker = (
|
||||
vol.Required(CONF_CAMERA, default=defaults[CONF_CAMERA])
|
||||
if defaults.get(CONF_CAMERA)
|
||||
else vol.Required(CONF_CAMERA)
|
||||
)
|
||||
data_schema[camera_marker] = selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain="camera")
|
||||
)
|
||||
|
||||
data_schema[
|
||||
vol.Optional(
|
||||
CONF_SNAPSHOT_URL,
|
||||
default=_default_value(defaults, CONF_SNAPSHOT_URL, ""),
|
||||
)
|
||||
] = str
|
||||
|
||||
for key, domain in OPTIONAL_ENTITY_FIELDS:
|
||||
data_schema[_optional_marker(key, defaults)] = selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(domain=domain)
|
||||
)
|
||||
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_ACTIVE_PRINT_STATES,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_ACTIVE_PRINT_STATES,
|
||||
DEFAULT_ACTIVE_PRINT_STATES,
|
||||
),
|
||||
)
|
||||
] = str
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_LIGHT_CONTROL_MODE,
|
||||
default=_light_control_mode(defaults),
|
||||
)
|
||||
] = selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
{"label": "Do not control light", "value": LIGHT_CONTROL_OFF},
|
||||
{
|
||||
"label": "Turn on before detection and leave on",
|
||||
"value": LIGHT_CONTROL_LEAVE_ON,
|
||||
},
|
||||
{
|
||||
"label": "Restore previous state after detection",
|
||||
"value": LIGHT_CONTROL_RESTORE,
|
||||
},
|
||||
],
|
||||
mode="dropdown",
|
||||
)
|
||||
)
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_LIGHT_SETTLE_SECONDS,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_LIGHT_SETTLE_SECONDS,
|
||||
DEFAULT_LIGHT_SETTLE_SECONDS,
|
||||
),
|
||||
)
|
||||
] = selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
max=30,
|
||||
step=1,
|
||||
mode="box",
|
||||
unit_of_measurement="s",
|
||||
)
|
||||
)
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_RUN_WITHOUT_PRINTING,
|
||||
default=_default_value(defaults, CONF_RUN_WITHOUT_PRINTING, False),
|
||||
)
|
||||
] = selector.BooleanSelector()
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_DETECTION_INTERVAL,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_DETECTION_INTERVAL,
|
||||
DEFAULT_DETECTION_INTERVAL,
|
||||
),
|
||||
)
|
||||
] = selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=5,
|
||||
max=3600,
|
||||
step=5,
|
||||
mode="box",
|
||||
unit_of_measurement="s",
|
||||
)
|
||||
)
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_SENSITIVITY,
|
||||
default=_default_value(defaults, CONF_SENSITIVITY, DEFAULT_SENSITIVITY),
|
||||
)
|
||||
] = selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
{"label": "High sensitivity", "value": "high"},
|
||||
{"label": "Normal sensitivity", "value": "normal"},
|
||||
{"label": "Low sensitivity", "value": "low"},
|
||||
{"label": "Custom thresholds", "value": "custom"},
|
||||
],
|
||||
mode="dropdown",
|
||||
)
|
||||
)
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_WARNING_THRESHOLD,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_WARNING_THRESHOLD,
|
||||
DEFAULT_WARNING_THRESHOLD,
|
||||
),
|
||||
)
|
||||
] = selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(min=0, max=1, step=0.01, mode="box")
|
||||
)
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_FAILURE_THRESHOLD,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_FAILURE_THRESHOLD,
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
),
|
||||
)
|
||||
] = selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(min=0, max=1, step=0.01, mode="box")
|
||||
)
|
||||
data_schema[
|
||||
vol.Required(
|
||||
CONF_COOLDOWN_SECONDS,
|
||||
default=_default_value(
|
||||
defaults,
|
||||
CONF_COOLDOWN_SECONDS,
|
||||
DEFAULT_COOLDOWN_SECONDS,
|
||||
),
|
||||
)
|
||||
] = selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=0,
|
||||
max=3600,
|
||||
step=5,
|
||||
mode="box",
|
||||
unit_of_measurement="s",
|
||||
)
|
||||
)
|
||||
|
||||
return vol.Schema(data_schema)
|
||||
|
||||
|
||||
def _build_image_url(
|
||||
hass,
|
||||
data: dict[str, Any],
|
||||
) -> str | None:
|
||||
"""Build the image URL that the ML server will fetch during checks."""
|
||||
if snapshot_url := data.get(CONF_SNAPSHOT_URL):
|
||||
return snapshot_url
|
||||
|
||||
state = hass.states.get(data[CONF_CAMERA])
|
||||
if state is None:
|
||||
return None
|
||||
entity_picture = state.attributes.get("entity_picture")
|
||||
if not entity_picture:
|
||||
return None
|
||||
return f"{data[CONF_HOME_ASSISTANT_HOST].rstrip('/')}{entity_picture}"
|
||||
|
||||
|
||||
def _validate_thresholds(data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate threshold fields."""
|
||||
if float(data[CONF_WARNING_THRESHOLD]) > float(data[CONF_FAILURE_THRESHOLD]):
|
||||
return {CONF_WARNING_THRESHOLD: "warning_above_failure"}
|
||||
return {}
|
||||
|
||||
|
||||
def _camera_in_use(
|
||||
entries: list[ConfigEntry],
|
||||
camera: str,
|
||||
*,
|
||||
exclude_entry_id: str | None = None,
|
||||
) -> bool:
|
||||
"""Return whether a camera is already used by a detector."""
|
||||
return any(
|
||||
entry.entry_id != exclude_entry_id
|
||||
and _entry_values(entry).get(CONF_CAMERA) == camera
|
||||
for entry in entries
|
||||
)
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Elegoo spaghetti detection."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Configure one detector target."""
|
||||
errors: dict[str, str] = {}
|
||||
form_defaults = self._defaults_from_existing_entry()
|
||||
|
||||
if user_input is not None:
|
||||
data = dict(user_input)
|
||||
data[CONF_INSTANCE_ID] = slugify(data[CONF_INSTANCE_ID])
|
||||
errors.update(_validate_thresholds(data))
|
||||
|
||||
if not data[CONF_INSTANCE_ID]:
|
||||
errors[CONF_INSTANCE_ID] = "invalid_instance_id"
|
||||
elif self._instance_id_exists(data[CONF_INSTANCE_ID]):
|
||||
errors[CONF_INSTANCE_ID] = "instance_id_exists"
|
||||
elif not errors:
|
||||
await self.async_set_unique_id(data[CONF_CAMERA])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if not errors:
|
||||
errors.update(await self._async_validate_backend(data))
|
||||
|
||||
if not errors:
|
||||
name = data.pop(CONF_NAME)
|
||||
return self.async_create_entry(title=name, data=data)
|
||||
|
||||
form_defaults = {**form_defaults, **data}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=_schema(form_defaults, include_identity=True),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
def _defaults_from_existing_entry(self) -> dict[str, Any]:
|
||||
"""Use the first existing detector to reduce repeated server entry."""
|
||||
for entry in self._async_current_entries():
|
||||
values = _entry_values(entry)
|
||||
defaults = {
|
||||
CONF_HOME_ASSISTANT_HOST: values.get(CONF_HOME_ASSISTANT_HOST),
|
||||
CONF_OBICO_HOST: values.get(CONF_OBICO_HOST),
|
||||
CONF_OBICO_AUTH_TOKEN: values.get(CONF_OBICO_AUTH_TOKEN),
|
||||
CONF_DETECTION_INTERVAL: values.get(CONF_DETECTION_INTERVAL),
|
||||
CONF_LIGHT_CONTROL_MODE: values.get(CONF_LIGHT_CONTROL_MODE),
|
||||
CONF_LIGHT_SETTLE_SECONDS: values.get(CONF_LIGHT_SETTLE_SECONDS),
|
||||
CONF_SENSITIVITY: values.get(CONF_SENSITIVITY),
|
||||
CONF_WARNING_THRESHOLD: values.get(CONF_WARNING_THRESHOLD),
|
||||
CONF_FAILURE_THRESHOLD: values.get(CONF_FAILURE_THRESHOLD),
|
||||
CONF_COOLDOWN_SECONDS: values.get(CONF_COOLDOWN_SECONDS),
|
||||
}
|
||||
return {key: value for key, value in defaults.items() if value is not None}
|
||||
return {CONF_HOME_ASSISTANT_HOST: self._home_assistant_url_default()}
|
||||
|
||||
def _home_assistant_url_default(self) -> str:
|
||||
"""Return the best available HA URL for the ML server to fetch images."""
|
||||
return (
|
||||
getattr(self.hass.config, "internal_url", None)
|
||||
or getattr(self.hass.config, "external_url", None)
|
||||
or DEFAULT_HOME_ASSISTANT_HOST
|
||||
)
|
||||
|
||||
def _instance_id_exists(self, instance_id: str) -> bool:
|
||||
"""Return whether an entity prefix is already used."""
|
||||
return any(
|
||||
entry.data.get(CONF_INSTANCE_ID) == instance_id
|
||||
for entry in self._async_current_entries()
|
||||
)
|
||||
|
||||
def _camera_exists(self, camera: str) -> bool:
|
||||
"""Return whether a camera is already used by another detector."""
|
||||
return _camera_in_use(self._async_current_entries(), camera)
|
||||
|
||||
async def _async_validate_backend(self, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Validate ML health and whether it can fetch the configured image."""
|
||||
return await _async_validate_backend(self.hass, data)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlowWithReload):
|
||||
"""Handle detector options."""
|
||||
|
||||
async def async_step_init(
|
||||
self,
|
||||
user_input: dict[str, Any] | None = None,
|
||||
) -> FlowResult:
|
||||
"""Manage detector options."""
|
||||
errors: dict[str, str] = {}
|
||||
defaults = _entry_values(self.config_entry)
|
||||
|
||||
if user_input is not None:
|
||||
data = dict(user_input)
|
||||
errors.update(_validate_thresholds(data))
|
||||
|
||||
if _camera_in_use(
|
||||
self.hass.config_entries.async_entries(DOMAIN),
|
||||
data[CONF_CAMERA],
|
||||
exclude_entry_id=self.config_entry.entry_id,
|
||||
):
|
||||
errors[CONF_CAMERA] = "already_configured"
|
||||
|
||||
if not errors:
|
||||
errors.update(await _async_validate_backend(self.hass, data))
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(data=data)
|
||||
|
||||
defaults = {**defaults, **data}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=_schema(defaults, include_identity=False),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
async def _async_validate_backend(hass, data: dict[str, Any]) -> dict[str, str]:
|
||||
"""Return form errors for backend/camera connectivity problems."""
|
||||
image_url = _build_image_url(hass, data)
|
||||
if not image_url:
|
||||
return {CONF_CAMERA: "camera_image_unavailable"}
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
obico_host = data[CONF_OBICO_HOST].rstrip("/")
|
||||
token = data[CONF_OBICO_AUTH_TOKEN]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
f"{obico_host}/hc/",
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
return {CONF_OBICO_HOST: "ml_health_failed"}
|
||||
except (aiohttp.ClientError, TimeoutError):
|
||||
return {CONF_OBICO_HOST: "ml_health_failed"}
|
||||
|
||||
try:
|
||||
async with session.get(
|
||||
f"{obico_host}/debug/image",
|
||||
params={"img": image_url},
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=20),
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
return {CONF_OBICO_AUTH_TOKEN: "ml_auth_failed"}
|
||||
if response.status >= 400:
|
||||
return {CONF_CAMERA: "ml_image_fetch_failed"}
|
||||
except (aiohttp.ClientError, TimeoutError):
|
||||
return {CONF_CAMERA: "ml_image_fetch_failed"}
|
||||
|
||||
return {}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Constants for Elegoo spaghetti detection."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "elegoo_spaghetti_detection"
|
||||
BRAND = "Elegoo Spaghetti Detection"
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.BUTTON]
|
||||
|
||||
CONF_INSTANCE_ID = "instance_id"
|
||||
CONF_HOME_ASSISTANT_HOST = "home_assistant_host"
|
||||
CONF_OBICO_HOST = "obico_host"
|
||||
CONF_OBICO_AUTH_TOKEN = "obico_auth_token"
|
||||
CONF_CAMERA = "camera"
|
||||
CONF_SNAPSHOT_URL = "snapshot_url"
|
||||
CONF_PRINT_STATUS_SENSOR = "print_status_sensor"
|
||||
CONF_ACTIVE_PRINT_STATES = "active_print_states"
|
||||
CONF_CHAMBER_LIGHT = "chamber_light"
|
||||
CONF_LIGHT_CONTROL_MODE = "light_control_mode"
|
||||
CONF_LIGHT_SETTLE_SECONDS = "light_settle_seconds"
|
||||
CONF_DETECTION_INTERVAL = "detection_interval"
|
||||
CONF_RUN_WITHOUT_PRINTING = "run_without_printing"
|
||||
CONF_FAILURE_THRESHOLD = "failure_threshold"
|
||||
CONF_WARNING_THRESHOLD = "warning_threshold"
|
||||
CONF_SENSITIVITY = "sensitivity"
|
||||
CONF_COOLDOWN_SECONDS = "cooldown_seconds"
|
||||
CONF_IMAGE_URL = "image_url"
|
||||
CONF_CONFIG_ENTRY = "config_entry"
|
||||
CONF_DETECTOR = "detector"
|
||||
CONF_FORCE = "force"
|
||||
|
||||
DEFAULT_NAME = "Elegoo Spaghetti Detector"
|
||||
DEFAULT_INSTANCE_ID = DOMAIN
|
||||
DEFAULT_HOME_ASSISTANT_HOST = "http://homeassistant.local:8123"
|
||||
DEFAULT_OBICO_HOST = "http://192.168.1.123:3333"
|
||||
DEFAULT_OBICO_AUTH_TOKEN = "obico_api_secret"
|
||||
DEFAULT_ACTIVE_PRINT_STATES = "printing"
|
||||
DEFAULT_DETECTION_INTERVAL = 10
|
||||
DEFAULT_COOLDOWN_SECONDS = 900
|
||||
DEFAULT_FAILURE_THRESHOLD = 0.50
|
||||
DEFAULT_WARNING_THRESHOLD = 0.30
|
||||
DEFAULT_SENSITIVITY = "normal"
|
||||
DEFAULT_LIGHT_CONTROL_MODE = "restore"
|
||||
DEFAULT_LIGHT_SETTLE_SECONDS = 3
|
||||
|
||||
LIGHT_CONTROL_OFF = "off"
|
||||
LIGHT_CONTROL_LEAVE_ON = "leave_on"
|
||||
LIGHT_CONTROL_RESTORE = "restore"
|
||||
|
||||
REQUIRED_CONFIG_KEYS = frozenset(
|
||||
{
|
||||
CONF_INSTANCE_ID,
|
||||
CONF_HOME_ASSISTANT_HOST,
|
||||
CONF_OBICO_HOST,
|
||||
CONF_OBICO_AUTH_TOKEN,
|
||||
CONF_CAMERA,
|
||||
}
|
||||
)
|
||||
|
||||
SENSITIVITY_THRESHOLDS = {
|
||||
"high": (0.20, 0.35),
|
||||
"normal": (DEFAULT_WARNING_THRESHOLD, DEFAULT_FAILURE_THRESHOLD),
|
||||
"low": (0.45, 0.70),
|
||||
"custom": (DEFAULT_WARNING_THRESHOLD, DEFAULT_FAILURE_THRESHOLD),
|
||||
}
|
||||
|
||||
EVENT_DETECTION_RESULT = f"{DOMAIN}_result"
|
||||
EVENT_SPAGHETTI_DETECTED = f"{DOMAIN}_detected"
|
||||
|
||||
SERVICE_PREDICT = "predict"
|
||||
SERVICE_RUN_DETECTION = "run_detection"
|
||||
SERVICE_RESET_STATE = "reset_state"
|
||||
|
||||
RUNTIME_DATA = "runtime"
|
||||
RUNTIME_BY_DETECTOR = "runtime_by_detector"
|
||||
RUNTIME_ML_LOCK = "ml_lock"
|
||||
|
||||
ATTR_CONFIDENCE = "confidence"
|
||||
ATTR_RAW_SCORE = "raw_score"
|
||||
ATTR_DETECTED = "detected"
|
||||
ATTR_DETECTIONS = "detections"
|
||||
ATTR_IMAGE_URL = "image_url"
|
||||
ATTR_LAST_ERROR = "last_error"
|
||||
ATTR_LAST_RUN = "last_run"
|
||||
ATTR_NEXT_RUN = "next_run"
|
||||
ATTR_STATUS = "status"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Base entities for Elegoo spaghetti detection."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .runtime import SpaghettiDetectorRuntime
|
||||
|
||||
|
||||
class SpaghettiDetectorEntity(Entity):
|
||||
"""Base entity for a detector runtime."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
runtime: SpaghettiDetectorRuntime,
|
||||
key: str,
|
||||
) -> None:
|
||||
self.entry = entry
|
||||
self.runtime = runtime
|
||||
self._attr_unique_id = f"{entry.entry_id}_{key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Elegoo",
|
||||
model="Spaghetti detection",
|
||||
name=entry.title,
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to runtime updates."""
|
||||
self.async_on_remove(
|
||||
self.runtime.async_add_listener(self.async_write_ha_state)
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"domain": "elegoo_spaghetti_detection",
|
||||
"name": "Elegoo Spaghetti Detection",
|
||||
"codeowners": [
|
||||
"@hepter"
|
||||
],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/hepter/ha-elegoo-spaghetti-detection",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "calculated",
|
||||
"issue_tracker": "https://github.com/hepter/ha-elegoo-spaghetti-detection/issues",
|
||||
"requirements": [],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
"""Runtime detection logic for Elegoo spaghetti detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_state_change_event,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
ATTR_CONFIDENCE,
|
||||
ATTR_DETECTED,
|
||||
ATTR_DETECTIONS,
|
||||
ATTR_IMAGE_URL,
|
||||
ATTR_LAST_ERROR,
|
||||
ATTR_LAST_RUN,
|
||||
ATTR_NEXT_RUN,
|
||||
ATTR_RAW_SCORE,
|
||||
ATTR_STATUS,
|
||||
CONF_ACTIVE_PRINT_STATES,
|
||||
CONF_CAMERA,
|
||||
CONF_CHAMBER_LIGHT,
|
||||
CONF_COOLDOWN_SECONDS,
|
||||
CONF_DETECTION_INTERVAL,
|
||||
CONF_FAILURE_THRESHOLD,
|
||||
CONF_HOME_ASSISTANT_HOST,
|
||||
CONF_INSTANCE_ID,
|
||||
CONF_LIGHT_CONTROL_MODE,
|
||||
CONF_LIGHT_SETTLE_SECONDS,
|
||||
CONF_OBICO_AUTH_TOKEN,
|
||||
CONF_OBICO_HOST,
|
||||
CONF_PRINT_STATUS_SENSOR,
|
||||
CONF_RUN_WITHOUT_PRINTING,
|
||||
CONF_SENSITIVITY,
|
||||
CONF_SNAPSHOT_URL,
|
||||
CONF_WARNING_THRESHOLD,
|
||||
DEFAULT_ACTIVE_PRINT_STATES,
|
||||
DEFAULT_COOLDOWN_SECONDS,
|
||||
DEFAULT_DETECTION_INTERVAL,
|
||||
DEFAULT_FAILURE_THRESHOLD,
|
||||
DEFAULT_LIGHT_CONTROL_MODE,
|
||||
DEFAULT_LIGHT_SETTLE_SECONDS,
|
||||
DEFAULT_SENSITIVITY,
|
||||
DEFAULT_WARNING_THRESHOLD,
|
||||
DOMAIN,
|
||||
EVENT_DETECTION_RESULT,
|
||||
EVENT_SPAGHETTI_DETECTED,
|
||||
LIGHT_CONTROL_LEAVE_ON,
|
||||
LIGHT_CONTROL_OFF,
|
||||
LIGHT_CONTROL_RESTORE,
|
||||
RUNTIME_ML_LOCK,
|
||||
SENSITIVITY_THRESHOLDS,
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_states(value: str | None) -> set[str]:
|
||||
"""Parse a comma-separated list of states."""
|
||||
if not value:
|
||||
value = DEFAULT_ACTIVE_PRINT_STATES
|
||||
return {item.strip().lower() for item in value.split(",") if item.strip()}
|
||||
|
||||
|
||||
def _normalize_state(value: Any) -> str:
|
||||
"""Normalize a Home Assistant state string for comparisons."""
|
||||
return str(value).strip().lower()
|
||||
|
||||
|
||||
def _score_detections(result: dict[str, Any]) -> tuple[float, int]:
|
||||
"""Return a simple confidence score from the Obico detection payload."""
|
||||
score = 0.0
|
||||
detections = result.get("detections") or []
|
||||
for detection in detections:
|
||||
try:
|
||||
score += float(detection[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
return min(1.0, max(0.0, score)), len(detections)
|
||||
|
||||
|
||||
class SpaghettiDetectorRuntime:
|
||||
"""Manage one camera/detector target."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
self.data = {**entry.data, **entry.options}
|
||||
self.detector_id: str = self.data[CONF_INSTANCE_ID]
|
||||
self.name = entry.title
|
||||
self.listeners: list[Callable[[], None]] = []
|
||||
self.unsubscribers: list[CALLBACK_TYPE] = []
|
||||
|
||||
self.enabled = True
|
||||
self.running = False
|
||||
self.status = "idle"
|
||||
self.printer_state: str | None = None
|
||||
self.confidence = 0.0
|
||||
self.raw_score = 0.0
|
||||
self.detection_count = 0
|
||||
self.detected = False
|
||||
self.warning = False
|
||||
self.last_run: datetime | None = None
|
||||
self.last_detected: datetime | None = None
|
||||
self.next_run: datetime | None = None
|
||||
self.last_error: str | None = None
|
||||
self.last_image_url: str | None = None
|
||||
self.last_result: dict[str, Any] = {"detections": []}
|
||||
self.lifetime_frames = 0
|
||||
self.detected_event_sent_for_active_period = False
|
||||
|
||||
@property
|
||||
def active_states(self) -> set[str]:
|
||||
"""Return states that mean the printer is actively printing."""
|
||||
return _parse_states(self.data.get(CONF_ACTIVE_PRINT_STATES))
|
||||
|
||||
@property
|
||||
def warning_threshold(self) -> float:
|
||||
"""Return warning threshold for this detector."""
|
||||
sensitivity = self.data.get(CONF_SENSITIVITY, DEFAULT_SENSITIVITY)
|
||||
default_warning, _ = SENSITIVITY_THRESHOLDS.get(
|
||||
sensitivity,
|
||||
SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY],
|
||||
)
|
||||
if sensitivity != "custom":
|
||||
return float(default_warning)
|
||||
return float(self.data.get(CONF_WARNING_THRESHOLD, default_warning))
|
||||
|
||||
@property
|
||||
def failure_threshold(self) -> float:
|
||||
"""Return failure threshold for this detector."""
|
||||
sensitivity = self.data.get(CONF_SENSITIVITY, DEFAULT_SENSITIVITY)
|
||||
_, default_failure = SENSITIVITY_THRESHOLDS.get(
|
||||
sensitivity,
|
||||
SENSITIVITY_THRESHOLDS[DEFAULT_SENSITIVITY],
|
||||
)
|
||||
if sensitivity != "custom":
|
||||
return float(default_failure)
|
||||
return float(self.data.get(CONF_FAILURE_THRESHOLD, default_failure))
|
||||
|
||||
@property
|
||||
def cooldown(self) -> timedelta:
|
||||
"""Return notification/action cooldown."""
|
||||
return timedelta(
|
||||
seconds=int(self.data.get(CONF_COOLDOWN_SECONDS, DEFAULT_COOLDOWN_SECONDS))
|
||||
)
|
||||
|
||||
@property
|
||||
def detection_interval(self) -> timedelta:
|
||||
"""Return scheduled detection interval."""
|
||||
return timedelta(
|
||||
seconds=int(
|
||||
self.data.get(CONF_DETECTION_INTERVAL, DEFAULT_DETECTION_INTERVAL)
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def light_control_mode(self) -> str:
|
||||
"""Return how the detector should manage the configured light."""
|
||||
mode = self.data.get(CONF_LIGHT_CONTROL_MODE, DEFAULT_LIGHT_CONTROL_MODE)
|
||||
if mode in {LIGHT_CONTROL_OFF, LIGHT_CONTROL_LEAVE_ON, LIGHT_CONTROL_RESTORE}:
|
||||
return mode
|
||||
return DEFAULT_LIGHT_CONTROL_MODE
|
||||
|
||||
@property
|
||||
def light_settle_seconds(self) -> int:
|
||||
"""Return seconds to wait after turning on a light before snapshot."""
|
||||
try:
|
||||
seconds = int(
|
||||
float(
|
||||
self.data.get(
|
||||
CONF_LIGHT_SETTLE_SECONDS,
|
||||
DEFAULT_LIGHT_SETTLE_SECONDS,
|
||||
)
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
seconds = DEFAULT_LIGHT_SETTLE_SECONDS
|
||||
return max(0, seconds)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Start scheduled detection."""
|
||||
interval = self.detection_interval
|
||||
self.next_run = dt_util.utcnow() + interval
|
||||
self.unsubscribers.append(
|
||||
async_track_time_interval(
|
||||
self.hass,
|
||||
self._async_interval_update,
|
||||
interval,
|
||||
)
|
||||
)
|
||||
|
||||
if status_entity := self.data.get(CONF_PRINT_STATUS_SENSOR):
|
||||
status_entities = [status_entity]
|
||||
if guard_entity := self._inferred_guard_entity(status_entity):
|
||||
status_entities.append(guard_entity)
|
||||
self.unsubscribers.append(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
status_entities,
|
||||
self._async_status_changed,
|
||||
)
|
||||
)
|
||||
|
||||
async def async_unload(self) -> None:
|
||||
"""Stop scheduled detection."""
|
||||
for unsubscribe in self.unsubscribers:
|
||||
unsubscribe()
|
||||
self.unsubscribers.clear()
|
||||
self.listeners.clear()
|
||||
|
||||
@callback
|
||||
def async_add_listener(self, listener: Callable[[], None]) -> CALLBACK_TYPE:
|
||||
"""Add a listener for runtime state changes."""
|
||||
self.listeners.append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
self.listeners.remove(listener)
|
||||
|
||||
return remove_listener
|
||||
|
||||
@callback
|
||||
def _notify_listeners(self) -> None:
|
||||
"""Notify entities that runtime state changed."""
|
||||
for listener in list(self.listeners):
|
||||
listener()
|
||||
|
||||
async def _async_interval_update(self, now: datetime) -> None:
|
||||
"""Run detection on interval if the target is active."""
|
||||
self.next_run = now + self.detection_interval
|
||||
self._notify_listeners()
|
||||
if self.enabled and self._should_run_scheduled():
|
||||
await self.async_run_detection(manual=False)
|
||||
|
||||
@callback
|
||||
def _async_status_changed(self, event) -> None:
|
||||
"""Reset state when a new print starts."""
|
||||
old_state = event.data.get("old_state")
|
||||
new_state = event.data.get("new_state")
|
||||
if new_state is None:
|
||||
return
|
||||
was_active = (
|
||||
old_state is not None
|
||||
and _normalize_state(old_state.state) in self.active_states
|
||||
)
|
||||
is_active = _normalize_state(new_state.state) in self.active_states
|
||||
if was_active and not is_active:
|
||||
self.detected_event_sent_for_active_period = False
|
||||
if is_active and not was_active:
|
||||
self.reset()
|
||||
|
||||
def _should_run_scheduled(self) -> bool:
|
||||
"""Return if scheduled detection should run."""
|
||||
status_entity = self.data.get(CONF_PRINT_STATUS_SENSOR)
|
||||
if not status_entity:
|
||||
self.printer_state = None
|
||||
if bool(self.data.get(CONF_RUN_WITHOUT_PRINTING, False)):
|
||||
return True
|
||||
self.status = "waiting_for_print"
|
||||
self._notify_listeners()
|
||||
return False
|
||||
|
||||
state = self.hass.states.get(status_entity)
|
||||
if state is None:
|
||||
self.status = "status_unavailable"
|
||||
self.printer_state = None
|
||||
self._notify_listeners()
|
||||
return False
|
||||
|
||||
self.printer_state = str(state.state)
|
||||
normalized_state = _normalize_state(state.state)
|
||||
if normalized_state not in self.active_states:
|
||||
self.status = (
|
||||
"status_unavailable"
|
||||
if normalized_state in {"unknown", "unavailable"}
|
||||
else "waiting_for_print"
|
||||
)
|
||||
self._notify_listeners()
|
||||
return False
|
||||
|
||||
if not self._passes_inferred_guard_sensor(status_entity):
|
||||
if self.status != "status_unavailable":
|
||||
self.status = "waiting_for_print"
|
||||
self._notify_listeners()
|
||||
return False
|
||||
|
||||
self._notify_listeners()
|
||||
return True
|
||||
|
||||
def _passes_inferred_guard_sensor(self, status_entity: str) -> bool:
|
||||
"""Return false when an inferred companion status says not active."""
|
||||
guard_entity = self._inferred_guard_entity(status_entity)
|
||||
if guard_entity is None:
|
||||
return True
|
||||
|
||||
state = self.hass.states.get(guard_entity)
|
||||
if state is None:
|
||||
return True
|
||||
|
||||
self.printer_state = f"{self.printer_state}; {guard_entity}={state.state}"
|
||||
normalized_state = _normalize_state(state.state)
|
||||
if normalized_state in {"unknown", "unavailable"}:
|
||||
self.status = "status_unavailable"
|
||||
return False
|
||||
return normalized_state in self.active_states
|
||||
|
||||
def _inferred_guard_entity(self, status_entity: str) -> str | None:
|
||||
"""Infer an Elegoo companion current-status sensor when available."""
|
||||
suffix = "_print_status"
|
||||
if not status_entity.endswith(suffix):
|
||||
return None
|
||||
candidate = f"{status_entity[: -len(suffix)]}_current_status"
|
||||
if candidate == status_entity:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset detection state."""
|
||||
self.status = "idle"
|
||||
self.confidence = 0.0
|
||||
self.raw_score = 0.0
|
||||
self.detection_count = 0
|
||||
self.detected = False
|
||||
self.warning = False
|
||||
self.last_detected = None
|
||||
self.last_error = None
|
||||
self.last_result = {"detections": []}
|
||||
self.lifetime_frames = 0
|
||||
self.detected_event_sent_for_active_period = False
|
||||
self._notify_listeners()
|
||||
|
||||
async def async_run_detection(self, *, manual: bool) -> dict[str, Any]:
|
||||
"""Run one detection request."""
|
||||
if self.running:
|
||||
self.status = "busy"
|
||||
self._notify_listeners()
|
||||
return self._service_result()
|
||||
|
||||
if not manual and not self._should_run_scheduled():
|
||||
return self._service_result()
|
||||
|
||||
self.running = True
|
||||
self.status = "checking"
|
||||
self.last_run = dt_util.utcnow()
|
||||
self.last_error = None
|
||||
self._notify_listeners()
|
||||
|
||||
restore_light: str | None = None
|
||||
try:
|
||||
restore_light = await self._async_prepare_light()
|
||||
if not manual and not self._should_run_scheduled():
|
||||
return self._service_result()
|
||||
|
||||
image_url = self._build_image_url()
|
||||
if not image_url:
|
||||
self._set_error("camera_image_unavailable")
|
||||
return self._service_result()
|
||||
|
||||
self.last_image_url = image_url
|
||||
|
||||
try:
|
||||
ml_lock = self.hass.data[DOMAIN][RUNTIME_ML_LOCK]
|
||||
async with ml_lock:
|
||||
result = await self._async_predict(image_url)
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
self._set_error(str(err))
|
||||
LOGGER.warning(
|
||||
"Obico ML request failed for %s: %s",
|
||||
self.detector_id,
|
||||
err,
|
||||
)
|
||||
return self._service_result()
|
||||
|
||||
self.last_result = result
|
||||
self.raw_score, self.detection_count = _score_detections(result)
|
||||
self.confidence = self.raw_score
|
||||
self.warning = self.confidence >= self.warning_threshold
|
||||
self.detected = self.confidence >= self.failure_threshold
|
||||
self.status = (
|
||||
"detected" if self.detected else "warning" if self.warning else "clear"
|
||||
)
|
||||
self.lifetime_frames += 1
|
||||
|
||||
self._fire_result_event(manual)
|
||||
if self.detected and self._can_fire_detected_event(manual):
|
||||
self.last_detected = dt_util.utcnow()
|
||||
if not manual and self.data.get(CONF_PRINT_STATUS_SENSOR):
|
||||
self.detected_event_sent_for_active_period = True
|
||||
self._fire_detected_event(manual)
|
||||
|
||||
self._notify_listeners()
|
||||
return self._service_result()
|
||||
finally:
|
||||
if restore_light is not None:
|
||||
await self._async_restore_light(restore_light)
|
||||
self.running = False
|
||||
|
||||
async def _async_predict(self, image_url: str) -> dict[str, Any]:
|
||||
"""Call the Obico ML API."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with session.get(
|
||||
f"{self.data[CONF_OBICO_HOST].rstrip('/')}/p/",
|
||||
params={"img": image_url},
|
||||
headers={"Authorization": f"Bearer {self.data[CONF_OBICO_AUTH_TOKEN]}"},
|
||||
timeout=aiohttp.ClientTimeout(total=60),
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
error_message = await _response_error_message(response)
|
||||
raise aiohttp.ClientResponseError(
|
||||
response.request_info,
|
||||
response.history,
|
||||
status=response.status,
|
||||
message=error_message,
|
||||
headers=response.headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = await response.json()
|
||||
if not isinstance(result, dict):
|
||||
return {"detections": []}
|
||||
return result
|
||||
|
||||
async def _async_prepare_light(self) -> str | None:
|
||||
"""Prepare the configured light before detection.
|
||||
|
||||
Returns the entity ID to restore when the integration turned an off
|
||||
light on and the selected mode wants the previous state restored.
|
||||
"""
|
||||
if self.light_control_mode == LIGHT_CONTROL_OFF:
|
||||
return None
|
||||
|
||||
light_entity = self.data.get(CONF_CHAMBER_LIGHT)
|
||||
if not light_entity:
|
||||
return None
|
||||
state = self.hass.states.get(light_entity)
|
||||
if state is None or _normalize_state(state.state) != "off":
|
||||
return None
|
||||
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light_entity},
|
||||
blocking=True,
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
LOGGER.warning("Could not turn on light %s: %s", light_entity, err)
|
||||
return None
|
||||
|
||||
restore_light = (
|
||||
light_entity if self.light_control_mode == LIGHT_CONTROL_RESTORE else None
|
||||
)
|
||||
|
||||
try:
|
||||
if self.light_settle_seconds:
|
||||
await asyncio.sleep(self.light_settle_seconds)
|
||||
except asyncio.CancelledError:
|
||||
if restore_light is not None:
|
||||
await self._async_restore_light(restore_light)
|
||||
raise
|
||||
|
||||
return restore_light
|
||||
|
||||
async def _async_restore_light(self, light_entity: str) -> None:
|
||||
"""Restore a light that the detector temporarily turned on."""
|
||||
state = self.hass.states.get(light_entity)
|
||||
if state is not None and _normalize_state(state.state) == "off":
|
||||
return
|
||||
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
"light",
|
||||
"turn_off",
|
||||
{"entity_id": light_entity},
|
||||
blocking=True,
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
LOGGER.warning("Could not restore light %s: %s", light_entity, err)
|
||||
|
||||
def _build_image_url(self) -> str | None:
|
||||
"""Build a snapshot URL for the configured camera."""
|
||||
if snapshot_url := self.data.get(CONF_SNAPSHOT_URL):
|
||||
return snapshot_url
|
||||
|
||||
camera_entity = self.data.get(CONF_CAMERA)
|
||||
state = self.hass.states.get(camera_entity)
|
||||
if state is None:
|
||||
return None
|
||||
entity_picture = state.attributes.get("entity_picture")
|
||||
if not entity_picture:
|
||||
return None
|
||||
return f"{self.data[CONF_HOME_ASSISTANT_HOST].rstrip('/')}{entity_picture}"
|
||||
|
||||
def _cooldown_elapsed(self) -> bool:
|
||||
"""Return whether a detected event can be fired."""
|
||||
if self.last_detected is None:
|
||||
return True
|
||||
return dt_util.utcnow() - self.last_detected >= self.cooldown
|
||||
|
||||
def _can_fire_detected_event(self, manual: bool) -> bool:
|
||||
"""Return whether the detected event should be emitted."""
|
||||
if not manual and self.data.get(CONF_PRINT_STATUS_SENSOR):
|
||||
return not self.detected_event_sent_for_active_period
|
||||
return self._cooldown_elapsed()
|
||||
|
||||
def _event_data(self, manual: bool) -> dict[str, Any]:
|
||||
"""Return event payload."""
|
||||
return {
|
||||
"config_entry": self.entry.entry_id,
|
||||
"detector": self.detector_id,
|
||||
"name": self.name,
|
||||
"camera": self.data.get(CONF_CAMERA),
|
||||
"manual": manual,
|
||||
"printer_state": self.printer_state,
|
||||
ATTR_CONFIDENCE: self.confidence,
|
||||
ATTR_RAW_SCORE: self.raw_score,
|
||||
ATTR_DETECTED: self.detected,
|
||||
ATTR_DETECTIONS: self.detection_count,
|
||||
ATTR_IMAGE_URL: self.last_image_url,
|
||||
ATTR_LAST_ERROR: self.last_error,
|
||||
ATTR_LAST_RUN: self.last_run.isoformat() if self.last_run else None,
|
||||
ATTR_NEXT_RUN: self.next_run.isoformat() if self.next_run else None,
|
||||
ATTR_STATUS: self.status,
|
||||
}
|
||||
|
||||
def _fire_result_event(self, manual: bool) -> None:
|
||||
"""Fire an event for every detection result."""
|
||||
self.hass.bus.async_fire(EVENT_DETECTION_RESULT, self._event_data(manual))
|
||||
|
||||
def _fire_detected_event(self, manual: bool) -> None:
|
||||
"""Fire an event when spaghetti is detected."""
|
||||
self.hass.bus.async_fire(EVENT_SPAGHETTI_DETECTED, self._event_data(manual))
|
||||
|
||||
def _service_result(self) -> dict[str, Any]:
|
||||
"""Return service response payload."""
|
||||
return {
|
||||
"result": self.last_result,
|
||||
ATTR_CONFIDENCE: self.confidence,
|
||||
ATTR_RAW_SCORE: self.raw_score,
|
||||
ATTR_DETECTED: self.detected,
|
||||
ATTR_DETECTIONS: self.detection_count,
|
||||
ATTR_IMAGE_URL: self.last_image_url,
|
||||
ATTR_LAST_ERROR: self.last_error,
|
||||
ATTR_NEXT_RUN: self.next_run.isoformat() if self.next_run else None,
|
||||
ATTR_STATUS: self.status,
|
||||
}
|
||||
|
||||
def _set_error(self, error: str) -> None:
|
||||
"""Set a runtime error and notify listeners."""
|
||||
self.status = "error"
|
||||
self.last_error = error
|
||||
self.detected = False
|
||||
self.warning = False
|
||||
self.confidence = 0.0
|
||||
self.raw_score = 0.0
|
||||
self.detection_count = 0
|
||||
self._notify_listeners()
|
||||
|
||||
|
||||
async def _response_error_message(response: aiohttp.ClientResponse) -> str:
|
||||
"""Return a useful error message from an ML server error response."""
|
||||
try:
|
||||
payload = await response.json()
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
return await response.text()
|
||||
if not isinstance(payload, dict):
|
||||
return str(payload)
|
||||
if error := payload.get("error"):
|
||||
message = payload.get("message")
|
||||
return f"{error}: {message}" if message else str(error)
|
||||
return str(payload)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Sensors for Elegoo spaghetti detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
|
||||
from .const import CONF_INSTANCE_ID, DOMAIN, RUNTIME_DATA
|
||||
from .entity import SpaghettiDetectorEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DetectorSensorDescription(SensorEntityDescription):
|
||||
"""Detector sensor description."""
|
||||
|
||||
value_fn: Any
|
||||
|
||||
|
||||
SENSORS: tuple[DetectorSensorDescription, ...] = (
|
||||
DetectorSensorDescription(
|
||||
key="confidence",
|
||||
name="Confidence",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda runtime: round(runtime.confidence * 100, 1),
|
||||
),
|
||||
DetectorSensorDescription(
|
||||
key="raw_score",
|
||||
name="Raw Score",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda runtime: round(runtime.raw_score, 4),
|
||||
),
|
||||
DetectorSensorDescription(
|
||||
key="detections",
|
||||
name="Detection Count",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda runtime: runtime.detection_count,
|
||||
),
|
||||
DetectorSensorDescription(
|
||||
key="status",
|
||||
name="Status",
|
||||
value_fn=lambda runtime: runtime.status,
|
||||
),
|
||||
DetectorSensorDescription(
|
||||
key="last_error",
|
||||
name="Last Error",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda runtime: runtime.last_error or "none",
|
||||
),
|
||||
DetectorSensorDescription(
|
||||
key="last_run",
|
||||
name="Last Run",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda runtime: runtime.last_run,
|
||||
),
|
||||
DetectorSensorDescription(
|
||||
key="next_run",
|
||||
name="Next Run",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=lambda runtime: runtime.next_run,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities,
|
||||
) -> None:
|
||||
"""Set up sensors."""
|
||||
runtime = hass.data[DOMAIN][RUNTIME_DATA][entry.entry_id]
|
||||
async_add_entities(
|
||||
DetectorSensor(entry, runtime, description) for description in SENSORS
|
||||
)
|
||||
|
||||
|
||||
class DetectorSensor(SpaghettiDetectorEntity, SensorEntity):
|
||||
"""Detector sensor."""
|
||||
|
||||
entity_description: DetectorSensorDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
runtime,
|
||||
description: DetectorSensorDescription,
|
||||
) -> None:
|
||||
super().__init__(entry, runtime, description.key)
|
||||
self.entity_description = description
|
||||
self.entity_id = f"sensor.{entry.data[CONF_INSTANCE_ID]}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the current sensor value."""
|
||||
return self.entity_description.value_fn(self.runtime)
|
||||
@@ -0,0 +1,60 @@
|
||||
predict:
|
||||
name: "Predict spaghetti from URL"
|
||||
description: "Runs the Obico ML model against a raw image URL. This is mainly for debugging."
|
||||
fields:
|
||||
obico_host:
|
||||
description: "Obico ML Server URL."
|
||||
example: "http://192.168.1.123:3333"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
obico_auth_token:
|
||||
description: "Obico ML Server authentication token."
|
||||
example: "obico_api_secret"
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
image_url:
|
||||
description: "Snapshot URL to check."
|
||||
example: "https://home.example.com/api/camera_proxy/camera.example?token=..."
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
|
||||
run_detection:
|
||||
name: "Run detection"
|
||||
description: "Runs one detection check for a configured detector. With force enabled this works even when the printer is not printing."
|
||||
fields:
|
||||
detector:
|
||||
description: "Detector/entity prefix, for example elegoo_spaghetti_detection."
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
config_entry:
|
||||
description: "Detector config entry."
|
||||
required: false
|
||||
selector:
|
||||
config_entry:
|
||||
integration: elegoo_spaghetti_detection
|
||||
force:
|
||||
description: "Run as a manual test and bypass the print-status gate."
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
reset_state:
|
||||
name: "Reset detection state"
|
||||
description: "Clears the current detector confidence, result, and error state."
|
||||
fields:
|
||||
detector:
|
||||
description: "Detector/entity prefix."
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
config_entry:
|
||||
description: "Detector config entry."
|
||||
required: false
|
||||
selector:
|
||||
config_entry:
|
||||
integration: elegoo_spaghetti_detection
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Elegoo Spaghetti Detection",
|
||||
"description": "ينشئ كاشفا لكاميرا طابعة Elegoo. هذا التكامل يكتشف الاخطاء فقط وينشئ entities/events؛ تبقى اجراءات pause و stop والتنبيهات داخل automations الخاصة بك.",
|
||||
"data": {
|
||||
"name": "اسم الكاشف",
|
||||
"instance_id": "بادئة entity",
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "الكاميرا",
|
||||
"snapshot_url": "رابط snapshot مباشر",
|
||||
"print_status_sensor": "حساس حالة الطباعة",
|
||||
"active_print_states": "حالات الطباعة النشطة",
|
||||
"chamber_light": "ضوء الحجرة",
|
||||
"light_control_mode": "التحكم بالضوء",
|
||||
"light_settle_seconds": "تأخير استقرار الضوء",
|
||||
"run_without_printing": "تشغيل الكشف المجدول بدون حالة طباعة",
|
||||
"detection_interval": "فاصل الكشف",
|
||||
"sensitivity": "الحساسية",
|
||||
"warning_threshold": "حد التحذير",
|
||||
"failure_threshold": "حد الفشل",
|
||||
"cooldown_seconds": "فترة تهدئة حدث detected"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "URL يمكن لخادم ML الوصول اليه. عند تشغيل Docker على مضيف LAN اخر، استخدم HA LAN URL مثل http://192.168.1.90:8123.",
|
||||
"obico_host": "Base URL لخادم ML الخاص بهذا المشروع، مثل http://192.168.1.100:3333. يقوم الاعداد بفحص /hc/ و /debug/image.",
|
||||
"obico_auth_token": "يجب ان يطابق ML_API_TOKEN / obico_api_secret المكون على خادم ML.",
|
||||
"instance_id": "Slug ثابت يستخدم في entity IDs. استخدم بادئة مختلفة لكل كاشف، مثل elegoo_cc2_left.",
|
||||
"camera": "اي HA camera entity. مثال من elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. قد يختلف اسم جهازك.",
|
||||
"snapshot_url": "اختياري. اتركه فارغا لاستخدام صورة Home Assistant camera proxy للكاميرا المحددة. استخدمه فقط للكاميرات غير المعتادة.",
|
||||
"print_status_sensor": "اختياري لكن موصى به. مثال: sensor.elegoo_centauri_carbon2_print_status. للاسماء بأسلوب Elegoo، يتم استخدام حساس current_status المطابق تلقائيا كحارس اضافي.",
|
||||
"active_print_states": "حالات مفصولة بفواصل تعني الطباعة، مثل printing,printing_recovery. تستخدم Elegoo CC2 عادة printing.",
|
||||
"chamber_light": "اختياري. مثال: light.elegoo_centauri_carbon2_chamber_light. يستخدم فقط بواسطة اعداد التحكم بالضوء.",
|
||||
"light_control_mode": "اختر ما اذا كان الكشف لا يتحكم بالضوء، او يشغله ويبقيه مشغلا، او يعيد حالة الضوء السابقة بعد كل snapshot.",
|
||||
"light_settle_seconds": "عدد الثواني للانتظار بعد ان يشغل التكامل ضوءا كان مطفأ قبل اخذ snapshot. الافتراضي 3 ثوان للتعريض/التركيز.",
|
||||
"run_without_printing": "اذا لم يتم تحديد حساس حالة طباعة، يعمل الكشف المجدول فقط عند تفعيل هذا الخيار. زر Test يشغل فحصا واحدا دائما.",
|
||||
"detection_interval": "عدد الثواني بين الفحوصات المجدولة عندما تكون حالة الطباعة نشطة. امثلة: 600 لعشر دقائق، 900 لخمس عشرة دقيقة.",
|
||||
"warning_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
|
||||
"failure_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
|
||||
"cooldown_seconds": "الافتراضي 900 ثانية. مع حساس حالة طباعة، ترسل الفحوصات المجدولة حدث detected واحدا لكل نافذة طباعة نشطة؛ وتستمر result events في الارسال مع كل فحص."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_instance_id": "يجب ان تحتوي بادئة entity على حرف slug صالح واحد على الاقل.",
|
||||
"instance_id_exists": "بادئة entity هذه مستخدمة بالفعل بواسطة كاشف اخر.",
|
||||
"warning_above_failure": "يجب ان يكون حد التحذير اقل من حد الفشل او مساويا له.",
|
||||
"already_configured": "هذه الكاميرا مكونة بالفعل.",
|
||||
"camera_image_unavailable": "الكاميرا المحددة لا تعرض entity_picture URL. جرب كاميرا اخرى او اضبط snapshot URL مباشر.",
|
||||
"ml_health_failed": "فشل فحص صحة خادم ML. تأكد ان المضيف قابل للوصول ويشير الى base URL مثل http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "رفض خادم ML الرمز.",
|
||||
"ml_image_fetch_failed": "تعذر على خادم ML جلب صورة الكاميرا او فك ترميزها. تحقق من Home Assistant Host ووصول الكاميرا من خادم ML."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "هذه الكاميرا مكونة بالفعل."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "اعدادات الكاشف",
|
||||
"description": "حدث اعدادات الكاميرا وخادم ML وحالة الطباعة والكشف لهذا الكاشف.",
|
||||
"data": {
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "الكاميرا",
|
||||
"snapshot_url": "رابط snapshot مباشر",
|
||||
"print_status_sensor": "حساس حالة الطباعة",
|
||||
"active_print_states": "حالات الطباعة النشطة",
|
||||
"chamber_light": "ضوء الحجرة",
|
||||
"light_control_mode": "التحكم بالضوء",
|
||||
"light_settle_seconds": "تأخير استقرار الضوء",
|
||||
"run_without_printing": "تشغيل الكشف المجدول بدون حالة طباعة",
|
||||
"detection_interval": "فاصل الكشف",
|
||||
"sensitivity": "الحساسية",
|
||||
"warning_threshold": "حد التحذير",
|
||||
"failure_threshold": "حد الفشل",
|
||||
"cooldown_seconds": "فترة تهدئة حدث detected"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "URL يمكن لخادم ML الوصول اليه. عند تشغيل Docker على مضيف LAN اخر، استخدم HA LAN URL مثل http://192.168.1.90:8123.",
|
||||
"obico_host": "Base URL لخادم ML الخاص بهذا المشروع، مثل http://192.168.1.100:3333. يقوم الاعداد بفحص /hc/ و /debug/image.",
|
||||
"obico_auth_token": "يجب ان يطابق ML_API_TOKEN / obico_api_secret المكون على خادم ML.",
|
||||
"camera": "اي HA camera entity. مثال من elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. قد يختلف اسم جهازك.",
|
||||
"snapshot_url": "اختياري. اتركه فارغا لاستخدام صورة Home Assistant camera proxy للكاميرا المحددة.",
|
||||
"print_status_sensor": "اختياري لكن موصى به. مثال: sensor.elegoo_centauri_carbon2_print_status. للاسماء بأسلوب Elegoo، يتم استخدام حساس current_status المطابق تلقائيا كحارس اضافي.",
|
||||
"active_print_states": "حالات مفصولة بفواصل تعني الطباعة، مثل printing,printing_recovery.",
|
||||
"chamber_light": "اختياري. مثال: light.elegoo_centauri_carbon2_chamber_light. يستخدم فقط بواسطة اعداد التحكم بالضوء.",
|
||||
"light_control_mode": "اختر ما اذا كان الكشف لا يتحكم بالضوء، او يشغله ويبقيه مشغلا، او يعيد حالة الضوء السابقة بعد كل snapshot.",
|
||||
"light_settle_seconds": "عدد الثواني للانتظار بعد ان يشغل التكامل ضوءا كان مطفأ قبل اخذ snapshot. الافتراضي 3 ثوان للتعريض/التركيز.",
|
||||
"run_without_printing": "اذا لم يتم تحديد حساس حالة طباعة، يعمل الكشف المجدول فقط عند تفعيل هذا الخيار. زر Test يشغل فحصا واحدا دائما.",
|
||||
"detection_interval": "عدد الثواني بين الفحوصات المجدولة عندما تكون حالة الطباعة نشطة. امثلة: 600 لعشر دقائق، 900 لخمس عشرة دقيقة.",
|
||||
"warning_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
|
||||
"failure_threshold": "يستخدم عندما تكون الحساسية Custom thresholds.",
|
||||
"cooldown_seconds": "الافتراضي 900 ثانية. مع حساس حالة طباعة، ترسل الفحوصات المجدولة حدث detected واحدا لكل نافذة طباعة نشطة؛ وتستمر result events في الارسال مع كل فحص."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"warning_above_failure": "يجب ان يكون حد التحذير اقل من حد الفشل او مساويا له.",
|
||||
"camera_image_unavailable": "الكاميرا المحددة لا تعرض entity_picture URL. جرب كاميرا اخرى او اضبط snapshot URL مباشر.",
|
||||
"ml_health_failed": "فشل فحص صحة خادم ML. تأكد ان المضيف قابل للوصول ويشير الى base URL مثل http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "رفض خادم ML الرمز.",
|
||||
"ml_image_fetch_failed": "تعذر على خادم ML جلب صورة الكاميرا او فك ترميزها. تحقق من Home Assistant Host ووصول الكاميرا من خادم ML."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"predict": {
|
||||
"name": "توقع spaghetti من URL",
|
||||
"description": "يشغل نموذج Obico ML على URL صورة خام",
|
||||
"fields": {
|
||||
"obico_host": {
|
||||
"name": "Obico ML API Host",
|
||||
"description": "Obico ML API host"
|
||||
},
|
||||
"obico_auth_token": {
|
||||
"name": "Obico ML API Auth Token",
|
||||
"description": "رمز مصادقة Obico ML API"
|
||||
},
|
||||
"image_url": {
|
||||
"name": "Image URL",
|
||||
"description": "Snapshot URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"run_detection": {
|
||||
"name": "تشغيل الكشف",
|
||||
"description": "يشغل فحص كشف واحدا لكاشف مكون.",
|
||||
"fields": {
|
||||
"detector": {
|
||||
"name": "الكاشف"
|
||||
},
|
||||
"config_entry": {
|
||||
"name": "Config entry"
|
||||
},
|
||||
"force": {
|
||||
"name": "اجبار"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reset_state": {
|
||||
"name": "اعادة ضبط حالة الكشف",
|
||||
"description": "يمسح confidence والنتيجة وحالة الخطأ للكاشف."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Elegoo Spaghetti Detection",
|
||||
"description": "Create one detector for an Elegoo printer camera. The integration only detects failures and fires entities/events; pause, stop, and notify actions stay in your own automations.",
|
||||
"data": {
|
||||
"name": "Detector name",
|
||||
"instance_id": "Entity prefix",
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "Camera",
|
||||
"snapshot_url": "Direct snapshot URL",
|
||||
"print_status_sensor": "Print status sensor",
|
||||
"active_print_states": "Active print states",
|
||||
"chamber_light": "Chamber light",
|
||||
"light_control_mode": "Light control",
|
||||
"light_settle_seconds": "Light settle delay",
|
||||
"run_without_printing": "Run scheduled detection without print status",
|
||||
"detection_interval": "Detection interval",
|
||||
"sensitivity": "Sensitivity",
|
||||
"warning_threshold": "Warning threshold",
|
||||
"failure_threshold": "Failure threshold",
|
||||
"cooldown_seconds": "Detected event cooldown"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "URL reachable by the ML server. For Docker on another LAN host, use the HA LAN URL, for example http://192.168.1.90:8123.",
|
||||
"obico_host": "Base URL of this project's ML server, for example http://192.168.1.100:3333. The setup checks /hc/ and /debug/image.",
|
||||
"obico_auth_token": "Must match ML_API_TOKEN / obico_api_secret configured on the ML server.",
|
||||
"instance_id": "Stable slug used in entity IDs. Use a different prefix for each detector, for example elegoo_cc2_left.",
|
||||
"camera": "Any HA camera entity. Example from elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. Your device name may differ.",
|
||||
"snapshot_url": "Optional. Leave empty to use the selected camera entity's Home Assistant camera proxy image. Use this only for unusual cameras.",
|
||||
"print_status_sensor": "Optional but recommended. Example: sensor.elegoo_centauri_carbon2_print_status. For Elegoo-style names, the matching current_status sensor is used automatically as an extra guard.",
|
||||
"active_print_states": "Comma-separated states that mean printing, for example printing,printing_recovery. Elegoo CC2 usually uses printing.",
|
||||
"chamber_light": "Optional. Example: light.elegoo_centauri_carbon2_chamber_light. Used only by the light-control setting.",
|
||||
"light_control_mode": "Choose whether detection should leave the light alone, turn it on and leave it on, or restore the previous light state after each snapshot.",
|
||||
"light_settle_seconds": "Seconds to wait after this integration turns on an off light before taking the snapshot. Default is 3 seconds for camera exposure/focus.",
|
||||
"run_without_printing": "If no print status sensor is selected, scheduled detection only runs when this is enabled. The Test button always runs one check.",
|
||||
"detection_interval": "Seconds between scheduled checks while the print status is active. Examples: 600 for 10 minutes, 900 for 15 minutes.",
|
||||
"warning_threshold": "Used when Sensitivity is set to Custom thresholds.",
|
||||
"failure_threshold": "Used when Sensitivity is set to Custom thresholds.",
|
||||
"cooldown_seconds": "Default is 900 seconds. Scheduled checks with a print status sensor emit one detected event per active print window; result events still fire for every check."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_instance_id": "Entity prefix must contain at least one valid slug character.",
|
||||
"instance_id_exists": "This entity prefix is already used by another detector.",
|
||||
"warning_above_failure": "Warning threshold must be lower than or equal to failure threshold.",
|
||||
"already_configured": "This camera is already configured.",
|
||||
"camera_image_unavailable": "The selected camera does not expose an entity_picture URL. Try another camera or set a direct snapshot URL.",
|
||||
"ml_health_failed": "The ML server health check failed. Confirm the host is reachable and points to the base URL, for example http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "The ML server rejected the token.",
|
||||
"ml_image_fetch_failed": "The ML server could not fetch or decode the camera image. Check Home Assistant Host and camera access from the ML server."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This camera is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Detector settings",
|
||||
"description": "Update camera, ML server, print-state, and detection settings for this detector.",
|
||||
"data": {
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "Camera",
|
||||
"snapshot_url": "Direct snapshot URL",
|
||||
"print_status_sensor": "Print status sensor",
|
||||
"active_print_states": "Active print states",
|
||||
"chamber_light": "Chamber light",
|
||||
"light_control_mode": "Light control",
|
||||
"light_settle_seconds": "Light settle delay",
|
||||
"run_without_printing": "Run scheduled detection without print status",
|
||||
"detection_interval": "Detection interval",
|
||||
"sensitivity": "Sensitivity",
|
||||
"warning_threshold": "Warning threshold",
|
||||
"failure_threshold": "Failure threshold",
|
||||
"cooldown_seconds": "Detected event cooldown"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "URL reachable by the ML server. For Docker on another LAN host, use the HA LAN URL, for example http://192.168.1.90:8123.",
|
||||
"obico_host": "Base URL of this project's ML server, for example http://192.168.1.100:3333. The setup checks /hc/ and /debug/image.",
|
||||
"obico_auth_token": "Must match ML_API_TOKEN / obico_api_secret configured on the ML server.",
|
||||
"camera": "Any HA camera entity. Example from elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. Your device name may differ.",
|
||||
"snapshot_url": "Optional. Leave empty to use the selected camera entity's Home Assistant camera proxy image.",
|
||||
"print_status_sensor": "Optional but recommended. Example: sensor.elegoo_centauri_carbon2_print_status. For Elegoo-style names, the matching current_status sensor is used automatically as an extra guard.",
|
||||
"active_print_states": "Comma-separated states that mean printing, for example printing,printing_recovery.",
|
||||
"chamber_light": "Optional. Example: light.elegoo_centauri_carbon2_chamber_light. Used only by the light-control setting.",
|
||||
"light_control_mode": "Choose whether detection should leave the light alone, turn it on and leave it on, or restore the previous light state after each snapshot.",
|
||||
"light_settle_seconds": "Seconds to wait after this integration turns on an off light before taking the snapshot. Default is 3 seconds for camera exposure/focus.",
|
||||
"run_without_printing": "If no print status sensor is selected, scheduled detection only runs when this is enabled. The Test button always runs one check.",
|
||||
"detection_interval": "Seconds between scheduled checks while the print status is active. Examples: 600 for 10 minutes, 900 for 15 minutes.",
|
||||
"warning_threshold": "Used when Sensitivity is set to Custom thresholds.",
|
||||
"failure_threshold": "Used when Sensitivity is set to Custom thresholds.",
|
||||
"cooldown_seconds": "Default is 900 seconds. Scheduled checks with a print status sensor emit one detected event per active print window; result events still fire for every check."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"warning_above_failure": "Warning threshold must be lower than or equal to failure threshold.",
|
||||
"camera_image_unavailable": "The selected camera does not expose an entity_picture URL. Try another camera or set a direct snapshot URL.",
|
||||
"ml_health_failed": "The ML server health check failed. Confirm the host is reachable and points to the base URL, for example http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "The ML server rejected the token.",
|
||||
"ml_image_fetch_failed": "The ML server could not fetch or decode the camera image. Check Home Assistant Host and camera access from the ML server."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"predict": {
|
||||
"name": "Predict spaghetti from URL",
|
||||
"description": "Runs the Obico ML model against a raw image URL",
|
||||
"fields": {
|
||||
"obico_host": {
|
||||
"name": "Obico ML API Host",
|
||||
"description": "Obico ML API host"
|
||||
},
|
||||
"obico_auth_token": {
|
||||
"name": "Obico ML API Auth Token",
|
||||
"description": "Obico ML API authentication token"
|
||||
},
|
||||
"image_url": {
|
||||
"name": "Image URL",
|
||||
"description": "Snapshot URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"run_detection": {
|
||||
"name": "Run detection",
|
||||
"description": "Runs one detection check for a configured detector.",
|
||||
"fields": {
|
||||
"detector": {
|
||||
"name": "Detector"
|
||||
},
|
||||
"config_entry": {
|
||||
"name": "Config entry"
|
||||
},
|
||||
"force": {
|
||||
"name": "Force"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reset_state": {
|
||||
"name": "Reset detection state",
|
||||
"description": "Clears the detector confidence, result, and error state."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Elegoo Spaghetti Detection",
|
||||
"description": "Crea un detector para una camara de impresora Elegoo. La integracion solo detecta fallos y genera entidades/eventos; las acciones de pausar, detener y notificar quedan en tus automatizaciones.",
|
||||
"data": {
|
||||
"name": "Nombre del detector",
|
||||
"instance_id": "Prefijo de entidad",
|
||||
"home_assistant_host": "Host de Home Assistant",
|
||||
"obico_host": "Host de la API ML de Obico",
|
||||
"obico_auth_token": "Token de API ML de Obico",
|
||||
"camera": "Camara",
|
||||
"snapshot_url": "URL directa de captura",
|
||||
"print_status_sensor": "Sensor de estado de impresion",
|
||||
"active_print_states": "Estados activos de impresion",
|
||||
"chamber_light": "Luz de camara",
|
||||
"light_control_mode": "Control de luz",
|
||||
"light_settle_seconds": "Espera de luz",
|
||||
"run_without_printing": "Ejecutar deteccion programada sin estado de impresion",
|
||||
"detection_interval": "Intervalo de deteccion",
|
||||
"sensitivity": "Sensibilidad",
|
||||
"warning_threshold": "Umbral de advertencia",
|
||||
"failure_threshold": "Umbral de fallo",
|
||||
"cooldown_seconds": "Enfriamiento del evento detectado"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "URL accesible por el servidor ML. Para Docker en otro host LAN, usa la URL LAN de HA, por ejemplo http://192.168.1.90:8123.",
|
||||
"obico_host": "URL base del servidor ML de este proyecto, por ejemplo http://192.168.1.100:3333. La configuracion comprueba /hc/ y /debug/image.",
|
||||
"obico_auth_token": "Debe coincidir con ML_API_TOKEN / obico_api_secret configurado en el servidor ML.",
|
||||
"instance_id": "Slug estable usado en los ID de entidad. Usa un prefijo distinto para cada detector, por ejemplo elegoo_cc2_left.",
|
||||
"camera": "Cualquier entidad de camara de HA. Ejemplo de elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. El nombre de tu dispositivo puede variar.",
|
||||
"snapshot_url": "Opcional. Dejalo vacio para usar la imagen proxy de la camara seleccionada en Home Assistant. Usalo solo para camaras poco comunes.",
|
||||
"print_status_sensor": "Opcional pero recomendado. Ejemplo: sensor.elegoo_centauri_carbon2_print_status. Para nombres estilo Elegoo, el sensor current_status coincidente se usa automaticamente como proteccion extra.",
|
||||
"active_print_states": "Estados separados por comas que significan impresion, por ejemplo printing,printing_recovery. Elegoo CC2 normalmente usa printing.",
|
||||
"chamber_light": "Opcional. Ejemplo: light.elegoo_centauri_carbon2_chamber_light. Solo lo usa el ajuste de control de luz.",
|
||||
"light_control_mode": "Elige si la deteccion no controla la luz, la enciende y la deja encendida, o restaura el estado anterior despues de cada captura.",
|
||||
"light_settle_seconds": "Segundos que se esperan despues de encender una luz apagada antes de tomar la captura. El valor predeterminado es 3 segundos para exposicion/enfoque.",
|
||||
"run_without_printing": "Si no se selecciona sensor de estado, la deteccion programada solo se ejecuta cuando esto esta activado. El boton Test siempre ejecuta una comprobacion.",
|
||||
"detection_interval": "Segundos entre comprobaciones programadas mientras el estado de impresion esta activo. Ejemplos: 600 para 10 minutos, 900 para 15 minutos.",
|
||||
"warning_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
|
||||
"failure_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
|
||||
"cooldown_seconds": "El valor predeterminado es 900 segundos. Con sensor de estado, las comprobaciones programadas emiten un evento detected por ventana activa de impresion; los eventos result siguen emitiendose en cada comprobacion."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_instance_id": "El prefijo de entidad debe contener al menos un caracter slug valido.",
|
||||
"instance_id_exists": "Este prefijo de entidad ya lo usa otro detector.",
|
||||
"warning_above_failure": "El umbral de advertencia debe ser menor o igual que el umbral de fallo.",
|
||||
"already_configured": "Esta camara ya esta configurada.",
|
||||
"camera_image_unavailable": "La camara seleccionada no expone una URL entity_picture. Prueba otra camara o define una URL directa de captura.",
|
||||
"ml_health_failed": "La comprobacion de salud del servidor ML fallo. Confirma que el host sea accesible y apunte a la URL base, por ejemplo http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "El servidor ML rechazo el token.",
|
||||
"ml_image_fetch_failed": "El servidor ML no pudo obtener o decodificar la imagen de la camara. Revisa Home Assistant Host y el acceso a la camara desde el servidor ML."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Esta camara ya esta configurada."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Ajustes del detector",
|
||||
"description": "Actualiza la camara, servidor ML, estado de impresion y ajustes de deteccion para este detector.",
|
||||
"data": {
|
||||
"home_assistant_host": "Host de Home Assistant",
|
||||
"obico_host": "Host de la API ML de Obico",
|
||||
"obico_auth_token": "Token de API ML de Obico",
|
||||
"camera": "Camara",
|
||||
"snapshot_url": "URL directa de captura",
|
||||
"print_status_sensor": "Sensor de estado de impresion",
|
||||
"active_print_states": "Estados activos de impresion",
|
||||
"chamber_light": "Luz de camara",
|
||||
"light_control_mode": "Control de luz",
|
||||
"light_settle_seconds": "Espera de luz",
|
||||
"run_without_printing": "Ejecutar deteccion programada sin estado de impresion",
|
||||
"detection_interval": "Intervalo de deteccion",
|
||||
"sensitivity": "Sensibilidad",
|
||||
"warning_threshold": "Umbral de advertencia",
|
||||
"failure_threshold": "Umbral de fallo",
|
||||
"cooldown_seconds": "Enfriamiento del evento detectado"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "URL accesible por el servidor ML. Para Docker en otro host LAN, usa la URL LAN de HA, por ejemplo http://192.168.1.90:8123.",
|
||||
"obico_host": "URL base del servidor ML de este proyecto, por ejemplo http://192.168.1.100:3333. La configuracion comprueba /hc/ y /debug/image.",
|
||||
"obico_auth_token": "Debe coincidir con ML_API_TOKEN / obico_api_secret configurado en el servidor ML.",
|
||||
"camera": "Cualquier entidad de camara de HA. Ejemplo de elegoo-homeassistant: camera.elegoo_centauri_carbon2_chamber_camera. El nombre de tu dispositivo puede variar.",
|
||||
"snapshot_url": "Opcional. Dejalo vacio para usar la imagen proxy de la camara seleccionada en Home Assistant.",
|
||||
"print_status_sensor": "Opcional pero recomendado. Ejemplo: sensor.elegoo_centauri_carbon2_print_status. Para nombres estilo Elegoo, el sensor current_status coincidente se usa automaticamente como proteccion extra.",
|
||||
"active_print_states": "Estados separados por comas que significan impresion, por ejemplo printing,printing_recovery.",
|
||||
"chamber_light": "Opcional. Ejemplo: light.elegoo_centauri_carbon2_chamber_light. Solo lo usa el ajuste de control de luz.",
|
||||
"light_control_mode": "Elige si la deteccion no controla la luz, la enciende y la deja encendida, o restaura el estado anterior despues de cada captura.",
|
||||
"light_settle_seconds": "Segundos que se esperan despues de encender una luz apagada antes de tomar la captura. El valor predeterminado es 3 segundos para exposicion/enfoque.",
|
||||
"run_without_printing": "Si no se selecciona sensor de estado, la deteccion programada solo se ejecuta cuando esto esta activado. El boton Test siempre ejecuta una comprobacion.",
|
||||
"detection_interval": "Segundos entre comprobaciones programadas mientras el estado de impresion esta activo. Ejemplos: 600 para 10 minutos, 900 para 15 minutos.",
|
||||
"warning_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
|
||||
"failure_threshold": "Se usa cuando Sensibilidad esta en Custom thresholds.",
|
||||
"cooldown_seconds": "El valor predeterminado es 900 segundos. Con sensor de estado, las comprobaciones programadas emiten un evento detected por ventana activa de impresion; los eventos result siguen emitiendose en cada comprobacion."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"warning_above_failure": "El umbral de advertencia debe ser menor o igual que el umbral de fallo.",
|
||||
"camera_image_unavailable": "La camara seleccionada no expone una URL entity_picture. Prueba otra camara o define una URL directa de captura.",
|
||||
"ml_health_failed": "La comprobacion de salud del servidor ML fallo. Confirma que el host sea accesible y apunte a la URL base, por ejemplo http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "El servidor ML rechazo el token.",
|
||||
"ml_image_fetch_failed": "El servidor ML no pudo obtener o decodificar la imagen de la camara. Revisa Home Assistant Host y el acceso a la camara desde el servidor ML."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"predict": {
|
||||
"name": "Predecir spaghetti desde URL",
|
||||
"description": "Ejecuta el modelo ML de Obico sobre una URL de imagen sin procesar",
|
||||
"fields": {
|
||||
"obico_host": {
|
||||
"name": "Host de la API ML de Obico",
|
||||
"description": "Host de la API ML de Obico"
|
||||
},
|
||||
"obico_auth_token": {
|
||||
"name": "Token de API ML de Obico",
|
||||
"description": "Token de autenticacion de la API ML de Obico"
|
||||
},
|
||||
"image_url": {
|
||||
"name": "URL de imagen",
|
||||
"description": "URL de captura"
|
||||
}
|
||||
}
|
||||
},
|
||||
"run_detection": {
|
||||
"name": "Ejecutar deteccion",
|
||||
"description": "Ejecuta una comprobacion de deteccion para un detector configurado.",
|
||||
"fields": {
|
||||
"detector": {
|
||||
"name": "Detector"
|
||||
},
|
||||
"config_entry": {
|
||||
"name": "Entrada de configuracion"
|
||||
},
|
||||
"force": {
|
||||
"name": "Forzar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reset_state": {
|
||||
"name": "Restablecer estado de deteccion",
|
||||
"description": "Limpia la confianza, el resultado y el estado de error del detector."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Elegoo Spaghetti Detection",
|
||||
"description": "Elegoo प्रिंटर कैमरा के लिए एक detector बनाता है। यह integration केवल failures detect करता है और entities/events बनाता है; pause, stop और notify actions आपकी अपनी automations में रहते हैं।",
|
||||
"data": {
|
||||
"name": "Detector name",
|
||||
"instance_id": "Entity prefix",
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "Camera",
|
||||
"snapshot_url": "Direct snapshot URL",
|
||||
"print_status_sensor": "Print status sensor",
|
||||
"active_print_states": "Active print states",
|
||||
"chamber_light": "Chamber light",
|
||||
"light_control_mode": "Light control",
|
||||
"light_settle_seconds": "Light settle delay",
|
||||
"run_without_printing": "Print status के बिना scheduled detection चलाएं",
|
||||
"detection_interval": "Detection interval",
|
||||
"sensitivity": "Sensitivity",
|
||||
"warning_threshold": "Warning threshold",
|
||||
"failure_threshold": "Failure threshold",
|
||||
"cooldown_seconds": "Detected event cooldown"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "ML server द्वारा reachable URL। किसी दूसरे LAN host पर Docker के लिए HA LAN URL इस्तेमाल करें, जैसे http://192.168.1.90:8123.",
|
||||
"obico_host": "इस project के ML server का base URL, जैसे http://192.168.1.100:3333। Setup /hc/ और /debug/image check करता है।",
|
||||
"obico_auth_token": "ML server पर configured ML_API_TOKEN / obico_api_secret से match करना चाहिए।",
|
||||
"instance_id": "Entity IDs में इस्तेमाल होने वाला stable slug। हर detector के लिए अलग prefix इस्तेमाल करें, जैसे elegoo_cc2_left.",
|
||||
"camera": "कोई भी HA camera entity। elegoo-homeassistant example: camera.elegoo_centauri_carbon2_chamber_camera. आपका device name अलग हो सकता है।",
|
||||
"snapshot_url": "Optional। Selected camera entity की Home Assistant camera proxy image इस्तेमाल करने के लिए खाली छोड़ें। इसे केवल unusual cameras के लिए इस्तेमाल करें।",
|
||||
"print_status_sensor": "Optional लेकिन recommended। Example: sensor.elegoo_centauri_carbon2_print_status. Elegoo-style names में matching current_status sensor अपने आप extra guard के रूप में इस्तेमाल होता है।",
|
||||
"active_print_states": "Printing बताने वाले comma-separated states, जैसे printing,printing_recovery. Elegoo CC2 आम तौर पर printing इस्तेमाल करता है।",
|
||||
"chamber_light": "Optional। Example: light.elegoo_centauri_carbon2_chamber_light. केवल light-control setting द्वारा इस्तेमाल होता है।",
|
||||
"light_control_mode": "चुनें कि detection light को न छुए, उसे on करके on रखे, या हर snapshot के बाद पिछली state restore करे।",
|
||||
"light_settle_seconds": "Integration द्वारा off light को on करने के बाद snapshot से पहले wait करने के seconds। Camera exposure/focus के लिए default 3 seconds है।",
|
||||
"run_without_printing": "अगर print status sensor selected नहीं है, scheduled detection केवल यह enabled होने पर चलता है। Test button हमेशा एक check चलाता है।",
|
||||
"detection_interval": "Print status active होने पर scheduled checks के बीच seconds। Examples: 10 minutes के लिए 600, 15 minutes के लिए 900.",
|
||||
"warning_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
|
||||
"failure_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
|
||||
"cooldown_seconds": "Default 900 seconds है। Print status sensor के साथ scheduled checks हर active print window में एक detected event emit करते हैं; result events हर check पर आते रहते हैं।"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_instance_id": "Entity prefix में कम से कम एक valid slug character होना चाहिए।",
|
||||
"instance_id_exists": "यह entity prefix पहले से किसी दूसरे detector द्वारा इस्तेमाल हो रहा है।",
|
||||
"warning_above_failure": "Warning threshold failure threshold से कम या उसके बराबर होना चाहिए।",
|
||||
"already_configured": "यह camera पहले से configured है।",
|
||||
"camera_image_unavailable": "Selected camera entity_picture URL expose नहीं करता। दूसरा camera try करें या direct snapshot URL set करें।",
|
||||
"ml_health_failed": "ML server health check failed। Confirm करें कि host reachable है और base URL पर point करता है, जैसे http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "ML server ने token reject किया।",
|
||||
"ml_image_fetch_failed": "ML server camera image fetch या decode नहीं कर सका। Home Assistant Host और ML server से camera access check करें।"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "यह camera पहले से configured है।"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Detector settings",
|
||||
"description": "इस detector के लिए camera, ML server, print-state और detection settings update करें।",
|
||||
"data": {
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "Camera",
|
||||
"snapshot_url": "Direct snapshot URL",
|
||||
"print_status_sensor": "Print status sensor",
|
||||
"active_print_states": "Active print states",
|
||||
"chamber_light": "Chamber light",
|
||||
"light_control_mode": "Light control",
|
||||
"light_settle_seconds": "Light settle delay",
|
||||
"run_without_printing": "Print status के बिना scheduled detection चलाएं",
|
||||
"detection_interval": "Detection interval",
|
||||
"sensitivity": "Sensitivity",
|
||||
"warning_threshold": "Warning threshold",
|
||||
"failure_threshold": "Failure threshold",
|
||||
"cooldown_seconds": "Detected event cooldown"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "ML server द्वारा reachable URL। किसी दूसरे LAN host पर Docker के लिए HA LAN URL इस्तेमाल करें, जैसे http://192.168.1.90:8123.",
|
||||
"obico_host": "इस project के ML server का base URL, जैसे http://192.168.1.100:3333। Setup /hc/ और /debug/image check करता है।",
|
||||
"obico_auth_token": "ML server पर configured ML_API_TOKEN / obico_api_secret से match करना चाहिए।",
|
||||
"camera": "कोई भी HA camera entity। elegoo-homeassistant example: camera.elegoo_centauri_carbon2_chamber_camera. आपका device name अलग हो सकता है।",
|
||||
"snapshot_url": "Optional। Selected camera entity की Home Assistant camera proxy image इस्तेमाल करने के लिए खाली छोड़ें।",
|
||||
"print_status_sensor": "Optional लेकिन recommended। Example: sensor.elegoo_centauri_carbon2_print_status. Elegoo-style names में matching current_status sensor अपने आप extra guard के रूप में इस्तेमाल होता है।",
|
||||
"active_print_states": "Printing बताने वाले comma-separated states, जैसे printing,printing_recovery.",
|
||||
"chamber_light": "Optional। Example: light.elegoo_centauri_carbon2_chamber_light. केवल light-control setting द्वारा इस्तेमाल होता है।",
|
||||
"light_control_mode": "चुनें कि detection light को न छुए, उसे on करके on रखे, या हर snapshot के बाद पिछली state restore करे।",
|
||||
"light_settle_seconds": "Integration द्वारा off light को on करने के बाद snapshot से पहले wait करने के seconds। Camera exposure/focus के लिए default 3 seconds है।",
|
||||
"run_without_printing": "अगर print status sensor selected नहीं है, scheduled detection केवल यह enabled होने पर चलता है। Test button हमेशा एक check चलाता है।",
|
||||
"detection_interval": "Print status active होने पर scheduled checks के बीच seconds। Examples: 10 minutes के लिए 600, 15 minutes के लिए 900.",
|
||||
"warning_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
|
||||
"failure_threshold": "Sensitivity Custom thresholds होने पर इस्तेमाल होता है।",
|
||||
"cooldown_seconds": "Default 900 seconds है। Print status sensor के साथ scheduled checks हर active print window में एक detected event emit करते हैं; result events हर check पर आते रहते हैं।"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"warning_above_failure": "Warning threshold failure threshold से कम या उसके बराबर होना चाहिए।",
|
||||
"camera_image_unavailable": "Selected camera entity_picture URL expose नहीं करता। दूसरा camera try करें या direct snapshot URL set करें।",
|
||||
"ml_health_failed": "ML server health check failed। Confirm करें कि host reachable है और base URL पर point करता है, जैसे http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "ML server ने token reject किया।",
|
||||
"ml_image_fetch_failed": "ML server camera image fetch या decode नहीं कर सका। Home Assistant Host और ML server से camera access check करें।"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"predict": {
|
||||
"name": "URL से spaghetti predict करें",
|
||||
"description": "Raw image URL पर Obico ML model चलाता है",
|
||||
"fields": {
|
||||
"obico_host": {
|
||||
"name": "Obico ML API Host",
|
||||
"description": "Obico ML API host"
|
||||
},
|
||||
"obico_auth_token": {
|
||||
"name": "Obico ML API Auth Token",
|
||||
"description": "Obico ML API authentication token"
|
||||
},
|
||||
"image_url": {
|
||||
"name": "Image URL",
|
||||
"description": "Snapshot URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"run_detection": {
|
||||
"name": "Detection चलाएं",
|
||||
"description": "Configured detector के लिए एक detection check चलाता है।",
|
||||
"fields": {
|
||||
"detector": {
|
||||
"name": "Detector"
|
||||
},
|
||||
"config_entry": {
|
||||
"name": "Config entry"
|
||||
},
|
||||
"force": {
|
||||
"name": "Force"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reset_state": {
|
||||
"name": "Detection state reset करें",
|
||||
"description": "Detector confidence, result और error state साफ करता है।"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Elegoo Spaghetti Detection",
|
||||
"description": "Elegoo yazici kamerasi icin bir algilayici olusturur. Entegrasyon yalnizca hatalari algilar ve entity/event uretir; pause, stop ve bildirim aksiyonlari kendi otomasyonlarinizda kalir.",
|
||||
"data": {
|
||||
"name": "Algilayici adi",
|
||||
"instance_id": "Entity on eki",
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "Kamera",
|
||||
"snapshot_url": "Dogrudan snapshot URL",
|
||||
"print_status_sensor": "Baski durum sensoru",
|
||||
"active_print_states": "Aktif baski durumlari",
|
||||
"chamber_light": "Kabin isigi",
|
||||
"light_control_mode": "Isik kontrolu",
|
||||
"light_settle_seconds": "Isik bekleme suresi",
|
||||
"run_without_printing": "Baski durumu olmadan zamanlanmis algilama calistir",
|
||||
"detection_interval": "Algilama araligi",
|
||||
"sensitivity": "Hassasiyet",
|
||||
"warning_threshold": "Uyari esigi",
|
||||
"failure_threshold": "Hata esigi",
|
||||
"cooldown_seconds": "Algilandi eventi bekleme suresi"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "ML sunucusunun erisebildigi URL. Baska bir LAN makinesindeki Docker icin HA LAN URL kullanin; ornegin http://192.168.1.90:8123.",
|
||||
"obico_host": "Bu projenin ML sunucusu base URL adresi; ornegin http://192.168.1.100:3333. Kurulum /hc/ ve /debug/image kontrollerini yapar.",
|
||||
"obico_auth_token": "ML sunucusunda ayarlanan ML_API_TOKEN / obico_api_secret ile ayni olmalidir.",
|
||||
"instance_id": "Entity ID'lerinde kullanilan kalici slug. Her algilayici icin farkli bir on ek kullanin; ornegin elegoo_cc2_left.",
|
||||
"camera": "Herhangi bir HA kamera entity'si. elegoo-homeassistant ornegi: camera.elegoo_centauri_carbon2_chamber_camera. Cihaz adiniz farkli olabilir.",
|
||||
"snapshot_url": "Istege bagli. Secilen kamera entity'sinin Home Assistant camera proxy gorselini kullanmak icin bos birakin. Bunu yalnizca ozel kamera durumlarinda kullanin.",
|
||||
"print_status_sensor": "Istege bagli ama onerilir. Ornek: sensor.elegoo_centauri_carbon2_print_status. Elegoo tarzi adlarda eslesen current_status sensoru otomatik ek koruma olarak kullanilir.",
|
||||
"active_print_states": "Baski anlamina gelen virgulle ayrilmis durumlar; ornegin printing,printing_recovery. Elegoo CC2 genelde printing kullanir.",
|
||||
"chamber_light": "Istege bagli. Ornek: light.elegoo_centauri_carbon2_chamber_light. Yalnizca isik kontrol ayari tarafindan kullanilir.",
|
||||
"light_control_mode": "Algilama isigi hic kontrol etmesin mi, acip acik mi biraksin, yoksa her snapshot sonrasinda onceki duruma mi dondursun secin.",
|
||||
"light_settle_seconds": "Bu entegrasyon kapali isigi actiktan sonra snapshot almadan once bekleyecegi saniye. Kamera pozlama/netleme icin varsayilan 3 saniyedir.",
|
||||
"run_without_printing": "Baski durum sensoru secilmediyse zamanlanmis algilama yalnizca bu ayar acikken calisir. Test butonu her zaman tek kontrol calistirir.",
|
||||
"detection_interval": "Baski durumu aktifken zamanlanmis kontroller arasindaki saniye. Ornek: 10 dakika icin 600, 15 dakika icin 900.",
|
||||
"warning_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
|
||||
"failure_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
|
||||
"cooldown_seconds": "Varsayilan 900 saniyedir. Baski durum sensoru olan zamanlanmis kontroller aktif baski penceresi basina bir detected event uretir; result event'leri her kontrolde gelmeye devam eder."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_instance_id": "Entity on eki en az bir gecerli slug karakteri icermelidir.",
|
||||
"instance_id_exists": "Bu entity on eki baska bir algilayici tarafindan kullaniliyor.",
|
||||
"warning_above_failure": "Uyari esigi hata esiginden kucuk veya ona esit olmalidir.",
|
||||
"already_configured": "Bu kamera zaten yapilandirilmis.",
|
||||
"camera_image_unavailable": "Secilen kamera entity_picture URL sunmuyor. Baska kamera deneyin veya dogrudan snapshot URL ayarlayin.",
|
||||
"ml_health_failed": "ML sunucusu saglik kontrolu basarisiz oldu. Host erisilebilir olmali ve base URL'ye isaret etmelidir; ornegin http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "ML sunucusu token'i reddetti.",
|
||||
"ml_image_fetch_failed": "ML sunucusu kamera gorselini alamadi veya decode edemedi. Home Assistant Host ve kamera erisimini ML sunucusundan kontrol edin."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Bu kamera zaten yapilandirilmis."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Algilayici ayarlari",
|
||||
"description": "Bu algilayici icin kamera, ML sunucusu, baski durumu ve algilama ayarlarini guncelleyin.",
|
||||
"data": {
|
||||
"home_assistant_host": "Home Assistant Host",
|
||||
"obico_host": "Obico ML API Host",
|
||||
"obico_auth_token": "Obico ML API Auth Token",
|
||||
"camera": "Kamera",
|
||||
"snapshot_url": "Dogrudan snapshot URL",
|
||||
"print_status_sensor": "Baski durum sensoru",
|
||||
"active_print_states": "Aktif baski durumlari",
|
||||
"chamber_light": "Kabin isigi",
|
||||
"light_control_mode": "Isik kontrolu",
|
||||
"light_settle_seconds": "Isik bekleme suresi",
|
||||
"run_without_printing": "Baski durumu olmadan zamanlanmis algilama calistir",
|
||||
"detection_interval": "Algilama araligi",
|
||||
"sensitivity": "Hassasiyet",
|
||||
"warning_threshold": "Uyari esigi",
|
||||
"failure_threshold": "Hata esigi",
|
||||
"cooldown_seconds": "Algilandi eventi bekleme suresi"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "ML sunucusunun erisebildigi URL. Baska bir LAN makinesindeki Docker icin HA LAN URL kullanin; ornegin http://192.168.1.90:8123.",
|
||||
"obico_host": "Bu projenin ML sunucusu base URL adresi; ornegin http://192.168.1.100:3333. Kurulum /hc/ ve /debug/image kontrollerini yapar.",
|
||||
"obico_auth_token": "ML sunucusunda ayarlanan ML_API_TOKEN / obico_api_secret ile ayni olmalidir.",
|
||||
"camera": "Herhangi bir HA kamera entity'si. elegoo-homeassistant ornegi: camera.elegoo_centauri_carbon2_chamber_camera. Cihaz adiniz farkli olabilir.",
|
||||
"snapshot_url": "Istege bagli. Secilen kamera entity'sinin Home Assistant camera proxy gorselini kullanmak icin bos birakin.",
|
||||
"print_status_sensor": "Istege bagli ama onerilir. Ornek: sensor.elegoo_centauri_carbon2_print_status. Elegoo tarzi adlarda eslesen current_status sensoru otomatik ek koruma olarak kullanilir.",
|
||||
"active_print_states": "Baski anlamina gelen virgulle ayrilmis durumlar; ornegin printing,printing_recovery.",
|
||||
"chamber_light": "Istege bagli. Ornek: light.elegoo_centauri_carbon2_chamber_light. Yalnizca isik kontrol ayari tarafindan kullanilir.",
|
||||
"light_control_mode": "Algilama isigi hic kontrol etmesin mi, acip acik mi biraksin, yoksa her snapshot sonrasinda onceki duruma mi dondursun secin.",
|
||||
"light_settle_seconds": "Bu entegrasyon kapali isigi actiktan sonra snapshot almadan once bekleyecegi saniye. Kamera pozlama/netleme icin varsayilan 3 saniyedir.",
|
||||
"run_without_printing": "Baski durum sensoru secilmediyse zamanlanmis algilama yalnizca bu ayar acikken calisir. Test butonu her zaman tek kontrol calistirir.",
|
||||
"detection_interval": "Baski durumu aktifken zamanlanmis kontroller arasindaki saniye. Ornek: 10 dakika icin 600, 15 dakika icin 900.",
|
||||
"warning_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
|
||||
"failure_threshold": "Hassasiyet Custom thresholds oldugunda kullanilir.",
|
||||
"cooldown_seconds": "Varsayilan 900 saniyedir. Baski durum sensoru olan zamanlanmis kontroller aktif baski penceresi basina bir detected event uretir; result event'leri her kontrolde gelmeye devam eder."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"warning_above_failure": "Uyari esigi hata esiginden kucuk veya ona esit olmalidir.",
|
||||
"camera_image_unavailable": "Secilen kamera entity_picture URL sunmuyor. Baska kamera deneyin veya dogrudan snapshot URL ayarlayin.",
|
||||
"ml_health_failed": "ML sunucusu saglik kontrolu basarisiz oldu. Host erisilebilir olmali ve base URL'ye isaret etmelidir; ornegin http://192.168.1.100:3333.",
|
||||
"ml_auth_failed": "ML sunucusu token'i reddetti.",
|
||||
"ml_image_fetch_failed": "ML sunucusu kamera gorselini alamadi veya decode edemedi. Home Assistant Host ve kamera erisimini ML sunucusundan kontrol edin."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"predict": {
|
||||
"name": "URL'den spaghetti tahmini",
|
||||
"description": "Obico ML modelini ham bir gorsel URL'si uzerinde calistirir",
|
||||
"fields": {
|
||||
"obico_host": {
|
||||
"name": "Obico ML API Host",
|
||||
"description": "Obico ML API host"
|
||||
},
|
||||
"obico_auth_token": {
|
||||
"name": "Obico ML API Auth Token",
|
||||
"description": "Obico ML API kimlik dogrulama token'i"
|
||||
},
|
||||
"image_url": {
|
||||
"name": "Gorsel URL",
|
||||
"description": "Snapshot URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"run_detection": {
|
||||
"name": "Algilama calistir",
|
||||
"description": "Yapilandirilmis bir algilayici icin tek algilama kontrolu calistirir.",
|
||||
"fields": {
|
||||
"detector": {
|
||||
"name": "Algilayici"
|
||||
},
|
||||
"config_entry": {
|
||||
"name": "Config entry"
|
||||
},
|
||||
"force": {
|
||||
"name": "Zorla"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reset_state": {
|
||||
"name": "Algilama durumunu sifirla",
|
||||
"description": "Algilayici confidence, sonuc ve hata durumunu temizler."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Elegoo Spaghetti Detection",
|
||||
"description": "为 Elegoo 打印机摄像头创建一个检测器。该集成只检测失败并产生实体/事件;暂停、停止和通知动作仍由你的自动化处理。",
|
||||
"data": {
|
||||
"name": "检测器名称",
|
||||
"instance_id": "实体前缀",
|
||||
"home_assistant_host": "Home Assistant 主机",
|
||||
"obico_host": "Obico ML API 主机",
|
||||
"obico_auth_token": "Obico ML API 令牌",
|
||||
"camera": "摄像头",
|
||||
"snapshot_url": "直接快照 URL",
|
||||
"print_status_sensor": "打印状态传感器",
|
||||
"active_print_states": "活动打印状态",
|
||||
"chamber_light": "腔体灯",
|
||||
"light_control_mode": "灯光控制",
|
||||
"light_settle_seconds": "灯光稳定延迟",
|
||||
"run_without_printing": "无打印状态时运行计划检测",
|
||||
"detection_interval": "检测间隔",
|
||||
"sensitivity": "灵敏度",
|
||||
"warning_threshold": "警告阈值",
|
||||
"failure_threshold": "失败阈值",
|
||||
"cooldown_seconds": "检测事件冷却时间"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "ML 服务器可访问的 URL。Docker 在另一台局域网主机上运行时,请使用 HA 的局域网 URL,例如 http://192.168.1.90:8123。",
|
||||
"obico_host": "本项目 ML 服务器的基础 URL,例如 http://192.168.1.100:3333。配置会检查 /hc/ 和 /debug/image。",
|
||||
"obico_auth_token": "必须与 ML 服务器上配置的 ML_API_TOKEN / obico_api_secret 匹配。",
|
||||
"instance_id": "用于实体 ID 的稳定 slug。每个检测器使用不同前缀,例如 elegoo_cc2_left。",
|
||||
"camera": "任意 HA 摄像头实体。elegoo-homeassistant 示例:camera.elegoo_centauri_carbon2_chamber_camera。你的设备名称可能不同。",
|
||||
"snapshot_url": "可选。留空则使用所选摄像头实体的 Home Assistant camera proxy 图像。仅在特殊摄像头场景中使用。",
|
||||
"print_status_sensor": "可选但推荐。示例:sensor.elegoo_centauri_carbon2_print_status。对于 Elegoo 风格命名,匹配的 current_status 传感器会自动作为额外保护。",
|
||||
"active_print_states": "表示正在打印的逗号分隔状态,例如 printing,printing_recovery。Elegoo CC2 通常使用 printing。",
|
||||
"chamber_light": "可选。示例:light.elegoo_centauri_carbon2_chamber_light。仅由灯光控制设置使用。",
|
||||
"light_control_mode": "选择检测时不控制灯光、打开并保持开启,或每次快照后恢复之前的灯光状态。",
|
||||
"light_settle_seconds": "集成打开原本关闭的灯光后,拍摄快照前等待的秒数。默认 3 秒,用于曝光/对焦。",
|
||||
"run_without_printing": "未选择打印状态传感器时,计划检测只会在启用此项后运行。测试按钮始终运行一次检查。",
|
||||
"detection_interval": "打印状态活动时,两次计划检查之间的秒数。示例:600 表示 10 分钟,900 表示 15 分钟。",
|
||||
"warning_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
|
||||
"failure_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
|
||||
"cooldown_seconds": "默认 900 秒。带打印状态传感器的计划检查在每个活动打印窗口只发出一个 detected 事件;result 事件仍会在每次检查时发出。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_instance_id": "实体前缀必须至少包含一个有效的 slug 字符。",
|
||||
"instance_id_exists": "此实体前缀已被另一个检测器使用。",
|
||||
"warning_above_failure": "警告阈值必须小于或等于失败阈值。",
|
||||
"already_configured": "此摄像头已配置。",
|
||||
"camera_image_unavailable": "所选摄像头没有提供 entity_picture URL。请尝试其他摄像头或设置直接快照 URL。",
|
||||
"ml_health_failed": "ML 服务器健康检查失败。请确认主机可访问并指向基础 URL,例如 http://192.168.1.100:3333。",
|
||||
"ml_auth_failed": "ML 服务器拒绝了令牌。",
|
||||
"ml_image_fetch_failed": "ML 服务器无法获取或解码摄像头图像。请检查 Home Assistant Host 以及 ML 服务器对摄像头的访问。"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "此摄像头已配置。"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "检测器设置",
|
||||
"description": "更新此检测器的摄像头、ML 服务器、打印状态和检测设置。",
|
||||
"data": {
|
||||
"home_assistant_host": "Home Assistant 主机",
|
||||
"obico_host": "Obico ML API 主机",
|
||||
"obico_auth_token": "Obico ML API 令牌",
|
||||
"camera": "摄像头",
|
||||
"snapshot_url": "直接快照 URL",
|
||||
"print_status_sensor": "打印状态传感器",
|
||||
"active_print_states": "活动打印状态",
|
||||
"chamber_light": "腔体灯",
|
||||
"light_control_mode": "灯光控制",
|
||||
"light_settle_seconds": "灯光稳定延迟",
|
||||
"run_without_printing": "无打印状态时运行计划检测",
|
||||
"detection_interval": "检测间隔",
|
||||
"sensitivity": "灵敏度",
|
||||
"warning_threshold": "警告阈值",
|
||||
"failure_threshold": "失败阈值",
|
||||
"cooldown_seconds": "检测事件冷却时间"
|
||||
},
|
||||
"data_description": {
|
||||
"home_assistant_host": "ML 服务器可访问的 URL。Docker 在另一台局域网主机上运行时,请使用 HA 的局域网 URL,例如 http://192.168.1.90:8123。",
|
||||
"obico_host": "本项目 ML 服务器的基础 URL,例如 http://192.168.1.100:3333。配置会检查 /hc/ 和 /debug/image。",
|
||||
"obico_auth_token": "必须与 ML 服务器上配置的 ML_API_TOKEN / obico_api_secret 匹配。",
|
||||
"camera": "任意 HA 摄像头实体。elegoo-homeassistant 示例:camera.elegoo_centauri_carbon2_chamber_camera。你的设备名称可能不同。",
|
||||
"snapshot_url": "可选。留空则使用所选摄像头实体的 Home Assistant camera proxy 图像。",
|
||||
"print_status_sensor": "可选但推荐。示例:sensor.elegoo_centauri_carbon2_print_status。对于 Elegoo 风格命名,匹配的 current_status 传感器会自动作为额外保护。",
|
||||
"active_print_states": "表示正在打印的逗号分隔状态,例如 printing,printing_recovery。",
|
||||
"chamber_light": "可选。示例:light.elegoo_centauri_carbon2_chamber_light。仅由灯光控制设置使用。",
|
||||
"light_control_mode": "选择检测时不控制灯光、打开并保持开启,或每次快照后恢复之前的灯光状态。",
|
||||
"light_settle_seconds": "集成打开原本关闭的灯光后,拍摄快照前等待的秒数。默认 3 秒,用于曝光/对焦。",
|
||||
"run_without_printing": "未选择打印状态传感器时,计划检测只会在启用此项后运行。测试按钮始终运行一次检查。",
|
||||
"detection_interval": "打印状态活动时,两次计划检查之间的秒数。示例:600 表示 10 分钟,900 表示 15 分钟。",
|
||||
"warning_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
|
||||
"failure_threshold": "当灵敏度设置为 Custom thresholds 时使用。",
|
||||
"cooldown_seconds": "默认 900 秒。带打印状态传感器的计划检查在每个活动打印窗口只发出一个 detected 事件;result 事件仍会在每次检查时发出。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"warning_above_failure": "警告阈值必须小于或等于失败阈值。",
|
||||
"camera_image_unavailable": "所选摄像头没有提供 entity_picture URL。请尝试其他摄像头或设置直接快照 URL。",
|
||||
"ml_health_failed": "ML 服务器健康检查失败。请确认主机可访问并指向基础 URL,例如 http://192.168.1.100:3333。",
|
||||
"ml_auth_failed": "ML 服务器拒绝了令牌。",
|
||||
"ml_image_fetch_failed": "ML 服务器无法获取或解码摄像头图像。请检查 Home Assistant Host 以及 ML 服务器对摄像头的访问。"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"predict": {
|
||||
"name": "从 URL 预测 spaghetti",
|
||||
"description": "针对原始图像 URL 运行 Obico ML 模型",
|
||||
"fields": {
|
||||
"obico_host": {
|
||||
"name": "Obico ML API 主机",
|
||||
"description": "Obico ML API 主机"
|
||||
},
|
||||
"obico_auth_token": {
|
||||
"name": "Obico ML API 令牌",
|
||||
"description": "Obico ML API 认证令牌"
|
||||
},
|
||||
"image_url": {
|
||||
"name": "图像 URL",
|
||||
"description": "快照 URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"run_detection": {
|
||||
"name": "运行检测",
|
||||
"description": "为已配置的检测器运行一次检测检查。",
|
||||
"fields": {
|
||||
"detector": {
|
||||
"name": "检测器"
|
||||
},
|
||||
"config_entry": {
|
||||
"name": "配置项"
|
||||
},
|
||||
"force": {
|
||||
"name": "强制"
|
||||
}
|
||||
}
|
||||
},
|
||||
"reset_state": {
|
||||
"name": "重置检测状态",
|
||||
"description": "清除检测器的置信度、结果和错误状态。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
services:
|
||||
ha_elegoo_spaghetti_detection:
|
||||
build:
|
||||
context: ./addon
|
||||
dockerfile: Dockerfile.standalone.base
|
||||
image: hepter/ha_elegoo_spaghetti_detection_standalone:latest
|
||||
container_name: ha_elegoo_spaghetti_detection
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3333:3333/tcp
|
||||
environment:
|
||||
- ML_API_TOKEN=obico_api_secret
|
||||
- ML_USE_GPU=false
|
||||
- ML_MODEL_BACKEND=onnx
|
||||
- GUNICORN_TIMEOUT=120
|
||||
- GUNICORN_WORKERS=1
|
||||
- TZ=Europe/Istanbul
|
||||
@@ -0,0 +1,107 @@
|
||||
# HACS Publishing Checklist
|
||||
|
||||
This repository is a HACS `integration` because it installs a Home Assistant
|
||||
custom integration under `custom_components/elegoo_spaghetti_detection`.
|
||||
It is not a HACS `plugin`/Dashboard item. Dashboard plugins are JavaScript
|
||||
frontend assets, usually installed from `dist/`.
|
||||
|
||||
## Current HACS Requirements
|
||||
|
||||
For a custom integration repository:
|
||||
|
||||
- The repository must be public and hosted on GitHub.
|
||||
- The repository must have a clear GitHub description.
|
||||
- The repository must have GitHub topics.
|
||||
- GitHub issues must be enabled.
|
||||
- The repository must have a README that explains how to use the integration.
|
||||
- `hacs.json` must exist in the repository root and contain at least `name`.
|
||||
- There must be only one integration directory under `custom_components/`.
|
||||
- All files required for the integration itself must be inside
|
||||
`custom_components/elegoo_spaghetti_detection/`.
|
||||
- The integration `manifest.json` must define at least:
|
||||
- `domain`
|
||||
- `documentation`
|
||||
- `issue_tracker`
|
||||
- `codeowners`
|
||||
- `name`
|
||||
- `version`
|
||||
- The integration must provide brand assets. This repo includes:
|
||||
- `custom_components/elegoo_spaghetti_detection/brand/icon.png`
|
||||
- `custom_components/elegoo_spaghetti_detection/brand/logo.png`
|
||||
- If submitted as a default HACS repository, these GitHub Actions must pass:
|
||||
- HACS Action with `category: integration`
|
||||
- Hassfest
|
||||
- A full GitHub release is required before submitting to `hacs/default`. A tag
|
||||
alone is not enough.
|
||||
|
||||
## Default Store Submission
|
||||
|
||||
To request inclusion in the default HACS store:
|
||||
|
||||
1. Confirm the repository can be added manually as a HACS custom repository.
|
||||
2. Confirm HACS Action passes without errors or ignored checks.
|
||||
3. Confirm Hassfest passes.
|
||||
4. Create a full GitHub release, for example `v1.0.0`.
|
||||
5. Fork `hacs/default`.
|
||||
6. Add `hepter/ha-elegoo-spaghetti-detection` alphabetically to the
|
||||
`integration` file.
|
||||
7. Open a PR from a branch in the fork. Do not submit the PR from an
|
||||
organization account, because the PR must be editable.
|
||||
|
||||
HACS default repository reviews can take months. Until it is accepted, users can
|
||||
install this repo through HACS as a custom repository.
|
||||
|
||||
## Repository Metadata To Set On GitHub
|
||||
|
||||
These were set on GitHub on 2026-05-01. Verify them before opening a HACS
|
||||
default PR:
|
||||
|
||||
- Description:
|
||||
- `Elegoo FDM printer spaghetti detection for Home Assistant with a local Obico ML server`
|
||||
- Topics:
|
||||
- `home-assistant`
|
||||
- `hacs`
|
||||
- `hacs-integration`
|
||||
- `custom-integration`
|
||||
- `elegoo`
|
||||
- `fdm`
|
||||
- `3d-printer`
|
||||
- `spaghetti-detection`
|
||||
- `obico`
|
||||
- Issues:
|
||||
- Enabled
|
||||
|
||||
## Workflows In This Repo
|
||||
|
||||
- `.github/workflows/validate.yaml`
|
||||
- Runs `hacs/action@main` with `category: integration`.
|
||||
- `.github/workflows/hassfest.yaml`
|
||||
- Runs `home-assistant/actions/hassfest@master`.
|
||||
- `.github/workflows/ci.yaml`
|
||||
- Runs basic JSON, Python syntax, and YAML validation.
|
||||
- `.github/dependabot.yml`
|
||||
- Keeps GitHub Actions versions current.
|
||||
|
||||
## Release Notes
|
||||
|
||||
For the first HACS-ready release:
|
||||
|
||||
- Use a SemVer tag such as `v1.0.0`.
|
||||
- Ensure `custom_components/elegoo_spaghetti_detection/manifest.json`
|
||||
contains the matching version without the leading `v`, for example `1.0.0`.
|
||||
- Publish a full GitHub release after workflows pass.
|
||||
|
||||
## References
|
||||
|
||||
- HACS publish general requirements:
|
||||
- https://hacs.xyz/docs/publish/start/
|
||||
- HACS integration requirements:
|
||||
- https://hacs.xyz/docs/publish/integration/
|
||||
- HACS default repository inclusion:
|
||||
- https://hacs.xyz/docs/publish/include/
|
||||
- HACS validation action:
|
||||
- https://hacs.xyz/docs/publish/action/
|
||||
- Home Assistant integration manifest:
|
||||
- https://developers.home-assistant.io/docs/creating_integration_manifest/
|
||||
- Local custom integration brand assets:
|
||||
- https://developers.home-assistant.io/blog/2026/02/24/brands-proxy-api
|
||||
@@ -0,0 +1,67 @@
|
||||
# Automation Examples
|
||||
|
||||
The integration only detects failures and emits entities/events. Printer actions
|
||||
use your own Home Assistant entities directly.
|
||||
|
||||
Example Elegoo CC2 entities used by the templates:
|
||||
|
||||
```text
|
||||
button.elegoo_centauri_carbon2_pause_print
|
||||
button.elegoo_centauri_carbon2_resume_print
|
||||
button.elegoo_centauri_carbon2_stop_print
|
||||
camera.elegoo_centauri_carbon2_chamber_camera
|
||||
sensor.elegoo_centauri_carbon2_print_status
|
||||
```
|
||||
|
||||
Your entity IDs may differ if your printer/device name differs.
|
||||
|
||||
## Included Examples
|
||||
|
||||
- [Notify only](../examples/notify_only.yaml)
|
||||
- [Actionable mobile notification with pause/stop/resume](../examples/actionable_notification.yaml)
|
||||
- [Confidence-based pause/stop](../examples/smart_pause_stop_by_confidence.yaml)
|
||||
- [Manual test notification](../examples/manual_test_notification.yaml)
|
||||
|
||||
## Event Data
|
||||
|
||||
Use `elegoo_spaghetti_detection_detected` for notifications and printer
|
||||
actions. When a print status sensor is configured, scheduled detected events are
|
||||
sent once per active print window to avoid repeated pause/notify loops. Use
|
||||
`elegoo_spaghetti_detection_result` only when you intentionally want every
|
||||
detection result, including clear and warning checks.
|
||||
|
||||
These two events are intentionally different:
|
||||
|
||||
| Event | When it fires | Use for notifications/actions? |
|
||||
| --- | --- | --- |
|
||||
| `elegoo_spaghetti_detection_detected` | Only when a failure is detected and the active print window has not already emitted one detected event. | Yes. Use this for Pushbullet, mobile notifications, pause, and stop automations. |
|
||||
| `elegoo_spaghetti_detection_result` | Every completed detection check, including clear, warning, and repeated detected checks. | Usually no. Use it only for logging, dashboards, or advanced automations that implement their own throttling. |
|
||||
|
||||
If an automation sends notifications from
|
||||
`elegoo_spaghetti_detection_result`, it can still notify repeatedly every
|
||||
detection interval. The included notification and pause/stop examples use
|
||||
`elegoo_spaghetti_detection_detected` to avoid that.
|
||||
|
||||
Use these fields in templates:
|
||||
|
||||
```text
|
||||
trigger.event.data.confidence
|
||||
trigger.event.data.raw_score
|
||||
trigger.event.data.detected
|
||||
trigger.event.data.detections
|
||||
trigger.event.data.image_url
|
||||
trigger.event.data.printer_state
|
||||
trigger.event.data.status
|
||||
trigger.event.data.last_error
|
||||
```
|
||||
|
||||
Confidence is a number between `0` and `1`. For notification text:
|
||||
|
||||
```jinja
|
||||
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%
|
||||
```
|
||||
|
||||
The `image_url` field is the exact camera snapshot URL the ML server checked.
|
||||
Mobile notifications can use it as an image attachment. If your phone is away
|
||||
from the LAN, make sure the `Home Assistant Host` you configured is reachable
|
||||
from that phone, or use a notification-only message without the image.
|
||||
@@ -0,0 +1,124 @@
|
||||
# Configuration
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
Settings -> Devices & services -> Add integration -> Elegoo Spaghetti Detection
|
||||
```
|
||||
|
||||

|
||||
|
||||
The setup form creates one detector. Add another detector for another camera.
|
||||
Existing detector settings are reused as defaults to reduce repeated server
|
||||
entry.
|
||||
|
||||
## Fields
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| `Detector name` | Display name for this camera/detector. |
|
||||
| `Entity prefix` | Stable entity ID prefix, for example `elegoo_spaghetti_detection` or `elegoo_cc2_left`. |
|
||||
| `Home Assistant Host` | URL reachable by the ML server. For Docker on another LAN host, prefer the HA LAN URL, for example `http://192.168.1.90:8123`. Do not use `homeassistant.local` unless the Docker host can resolve mDNS. |
|
||||
| `Obico ML API Host` | Base URL of this project's ML server, for example `http://192.168.1.100:3333`. Do not enter `/hc/` or `/p/`. |
|
||||
| `Obico ML API Auth Token` | Must match `ML_API_TOKEN` / `obico_api_secret` configured on the ML server. |
|
||||
| `Camera` | Any HA camera entity. Example: `camera.elegoo_centauri_carbon2_chamber_camera`. Your device name may differ. |
|
||||
| `Direct snapshot URL` | Optional. Leave empty to use the selected camera's HA camera proxy image. Use this only for unusual camera integrations. |
|
||||
| `Print status sensor` | Optional but recommended. Example: `sensor.elegoo_centauri_carbon2_print_status`. Scheduled detection runs only when this entity is in an active print state. For Elegoo-style entity names, a matching `sensor.<printer>_current_status` is used automatically as an extra guard; you do not select it separately. |
|
||||
| `Active print states` | Comma-separated states that mean printing. For Elegoo CC2, `printing` is usually enough. You can use `printing,printing_recovery`. |
|
||||
| `Chamber light` | Optional. Example: `light.elegoo_centauri_carbon2_chamber_light`. |
|
||||
| `Light control` | `Do not control light`, `Turn on before detection and leave on`, or `Restore previous state after detection`. Restore mode only turns the light off again when it was off before this detection cycle. |
|
||||
| `Light settle delay` | Seconds to wait after the integration turns on an off light before taking the snapshot. Default is `3`; useful for camera exposure/focus. |
|
||||
| `Run scheduled detection without print status` | Keep off unless this detector is camera-only and has no print status entity. |
|
||||
| `Detection interval` | Seconds between scheduled checks while the print status is active. Examples: `600` for 10 minutes, `900` for 15 minutes. |
|
||||
| `Sensitivity` | High, normal, low, or custom thresholds. |
|
||||
| `Warning/Failure threshold` | Used when sensitivity is `Custom thresholds`. |
|
||||
| `Detected event cooldown` | Minimum seconds between detected events. Default is `900` seconds. Scheduled checks with a print status sensor use the active print window limit instead, so this is mainly a fallback for camera-only setups and manual testing. |
|
||||
|
||||
## Print-State Guarding
|
||||
|
||||
The setup form asks for one status entity: `Print status sensor`.
|
||||
|
||||
For Elegoo printers, select the `print_status` entity:
|
||||
|
||||
```text
|
||||
sensor.elegoo_centauri_carbon2_print_status
|
||||
```
|
||||
|
||||
If the selected entity ends with `_print_status`, the integration automatically
|
||||
checks for a sibling `_current_status` entity:
|
||||
|
||||
```text
|
||||
sensor.elegoo_centauri_carbon2_current_status
|
||||
```
|
||||
|
||||
There is no separate field for `current_status`. It is an automatic fallback
|
||||
guard. Scheduled detection runs only when the selected `print_status` is active
|
||||
and the inferred `current_status`, when present, is also active.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
print_status = printing
|
||||
current_status = idle
|
||||
result: scheduled detection does not run
|
||||
```
|
||||
|
||||
This protects against printer/integration states where `print_status` remains
|
||||
`printing` during homing, idle, or other non-print movement states.
|
||||
|
||||
## Repeated Notification Guarding
|
||||
|
||||
When a `Print status sensor` is selected, scheduled detection emits at most one
|
||||
`elegoo_spaghetti_detection_detected` event while the printer remains in one of
|
||||
the configured `Active print states`. If the selected status sensor or the
|
||||
automatic `current_status` guard leaves the active states and later returns to
|
||||
an active state, a new failure can emit one new detected event.
|
||||
|
||||
This is separate from the fallback guard above. The fallback guard decides
|
||||
whether scheduled detection should run. The repeated-notification guard decides
|
||||
whether a detected result should fire another notification/action event.
|
||||
|
||||
## Example Elegoo CC2 Values
|
||||
|
||||
These are examples from one `elegoo-homeassistant` install. Your entity IDs may
|
||||
change if the Home Assistant device name differs.
|
||||
|
||||
```text
|
||||
Entity prefix: elegoo_spaghetti_detection
|
||||
Home Assistant Host: http://192.168.1.90:8123
|
||||
Obico ML API Host: http://192.168.1.100:3333
|
||||
Obico ML API Auth Token: obico_api_secret
|
||||
Camera: camera.elegoo_centauri_carbon2_chamber_camera
|
||||
Print status sensor: sensor.elegoo_centauri_carbon2_print_status
|
||||
Active print states: printing
|
||||
Chamber light: light.elegoo_centauri_carbon2_chamber_light
|
||||
Light control: Restore previous state after detection
|
||||
Light settle delay: 3
|
||||
```
|
||||
|
||||
With restore mode, a scheduled check does this:
|
||||
|
||||
```text
|
||||
light was off -> turn on -> wait -> snapshot/detect -> turn off
|
||||
light was on -> snapshot/detect -> keep on
|
||||
```
|
||||
|
||||
## Created Entities
|
||||
|
||||
With prefix `elegoo_spaghetti_detection`, the integration creates:
|
||||
|
||||
```text
|
||||
binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
|
||||
sensor.elegoo_spaghetti_detection_confidence
|
||||
sensor.elegoo_spaghetti_detection_raw_score
|
||||
sensor.elegoo_spaghetti_detection_detections
|
||||
sensor.elegoo_spaghetti_detection_status
|
||||
sensor.elegoo_spaghetti_detection_last_error
|
||||
sensor.elegoo_spaghetti_detection_last_run
|
||||
sensor.elegoo_spaghetti_detection_next_run
|
||||
button.elegoo_spaghetti_detection_test_spaghetti_detection
|
||||
button.elegoo_spaghetti_detection_reset_detection_state
|
||||
```
|
||||
|
||||
Press `Test Spaghetti Detection` to run one check immediately from the current
|
||||
camera image, even if the printer is not printing.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Dashboard Examples
|
||||
|
||||
These examples create a small Home Assistant dashboard section for one
|
||||
spaghetti detector.
|
||||
|
||||
Replace entity IDs if your detector prefix, camera, printer, or automation names
|
||||
are different. The examples use:
|
||||
|
||||
```text
|
||||
camera.elegoo_centauri_carbon2_chamber_camera
|
||||
sensor.elegoo_centauri_carbon2_print_status
|
||||
sensor.elegoo_spaghetti_detection_status
|
||||
sensor.elegoo_spaghetti_detection_next_run
|
||||
sensor.elegoo_spaghetti_detection_last_run
|
||||
sensor.elegoo_spaghetti_detection_last_error
|
||||
sensor.elegoo_spaghetti_detection_confidence
|
||||
sensor.elegoo_spaghetti_detection_raw_score
|
||||
sensor.elegoo_spaghetti_detection_detections
|
||||
binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
|
||||
button.elegoo_spaghetti_detection_test_spaghetti_detection
|
||||
button.elegoo_spaghetti_detection_reset_detection_state
|
||||
```
|
||||
|
||||
The enhanced example also references this placeholder automation:
|
||||
|
||||
```text
|
||||
automation.elegoo_cc2_spaghetti_pause_and_notify
|
||||
```
|
||||
|
||||
Replace it with your own notification or pause automation entity.
|
||||
The toggle card in `dashboard_hacs.yaml` is only a placeholder until you make
|
||||
that replacement.
|
||||
|
||||
## Status Labels
|
||||
|
||||
The dashboard examples render raw detector states as user-facing English labels:
|
||||
|
||||
| Raw state | Dashboard label |
|
||||
| --- | --- |
|
||||
| `clear` | Clear |
|
||||
| `detected` | Failure detected |
|
||||
| `warning` | Warning |
|
||||
| `checking` | Checking |
|
||||
| `waiting_for_print` | Waiting for print |
|
||||
| `status_unavailable` | Print status unavailable |
|
||||
| `busy` | Busy |
|
||||
| `error` | Error |
|
||||
| `idle` | Idle |
|
||||
|
||||
This avoids a mixed display where Home Assistant shows an unknown printer state
|
||||
while the detector correctly reports `waiting_for_print`.
|
||||
|
||||
## Core Home Assistant Example
|
||||
|
||||
Use this when you do not want extra Lovelace dependencies:
|
||||
|
||||
- [examples/dashboard_core.yaml](../examples/dashboard_core.yaml)
|
||||
|
||||
It uses only built-in cards:
|
||||
|
||||
- `picture-entity`
|
||||
- `markdown`
|
||||
- `gauge`
|
||||
- `conditional`
|
||||
- `button`
|
||||
- `entities`
|
||||
|
||||
## Enhanced HACS Example
|
||||
|
||||
Use this when custom Lovelace cards are allowed:
|
||||
|
||||
- [examples/dashboard_hacs.yaml](../examples/dashboard_hacs.yaml)
|
||||
|
||||
Idle state:
|
||||
|
||||

|
||||
|
||||
Detected failure state:
|
||||
|
||||

|
||||
|
||||
Recommended custom cards:
|
||||
|
||||
- `custom:button-card`
|
||||
- `custom:mushroom-template-card`
|
||||
- `card_mod`
|
||||
|
||||
Install those through HACS before pasting the enhanced YAML. The enhanced
|
||||
version adds a compact status panel, better visual states, responsive metric
|
||||
tiles, and an alert card when spaghetti is detected.
|
||||
|
||||
## Next Scheduled Check
|
||||
|
||||
The integration exposes:
|
||||
|
||||
```text
|
||||
sensor.<prefix>_next_run
|
||||
```
|
||||
|
||||
For the default prefix this is:
|
||||
|
||||
```text
|
||||
sensor.elegoo_spaghetti_detection_next_run
|
||||
```
|
||||
|
||||
The value is updated when the integration starts and every time the scheduled
|
||||
interval fires. It represents the next scheduled interval tick. If the print
|
||||
status is not active, the detector still waits at that tick and keeps the status
|
||||
as `waiting_for_print`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 715 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,43 @@
|
||||
# Installation
|
||||
|
||||
## HACS Custom Repository
|
||||
|
||||
This repository is a HACS `integration`. It is not a dashboard card or Lovelace
|
||||
plugin.
|
||||
|
||||
Until it is accepted into the default HACS store, add it manually:
|
||||
|
||||
1. Open Home Assistant.
|
||||
2. Open `HACS`.
|
||||
3. Open the three-dot menu.
|
||||
4. Choose `Custom repositories`.
|
||||
5. Repository:
|
||||
|
||||
```text
|
||||
https://github.com/hepter/ha-elegoo-spaghetti-detection
|
||||
```
|
||||
|
||||
6. Category: `Integration`.
|
||||
7. Install `Elegoo Spaghetti Detection`.
|
||||
8. Restart Home Assistant.
|
||||
|
||||
## Manual Install
|
||||
|
||||
Copy:
|
||||
|
||||
```text
|
||||
custom_components/elegoo_spaghetti_detection
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
/config/custom_components/elegoo_spaghetti_detection
|
||||
```
|
||||
|
||||
Restart Home Assistant.
|
||||
|
||||
## ML Server
|
||||
|
||||
The integration needs the local ML server before setup can complete. See
|
||||
[ML server and logs](ml-server.md).
|
||||
@@ -0,0 +1,96 @@
|
||||
# ML Server And Logs
|
||||
|
||||
The ML server listens on port `3333` and exposes the Obico/TSD model used for
|
||||
failure detection.
|
||||
|
||||
## Standalone Docker Compose
|
||||
|
||||
```bash
|
||||
git clone https://github.com/hepter/ha-elegoo-spaghetti-detection.git
|
||||
cd ha-elegoo-spaghetti-detection
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Default URL:
|
||||
|
||||
```text
|
||||
http://<server-ip>:3333
|
||||
```
|
||||
|
||||
Default token:
|
||||
|
||||
```text
|
||||
obico_api_secret
|
||||
```
|
||||
|
||||
Change `ML_API_TOKEN` before exposing this service outside a trusted local
|
||||
network.
|
||||
|
||||
## Runtime Endpoints
|
||||
|
||||
| Endpoint | Auth | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `/` | no | Small browser status page and recent redacted requests. |
|
||||
| `/hc/` | no | Health check, returns `ok`. |
|
||||
| `/api/status` | no | JSON status, model backend, threshold, request count. |
|
||||
| `/api/logs?token=<token>` | yes | Recent request logs. Image query tokens are not shown in the dashboard. |
|
||||
| `/debug/image?img=<url>&token=<token>` | yes | Fetch and decode a camera image without running inference. Used by setup validation. |
|
||||
| `/p/?img=<url>` | yes | Prediction endpoint used by Home Assistant. |
|
||||
|
||||
The token can be passed as either:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
or, for browser debugging only:
|
||||
|
||||
```text
|
||||
?token=<token>
|
||||
```
|
||||
|
||||
## CPU/GPU Behavior
|
||||
|
||||
The server is CPU-first by default:
|
||||
|
||||
```text
|
||||
ML_USE_GPU=false
|
||||
ML_MODEL_BACKEND=onnx
|
||||
GUNICORN_TIMEOUT=120
|
||||
GUNICORN_WORKERS=1
|
||||
```
|
||||
|
||||
This avoids slow CUDA probing and gunicorn worker timeouts on machines without a
|
||||
working NVIDIA runtime. Enable GPU only when Docker has working NVIDIA support:
|
||||
|
||||
```text
|
||||
ML_USE_GPU=true
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker logs -f ha_elegoo_spaghetti_detection
|
||||
```
|
||||
|
||||
Recent in-app request log:
|
||||
|
||||
```bash
|
||||
curl "http://<server-ip>:3333/api/logs?token=obico_api_secret"
|
||||
```
|
||||
|
||||
Health and model backend:
|
||||
|
||||
```bash
|
||||
curl "http://<server-ip>:3333/api/status"
|
||||
```
|
||||
|
||||
When testing a camera URL by hand, URL-encode the image URL:
|
||||
|
||||
```bash
|
||||
curl --get "http://<server-ip>:3333/debug/image" \
|
||||
--data-urlencode "img=http://homeassistant.local:8123/api/camera_proxy/camera.example?token=..." \
|
||||
--data-urlencode "token=obico_api_secret"
|
||||
```
|
||||
@@ -0,0 +1,145 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Setup Fails On ML Health
|
||||
|
||||
Use the ML server base URL, not a specific endpoint:
|
||||
|
||||
```text
|
||||
http://192.168.1.100:3333
|
||||
```
|
||||
|
||||
Do not enter:
|
||||
|
||||
```text
|
||||
http://192.168.1.100:3333/hc/
|
||||
http://192.168.1.100:3333/p/
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```bash
|
||||
curl "http://192.168.1.100:3333/hc/"
|
||||
```
|
||||
|
||||
The browser dashboard is also useful:
|
||||
|
||||
```text
|
||||
http://192.168.1.100:3333/
|
||||
```
|
||||
|
||||
## Setup Fails On Camera Image Fetch
|
||||
|
||||
The ML server must be able to fetch the Home Assistant camera image URL.
|
||||
|
||||
Common cause:
|
||||
|
||||
```text
|
||||
http://homeassistant.local:8123
|
||||
```
|
||||
|
||||
works from a browser but not from a Docker container because mDNS is not
|
||||
resolved there.
|
||||
|
||||
Use a LAN IP URL reachable by the ML server:
|
||||
|
||||
```text
|
||||
http://192.168.1.90:8123
|
||||
```
|
||||
|
||||
You can test only image fetch/decode without running the model:
|
||||
|
||||
```bash
|
||||
curl --get "http://192.168.1.100:3333/debug/image" \
|
||||
--data-urlencode "img=http://192.168.1.90:8123/api/camera_proxy/camera.example?token=..." \
|
||||
--data-urlencode "token=obico_api_secret"
|
||||
```
|
||||
|
||||
## Camera Entity Does Not Provide An Image
|
||||
|
||||
Some camera integrations expose stream-only entities or changed entity IDs after
|
||||
updates. Set `Direct snapshot URL` if the selected camera has no `entity_picture`
|
||||
attribute, or update the detector options after the camera entity ID changes.
|
||||
|
||||
## Multiple Detectors
|
||||
|
||||
Each config entry has a separate detector runtime and entity prefix. The Home
|
||||
Assistant side supports multiple cameras.
|
||||
|
||||
The ML server is intentionally single-worker by default. The integration
|
||||
serializes ML calls so multiple detectors do not hit the single worker at the
|
||||
same instant. If you have a stronger host and many cameras, increase
|
||||
`GUNICORN_WORKERS` carefully.
|
||||
|
||||
## CUDA Or Worker Timeout
|
||||
|
||||
The server defaults to CPU mode:
|
||||
|
||||
```text
|
||||
ML_USE_GPU=false
|
||||
GUNICORN_TIMEOUT=120
|
||||
```
|
||||
|
||||
Only enable GPU when Docker has a working NVIDIA runtime. If you see CUDA driver
|
||||
errors, keep GPU disabled.
|
||||
|
||||
## Detection Looks Too Quiet
|
||||
|
||||
Open the ML server dashboard:
|
||||
|
||||
```text
|
||||
http://<server-ip>:3333/
|
||||
```
|
||||
|
||||
Or check recent request logs:
|
||||
|
||||
```bash
|
||||
curl "http://<server-ip>:3333/api/logs?token=obico_api_secret"
|
||||
```
|
||||
|
||||
You can also press the `Test Spaghetti Detection` button in Home Assistant.
|
||||
|
||||
## Scheduled Detection Runs While Printer Is Idle
|
||||
|
||||
Scheduled detection is gated by `Print status sensor` unless
|
||||
`Run scheduled detection without print status` is enabled.
|
||||
|
||||
Check these options first:
|
||||
|
||||
```text
|
||||
Print status sensor: sensor.elegoo_centauri_carbon2_print_status
|
||||
Active print states: printing
|
||||
Run scheduled detection without print status: off
|
||||
```
|
||||
|
||||
If the status sensor is `idle`, `complete`, `paused`, `unknown`, or
|
||||
`unavailable`, the scheduled interval should wait and the detector status should
|
||||
show `waiting_for_print` or `status_unavailable`.
|
||||
|
||||
For Elegoo-style entity names, a selected
|
||||
`sensor.<printer>_print_status` automatically uses
|
||||
`sensor.<printer>_current_status` as a second guard when that entity exists. If
|
||||
`print_status` is `printing` but `current_status` is `idle`, `homing`, or
|
||||
another non-active state, scheduled detection waits and `last_run` does not
|
||||
advance.
|
||||
|
||||
The `Test Spaghetti Detection` button and the `run_detection` service with
|
||||
`force: true` always run one manual check, even when the printer is not
|
||||
printing.
|
||||
|
||||
## Notifications Repeat Every Interval
|
||||
|
||||
Notification, pause, and stop automations should trigger on:
|
||||
|
||||
```text
|
||||
elegoo_spaghetti_detection_detected
|
||||
```
|
||||
|
||||
Do not use this event for normal notifications:
|
||||
|
||||
```text
|
||||
elegoo_spaghetti_detection_result
|
||||
```
|
||||
|
||||
The result event fires after every completed detection check, including repeated
|
||||
detected checks. The detected event is limited to one scheduled failure event
|
||||
per active print window when a print status sensor is configured.
|
||||
@@ -0,0 +1,77 @@
|
||||
alias: Elegoo Spaghetti - Actionable Notification
|
||||
mode: single
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: elegoo_spaghetti_detection_detected
|
||||
event_data:
|
||||
detector: elegoo_spaghetti_detection
|
||||
id: detected
|
||||
- trigger: event
|
||||
event_type: mobile_app_notification_action
|
||||
event_data:
|
||||
action: ELEGOO_SPAGHETTI_PAUSE_PRINT
|
||||
id: pause_action
|
||||
- trigger: event
|
||||
event_type: mobile_app_notification_action
|
||||
event_data:
|
||||
action: ELEGOO_SPAGHETTI_STOP_PRINT
|
||||
id: stop_action
|
||||
- trigger: event
|
||||
event_type: mobile_app_notification_action
|
||||
event_data:
|
||||
action: ELEGOO_SPAGHETTI_RESUME_PRINT
|
||||
id: resume_action
|
||||
|
||||
variables:
|
||||
notify_service: notify.mobile_app_your_phone
|
||||
pause_button: button.elegoo_centauri_carbon2_pause_print
|
||||
resume_button: button.elegoo_centauri_carbon2_resume_print
|
||||
stop_button: button.elegoo_centauri_carbon2_stop_print
|
||||
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: detected
|
||||
sequence:
|
||||
- action: "{{ notify_service }}"
|
||||
data:
|
||||
title: "Possible print failure"
|
||||
message: >
|
||||
Confidence:
|
||||
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%.
|
||||
Detections: {{ trigger.event.data.detections }}.
|
||||
data:
|
||||
image: "{{ trigger.event.data.image_url }}"
|
||||
actions:
|
||||
- action: ELEGOO_SPAGHETTI_PAUSE_PRINT
|
||||
title: Pause print
|
||||
- action: ELEGOO_SPAGHETTI_STOP_PRINT
|
||||
title: Stop print
|
||||
- action: ELEGOO_SPAGHETTI_RESUME_PRINT
|
||||
title: Resume print
|
||||
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: pause_action
|
||||
sequence:
|
||||
- action: button.press
|
||||
target:
|
||||
entity_id: "{{ pause_button }}"
|
||||
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: stop_action
|
||||
sequence:
|
||||
- action: button.press
|
||||
target:
|
||||
entity_id: "{{ stop_button }}"
|
||||
|
||||
- conditions:
|
||||
- condition: trigger
|
||||
id: resume_action
|
||||
sequence:
|
||||
- action: button.press
|
||||
target:
|
||||
entity_id: "{{ resume_button }}"
|
||||
@@ -0,0 +1,116 @@
|
||||
type: vertical-stack
|
||||
cards:
|
||||
- type: picture-entity
|
||||
entity: camera.elegoo_centauri_carbon2_chamber_camera
|
||||
name: Printer camera
|
||||
camera_view: live
|
||||
show_state: false
|
||||
|
||||
- type: markdown
|
||||
title: Spaghetti Detection
|
||||
content: |
|
||||
{% set status = states('sensor.elegoo_spaghetti_detection_status') %}
|
||||
{% set confidence = states('sensor.elegoo_spaghetti_detection_confidence') | float(0) %}
|
||||
{% set detections = states('sensor.elegoo_spaghetti_detection_detections') %}
|
||||
{% set print_status = states('sensor.elegoo_centauri_carbon2_print_status') %}
|
||||
{% set next_run = states('sensor.elegoo_spaghetti_detection_next_run') %}
|
||||
{% set last_run = states('sensor.elegoo_spaghetti_detection_last_run') %}
|
||||
{% set last_error = states('sensor.elegoo_spaghetti_detection_last_error') %}
|
||||
|
||||
{% if status == 'detected' %}
|
||||
## Failure detected
|
||||
The detector is above the failure threshold.
|
||||
{% elif status == 'warning' %}
|
||||
## Warning
|
||||
The detector is above the warning threshold.
|
||||
{% elif status == 'checking' %}
|
||||
## Checking camera image
|
||||
The current image is being analyzed by the ML server.
|
||||
{% elif status == 'waiting_for_print' %}
|
||||
## Waiting for print
|
||||
Scheduled checks are paused because the selected print status is not active.
|
||||
{% elif status == 'status_unavailable' %}
|
||||
## Print status unavailable
|
||||
The selected print status entity is missing, unknown, or unavailable.
|
||||
{% elif status == 'clear' %}
|
||||
## Clear
|
||||
No spaghetti was detected in the last check.
|
||||
{% else %}
|
||||
## {{ status | replace('_', ' ') | title }}
|
||||
The detector state is currently {{ status }}.
|
||||
{% endif %}
|
||||
|
||||
**Confidence:** {{ confidence | round(1) }}%
|
||||
**Detections:** {{ detections }}
|
||||
**Print status:** {{ print_status }}
|
||||
**Next scheduled check:** {% if next_run in ['unknown', 'unavailable', 'none', ''] %}Not scheduled yet{% else %}{{ as_timestamp(next_run) | timestamp_custom('%Y-%m-%d %H:%M:%S', true) }}{% endif %}
|
||||
**Last check:** {% if last_run in ['unknown', 'unavailable', 'none', ''] %}Never{% else %}{{ as_timestamp(last_run) | timestamp_custom('%Y-%m-%d %H:%M:%S', true) }}{% endif %}
|
||||
**Last error:** {% if last_error in ['none', 'unknown', 'unavailable', ''] %}None{% else %}{{ last_error }}{% endif %}
|
||||
|
||||
- type: gauge
|
||||
entity: sensor.elegoo_spaghetti_detection_confidence
|
||||
name: Confidence
|
||||
min: 0
|
||||
max: 100
|
||||
needle: true
|
||||
severity:
|
||||
green: 0
|
||||
yellow: 30
|
||||
red: 50
|
||||
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
|
||||
state: "on"
|
||||
card:
|
||||
type: markdown
|
||||
title: Action needed
|
||||
content: |
|
||||
Spaghetti was detected.
|
||||
|
||||
Check the printer camera before resuming or stopping the print.
|
||||
|
||||
- type: grid
|
||||
columns: 2
|
||||
square: false
|
||||
cards:
|
||||
- type: button
|
||||
entity: button.elegoo_spaghetti_detection_test_spaghetti_detection
|
||||
name: Test detection
|
||||
icon: mdi:camera-iris
|
||||
tap_action:
|
||||
action: perform-action
|
||||
perform_action: button.press
|
||||
target:
|
||||
entity_id: button.elegoo_spaghetti_detection_test_spaghetti_detection
|
||||
|
||||
- type: button
|
||||
entity: button.elegoo_spaghetti_detection_reset_detection_state
|
||||
name: Reset detector
|
||||
icon: mdi:restart
|
||||
tap_action:
|
||||
action: perform-action
|
||||
perform_action: button.press
|
||||
target:
|
||||
entity_id: button.elegoo_spaghetti_detection_reset_detection_state
|
||||
|
||||
- type: entities
|
||||
title: Detector details
|
||||
show_header_toggle: false
|
||||
entities:
|
||||
- entity: sensor.elegoo_spaghetti_detection_status
|
||||
name: Detector status
|
||||
- entity: sensor.elegoo_spaghetti_detection_next_run
|
||||
name: Next scheduled check
|
||||
- entity: sensor.elegoo_spaghetti_detection_last_run
|
||||
name: Last check
|
||||
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
|
||||
name: Spaghetti detected
|
||||
- entity: sensor.elegoo_spaghetti_detection_confidence
|
||||
name: Confidence
|
||||
- entity: sensor.elegoo_spaghetti_detection_raw_score
|
||||
name: Raw score
|
||||
- entity: sensor.elegoo_spaghetti_detection_detections
|
||||
name: Detection count
|
||||
- entity: sensor.elegoo_spaghetti_detection_last_error
|
||||
name: Last error
|
||||
@@ -0,0 +1,492 @@
|
||||
type: vertical-stack
|
||||
cards:
|
||||
- type: custom:button-card
|
||||
entity: sensor.elegoo_spaghetti_detection_status
|
||||
show_name: false
|
||||
show_icon: false
|
||||
show_state: false
|
||||
tap_action:
|
||||
action: more-info
|
||||
custom_fields:
|
||||
content: |
|
||||
[[[
|
||||
const state = (id) => states[id]?.state ?? 'unknown';
|
||||
const numberState = (id) => Number.parseFloat(state(id)) || 0;
|
||||
const formatDate = (value, fallback = 'Not available') => {
|
||||
if (!value || ['unknown', 'unavailable', 'none'].includes(value)) return fallback;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
};
|
||||
|
||||
const status = state('sensor.elegoo_spaghetti_detection_status').trim();
|
||||
const detected = state('binary_sensor.elegoo_spaghetti_detection_spaghetti_detected') === 'on';
|
||||
const confidence = numberState('sensor.elegoo_spaghetti_detection_confidence');
|
||||
const raw = numberState('sensor.elegoo_spaghetti_detection_raw_score');
|
||||
const detections = state('sensor.elegoo_spaghetti_detection_detections');
|
||||
const printStatus = state('sensor.elegoo_centauri_carbon2_print_status');
|
||||
const nextRun = state('sensor.elegoo_spaghetti_detection_next_run');
|
||||
const lastRun = state('sensor.elegoo_spaghetti_detection_last_run');
|
||||
const lastError = state('sensor.elegoo_spaghetti_detection_last_error');
|
||||
const automation = state('automation.elegoo_cc2_spaghetti_pause_and_notify');
|
||||
|
||||
const statusMap = {
|
||||
clear: {
|
||||
label: 'Clear',
|
||||
color: '#34c759',
|
||||
bg: 'rgba(52,199,89,.14)',
|
||||
desc: 'No spaghetti was detected in the last check.'
|
||||
},
|
||||
detected: {
|
||||
label: 'Failure detected',
|
||||
color: '#ff453a',
|
||||
bg: 'rgba(255,69,58,.18)',
|
||||
desc: 'Check the camera before continuing.'
|
||||
},
|
||||
warning: {
|
||||
label: 'Warning',
|
||||
color: '#ff9f0a',
|
||||
bg: 'rgba(255,159,10,.16)',
|
||||
desc: 'The score is above the warning threshold but below failure.'
|
||||
},
|
||||
checking: {
|
||||
label: 'Checking',
|
||||
color: '#0a84ff',
|
||||
bg: 'rgba(10,132,255,.16)',
|
||||
desc: 'The current camera image is being analyzed.'
|
||||
},
|
||||
waiting_for_print: {
|
||||
label: 'Waiting for print',
|
||||
color: '#8e8e93',
|
||||
bg: 'rgba(142,142,147,.14)',
|
||||
desc: 'Waiting for an active print.'
|
||||
},
|
||||
status_unavailable: {
|
||||
label: 'Print status unavailable',
|
||||
color: '#ff9f0a',
|
||||
bg: 'rgba(255,159,10,.14)',
|
||||
desc: 'Check the selected print status entity in integration options.'
|
||||
},
|
||||
busy: {
|
||||
label: 'Busy',
|
||||
color: '#0a84ff',
|
||||
bg: 'rgba(10,132,255,.14)',
|
||||
desc: 'Another detection request is still running.'
|
||||
},
|
||||
error: {
|
||||
label: 'Error',
|
||||
color: '#ff453a',
|
||||
bg: 'rgba(255,69,58,.16)',
|
||||
desc: 'The last run failed. Check the error field below.'
|
||||
},
|
||||
idle: {
|
||||
label: 'Idle',
|
||||
color: '#8e8e93',
|
||||
bg: 'rgba(142,142,147,.14)',
|
||||
desc: 'The detector is idle.'
|
||||
}
|
||||
};
|
||||
|
||||
const view = statusMap[status] || {
|
||||
label: status.replaceAll('_', ' '),
|
||||
color: '#8e8e93',
|
||||
bg: 'rgba(142,142,147,.14)',
|
||||
desc: 'The detector state is currently unknown.'
|
||||
};
|
||||
|
||||
const automationColor = automation === 'on' ? '#34c759' : automation === 'off' ? '#ff9f0a' : '#8e8e93';
|
||||
const automationText = automation === 'on'
|
||||
? 'Automation enabled'
|
||||
: automation === 'off'
|
||||
? 'Automation disabled'
|
||||
: 'Automation not found';
|
||||
|
||||
const errorText =
|
||||
lastError && !['none', 'unknown', 'unavailable', ''].includes(lastError)
|
||||
? lastError
|
||||
: 'None';
|
||||
|
||||
return `
|
||||
<div class="wrap">
|
||||
<div class="header">
|
||||
<div class="status-dot" style="background:${view.color}; box-shadow:0 0 24px ${view.color}66;"></div>
|
||||
<div class="header-main">
|
||||
<div class="title">Spaghetti Detection</div>
|
||||
<div class="subtitle">${view.label}</div>
|
||||
</div>
|
||||
<div class="automation-pill" style="border-color:${automationColor}55; background:${automationColor}18; color:${automationColor};">
|
||||
${automationText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="desc" style="background:${view.bg}; border-color:${view.color}33;">
|
||||
${view.desc}
|
||||
</div>
|
||||
|
||||
<div class="confidence">
|
||||
<div class="confidence-top">
|
||||
<span>Confidence</span>
|
||||
<b>${confidence.toFixed(1)}%</b>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" style="
|
||||
width:${Math.max(0, Math.min(100, confidence))}%;
|
||||
background:${detected ? '#ff453a' : view.color};
|
||||
box-shadow:0 0 18px ${detected ? '#ff453a' : view.color};
|
||||
"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">Print</div>
|
||||
<div class="metric-value">${printStatus}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Next check</div>
|
||||
<div class="metric-value">${formatDate(nextRun, 'Not scheduled')}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Detections</div>
|
||||
<div class="metric-value">${detections}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Raw score</div>
|
||||
<div class="metric-value">${raw.toFixed(3)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div><b>Last check:</b> ${formatDate(lastRun, 'Never')}</div>
|
||||
<div><b>Last error:</b> ${errorText}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
]]]
|
||||
styles:
|
||||
card:
|
||||
- border-radius: 22px
|
||||
- padding: 14px
|
||||
- background: rgba(255,255,255,.04)
|
||||
- border: 1px solid rgba(255,255,255,.08)
|
||||
- box-shadow: none
|
||||
- overflow: hidden
|
||||
grid:
|
||||
- grid-template-areas: "\"content\""
|
||||
- grid-template-columns: 1fr
|
||||
- grid-template-rows: auto
|
||||
custom_fields:
|
||||
content:
|
||||
- width: 100%
|
||||
- justify-self: stretch
|
||||
- text-align: left
|
||||
extra_styles: |
|
||||
.wrap {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 21px;
|
||||
font-weight: 800;
|
||||
color: rgba(255,255,255,.96);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 3px;
|
||||
font-size: 14px;
|
||||
opacity: .74;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.automation-pill {
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: 14px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid;
|
||||
border-radius: 14px;
|
||||
color: rgba(255,255,255,.82);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.confidence {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.confidence-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 7px;
|
||||
font-size: 13px;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.11);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width .3s ease;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: rgba(255,255,255,.06);
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
opacity: .62;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: rgba(255,255,255,.96);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 13px;
|
||||
font-size: 12px;
|
||||
opacity: .70;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.header {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.automation-pill {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
- type: conditional
|
||||
conditions:
|
||||
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
|
||||
state: "on"
|
||||
card:
|
||||
type: custom:mushroom-template-card
|
||||
entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
|
||||
primary: Spaghetti detected
|
||||
secondary: >-
|
||||
Confidence: {{ states('sensor.elegoo_spaghetti_detection_confidence') }}%
|
||||
• Detections: {{ states('sensor.elegoo_spaghetti_detection_detections') }}
|
||||
icon: mdi:alert-octagon
|
||||
icon_color: red
|
||||
badge_icon: mdi:pause
|
||||
badge_color: red
|
||||
layout: horizontal
|
||||
tap_action:
|
||||
action: more-info
|
||||
card_mod:
|
||||
style: |
|
||||
ha-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255,69,58,.16);
|
||||
border: 1px solid rgba(255,69,58,.34);
|
||||
animation: spaghettiPulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spaghettiPulse {
|
||||
0% { box-shadow: 0 0 0 rgba(255,69,58,0); }
|
||||
50% { box-shadow: 0 0 28px rgba(255,69,58,.30); }
|
||||
100% { box-shadow: 0 0 0 rgba(255,69,58,0); }
|
||||
}
|
||||
|
||||
- type: grid
|
||||
columns: 2
|
||||
square: false
|
||||
cards:
|
||||
# Replace this placeholder automation with your own notification/pause
|
||||
# automation entity before using the toggle card below.
|
||||
- type: custom:mushroom-template-card
|
||||
entity: automation.elegoo_cc2_spaghetti_pause_and_notify
|
||||
primary: Auto pause
|
||||
secondary: >-
|
||||
{% if is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'on') %}
|
||||
Enabled: pause and notify on failure
|
||||
{% elif is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'off') %}
|
||||
Disabled: notification-only or manual review mode
|
||||
{% else %}
|
||||
Replace this entity with your own automation
|
||||
{% endif %}
|
||||
icon: >-
|
||||
{% if is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'on') %}
|
||||
mdi:shield-check
|
||||
{% else %}
|
||||
mdi:shield-off
|
||||
{% endif %}
|
||||
icon_color: >-
|
||||
{% if is_state('automation.elegoo_cc2_spaghetti_pause_and_notify', 'on') %}
|
||||
green
|
||||
{% else %}
|
||||
amber
|
||||
{% endif %}
|
||||
layout: vertical
|
||||
tap_action:
|
||||
action: toggle
|
||||
hold_action:
|
||||
action: more-info
|
||||
card_mod:
|
||||
style: |
|
||||
ha-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,.045);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
- type: custom:mushroom-template-card
|
||||
entity: button.elegoo_spaghetti_detection_test_spaghetti_detection
|
||||
primary: Manual test
|
||||
secondary: Take one snapshot and run detection
|
||||
icon: mdi:camera-iris
|
||||
icon_color: blue
|
||||
layout: vertical
|
||||
tap_action:
|
||||
action: perform-action
|
||||
perform_action: button.press
|
||||
target:
|
||||
entity_id: button.elegoo_spaghetti_detection_test_spaghetti_detection
|
||||
card_mod:
|
||||
style: |
|
||||
ha-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(10,132,255,.10);
|
||||
border: 1px solid rgba(10,132,255,.24);
|
||||
}
|
||||
|
||||
- type: custom:mushroom-template-card
|
||||
entity: button.elegoo_spaghetti_detection_reset_detection_state
|
||||
primary: Reset detector
|
||||
secondary: Clear confidence and detected state
|
||||
icon: mdi:restart
|
||||
icon_color: purple
|
||||
layout: vertical
|
||||
tap_action:
|
||||
action: perform-action
|
||||
perform_action: button.press
|
||||
target:
|
||||
entity_id: button.elegoo_spaghetti_detection_reset_detection_state
|
||||
card_mod:
|
||||
style: |
|
||||
ha-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(175,82,222,.10);
|
||||
border: 1px solid rgba(175,82,222,.24);
|
||||
}
|
||||
|
||||
- type: custom:mushroom-template-card
|
||||
entity: sensor.elegoo_spaghetti_detection_last_error
|
||||
primary: Last error
|
||||
secondary: >-
|
||||
{% set e = states('sensor.elegoo_spaghetti_detection_last_error') %}
|
||||
{% if e in ['none', 'unknown', 'unavailable', ''] %}
|
||||
None
|
||||
{% else %}
|
||||
{{ e }}
|
||||
{% endif %}
|
||||
icon: >-
|
||||
{% set e = states('sensor.elegoo_spaghetti_detection_last_error') %}
|
||||
{% if e in ['none', 'unknown', 'unavailable', ''] %}
|
||||
mdi:check-circle
|
||||
{% else %}
|
||||
mdi:alert-circle
|
||||
{% endif %}
|
||||
icon_color: >-
|
||||
{% set e = states('sensor.elegoo_spaghetti_detection_last_error') %}
|
||||
{% if e in ['none', 'unknown', 'unavailable', ''] %}
|
||||
green
|
||||
{% else %}
|
||||
red
|
||||
{% endif %}
|
||||
layout: vertical
|
||||
multiline_secondary: true
|
||||
tap_action:
|
||||
action: more-info
|
||||
card_mod:
|
||||
style: |
|
||||
ha-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,.045);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
- type: entities
|
||||
title: Spaghetti detector details
|
||||
show_header_toggle: false
|
||||
entities:
|
||||
- entity: sensor.elegoo_spaghetti_detection_status
|
||||
name: Status
|
||||
- entity: sensor.elegoo_spaghetti_detection_next_run
|
||||
name: Next scheduled check
|
||||
- entity: binary_sensor.elegoo_spaghetti_detection_spaghetti_detected
|
||||
name: Detected
|
||||
- entity: sensor.elegoo_spaghetti_detection_confidence
|
||||
name: Confidence
|
||||
- entity: sensor.elegoo_spaghetti_detection_raw_score
|
||||
name: Raw score
|
||||
- entity: sensor.elegoo_spaghetti_detection_detections
|
||||
name: Detection count
|
||||
- entity: sensor.elegoo_spaghetti_detection_last_run
|
||||
name: Last check
|
||||
- entity: automation.elegoo_cc2_spaghetti_pause_and_notify
|
||||
name: Auto pause automation
|
||||
card_mod:
|
||||
style: |
|
||||
ha-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
alias: Elegoo Spaghetti - Manual Test Result Notification
|
||||
mode: single
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: elegoo_spaghetti_detection_result
|
||||
event_data:
|
||||
detector: elegoo_spaghetti_detection
|
||||
manual: true
|
||||
|
||||
variables:
|
||||
notify_service: notify.mobile_app_your_phone
|
||||
|
||||
actions:
|
||||
- action: "{{ notify_service }}"
|
||||
data:
|
||||
title: "Spaghetti detection test result"
|
||||
message: >
|
||||
Status: {{ trigger.event.data.status }}.
|
||||
Confidence:
|
||||
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%.
|
||||
Detections: {{ trigger.event.data.detections }}.
|
||||
Error: {{ trigger.event.data.last_error or 'none' }}.
|
||||
data:
|
||||
image: "{{ trigger.event.data.image_url }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
alias: Elegoo Spaghetti - Notify Only
|
||||
mode: single
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: elegoo_spaghetti_detection_detected
|
||||
event_data:
|
||||
detector: elegoo_spaghetti_detection
|
||||
|
||||
variables:
|
||||
notify_service: notify.mobile_app_your_phone
|
||||
|
||||
actions:
|
||||
- action: "{{ notify_service }}"
|
||||
data:
|
||||
title: "Possible print failure"
|
||||
message: >
|
||||
{{ trigger.event.data.name }} detected a possible spaghetti failure.
|
||||
Confidence:
|
||||
{{ (trigger.event.data.confidence | float(0) * 100) | round(1) }}%.
|
||||
Detections: {{ trigger.event.data.detections }}.
|
||||
data:
|
||||
image: "{{ trigger.event.data.image_url }}"
|
||||
@@ -0,0 +1,50 @@
|
||||
alias: Elegoo Spaghetti - Smart Pause Stop
|
||||
mode: single
|
||||
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: elegoo_spaghetti_detection_detected
|
||||
event_data:
|
||||
detector: elegoo_spaghetti_detection
|
||||
|
||||
variables:
|
||||
notify_service: notify.mobile_app_your_phone
|
||||
pause_button: button.elegoo_centauri_carbon2_pause_print
|
||||
stop_button: button.elegoo_centauri_carbon2_stop_print
|
||||
confidence: "{{ trigger.event.data.confidence | float(0) }}"
|
||||
confidence_percent: "{{ (confidence | float(0) * 100) | round(1) }}"
|
||||
detections: "{{ trigger.event.data.detections | int(0) }}"
|
||||
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ confidence >= 0.85 }}"
|
||||
sequence:
|
||||
- action: "{{ notify_service }}"
|
||||
data:
|
||||
title: "High confidence print failure"
|
||||
message: >
|
||||
Confidence {{ confidence_percent }}%, detections {{ detections }}.
|
||||
Stopping the print.
|
||||
data:
|
||||
image: "{{ trigger.event.data.image_url }}"
|
||||
- action: button.press
|
||||
target:
|
||||
entity_id: "{{ stop_button }}"
|
||||
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{ confidence < 0.85 }}"
|
||||
sequence:
|
||||
- action: "{{ notify_service }}"
|
||||
data:
|
||||
title: "Print paused for review"
|
||||
message: >
|
||||
Confidence {{ confidence_percent }}%, detections {{ detections }}.
|
||||
Pausing the print for manual review.
|
||||
data:
|
||||
image: "{{ trigger.event.data.image_url }}"
|
||||
- action: button.press
|
||||
target:
|
||||
entity_id: "{{ pause_button }}"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Elegoo Spaghetti Detection",
|
||||
"homeassistant": "2026.4.0"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Elegoo Spaghetti Detection",
|
||||
"url": "https://github.com/hepter/ha-elegoo-spaghetti-detection",
|
||||
"maintainer": "hepter"
|
||||
}
|
||||
Reference in New Issue
Block a user