Repository: ad-ha/kidschores-ha
Branch: main
Commit: d8f5f8817e47
Files: 29
Total size: 691.8 KB
Directory structure:
gitextract_644k2xfj/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── 01-issue_report.yml
│ │ ├── 02-feature_reques.yml
│ │ └── config.yml
│ └── workflows/
│ ├── hassfest.yaml
│ └── validate.yaml
├── .gitignore
├── LICENSE
├── README.md
├── custom_components/
│ └── kidschores/
│ ├── __init__.py
│ ├── button.py
│ ├── calendar.py
│ ├── config_flow.py
│ ├── const.py
│ ├── coordinator.py
│ ├── flow_helpers.py
│ ├── kc_helpers.py
│ ├── manifest.json
│ ├── notification_action_handler.py
│ ├── notification_helper.py
│ ├── options_flow.py
│ ├── select.py
│ ├── sensor.py
│ ├── services.py
│ ├── services.yaml
│ ├── storage_manager.py
│ └── translations/
│ ├── en.json
│ └── es.json
└── hacs.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: varetas3d
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/ISSUE_TEMPLATE/01-issue_report.yml
================================================
---
name: Issue Report
description: Create an issue report to help us improve
title: "[ISSUE] "
labels: bug
assignees: []
body:
- type: markdown
attributes:
value: |
**Please fill out this form to report a bug with the KidsChore Integration.**
- type: input
id: home_assistant_version
attributes:
label: Home Assistant Version
description: "What version of Home Assistant are you using?"
placeholder: "e.g., 2024.11.1"
validations:
required: true
- type: input
id: integration_version
attributes:
label: KidsChore Integration Version
description: "What version of the integration are you using?"
placeholder: "e.g., 0.4.8"
validations:
required: true
- type: dropdown
id: installation_method
attributes:
label: Installation Method
description: "How did you install the integration?"
options:
- HACS
- Manual
validations:
required: true
- type: checkboxes
id: prior_issue_check
attributes:
label: Did you check for existing issues?
description: "You should check if there's a current or closed issue."
options:
- label: Yes, I have checked for existing issues
required: true
- label: No, I have not checked for existing issues
- type: checkboxes
id: debug_enabled
attributes:
label: Did you enable debug logging before and are ready to post logs?
options:
- label: Yes, I have enabled debug logging
required: true
- label: No, I have not enabled debug logging
- type: textarea
id: issue_description
attributes:
label: Describe the Issue
description: "A clear and concise description of what the bug is."
placeholder: "Provide a detailed description..."
validations:
required: true
- type: markdown
attributes:
value: |
### **Logs**
Please add the following to your `configuration.yaml` on your Home Assistant and restart:
```yaml
logger:
default: warning
logs:
custom_components.kidschores: debug
```
See [Home Assistant Logger Documentation](https://www.home-assistant.io/integrations/logger) for more information.
- type: textarea
id: logs
attributes:
label: Logs
description: "Paste your logs here."
render: yaml
- type: textarea
id: additional_context
attributes:
label: Additional Context
description: "Add any other context about the problem here."
placeholder: "Any additional information..."
---
================================================
FILE: .github/ISSUE_TEMPLATE/02-feature_reques.yml
================================================
---
name: Feature Request
description: Suggest an idea for this project
title: "[REQ] "
labels: enhancement
assignees: []
body:
- type: markdown
attributes:
value: |
**Please describe the feature you would like to see added to the KidsChores Integration.**
- type: checkboxes
id: problem_exists
attributes:
label: Is your feature request related to a problem?
options:
- label: "Yes"
- type: textarea
id: problem
attributes:
label: Please describe the problem
description: A clear and concise description of what the problem is.
placeholder: Ex. I'm always frustrated when [...]
validations:
required: false
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
id: additional_context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
================================================
FILE: .github/workflows/hassfest.yaml
================================================
name: Validate with hassfest
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- uses: home-assistant/actions/hassfest@master
================================================
FILE: .github/workflows/validate.yaml
================================================
name: HACS Action
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
validate-hacs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v3"
- name: HACS validation
uses: "hacs/action@main"
with:
category: integration
================================================
FILE: .gitignore
================================================
__pycache__/
*.py[cod]
================================================
FILE: LICENSE
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
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.
Copyright (C)
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 .
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:
Copyright (C)
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
.
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
.
================================================
FILE: README.md
================================================
> [!IMPORTANT]
> **⚠️ ACTIVE DEVELOPMENT HAS MOVED TO CHOREOPS**
>
> `KidsChores` has officially evolved into a new, expanded integration called **[ChoreOps](https://github.com/ccpk1/choreops)**.
>
> Based on incredible feedback from this community, the backend has been completely re-architected to Home Assistant Platinum standards to support *everyone* in the household (not just kids!), along with an entirely new "Over-The-Air" dashboard system.
>
> **Please migrate to ChoreOps to access all new features and continued support.**
> Upgrading is safe and easy: ChoreOps includes a built-in migration tool and runs safely in parallel with KidsChores. You can set it up, migrate your data, and ensure you are completely happy before removing your old configuration.
>
> 👉 **[Get started with ChoreOps here!](https://github.com/ccpk1/choreops)**
---
[](https://github.com/custom-components/hacs)


[](https://github.com/ad-ha/kidschores-ha/actions/workflows/validate.yaml)
[](https://github.com/ad-ha/kidschores-ha/actions/workflows/hassfest.yaml)
KidsChores
# 🏆 KidsChores: The Ultimate Home Assistant Chore & Reward System
**The easiest-to-use and most feature-rich chore management system for Home Assistant.**
Get up and running in **10 minutes or less**, with **unmatched capabilities** to gamify the process and keep kids engaged!
✅ **Track chores effortlessly** – Assign chores, set due dates, and track completions.
✅ **Gamify the experience** – **Badges, Achievements, and Challenges** keep kids motivated.
✅ **Bonuses & Penalties** – Reward extra effort and enforce accountability.
✅ **Customizable Rewards** – Give coins, stars, points, or any currency system you choose.
✅ **Built-in User Access Control** – Restricts actions based on roles (kids, parents, admins).
✅ **Smart Notifications** – Notify kids and parents; parents can approve chores & rewards from their phone or watch.
✅ **Calendar Integration & Custom Scheduling** – Automatically manage recurring chores and sync with Home Assistant’s calendar.
✅ **Works Offline & Keeps Data Local** – Everything is processed locally for **privacy & security**.
**"Designed for kids, but flexible for the whole family—assign chores to anyone, from toddlers to teens to adults!"**
📖 **[System Overviews, FAQ's, Tips & Tricks, and Usage Examples in the Wiki →](https://github.com/ad-ha/kidschores-ha/wiki)**
---
## ⚡ Quick Installation
📌 **Via HACS (Recommended)**
1. Ensure **HACS** is installed. ([HACS Setup Guide](https://hacs.xyz/docs/installation/manual))
2. In Home Assistant, go to **HACS > Custom Repositories**.
3. Add `https://github.com/ad-ha/kidschores-ha` as an **Integration**.
4. Search for **KidsChores**, install, and **restart Home Assistant**.
📖 **[Full Setup & Configuration Guide →](https://github.com/ad-ha/kidschores-ha/wiki/Installation-&-Setup)**
---
## 🌟 Key Features
### 👧👦 Multi-User Management
- **Profile Creation & Customization:**
- Create and manage individual profiles for multiple kids and parents.
- Track each child’s progress, achievements, and performance with ease.
- **Effortless Management:**
- Handle multiple kids with a single integration while monitoring individual statistics and trends.
- **Built-in Access Control** (Restrict actions based on user roles to prevent unauthorized changes). **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Access-Control:-Overview-&-Best-Practices)**
---
### ⭐ **Customizable Points System**
- Personalize the points system by choosing your own name and icon (e.g., Stars, Bucks, Coins) to better resonate with your family.
---
### 🧹 **Chore Management**
- **Assign & Track Chores:**
- Easily define chores with descriptions, icons, due dates, and customizable recurring schedules. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Chore-Status-and-Recurrence-Handling)**
- Supports **individual chores** (assigned to a single kid) and **shared chores** (requiring participation from multiple kids).
- **Labels** can be used to **group chores** by type, location, or difficulty—or to **exclude specific chores** based on your family's needs.
- **Smart Notifications & Workflow Approvals:**
- Parents and kids receive **dynamic notifications** for **chore claims, approvals, and overdue tasks**.
- Notifications are **actionable** on **phones, tablets, and smartwatches**, allowing parents to **approve or reject** tasks with a single tap.
- **Customizable reminders** help ensure chores stay on track and are completed on time.
- **Dynamic Chore States & Actions:**
- Leverage dynamic buttons to claim, approve, or disapprove chores—completion with built-in authorization and contextual notifications.
- Monitor progress with sensors that update on a per-kid and global level.
---
### 🎁 **Reward System**
- Rewards help **motivate kids** by offering incentives they want while reinforcing responsibility. Parents can **create a list of rewards**, assign a **point cost**, and let kids claim them when they have enough points.
- **Customizable & Goal-Oriented:**
- Add rewards tailored to your kid’s interests (e.g., extra screen time, a special outing).
- Assign point values to **encourage saving** and **set goals**.
- **Seamless Claim & Workflow Approval Process:**
- Kids can **claim rewards** when they meet the point requirement.
- Parents receive an **approval notification**; once approved, **points are automatically deducted**, and the parent is responsible for delivering the reward.
---
### 🏅 **Badge System**
- Badges reward **milestone achievements** and encourage consistency by tracking progress over time. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Badges:-Overview-&-Examples)**
- **Earned Through Chores & Points:**
- Kids can unlock badges by **completing chores** or **earning points** (e.g., 100 chores or 100 points).
- Badge progress is **tracked from the start**, so kids receive credit for past achievements.
- **Multipliers & Tracking:**
- Badges can apply a **points multiplier** to boost future earnings (e.g., 1.5x points per chore).
- Tracks each kid’s **highest badge earned** and **full badge history**.
---
### ⚖️ **Bonuses & Penalties**
- Bonuses and penalties allow parents to **reinforce positive behavior** and **correct missteps** by adjusting points dynamically. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Bonuses-&-Penalties:-Overview-&-Examples)**
- **Bonuses: Reward Extra Effort**
- Award **extra points** for exceptional behavior, teamwork, or going above expectations.
- Can be applied manually or automatically through the system, with **custom labels and tracking**.
- **Penalties: Encourage Accountability**
- Deduct points for missed chores or rule-breaking to **reinforce responsibility**.
- Easily track applied penalties and ensure fair, transparent adjustments.
---
### 🏆 **Challenges & Achievements**
- Challenges and achievements **motivate kids with structured goals**, rewarding consistency beyond daily chore completions. **[Learn More →](https://github.com/ad-ha/kidschores-ha/wiki/Challenges-&-Achievements:-Overview-&-Functionality)**
- **Achievements: Personal Milestones**
- Earned by **completing a set number of chores** or **maintaining streaks** over time (e.g., 100 total chores, 30-day streak).
- Tracks individual progress and provides **long-term motivation**.
- **Challenges: Time-Bound Goals**
- Require kids to **complete specific tasks within a set timeframe** (e.g., 50 chores in a month).
- Can be **individual or shared**, encouraging teamwork toward a common goal.
---
### 📅 **Calendar Integration**
- KidsChores integrates with **Home Assistant’s calendar**, allowing chores and challenges to displayed alongside other household events.
- **Sync Chores to Calendar:**
- View **due dates** for individual and shared chores directly in the Home Assistant calendar.
- Helps parents and kids **plan ahead** and stay organized.
- **Track Challenges & Time-Sensitive Goals:**
- Challenges with set timeframes (e.g., "Complete 50 chores in a month") appear in the calendar for **easy progress tracking**.
- Provides a **visual timeline** of ongoing and upcoming challenges.
---
### 📊 **Detailed Statistics & Advanced Controls**
- KidsChores provides **comprehensive tracking** through **real-time sensors and interactive buttons**, giving parents full insight into chore activity and progress.
- **Comprehensive Sensors & Data Tracking:**
- Monitor **daily, weekly, and monthly stats** on chore completions, points earned, rewards redeemed, badges awarded, and penalties applied.
- Analyze **historical trends** to celebrate progress, adjust incentives, and identify areas for improvement.
- **Interactive Controls & Automation:**
- Use dynamic buttons for **claiming chores, approving rewards, and applying bonuses or penalties** directly from the UI.
- Seamlessly integrate with Home Assistant automations for **custom alerts, reports, and dashboard insights**.
- 📖 **[View the Full List of Sensors & Actions →](https://github.com/ad-ha/kidschores-ha/wiki/Sensors-&-Buttons)**
---
### 🛠 Customization & User-Friendly Interface
- **🛠 Dynamic Buttons & Actions:**
- Manage chores and points directly from the Home Assistant UI with buttons for claiming, approving, redeeming, and adjusting points.
- **🌐 Multilingual Support:**
- Currently available in English and Spanish to cater to a diverse user base.
- **🔧 Easy Setup & Maintenance:**
- KidsChores offers a **fully interactive Options Flow** with a **user-friendly setup wizard** and **comprehensive configuration menus**, allowing you to manage everything **directly from the Home Assistant UI**—**no YAML or coding required**. With an intuitive frontend interface, you can effortlessly configure:
- **Points**
- **Kids & Parents**
- **Chores**
- **Rewards**
- **Badges**
- **Penalties & Bonuses**
- **Achievements & Challenges**
- **Organize with Home Assistant Labels:**
- Use **labels** to categorize and manage chores, rewards, penalties, badges, and challenges—making it easier to filter, group, or exclude specific tasks based on your needs.
---
### ⚙️ Make KidsChores Your Own
---
- If that's still not enough for you—**this is Home Assistant!** With a little customization, you can make KidsChores work exactly how you want.
📅 **Want to set schedules from your Google Calendar?**
📲 **Want to claim chores using NFC tags?**
✅ **Want to automatically approve specific chores?**
⏳ **Want to automatically apply a penalty or a custom alert when a chore goes overdue?**
The **[Tips & Tricks](https://github.com/ad-ha/kidschores-ha/wiki/Tips-&-Tricks)** section of the Wiki is packed with ideas to help you **customize, automate, and extend** KidsChores to fit your family's needs.
---
## 🔐 **Security & Privacy**
🔹 **100% Local & Private** – Your data stays on your Home Assistant instance, ensuring complete privacy.
🔹 **No External Data Sharing** – No cloud services, no third-party access—everything runs securely on your local network.
🔹 **Built-in User Access Control** – Restrict actions based on roles to prevent unauthorized changes.
With **KidsChores**, your family’s information remains private, secure, and fully under your control.
---
## 🤝 Join the Community & Contribute
🚀 **Get Help & Share Ideas**
- 💬 **Join Community Discussions** → [Home Assistant Forum](https://community.home-assistant.io/t/kidschores-family-chore-management-integration)
- 🛠️ **Report Issues & Request Features** → [GitHub Issues](https://github.com/ad-ha/kidschores-ha/issues)
👨💻 **Want to contribute?**
- Submit a **pull request**: [GitHub Contributions](https://github.com/ad-ha/kidschores-ha/pulls).
- Help with **translations** and **documentation updates**.
---
KidsChores makes managing chores effortless, engaging, and rewarding for the whole family. With built-in gamification, smart automation, and flexible tracking, it turns daily routines into a fun and structured experience.
Whether you want to **encourage responsibility**, **motivate with rewards**, or simply **streamline household tasks**, KidsChores has you covered.
**Get started today and transform how your family manages chores, rewards, and accountability!**
---
## LICENSE
This project is licensed under the [GPL-3.0 license](LICENSE). See the LICENSE file for details.
---
## DISCLAIMER
THIS PROJECT IS NOT AFFILIATED WITH OR ENDORSED BY ANY OFFICIAL ENTITY. The information provided is for educational purposes only, and the developers assume no legal responsibility for the functionality or security of your devices.
================================================
FILE: custom_components/kidschores/__init__.py
================================================
# File: __init__.py
"""Initialization file for the KidsChores integration.
Handles setting up the integration, including loading configuration entries,
initializing data storage, and preparing the coordinator for data handling.
Key Features:
- Config entry setup and unload support.
- Coordinator initialization for data synchronization.
- Storage management for persistent data handling.
"""
from __future__ import annotations
import asyncio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
DOMAIN,
LOGGER,
NOTIFICATION_EVENT,
STORAGE_KEY,
PLATFORMS,
)
from .coordinator import KidsChoresDataCoordinator
from .notification_action_handler import async_handle_notification_action
from .storage_manager import KidsChoresStorageManager
from .services import async_setup_services, async_unload_services
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the integration from a config entry."""
LOGGER.info("Starting setup for KidsChores entry: %s", entry.entry_id)
# Initialize the storage manager to handle persistent data.
storage_manager = KidsChoresStorageManager(hass, STORAGE_KEY)
# Initialize new file.
await storage_manager.async_initialize()
# Create the data coordinator for managing updates and synchronization.
coordinator = KidsChoresDataCoordinator(hass, entry, storage_manager)
try:
# Perform the first refresh to load data.
await coordinator.async_config_entry_first_refresh()
except ConfigEntryNotReady as e:
LOGGER.error("Failed to refresh coordinator data: %s", e)
raise ConfigEntryNotReady from e
# Store the coordinator and data manager in hass.data.
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"coordinator": coordinator,
"storage_manager": storage_manager,
}
# Set up services required by the integration.
async_setup_services(hass)
# Forward the setup to supported platforms (sensors, buttons, etc.).
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Listen for notification actions from the companion app.
hass.bus.async_listen(
NOTIFICATION_EVENT,
lambda event: asyncio.run_coroutine_threadsafe(
async_handle_notification_action(hass, event), hass.loop
),
)
LOGGER.info("KidsChores setup complete for entry: %s", entry.entry_id)
return True
async def async_unload_entry(hass, entry):
"""Unload a config entry.
Args:
hass: Home Assistant instance.
entry: Config entry to unload.
"""
LOGGER.info("Unloading KidsChores entry: %s", entry.entry_id)
# Unload platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Await service unloading
await async_unload_services(hass)
return unload_ok
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle removal of a config entry."""
LOGGER.info("Removing KidsChores entry: %s", entry.entry_id)
# Safely check if data exists before attempting to access it
if DOMAIN in hass.data and entry.entry_id in hass.data[DOMAIN]:
storage_manager: KidsChoresStorageManager = hass.data[DOMAIN][entry.entry_id][
"storage_manager"
]
await storage_manager.async_delete_storage()
LOGGER.info("KidsChores entry data cleared: %s", entry.entry_id)
================================================
FILE: custom_components/kidschores/button.py
================================================
# File: button.py
"""Buttons for KidsChores integration.
Features:
1) Chore Buttons (Claim & Approve) with user-defined or default icons.
2) Reward Buttons using user-defined or default icons.
3) Penalty Buttons using user-defined or default icons.
4) Bonus Buttons using user-defined or default icons.
5) PointsAdjustButton: manually increments/decrements a kid's points (e.g., +1, -1, +2, -2, etc.).
6) ApproveRewardButton: allows parents to approve rewards claimed by kids.
"""
from homeassistant.auth.models import User
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.exceptions import HomeAssistantError
from .const import (
ATTR_LABELS,
BUTTON_BONUS_PREFIX,
BUTTON_DISAPPROVE_CHORE_PREFIX,
BUTTON_DISAPPROVE_REWARD_PREFIX,
BUTTON_PENALTY_PREFIX,
BUTTON_REWARD_PREFIX,
CONF_POINTS_LABEL,
DATA_PENDING_CHORE_APPROVALS,
DATA_PENDING_REWARD_APPROVALS,
DEFAULT_BONUS_ICON,
DEFAULT_CHORE_APPROVE_ICON,
DEFAULT_CHORE_CLAIM_ICON,
DEFAULT_DISAPPROVE_ICON,
DEFAULT_PENALTY_ICON,
DEFAULT_POINTS_ADJUST_MINUS_ICON,
DEFAULT_POINTS_ADJUST_MINUS_MULTIPLE_ICON,
DEFAULT_POINTS_ADJUST_PLUS_ICON,
DEFAULT_POINTS_ADJUST_PLUS_MULTIPLE_ICON,
DEFAULT_POINTS_LABEL,
DEFAULT_REWARD_ICON,
DOMAIN,
ERROR_NOT_AUTHORIZED_ACTION_FMT,
LOGGER,
)
from .coordinator import KidsChoresDataCoordinator
from .kc_helpers import (
is_user_authorized_for_global_action,
is_user_authorized_for_kid,
get_friendly_label,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
):
"""Set up dynamic buttons.
- Chores (Claim & Approve & Disapprove)
- Rewards (Redeem & Approve & Disapprove)
- Penalties
- Kid points adjustments (e.g., +1, -1, +10, -10, etc.)
- Approve Reward Workflow
"""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: KidsChoresDataCoordinator = data["coordinator"]
points_label = entry.options.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL)
entities = []
# Create buttons for chores (Claim, Approve & Disapprove)
for chore_id, chore_info in coordinator.chores_data.items():
chore_name = chore_info.get("name", f"Chore {chore_id}")
assigned_kids_ids = chore_info.get("assigned_kids", [])
# If user defined an icon, use it; else fallback to default for chore claim
chore_claim_icon = chore_info.get("icon", DEFAULT_CHORE_CLAIM_ICON)
# For "approve," use a distinct icon
chore_approve_icon = chore_info.get("icon", DEFAULT_CHORE_APPROVE_ICON)
for kid_id in assigned_kids_ids:
kid_name = coordinator._get_kid_name_by_id(kid_id) or f"Kid {kid_id}"
# Claim Button
entities.append(
ClaimChoreButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
chore_id=chore_id,
chore_name=chore_name,
icon=chore_claim_icon,
)
)
# Approve Button
entities.append(
ApproveChoreButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
chore_id=chore_id,
chore_name=chore_name,
icon=chore_approve_icon,
)
)
# Disapprove Button
entities.append(
DisapproveChoreButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
chore_id=chore_id,
chore_name=chore_name,
)
)
# Create reward buttons (Redeem, Approve & Disapprove)
for kid_id, kid_info in coordinator.kids_data.items():
kid_name = kid_info.get("name", f"Kid {kid_id}")
for reward_id, reward_info in coordinator.rewards_data.items():
# If no user-defined icon, fallback to DEFAULT_REWARD_ICON
reward_icon = reward_info.get("icon", DEFAULT_REWARD_ICON)
# Redeem Reward Button
entities.append(
RewardButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
reward_id=reward_id,
reward_name=reward_info.get("name", f"Reward {reward_id}"),
icon=reward_icon,
)
)
# Approve Reward Button
entities.append(
ApproveRewardButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
reward_id=reward_id,
reward_name=reward_info.get("name", f"Reward {reward_id}"),
icon=reward_info.get("icon", DEFAULT_REWARD_ICON),
)
)
# Disapprove Reward Button
entities.append(
DisapproveRewardButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
reward_id=reward_id,
reward_name=reward_info.get("name", f"Reward {reward_id}"),
)
)
# Create penalty buttons
for kid_id, kid_info in coordinator.kids_data.items():
kid_name = kid_info.get("name", f"Kid {kid_id}")
for penalty_id, penalty_info in coordinator.penalties_data.items():
# If no user-defined icon, fallback to DEFAULT_PENALTY_ICON
penalty_icon = penalty_info.get("icon", DEFAULT_PENALTY_ICON)
entities.append(
PenaltyButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
penalty_id=penalty_id,
penalty_name=penalty_info.get("name", f"Penalty {penalty_id}"),
icon=penalty_icon,
)
)
# Create bonus buttons
for kid_id, kid_info in coordinator.kids_data.items():
kid_name = kid_info.get("name", f"Kid {kid_id}")
for bonus_id, bonus_info in coordinator.bonuses_data.items():
# If no user-defined icon, fallback to DEFAULT_BONUS_ICON
bonus_icon = bonus_info.get("icon", DEFAULT_BONUS_ICON)
entities.append(
BonusButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
bonus_id=bonus_id,
bonus_name=bonus_info.get("name", f"Bonus {bonus_id}"),
icon=bonus_icon,
)
)
# Create "points adjustment" buttons for each kid (±1, ±2, ±10, etc.)
POINT_DELTAS = [+1, -1, +2, -2, +10, -10]
for kid_id, kid_info in coordinator.kids_data.items():
kid_name = kid_info.get("name", f"Kid {kid_id}")
for delta in POINT_DELTAS:
entities.append(
PointsAdjustButton(
coordinator=coordinator,
entry=entry,
kid_id=kid_id,
kid_name=kid_name,
delta=delta,
points_label=points_label,
)
)
async_add_entities(entities)
# ------------------ Chore Buttons ------------------
class ClaimChoreButton(CoordinatorEntity, ButtonEntity):
"""Button to claim a chore as done (set chore state=claimed)."""
_attr_has_entity_name = True
_attr_translation_key = "claim_chore_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
chore_id: str,
chore_name: str,
icon: str,
):
"""Initialize the claim chore button."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._chore_id = chore_id
self._chore_name = chore_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_claim"
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"chore_name": chore_name,
}
self.entity_id = f"button.kc_{kid_name}_chore_claim_{chore_name}"
async def async_press(self):
"""Handle the button press event."""
try:
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_kid(
self.hass, user_id, self._kid_id
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("claim chores")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
user_name = user_obj.name if user_obj else "Unknown"
self.coordinator.claim_chore(
kid_id=self._kid_id,
chore_id=self._chore_id,
user_name=user_name,
)
LOGGER.info(
"Chore '%s' claimed by kid '%s' (user: %s)",
self._chore_name,
self._kid_name,
user_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to claim chore '%s' for kid '%s': %s",
self._chore_name,
self._kid_name,
e,
)
except Exception as e:
LOGGER.error(
"Failed to claim chore '%s' for kid '%s': %s",
self._chore_name,
self._kid_name,
e,
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
stored_labels = chore_info.get("chore_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
class ApproveChoreButton(CoordinatorEntity, ButtonEntity):
"""Button to approve a claimed chore for a kid (set chore state=approved or partial)."""
_attr_has_entity_name = True
_attr_translation_key = "approve_chore_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
chore_id: str,
chore_name: str,
icon: str,
):
"""Initialize the approve chore button."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._chore_id = chore_id
self._chore_name = chore_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_approve"
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"chore_name": chore_name,
}
self.entity_id = f"button.kc_{kid_name}_chore_approval_{chore_name}"
async def async_press(self):
"""Handle the button press event."""
try:
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_global_action(
self.hass, user_id, "approve_chore"
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("approve chores")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
parent_name = user_obj.name if user_obj else "ParentOrAdmin"
self.coordinator.approve_chore(
parent_name=parent_name,
kid_id=self._kid_id,
chore_id=self._chore_id,
)
LOGGER.info(
"Chore '%s' approved for kid '%s'",
self._chore_name,
self._kid_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to approve chore '%s' for kid '%s': %s",
self._chore_name,
self._kid_name,
e,
)
except Exception as e:
LOGGER.error(
"Failed to approve chore '%s' for kid '%s': %s",
self._chore_name,
self._kid_name,
e,
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
stored_labels = chore_info.get("chore_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
class DisapproveChoreButton(CoordinatorEntity, ButtonEntity):
"""Button to disapprove a chore."""
_attr_has_entity_name = True
_attr_translation_key = "disapprove_chore_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
chore_id: str,
chore_name: str,
icon: str = DEFAULT_DISAPPROVE_ICON,
):
"""Initialize the disapprove chore button."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._chore_id = chore_id
self._chore_name = chore_name
self._attr_unique_id = (
f"{entry.entry_id}_{BUTTON_DISAPPROVE_CHORE_PREFIX}{kid_id}_{chore_id}"
)
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"chore_name": chore_name,
}
self.entity_id = f"button.kc_{kid_name}_chore_disapproval_{chore_name}"
async def async_press(self):
"""Handle the button press event."""
try:
# Check if there's a pending approval for this kid and chore.
pending_approvals = self.coordinator._data.get(
DATA_PENDING_CHORE_APPROVALS, []
)
if not any(
approval["kid_id"] == self._kid_id
and approval["chore_id"] == self._chore_id
for approval in pending_approvals
):
raise HomeAssistantError(
f"No pending approval found for chore '{self._chore_name}' for kid '{self._kid_name}'."
)
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_global_action(
self.hass, user_id, "disapprove_chore"
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("disapprove chores")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
parent_name = user_obj.name if user_obj else "ParentOrAdmin"
self.coordinator.disapprove_chore(
parent_name=parent_name,
kid_id=self._kid_id,
chore_id=self._chore_id,
)
LOGGER.info(
"Chore '%s' disapproved for kid '%s' by parent '%s'",
self._chore_name,
self._kid_name,
parent_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to disapprove chore '%s' for kid '%s': %s",
self._chore_name,
self._kid_name,
e,
)
except Exception as e:
LOGGER.error(
"Failed to disapprove chore '%s' for kid '%s': %s",
self._chore_name,
self._kid_name,
e,
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
stored_labels = chore_info.get("chore_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
# ------------------ Reward Buttons ------------------
class RewardButton(CoordinatorEntity, ButtonEntity):
"""Button to redeem a reward for a kid."""
_attr_has_entity_name = True
_attr_translation_key = "claim_reward_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
reward_id: str,
reward_name: str,
icon: str,
):
"""Initialize the reward button."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._reward_id = reward_id
self._reward_name = reward_name
self._attr_unique_id = (
f"{entry.entry_id}_{BUTTON_REWARD_PREFIX}{kid_id}_{reward_id}"
)
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"reward_name": reward_name,
}
self.entity_id = f"button.kc_{kid_name}_reward_claim_{reward_name}"
async def async_press(self):
"""Handle the button press event."""
try:
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_kid(
self.hass, user_id, self._kid_id
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("redeem rewards")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
parent_name = user_obj.name if user_obj else "Unknown"
self.coordinator.redeem_reward(
parent_name=parent_name,
kid_id=self._kid_id,
reward_id=self._reward_id,
)
LOGGER.info(
"Reward '%s' redeemed for kid '%s' by parent '%s'",
self._reward_name,
self._kid_name,
parent_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to redeem reward '%s' for kid '%s': %s",
self._reward_name,
self._kid_name,
e,
)
except Exception as e:
LOGGER.error(
"Failed to redeem reward '%s' for kid '%s': %s",
self._reward_name,
self._kid_name,
e,
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
reward_info = self.coordinator.rewards_data.get(self._reward_id, {})
stored_labels = reward_info.get("reward_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
class ApproveRewardButton(CoordinatorEntity, ButtonEntity):
"""Button for parents to approve a reward claimed by a kid.
Prevents unauthorized or premature reward approvals.
"""
_attr_has_entity_name = True
_attr_translation_key = "approve_reward_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
reward_id: str,
reward_name: str,
icon: str,
):
"""Initialize the approve reward button."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._reward_id = reward_id
self._reward_name = reward_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_approve_reward"
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"reward_name": reward_name,
}
self.entity_id = f"button.kc_{kid_name}_reward_approval_{reward_name}"
async def async_press(self):
"""Handle the button press event."""
try:
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_global_action(
self.hass, user_id, "approve_reward"
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("approve rewards")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
parent_name = user_obj.name if user_obj else "ParentOrAdmin"
# Approve the reward
self.coordinator.approve_reward(
parent_name=parent_name,
kid_id=self._kid_id,
reward_id=self._reward_id,
)
LOGGER.info(
"Reward '%s' approved for kid '%s' by parent '%s'",
self._reward_name,
self._kid_name,
parent_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to approve reward '%s' for kid '%s': %s",
self._reward_name,
self._kid_name,
e,
)
# Send a persistent notification for the error
if user_id:
self.hass.components.persistent_notification.create(
f"Failed to approve reward '{self._reward_name}' for {self._kid_name}: {e}",
title="Reward Approval Failed",
notification_id=f"approve_reward_error_{self._reward_id}",
)
except Exception as e:
LOGGER.error(
"Failed to approve reward '%s' for kid '%s': %s",
self._reward_name,
self._kid_name,
e,
)
# Send a persistent notification for the unexpected error
if user_id:
self.hass.components.persistent_notification.create(
f"An unexpected error occurred while approving reward '{self._reward_name}' for {self._kid_name}",
title="Reward Approval Error",
notification_id=f"approve_reward_unexpected_error_{self._reward_id}",
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
reward_info = self.coordinator.rewards_data.get(self._reward_id, {})
stored_labels = reward_info.get("reward_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
class DisapproveRewardButton(CoordinatorEntity, ButtonEntity):
"""Button to disapprove a reward."""
_attr_has_entity_name = True
_attr_translation_key = "disapprove_reward_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
reward_id: str,
reward_name: str,
icon: str = DEFAULT_DISAPPROVE_ICON,
):
"""Initialize the disapprove reward button."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._reward_id = reward_id
self._reward_name = reward_name
self._attr_unique_id = (
f"{entry.entry_id}_{BUTTON_DISAPPROVE_REWARD_PREFIX}{kid_id}_{reward_id}"
)
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"reward_name": reward_name,
}
self.entity_id = f"button.kc_{kid_name}_reward_disapproval_{reward_name}"
async def async_press(self):
"""Handle the button press event."""
try:
# Check if there's a pending approval for this kid and reward.
pending_approvals = self.coordinator._data.get(
DATA_PENDING_REWARD_APPROVALS, []
)
if not any(
approval["kid_id"] == self._kid_id
and approval["reward_id"] == self._reward_id
for approval in pending_approvals
):
raise HomeAssistantError(
f"No pending approval found for reward '{self._reward_name}' for kid '{self._kid_name}'."
)
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_global_action(
self.hass, user_id, "disapprove_reward"
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("disapprove rewards")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
parent_name = user_obj.name if user_obj else "ParentOrAdmin"
self.coordinator.disapprove_reward(
parent_name=parent_name,
kid_id=self._kid_id,
reward_id=self._reward_id,
)
LOGGER.info(
"Reward '%s' disapproved for kid '%s' by parent '%s'",
self._reward_name,
self._kid_name,
parent_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to disapprove reward '%s' for kid '%s': %s",
self._reward_name,
self._kid_name,
e,
)
except Exception as e:
LOGGER.error(
"Failed to disapprove reward '%s' for kid '%s': %s",
self._reward_name,
self._kid_name,
e,
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
reward_info = self.coordinator.rewards_data.get(self._reward_id, {})
stored_labels = reward_info.get("reward_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
# ------------------ Penalty Button ------------------
class PenaltyButton(CoordinatorEntity, ButtonEntity):
"""Button to apply a penalty for a kid.
Uses user-defined or default penalty icon.
"""
_attr_has_entity_name = True
_attr_translation_key = "penalty_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
penalty_id: str,
penalty_name: str,
icon: str,
):
"""Initialize the penalty button."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._penalty_id = penalty_id
self._penalty_name = penalty_name
self._attr_unique_id = (
f"{entry.entry_id}_{BUTTON_PENALTY_PREFIX}{kid_id}_{penalty_id}"
)
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"penalty_name": penalty_name,
}
self.entity_id = f"button.kc_{kid_name}_penalty_{penalty_name}"
async def async_press(self):
"""Handle the button press event."""
try:
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_global_action(
self.hass, user_id, "apply_penalty"
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("apply penalties")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
parent_name = user_obj.name if user_obj else "Unknown"
self.coordinator.apply_penalty(
parent_name=parent_name,
kid_id=self._kid_id,
penalty_id=self._penalty_id,
)
LOGGER.info(
"Penalty '%s' applied to kid '%s' by '%s'",
self._penalty_name,
self._kid_name,
parent_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to apply penalty '%s' for kid '%s': %s",
self._penalty_name,
self._kid_name,
e,
)
except Exception as e:
LOGGER.error(
"Failed to apply penalty '%s' for kid '%s': %s",
self._penalty_name,
self._kid_name,
e,
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
penalty_info = self.coordinator.penalties_data.get(self._penalty_id, {})
stored_labels = penalty_info.get("penalty_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
# ------------------ Points Adjust Button ------------------
class PointsAdjustButton(CoordinatorEntity, ButtonEntity):
"""Button that increments or decrements a kid's points by 'delta'.
For example: +1, -1, +10, -10, etc.
Uses icons from const.py for plus/minus, or fallback if desired.
"""
_attr_has_entity_name = True
_attr_translation_key = "manual_adjustment_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
delta: int,
points_label: str,
):
"""Initialize the points adjust buttons."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._delta = delta
self._points_label = str(points_label)
sign_label = f"+{delta}" if delta >= 0 else f"-{delta}"
sign_text = f"plus_{delta}" if delta >= 0 else f"minus_{delta}"
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_adjust_points_{delta}"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"sign_label": sign_label,
"points_label": points_label,
}
self.entity_id = f"button.kc_{kid_name}_{sign_text}_points"
# Decide the icon based on whether delta is positive or negative
if delta >= 2:
self._attr_icon = DEFAULT_POINTS_ADJUST_PLUS_MULTIPLE_ICON
elif delta > 0:
self._attr_icon = DEFAULT_POINTS_ADJUST_PLUS_ICON
elif delta <= -2:
self._attr_icon = DEFAULT_POINTS_ADJUST_MINUS_MULTIPLE_ICON
elif delta < 0:
self._attr_icon = DEFAULT_POINTS_ADJUST_MINUS_ICON
else:
self._attr_icon = DEFAULT_POINTS_ADJUST_PLUS_ICON
async def async_press(self):
"""Handle the button press event."""
try:
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_global_action(
self.hass, user_id, "adjust_points"
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("adjust points")
)
current_points = self.coordinator.kids_data[self._kid_id]["points"]
new_points = current_points + self._delta
self.coordinator.update_kid_points(
kid_id=self._kid_id,
new_points=new_points,
)
LOGGER.info(
"Adjusted points for kid '%s' by %d => total %d",
self._kid_name,
self._delta,
new_points,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to adjust points for kid '%s' by %d: %s",
self._kid_name,
self._delta,
e,
)
except Exception as e:
LOGGER.error(
"Failed to adjust points for kid '%s' by %d: %s",
self._kid_name,
self._delta,
e,
)
class BonusButton(CoordinatorEntity, ButtonEntity):
"""Button to apply a bonus for a kid.
Uses user-defined or default bonus icon.
"""
_attr_has_entity_name = True
_attr_translation_key = "bonus_button"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
bonus_id: str,
bonus_name: str,
icon: str,
):
"""Initialize the bonus button."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._bonus_id = bonus_id
self._bonus_name = bonus_name
self._attr_unique_id = (
f"{entry.entry_id}_{BUTTON_BONUS_PREFIX}{kid_id}_{bonus_id}"
)
self._attr_icon = icon
self._attr_translation_placeholders = {
"kid_name": kid_name,
"bonus_name": bonus_name,
}
self.entity_id = f"button.kc_{kid_name}_bonus_{bonus_name}"
async def async_press(self):
"""Handle the button press event."""
try:
user_id = self._context.user_id if self._context else None
if user_id and not await is_user_authorized_for_global_action(
self.hass, user_id, "apply_bonus"
):
raise HomeAssistantError(
ERROR_NOT_AUTHORIZED_ACTION_FMT.format("apply bonus")
)
user_obj = await self.hass.auth.async_get_user(user_id) if user_id else None
parent_name = user_obj.name if user_obj else "Unknown"
self.coordinator.apply_bonus(
parent_name=parent_name,
kid_id=self._kid_id,
bonus_id=self._bonus_id,
)
LOGGER.info(
"Bonus '%s' applied to kid '%s' by '%s'",
self._bonus_name,
self._kid_name,
parent_name,
)
await self.coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error(
"Authorization failed to apply bonus '%s' for kid '%s': %s",
self._bonus_name,
self._kid_name,
e,
)
except Exception as e:
LOGGER.error(
"Failed to apply bonus '%s' for kid '%s': %s",
self._bonus_name,
self._kid_name,
e,
)
@property
def extra_state_attributes(self):
"""Include extra state attributes for the button."""
bonus_info = self.coordinator.bonuses_data.get(self._bonus_id, {})
stored_labels = bonus_info.get("bonus_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_LABELS: friendly_labels,
}
return attributes
================================================
FILE: custom_components/kidschores/calendar.py
================================================
# File: calendar.py
import datetime
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.util import dt as dt_util
from .const import (
DOMAIN,
FREQUENCY_BIWEEKLY,
FREQUENCY_CUSTOM,
FREQUENCY_DAILY,
FREQUENCY_MONTHLY,
FREQUENCY_NONE,
FREQUENCY_WEEKLY,
LOGGER,
WEEKDAY_OPTIONS,
ATTR_KID_NAME,
)
# Map weekday integers (0=Monday, …) to e.g. "mon","tue","wed" in WEEKDAY_OPTIONS.
WEEKDAY_MAP = {i: key for i, key in enumerate(WEEKDAY_OPTIONS.keys())}
# For chores without a due_date, we generate up to 3 months
FOREVER_DURATION = datetime.timedelta(days=90)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
):
"""Set up the KidsChores calendar platform."""
try:
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
except KeyError:
LOGGER.error("Coordinator not found in hass.data for entry %s", entry.entry_id)
return
entities = []
for kid_id, kid_info in coordinator.kids_data.items():
kid_name = kid_info.get("name", f"Kid {kid_id}")
entities.append(KidsChoresCalendarEntity(coordinator, kid_id, kid_name, entry))
async_add_entities(entities)
class KidsChoresCalendarEntity(CalendarEntity):
"""Calendar entity representing a kid's combined chores + challenges."""
def __init__(self, coordinator, kid_id: str, kid_name: str, config_entry):
super().__init__()
self.coordinator = coordinator
self._kid_id = kid_id
self._kid_name = kid_name
self._config_entry = config_entry
self._attr_name = f"KidsChores Calendar: {kid_name}"
self._attr_unique_id = f"{config_entry.entry_id}_{kid_id}_calendar"
self.entity_id = f"calendar.kc_{kid_name}"
async def async_get_events(
self, hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime
) -> list[CalendarEvent]:
"""
Return CalendarEvent objects for:
- chores assigned to this kid
- challenges assigned to this kid
overlapping [start, end].
"""
local_tz = dt_util.get_time_zone(self.hass.config.time_zone)
if start.tzinfo is None:
start = start.replace(tzinfo=local_tz)
if end.tzinfo is None:
end = end.replace(tzinfo=local_tz)
events: list[CalendarEvent] = []
# 1) Generate chore events
for chore in self.coordinator.chores_data.values():
if self._kid_id in chore.get("assigned_kids", []):
events.extend(self._generate_events_for_chore(chore, start, end))
# 2) Generate challenge events
for challenge in self.coordinator.challenges_data.values():
if self._kid_id in challenge.get("assigned_kids", []):
evs = self._generate_events_for_challenge(challenge, start, end)
events.extend(evs)
return events
def _generate_events_for_chore(
self,
chore: dict,
window_start: datetime.datetime,
window_end: datetime.datetime,
) -> list[CalendarEvent]:
"""Same recurring-chores logic from earlier solutions."""
events: list[CalendarEvent] = []
summary = chore.get("name", "Unnamed Chore")
description = chore.get("description", "")
recurring = chore.get("recurring_frequency", FREQUENCY_NONE)
applicable_days = chore.get("applicable_days", [])
# Parse chore due_date if any
due_date_str = chore.get("due_date")
due_dt: datetime.datetime | None = None
if due_date_str:
dt_parsed = dt_util.parse_datetime(due_date_str)
if dt_parsed:
due_dt = dt_util.as_local(dt_parsed)
def is_midnight(dt_obj: datetime.datetime) -> bool:
return (dt_obj.hour, dt_obj.minute, dt_obj.second) == (0, 0, 0)
def overlaps(ev: CalendarEvent) -> bool:
"""Check if event overlaps [window_start, window_end]."""
sdt = ev.start
edt = ev.end
if isinstance(sdt, datetime.date) and not isinstance(
sdt, datetime.datetime
):
tz = dt_util.get_time_zone(self.hass.config.time_zone)
sdt = datetime.datetime.combine(sdt, datetime.time.min, tzinfo=tz)
if isinstance(edt, datetime.date) and not isinstance(
edt, datetime.datetime
):
tz = dt_util.get_time_zone(self.hass.config.time_zone)
edt = datetime.datetime.combine(edt, datetime.time.min, tzinfo=tz)
if not sdt or not edt:
return False
return (edt > window_start) and (sdt < window_end)
# --- Non-recurring chores ---
if recurring == FREQUENCY_NONE:
if due_dt:
# single event if in window
if window_start <= due_dt <= window_end:
if is_midnight(due_dt):
e = CalendarEvent(
summary=summary,
start=due_dt.date(),
end=due_dt.date() + datetime.timedelta(days=1),
description=description,
)
else:
e = CalendarEvent(
summary=summary,
start=due_dt,
end=due_dt + datetime.timedelta(hours=1),
description=description,
)
if overlaps(e):
events.append(e)
else:
# No due_date => possibly show on applicable_days for next 3 months
if applicable_days:
gen_start = window_start
gen_end = min(
window_end,
dt_util.as_local(datetime.datetime.now() + FOREVER_DURATION),
)
current = gen_start
while current <= gen_end:
if WEEKDAY_MAP[current.weekday()] in applicable_days:
e = CalendarEvent(
summary=summary,
start=current.date(),
end=current.date() + datetime.timedelta(days=1),
description=description,
)
if overlaps(e):
events.append(e)
current += datetime.timedelta(days=1)
return events
# --- Recurring chores with a due_date ---
if due_dt:
cutoff = min(due_dt, window_end)
if cutoff < window_start:
return events
if recurring == FREQUENCY_DAILY:
if window_start <= due_dt <= window_end:
if is_midnight(due_dt):
e = CalendarEvent(
summary=summary,
start=due_dt.date(),
end=due_dt.date() + datetime.timedelta(days=1),
description=description,
)
else:
e = CalendarEvent(
summary=summary,
start=due_dt,
end=due_dt + datetime.timedelta(hours=1),
description=description,
)
if overlaps(e):
events.append(e)
elif recurring == FREQUENCY_WEEKLY:
start_event = due_dt - datetime.timedelta(weeks=1)
end_event = due_dt
if start_event < window_end and end_event > window_start:
e = CalendarEvent(
summary=summary,
start=start_event.date(),
end=(end_event.date() + datetime.timedelta(days=1)),
description=description,
)
if overlaps(e):
events.append(e)
elif recurring == FREQUENCY_BIWEEKLY:
start_event = due_dt - datetime.timedelta(weeks=2)
end_event = due_dt
if start_event < window_end and end_event > window_start:
e = CalendarEvent(
summary=summary,
start=start_event.date(),
end=(end_event.date() + datetime.timedelta(days=1)),
description=description,
)
if overlaps(e):
events.append(e)
elif recurring == FREQUENCY_MONTHLY:
first_day = due_dt.replace(day=1)
if first_day < window_end and due_dt > window_start:
e = CalendarEvent(
summary=summary,
start=first_day.date(),
end=(due_dt.date() + datetime.timedelta(days=1)),
description=description,
)
if overlaps(e):
events.append(e)
elif recurring == FREQUENCY_CUSTOM:
interval = chore.get("custom_interval", 1)
unit = chore.get("custom_interval_unit", "days")
if unit == "days":
start_event = due_dt - datetime.timedelta(days=interval)
elif unit == "weeks":
start_event = due_dt - datetime.timedelta(weeks=interval)
elif unit == "months":
start_event = due_dt - datetime.timedelta(days=30 * interval)
else:
start_event = due_dt
if start_event < window_end and due_dt > window_start:
e = CalendarEvent(
summary=summary,
start=start_event.date(),
end=(due_dt.date() + datetime.timedelta(days=1)),
description=description,
)
if overlaps(e):
events.append(e)
return events
# --- Recurring chores without a due_date => next 3 months
gen_start = window_start
future_limit = dt_util.as_local(datetime.datetime.now() + FOREVER_DURATION)
cutoff = min(window_end, future_limit)
if recurring == FREQUENCY_DAILY:
current = gen_start
while current <= cutoff:
if (
applicable_days
and WEEKDAY_MAP[current.weekday()] not in applicable_days
):
current += datetime.timedelta(days=1)
continue
e = CalendarEvent(
summary=summary,
start=current.date(),
end=current.date() + datetime.timedelta(days=1),
description=description,
)
if overlaps(e):
events.append(e)
current += datetime.timedelta(days=1)
return events
if recurring in (FREQUENCY_WEEKLY, FREQUENCY_BIWEEKLY):
week_delta = 7 if recurring == FREQUENCY_WEEKLY else 14
current = gen_start
# align to Monday
while current.weekday() != 0:
current += datetime.timedelta(days=1)
while current <= cutoff:
# multi-day block from Monday..Sunday (or 2 weeks for biweekly)
block_days = 6 if recurring == FREQUENCY_WEEKLY else 13
start_block = current
end_block = current + datetime.timedelta(days=block_days)
e = CalendarEvent(
summary=summary,
start=start_block.date(),
end=end_block.date() + datetime.timedelta(days=1),
description=description,
)
if overlaps(e):
events.append(e)
current += datetime.timedelta(days=week_delta)
return events
if recurring == FREQUENCY_MONTHLY:
cur = gen_start
while cur <= cutoff:
first_day = cur.replace(day=1)
next_month = first_day + datetime.timedelta(days=32)
next_month = next_month.replace(day=1)
last_day = next_month - datetime.timedelta(days=1)
e = CalendarEvent(
summary=summary,
start=first_day.date(),
end=last_day.date() + datetime.timedelta(days=1),
description=description,
)
if overlaps(e):
events.append(e)
cur = next_month
return events
if recurring == FREQUENCY_CUSTOM:
interval = chore.get("custom_interval", 1)
unit = chore.get("custom_interval_unit", "days")
if unit == "days":
step = datetime.timedelta(days=interval)
elif unit == "weeks":
step = datetime.timedelta(weeks=interval)
elif unit == "months":
step = datetime.timedelta(days=30 * interval)
else:
step = datetime.timedelta(days=interval)
current = gen_start
while current <= cutoff:
# Check applicable days
if (
applicable_days
and WEEKDAY_MAP[current.weekday()] not in applicable_days
):
current += step
continue
e = CalendarEvent(
summary=summary,
start=current.date(),
end=current.date() + step,
description=description,
)
if overlaps(e):
events.append(e)
current += step
return events
return events
def _generate_events_for_challenge(
self,
challenge: dict,
window_start: datetime.datetime,
window_end: datetime.datetime,
) -> list[CalendarEvent]:
"""
Produce a single multi-day event for each challenge that has valid start_date/end_date.
Only if it overlaps the requested [window_start, window_end].
"""
events: list[CalendarEvent] = []
challenge_name = challenge.get("name", "Unnamed Challenge")
description = challenge.get("description", "")
start_str = challenge.get("start_date")
end_str = challenge.get("end_date")
if not start_str or not end_str:
return events # no valid date range => skip
start_dt = dt_util.parse_datetime(start_str)
end_dt = dt_util.parse_datetime(end_str)
if not start_dt or not end_dt:
return events # parsing failed => skip
# Convert to local
local_start = dt_util.as_local(start_dt)
local_end = dt_util.as_local(end_dt)
# If the challenge times are midnight-based, we can treat them as all-day.
# But let's keep it simpler => always treat as an all-day block from date(start) to date(end)+1
# so the user sees a big multi-day block.
if local_start > window_end or local_end < window_start:
return events # out of range
# Build an all-day event from local_start.date() to local_end.date() + 1 day
ev = CalendarEvent(
summary=f"Challenge: {challenge_name}",
start=local_start.date(),
end=local_end.date() + datetime.timedelta(days=1),
description=description,
)
# Overlap check (similar logic):
def overlaps(e: CalendarEvent) -> bool:
sdt = e.start
edt = e.end
# convert if needed
tz = dt_util.get_time_zone(self.hass.config.time_zone)
if isinstance(sdt, datetime.date) and not isinstance(
sdt, datetime.datetime
):
sdt = datetime.datetime.combine(sdt, datetime.time.min, tzinfo=tz)
if isinstance(edt, datetime.date) and not isinstance(
edt, datetime.datetime
):
edt = datetime.datetime.combine(edt, datetime.time.min, tzinfo=tz)
return bool(sdt and edt and (edt > window_start) and (sdt < window_end))
if overlaps(ev):
events.append(ev)
return events
@property
def event(self) -> CalendarEvent | None:
"""
Return a single "current" event (chore or challenge) if one is active now (±1h).
Otherwise None.
"""
now = dt_util.as_local(datetime.datetime.utcnow())
window_start = now - datetime.timedelta(hours=1)
window_end = now + datetime.timedelta(hours=1)
all_events = self._generate_all_events(window_start, window_end)
for e in all_events:
# Convert date->datetime for comparison
tz = dt_util.get_time_zone(self.hass.config.time_zone)
sdt = e.start
edt = e.end
if isinstance(sdt, datetime.date) and not isinstance(
sdt, datetime.datetime
):
sdt = datetime.datetime.combine(sdt, datetime.time.min, tzinfo=tz)
if isinstance(edt, datetime.date) and not isinstance(
edt, datetime.datetime
):
edt = datetime.datetime.combine(edt, datetime.time.min, tzinfo=tz)
if sdt and edt and sdt <= now < edt:
return e
return None
def _generate_all_events(
self, window_start: datetime.datetime, window_end: datetime.datetime
) -> list[CalendarEvent]:
"""Generate chores + challenges for this kid in the given window."""
events = []
# chores
for chore in self.coordinator.chores_data.values():
if self._kid_id in chore.get("assigned_kids", []):
events.extend(
self._generate_events_for_chore(chore, window_start, window_end)
)
# challenges
for challenge in self.coordinator.challenges_data.values():
if self._kid_id in challenge.get("assigned_kids", []):
events.extend(
self._generate_events_for_challenge(
challenge, window_start, window_end
)
)
return events
@property
def extra_state_attributes(self):
return {ATTR_KID_NAME: self._kid_name}
================================================
FILE: custom_components/kidschores/config_flow.py
================================================
# File: config_flow.py
"""Multi-step config flow for the KidsChores integration, storing entities by internal_id.
Ensures that all add/edit/delete operations reference entities via internal_id for consistency.
"""
import datetime
import uuid
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from typing import Any, Optional
from .const import (
ACHIEVEMENT_TYPE_STREAK,
CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW,
CONF_APPLICABLE_DAYS,
CONF_ACHIEVEMENTS,
CONF_BADGES,
CONF_CHALLENGES,
CONF_CHORES,
CONF_KIDS,
CONF_NOTIFY_ON_APPROVAL,
CONF_NOTIFY_ON_CLAIM,
CONF_NOTIFY_ON_DISAPPROVAL,
CONF_PARENTS,
CONF_PENALTIES,
CONF_POINTS_ICON,
CONF_POINTS_LABEL,
CONF_REWARDS,
CONF_BONUSES,
DEFAULT_APPLICABLE_DAYS,
DEFAULT_NOTIFY_ON_APPROVAL,
DEFAULT_NOTIFY_ON_CLAIM,
DEFAULT_NOTIFY_ON_DISAPPROVAL,
DEFAULT_POINTS_ICON,
DEFAULT_POINTS_LABEL,
FREQUENCY_CUSTOM,
DOMAIN,
LOGGER,
)
from .flow_helpers import (
build_points_schema,
build_kid_schema,
build_parent_schema,
build_chore_schema,
build_badge_schema,
build_reward_schema,
build_penalty_schema,
build_achievement_schema,
build_challenge_schema,
ensure_utc_datetime,
build_bonus_schema,
)
class KidsChoresConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config Flow for KidsChores with internal_id-based entity management."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._data: dict[str, Any] = {}
self._kids_temp: dict[str, dict[str, Any]] = {}
self._parents_temp: dict[str, dict[str, Any]] = {}
self._chores_temp: dict[str, dict[str, Any]] = {}
self._badges_temp: dict[str, dict[str, Any]] = {}
self._rewards_temp: dict[str, dict[str, Any]] = {}
self._achievements_temp: dict[str, dict[str, Any]] = {}
self._challenges_temp: dict[str, dict[str, Any]] = {}
self._penalties_temp: dict[str, dict[str, Any]] = {}
self._bonuses_temp: dict[str, dict[str, Any]] = {}
self._kid_count: int = 0
self._parents_count: int = 0
self._chore_count: int = 0
self._badge_count: int = 0
self._reward_count: int = 0
self._achievement_count: int = 0
self._challenge_count: int = 0
self._penalty_count: int = 0
self._bonus_count: int = 0
self._kid_index: int = 0
self._parents_index: int = 0
self._chore_index: int = 0
self._badge_index: int = 0
self._reward_index: int = 0
self._achievement_index: int = 0
self._challenge_index: int = 0
self._penalty_index: int = 0
self._bonus_index: int = 0
async def async_step_user(self, user_input: Optional[dict[str, Any]] = None):
"""Start the config flow with an intro step."""
# Check if there's an existing KidsChores entry
if any(self._async_current_entries()):
return self.async_abort(reason="single_instance_allowed")
# Continue your normal flow
return await self.async_step_intro()
async def async_step_intro(self, user_input=None):
"""Intro / welcome step. Press Next to continue."""
if user_input is not None:
return await self.async_step_points_label()
return self.async_show_form(step_id="intro", data_schema=vol.Schema({}))
async def async_step_points_label(self, user_input=None):
"""Let the user define a custom label for points."""
errors = {}
if user_input is not None:
points_label = user_input.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL)
points_icon = user_input.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON)
self._data[CONF_POINTS_LABEL] = points_label
self._data[CONF_POINTS_ICON] = points_icon
return await self.async_step_kid_count()
points_schema = build_points_schema(
default_label=DEFAULT_POINTS_LABEL, default_icon=DEFAULT_POINTS_ICON
)
return self.async_show_form(
step_id="points_label", data_schema=points_schema, errors=errors
)
# --------------------------------------------------------------------------
# KIDS
# --------------------------------------------------------------------------
async def async_step_kid_count(self, user_input=None):
"""Ask how many kids to define initially."""
errors = {}
if user_input is not None:
try:
self._kid_count = int(user_input["kid_count"])
if self._kid_count < 0:
raise ValueError
if self._kid_count == 0:
return await self.async_step_chore_count()
self._kid_index = 0
return await self.async_step_kids()
except ValueError:
errors["base"] = "invalid_kid_count"
schema = vol.Schema({vol.Required("kid_count", default=1): vol.Coerce(int)})
return self.async_show_form(
step_id="kid_count", data_schema=schema, errors=errors
)
async def async_step_kids(self, user_input=None):
"""Collect each kid's info using internal_id as the primary key.
Store in self._kids_temp as a dict keyed by internal_id.
"""
errors = {}
if user_input is not None:
kid_name = user_input["kid_name"].strip()
ha_user_id = user_input.get("ha_user") or ""
enable_mobile_notifications = user_input.get(
"enable_mobile_notifications", True
)
notify_service = user_input.get("mobile_notify_service") or ""
enable_persist = user_input.get("enable_persistent_notifications", True)
if not kid_name:
errors["kid_name"] = "invalid_kid_name"
elif any(
kid_data["name"] == kid_name for kid_data in self._kids_temp.values()
):
errors["kid_name"] = "duplicate_kid"
else:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
self._kids_temp[internal_id] = {
"name": kid_name,
"ha_user_id": ha_user_id,
"enable_notifications": enable_mobile_notifications,
"mobile_notify_service": notify_service,
"use_persistent_notifications": enable_persist,
"internal_id": internal_id,
}
LOGGER.debug("Added kid: %s with ID: %s", kid_name, internal_id)
self._kid_index += 1
if self._kid_index >= self._kid_count:
return await self.async_step_parent_count()
return await self.async_step_kids()
# Retrieve HA users for linking
users = await self.hass.auth.async_get_users()
kid_schema = build_kid_schema(
self.hass,
users=users,
default_kid_name="",
default_ha_user_id=None,
default_enable_mobile_notifications=False,
default_mobile_notify_service=None,
default_enable_persistent_notifications=False,
)
return self.async_show_form(
step_id="kids", data_schema=kid_schema, errors=errors
)
# --------------------------------------------------------------------------
# PARENTS
# --------------------------------------------------------------------------
async def async_step_parent_count(self, user_input=None):
"""Ask how many parents to define initially."""
errors = {}
if user_input is not None:
try:
self._parents_count = int(user_input["parent_count"])
if self._parents_count < 0:
raise ValueError
if self._parents_count == 0:
return await self.async_step_chore_count()
self._parents_index = 0
return await self.async_step_parents()
except ValueError:
errors["base"] = "invalid_parent_count"
schema = vol.Schema({vol.Required("parent_count", default=1): vol.Coerce(int)})
return self.async_show_form(
step_id="parent_count", data_schema=schema, errors=errors
)
async def async_step_parents(self, user_input=None):
"""Collect each parent's info using internal_id as the primary key.
Store in self._parents_temp as a dict keyed by internal_id.
"""
errors = {}
if user_input is not None:
parent_name = user_input["parent_name"].strip()
ha_user_id = user_input.get("ha_user_id") or ""
associated_kids = user_input.get("associated_kids", [])
enable_mobile_notifications = user_input.get(
"enable_mobile_notifications", True
)
notify_service = user_input.get("mobile_notify_service") or ""
enable_persist = user_input.get("enable_persistent_notifications", True)
if not parent_name:
errors["parent_name"] = "invalid_parent_name"
elif any(
parent_data["name"] == parent_name
for parent_data in self._parents_temp.values()
):
errors["parent_name"] = "duplicate_parent"
else:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
self._parents_temp[internal_id] = {
"name": parent_name,
"ha_user_id": ha_user_id,
"associated_kids": associated_kids,
"enable_notifications": enable_mobile_notifications,
"mobile_notify_service": notify_service,
"use_persistent_notifications": enable_persist,
"internal_id": internal_id,
}
LOGGER.debug("Added parent: %s with ID: %s", parent_name, internal_id)
self._parents_index += 1
if self._parents_index >= self._parents_count:
return await self.async_step_chore_count()
return await self.async_step_parents()
# Retrieve kids for association from _kids_temp
kids_dict = {
kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items()
}
users = await self.hass.auth.async_get_users()
parent_schema = build_parent_schema(
self.hass,
users=users,
kids_dict=kids_dict,
default_parent_name="",
default_ha_user_id=None,
default_associated_kids=[],
default_enable_mobile_notifications=False,
default_mobile_notify_service=None,
default_enable_persistent_notifications=False,
internal_id=None,
)
return self.async_show_form(
step_id="parents", data_schema=parent_schema, errors=errors
)
# --------------------------------------------------------------------------
# CHORES
# --------------------------------------------------------------------------
async def async_step_chore_count(self, user_input=None):
"""Ask how many chores to define."""
errors = {}
if user_input is not None:
try:
self._chore_count = int(user_input["chore_count"])
if self._chore_count < 0:
raise ValueError
if self._chore_count == 0:
return await self.async_step_badge_count()
self._chore_index = 0
return await self.async_step_chores()
except ValueError:
errors["base"] = "invalid_chore_count"
schema = vol.Schema({vol.Required("chore_count", default=1): vol.Coerce(int)})
return self.async_show_form(
step_id="chore_count", data_schema=schema, errors=errors
)
async def async_step_chores(self, user_input=None):
"""Collect chore details using internal_id as the primary key.
Store in self._chores_temp as a dict keyed by internal_id.
"""
errors = {}
if user_input is not None:
chore_name = user_input["chore_name"].strip()
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if user_input.get("due_date"):
raw_due = user_input["due_date"]
try:
due_date_str = ensure_utc_datetime(self.hass, raw_due)
due_dt = dt_util.parse_datetime(due_date_str)
if due_dt and due_dt < dt_util.utcnow():
errors["due_date"] = "due_date_in_past"
except ValueError:
errors["due_date"] = "invalid_due_date"
due_date_str = None
else:
due_date_str = None
if not chore_name:
errors["chore_name"] = "invalid_chore_name"
elif any(
chore_data["name"] == chore_name
for chore_data in self._chores_temp.values()
):
errors["chore_name"] = "duplicate_chore"
if errors:
kids_dict = {
kid_data["name"]: kid_id
for kid_id, kid_data in self._kids_temp.items()
}
# Re-show the form with the user's current input and errors:
default_data = user_input.copy()
return self.async_show_form(
step_id="chores",
data_schema=build_chore_schema(kids_dict, default_data),
errors=errors,
)
if user_input.get("recurring_frequency") != FREQUENCY_CUSTOM:
user_input.pop("custom_interval", None)
user_input.pop("custom_interval_unit", None)
# If no errors, store the chore
self._chores_temp[internal_id] = {
"name": chore_name,
"default_points": user_input["default_points"],
"partial_allowed": user_input["partial_allowed"],
"shared_chore": user_input["shared_chore"],
"assigned_kids": user_input["assigned_kids"],
"allow_multiple_claims_per_day": user_input[
"allow_multiple_claims_per_day"
],
"description": user_input.get("chore_description", ""),
"chore_labels": user_input.get("chore_labels", []),
"icon": user_input.get("icon", ""),
"recurring_frequency": user_input.get("recurring_frequency", "none"),
"custom_interval": user_input.get("custom_interval"),
"custom_interval_unit": user_input.get("custom_interval_unit"),
"due_date": due_date_str,
"applicable_days": user_input.get(
CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS
),
"notify_on_claim": user_input.get(
CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM
),
"notify_on_approval": user_input.get(
CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL
),
"notify_on_disapproval": user_input.get(
CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL
),
"internal_id": internal_id,
}
LOGGER.debug("Added chore: %s with ID: %s", chore_name, internal_id)
self._chore_index += 1
if self._chore_index >= self._chore_count:
return await self.async_step_badge_count()
return await self.async_step_chores()
# Use flow_helpers.build_chore_schema, passing the current kids
kids_dict = {
kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items()
}
default_data = {}
chore_schema = build_chore_schema(kids_dict, default_data)
return self.async_show_form(
step_id="chores", data_schema=chore_schema, errors=errors
)
# --------------------------------------------------------------------------
# BADGES
# --------------------------------------------------------------------------
async def async_step_badge_count(self, user_input=None):
"""Ask how many badges to define."""
errors = {}
if user_input is not None:
try:
self._badge_count = int(user_input["badge_count"])
if self._badge_count < 0:
raise ValueError
if self._badge_count == 0:
return await self.async_step_reward_count()
self._badge_index = 0
return await self.async_step_badges()
except ValueError:
errors["base"] = "invalid_badge_count"
schema = vol.Schema({vol.Required("badge_count", default=0): vol.Coerce(int)})
return self.async_show_form(
step_id="badge_count", data_schema=schema, errors=errors
)
async def async_step_badges(self, user_input=None):
"""Collect badge details using internal_id as the primary key.
Store in self._badges_temp as a dict keyed by internal_id.
"""
errors = {}
if user_input is not None:
badge_name = user_input["badge_name"].strip()
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if not badge_name:
errors["badge_name"] = "invalid_badge_name"
elif any(
badge_data["name"] == badge_name
for badge_data in self._badges_temp.values()
):
errors["badge_name"] = "duplicate_badge"
else:
self._badges_temp[internal_id] = {
"name": badge_name,
"threshold_type": user_input["threshold_type"],
"threshold_value": user_input["threshold_value"],
"points_multiplier": user_input["points_multiplier"],
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
"description": user_input.get("badge_description", ""),
"badge_labels": user_input.get("badge_labels", []),
}
LOGGER.debug("Added badge: %s with ID: %s", badge_name, internal_id)
self._badge_index += 1
if self._badge_index >= self._badge_count:
return await self.async_step_reward_count()
return await self.async_step_badges()
badge_schema = build_badge_schema()
return self.async_show_form(
step_id="badges", data_schema=badge_schema, errors=errors
)
# --------------------------------------------------------------------------
# REWARDS
# --------------------------------------------------------------------------
async def async_step_reward_count(self, user_input=None):
"""Ask how many rewards to define."""
errors = {}
if user_input is not None:
try:
self._reward_count = int(user_input["reward_count"])
if self._reward_count < 0:
raise ValueError
if self._reward_count == 0:
return await self.async_step_penalty_count()
self._reward_index = 0
return await self.async_step_rewards()
except ValueError:
errors["base"] = "invalid_reward_count"
schema = vol.Schema({vol.Required("reward_count", default=0): vol.Coerce(int)})
return self.async_show_form(
step_id="reward_count", data_schema=schema, errors=errors
)
async def async_step_rewards(self, user_input=None):
"""Collect reward details using internal_id as the primary key.
Store in self._rewards_temp as a dict keyed by internal_id.
"""
errors = {}
if user_input is not None:
reward_name = user_input["reward_name"].strip()
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if not reward_name:
errors["reward_name"] = "invalid_reward_name"
elif any(
reward_data["name"] == reward_name
for reward_data in self._rewards_temp.values()
):
errors["reward_name"] = "duplicate_reward"
else:
self._rewards_temp[internal_id] = {
"name": reward_name,
"cost": user_input["reward_cost"],
"description": user_input.get("reward_description", ""),
"reward_labels": user_input.get("reward_labels", []),
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
}
LOGGER.debug("Added reward: %s with ID: %s", reward_name, internal_id)
self._reward_index += 1
if self._reward_index >= self._reward_count:
return await self.async_step_penalty_count()
return await self.async_step_rewards()
reward_schema = build_reward_schema()
return self.async_show_form(
step_id="rewards", data_schema=reward_schema, errors=errors
)
# --------------------------------------------------------------------------
# PENALTIES
# --------------------------------------------------------------------------
async def async_step_penalty_count(self, user_input=None):
"""Ask how many penalties to define."""
errors = {}
if user_input is not None:
try:
self._penalty_count = int(user_input["penalty_count"])
if self._penalty_count < 0:
raise ValueError
if self._penalty_count == 0:
return await self.async_step_bonus_count()
self._penalty_index = 0
return await self.async_step_penalties()
except ValueError:
errors["base"] = "invalid_penalty_count"
schema = vol.Schema({vol.Required("penalty_count", default=0): vol.Coerce(int)})
return self.async_show_form(
step_id="penalty_count", data_schema=schema, errors=errors
)
async def async_step_penalties(self, user_input=None):
"""Collect penalty details using internal_id as the primary key.
Store in self._penalties_temp as a dict keyed by internal_id.
"""
errors = {}
if user_input is not None:
penalty_name = user_input["penalty_name"].strip()
penalty_points = user_input["penalty_points"]
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if not penalty_name:
errors["penalty_name"] = "invalid_penalty_name"
elif any(
penalty_data["name"] == penalty_name
for penalty_data in self._penalties_temp.values()
):
errors["penalty_name"] = "duplicate_penalty"
else:
self._penalties_temp[internal_id] = {
"name": penalty_name,
"description": user_input.get("penalty_description", ""),
"penalty_labels": user_input.get("penalty_labels", []),
"points": -abs(penalty_points), # Ensure points are negative
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
}
LOGGER.debug("Added penalty: %s with ID: %s", penalty_name, internal_id)
self._penalty_index += 1
if self._penalty_index >= self._penalty_count:
return await self.async_step_bonus_count()
return await self.async_step_penalties()
penalty_schema = build_penalty_schema()
return self.async_show_form(
step_id="penalties", data_schema=penalty_schema, errors=errors
)
# --------------------------------------------------------------------------
# BONUSES
# --------------------------------------------------------------------------
async def async_step_bonus_count(self, user_input=None):
"""Ask how many bonuses to define."""
errors = {}
if user_input is not None:
try:
self._bonus_count = int(user_input["bonus_count"])
if self._bonus_count < 0:
raise ValueError
if self._bonus_count == 0:
return await self.async_step_achievement_count()
self._bonus_index = 0
return await self.async_step_bonuses()
except ValueError:
errors["base"] = "invalid_bonus_count"
schema = vol.Schema({vol.Required("bonus_count", default=0): vol.Coerce(int)})
return self.async_show_form(
step_id="bonus_count", data_schema=schema, errors=errors
)
async def async_step_bonuses(self, user_input=None):
"""Collect bonus details using internal_id as the primary key.
Store in self._bonuses_temp as a dict keyed by internal_id.
"""
errors = {}
if user_input is not None:
bonus_name = user_input["bonus_name"].strip()
bonus_points = user_input["bonus_points"]
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if not bonus_name:
errors["bonus_name"] = "invalid_bonus_name"
elif any(
bonus_data["name"] == bonus_name
for bonus_data in self._bonuses_temp.values()
):
errors["bonus_name"] = "duplicate_bonus"
else:
self._bonuses_temp[internal_id] = {
"name": bonus_name,
"description": user_input.get("bonus_description", ""),
"bonus_labels": user_input.get("bonus_labels", []),
"points": abs(bonus_points), # Ensure points are positive
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
}
LOGGER.debug("Added bonus '%s' with ID: %s", bonus_name, internal_id)
self._bonus_index += 1
if self._bonus_index >= self._bonus_count:
return await self.async_step_achievement_count()
return await self.async_step_bonuses()
schema = build_bonus_schema()
return self.async_show_form(
step_id="bonuses", data_schema=schema, errors=errors
)
# --------------------------------------------------------------------------
# ACHIEVEMENTS
# --------------------------------------------------------------------------
async def async_step_achievement_count(self, user_input=None):
"""Ask how many achievements to define initially."""
errors = {}
if user_input is not None:
try:
self._achievement_count = int(user_input["achievement_count"])
if self._achievement_count < 0:
raise ValueError
if self._achievement_count == 0:
return await self.async_step_challenge_count()
self._achievement_index = 0
return await self.async_step_achievements()
except ValueError:
errors["base"] = "invalid_achievement_count"
schema = vol.Schema(
{vol.Required("achievement_count", default=0): vol.Coerce(int)}
)
return self.async_show_form(
step_id="achievement_count", data_schema=schema, errors=errors
)
async def async_step_achievements(self, user_input=None):
"""Collect each achievement's details using internal_id as the key."""
errors = {}
if user_input is not None:
achievement_name = user_input["name"].strip()
if not achievement_name:
errors["name"] = "invalid_achievement_name"
elif any(
achievement_data["name"] == achievement_name
for achievement_data in self._achievements_temp.values()
):
errors["name"] = "duplicate_achievement"
else:
_type = user_input["type"]
if _type == ACHIEVEMENT_TYPE_STREAK:
chore_id = user_input.get("selected_chore_id")
if not chore_id or chore_id == "None":
errors["selected_chore_id"] = "a_chore_must_be_selected"
final_chore_id = chore_id
else:
# Discard chore if not streak
final_chore_id = ""
if not errors:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
self._achievements_temp[internal_id] = {
"name": achievement_name,
"description": user_input.get("description", ""),
"achievement_labels": user_input.get("achievement_labels", []),
"icon": user_input.get("icon", ""),
"assigned_kids": user_input["assigned_kids"],
"type": _type,
"selected_chore_id": final_chore_id,
"criteria": user_input.get("criteria", "").strip(),
"target_value": user_input["target_value"],
"reward_points": user_input["reward_points"],
"internal_id": internal_id,
"progress": {},
}
self._achievement_index += 1
if self._achievement_index >= self._achievement_count:
return await self.async_step_challenge_count()
return await self.async_step_achievements()
kids_dict = {
kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items()
}
all_chores = self._chores_temp
achievement_schema = build_achievement_schema(
kids_dict=kids_dict, chores_dict=all_chores, default=None
)
return self.async_show_form(
step_id="achievements", data_schema=achievement_schema, errors=errors
)
# --------------------------------------------------------------------------
# CHALLENGES
# --------------------------------------------------------------------------
async def async_step_challenge_count(self, user_input=None):
"""Ask how many challenges to define initially."""
errors = {}
if user_input is not None:
try:
self._challenge_count = int(user_input["challenge_count"])
if self._challenge_count < 0:
raise ValueError
if self._challenge_count == 0:
return await self.async_step_finish()
self._challenge_index = 0
return await self.async_step_challenges()
except ValueError:
errors["base"] = "invalid_challenge_count"
schema = vol.Schema(
{vol.Required("challenge_count", default=0): vol.Coerce(int)}
)
return self.async_show_form(
step_id="challenge_count", data_schema=schema, errors=errors
)
async def async_step_challenges(self, user_input=None):
"""Collect each challenge's details using internal_id as the key."""
errors = {}
if user_input is not None:
challenge_name = user_input["name"].strip()
if not challenge_name:
errors["name"] = "invalid_challenge_name"
elif any(
challenge_data["name"] == challenge_name
for challenge_data in self._challenges_temp.values()
):
errors["name"] = "duplicate_challenge"
else:
_type = user_input["type"]
if _type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
chosen_chore_id = user_input.get("selected_chore_id")
if not chosen_chore_id or chosen_chore_id == "None":
errors["selected_chore_id"] = "a_chore_must_be_selected"
final_chore_id = chosen_chore_id
else:
# Discard chore if not "CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW"
final_chore_id = ""
# Process start_date and end_date using the helper:
start_date_input = user_input.get("start_date")
end_date_input = user_input.get("end_date")
if start_date_input:
try:
start_date = ensure_utc_datetime(self.hass, start_date_input)
start_dt = dt_util.parse_datetime(start_date)
if start_dt and start_dt < dt_util.utcnow():
errors["start_date"] = "start_date_in_past"
except Exception:
errors["start_date"] = "invalid_start_date"
start_date = None
else:
start_date = None
if end_date_input:
try:
end_date = ensure_utc_datetime(self.hass, end_date_input)
end_dt = dt_util.parse_datetime(end_date)
if end_dt and end_dt <= dt_util.utcnow():
errors["end_date"] = "end_date_in_past"
if start_date:
# Compare start_dt and end_dt if both are valid
if end_dt and start_dt and end_dt <= start_dt:
errors["end_date"] = "end_date_not_after_start_date"
except Exception:
errors["end_date"] = "invalid_end_date"
end_date = None
else:
end_date = None
if not errors:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
self._challenges_temp[internal_id] = {
"name": challenge_name,
"description": user_input.get("description", ""),
"challenge_labels": user_input.get("challenge_labels", []),
"icon": user_input.get("icon", ""),
"assigned_kids": user_input["assigned_kids"],
"type": _type,
"selected_chore_id": final_chore_id,
"criteria": user_input.get("criteria", "").strip(),
"target_value": user_input["target_value"],
"reward_points": user_input["reward_points"],
"start_date": start_date,
"end_date": end_date,
"internal_id": internal_id,
"progress": {},
}
self._challenge_index += 1
if self._challenge_index >= self._challenge_count:
return await self.async_step_finish()
return await self.async_step_challenges()
kids_dict = {
kid_data["name"]: kid_id for kid_id, kid_data in self._kids_temp.items()
}
all_chores = self._chores_temp
default_data = user_input if user_input else None
challenge_schema = build_challenge_schema(
kids_dict=kids_dict,
chores_dict=all_chores,
default=default_data,
)
return self.async_show_form(
step_id="challenges", data_schema=challenge_schema, errors=errors
)
# --------------------------------------------------------------------------
# FINISH
# --------------------------------------------------------------------------
async def async_step_finish(self, user_input=None):
"""Finalize summary and create the config entry."""
if user_input is not None:
return self._create_entry()
# Create a mapping from kid_id to kid_name for easy lookup
kid_id_to_name = {
kid_id: data["name"] for kid_id, data in self._kids_temp.items()
}
# Enhance parents summary to include associated kids by name
parents_summary = []
for parent in self._parents_temp.values():
associated_kids_names = [
kid_id_to_name.get(kid_id, "Unknown")
for kid_id in parent.get("associated_kids", [])
]
if associated_kids_names:
kids_str = ", ".join(associated_kids_names)
parents_summary.append(f"{parent['name']} (Kids: {kids_str})")
else:
parents_summary.append(parent["name"])
summary = (
f"\nKids: {', '.join(kid_data['name'] for kid_data in self._kids_temp.values()) or 'None'}\n\n"
f"Parents: {', '.join(parents_summary) or 'None'}\n\n"
f"Chores: {', '.join(chore_data['name'] for chore_data in self._chores_temp.values()) or 'None'}\n\n"
f"Badges: {', '.join(badge_data['name'] for badge_data in self._badges_temp.values()) or 'None'}\n\n"
f"Rewards: {', '.join(reward_data['name'] for reward_data in self._rewards_temp.values()) or 'None'}\n\n"
f"Penalties: {', '.join(penalty_data['name'] for penalty_data in self._penalties_temp.values()) or 'None'}\n\n"
f"Bonuses: {', '.join(bonus_data['name'] for bonus_data in self._bonuses_temp.values()) or 'None'}\n\n"
f"Achievements: {', '.join(achievement_data['name'] for achievement_data in self._achievements_temp.values()) or 'None'}\n\n"
f"Challenges: {', '.join(challenge_data['name'] for challenge_data in self._challenges_temp.values()) or 'None'}\n\n"
)
return self.async_show_form(
step_id="finish",
data_schema=vol.Schema({}),
description_placeholders={"summary": summary},
)
def _create_entry(self):
"""Finalize config entry with data and options using internal_id as keys."""
entry_data = {}
entry_options = {
CONF_POINTS_LABEL: self._data.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL),
CONF_POINTS_ICON: self._data.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON),
CONF_KIDS: self._kids_temp,
CONF_PARENTS: self._parents_temp,
CONF_CHORES: self._chores_temp,
CONF_BADGES: self._badges_temp,
CONF_REWARDS: self._rewards_temp,
CONF_PENALTIES: self._penalties_temp,
CONF_BONUSES: self._bonuses_temp,
CONF_ACHIEVEMENTS: self._achievements_temp,
CONF_CHALLENGES: self._challenges_temp,
}
LOGGER.debug(
"Creating entry with data=%s, options=%s", entry_data, entry_options
)
return self.async_create_entry(
title="KidsChores", data=entry_data, options=entry_options
)
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Return the Options Flow."""
from .options_flow import KidsChoresOptionsFlowHandler
return KidsChoresOptionsFlowHandler(config_entry)
================================================
FILE: custom_components/kidschores/const.py
================================================
# File: const.py
"""Constants for the KidsChores integration.
This file centralizes configuration keys, defaults, labels, domain names,
event names, and platform identifiers for consistency across the integration.
It also supports localization by defining all labels and UI texts used in sensors,
services, and options flow.
"""
import logging
from homeassistant.const import Platform
# -------------------- General --------------------
# Integration Domain and Logging
DOMAIN = "kidschores"
LOGGER = logging.getLogger(__package__)
# Supported Platforms
PLATFORMS = [
Platform.BUTTON,
Platform.CALENDAR,
Platform.SELECT,
Platform.SENSOR,
]
# Storage and Versioning
STORAGE_KEY = "kidschores_data" # Persistent storage key
STORAGE_VERSION = 1 # Storage version
# Update Interval
UPDATE_INTERVAL = 5 # Update interval for coordinator (in minutes)
# -------------------- Configuration --------------------
# Configuration Keys
CONF_ACHIEVEMENTS = "achievements"
CONF_APPLICABLE_DAYS = "applicable_days"
CONF_BADGES = "badges" # Key for badges configuration
CONF_CHALLENGES = "challenges"
CONF_CHORES = "chores" # Key for chores configuration
CONF_GLOBAL = "global"
CONF_KIDS = "kids" # Key for kids configuration
CONF_PARENTS = "parents" # Key for parents configuration
CONF_PENALTIES = "penalties" # Key for penalties configuration
CONF_POINTS_ICON = "points_icon"
CONF_POINTS_LABEL = "points_label" # Custom label for points
CONF_REWARDS = "rewards" # Key for rewards configuration
CONF_BONUSES = "bonuses"
# Options Flow Management
OPTIONS_FLOW_ACHIEVEMENTS = "manage_achievements" # Edit achivements step
OPTIONS_FLOW_BADGES = "manage_badges" # Edit badges step
OPTIONS_FLOW_CHALLENGES = "manage_challenges" # Edit challenges step
OPTIONS_FLOW_CHORES = "manage_chores" # Edit chores step
OPTIONS_FLOW_KIDS = "manage_kids" # Edit kids step
OPTIONS_FLOW_PARENTS = "manage_parents" # Edit parents step
OPTIONS_FLOW_PENALTIES = "manage_penalties" # Edit penalties step
OPTIONS_FLOW_REWARDS = "manage_rewards" # Edit rewards step
OPTIONS_FLOW_BONUSES = "manage_bonuses" # Edit bonuses step
# Validation Keys
VALIDATION_DUE_DATE = "due_date" # Optional due date for chores
VALIDATION_PARTIAL_ALLOWED = "partial_allowed" # Allow partial points in chores
VALIDATION_THRESHOLD_TYPE = "threshold_type" # Badge criteria type
VALIDATION_THRESHOLD_VALUE = "threshold_value" # Badge criteria value
# Notification configuration keys
CONF_ENABLE_MOBILE_NOTIFICATIONS = "enable_mobile_notifications"
CONF_MOBILE_NOTIFY_SERVICE = "mobile_notify_service"
CONF_ENABLE_PERSISTENT_NOTIFICATIONS = "enable_persistent_notifications"
CONF_NOTIFY_ON_CLAIM = "notify_on_claim"
CONF_NOTIFY_ON_APPROVAL = "notify_on_approval"
CONF_NOTIFY_ON_DISAPPROVAL = "notify_on_disapproval"
CONF_CHORE_NOTIFY_SERVICE = "chore_notify_service"
NOTIFICATION_EVENT = "mobile_app_notification_action"
# Achievement types
ACHIEVEMENT_TYPE_STREAK = "chore_streak" # e.g., "Make bed 20 days in a row"
ACHIEVEMENT_TYPE_TOTAL = "chore_total" # e.g., "Complete 100 chores overall"
ACHIEVEMENT_TYPE_DAILY_MIN = (
"daily_minimum" # e.g., "Complete minimum 5 chores in one day"
)
# Challenge types
CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW = (
"total_within_window" # e.g., "Complete 50 chores in 30 days"
)
CHALLENGE_TYPE_DAILY_MIN = "daily_minimum" # e.g., "Do 2 chores each day for 14 days"
# -------------------- Defaults --------------------
# Default Icons
DEFAULT_ACHIEVEMENTS_ICON = "mdi:trophy-award" # Default icon for achievements
DEFAULT_BADGE_ICON = "mdi:shield-star-outline" # Default icon for badges
DEFAULT_CALENDAR_ICON = "mdi:calendar" # Default icon for calendar sensors
DEFAULT_CHALLENGES_ICON = "mdi:trophy" # Default icon for achievements
DEFAULT_CHORE_APPROVE_ICON = "mdi:checkbox-marked-circle-outline"
DEFAULT_CHORE_BINARY_ICON = (
"mdi:checkbox-blank-circle-outline" # For chore status binary sensor
)
DEFAULT_CHORE_CLAIM_ICON = "mdi:clipboard-check-outline"
DEFAULT_CHORE_SENSOR_ICON = (
"mdi:checkbox-blank-circle-outline" # For chore status sensor
)
DEFAULT_DISAPPROVE_ICON = (
"mdi:close-circle-outline" # Default icon for disapprove buttons
)
DEFAULT_ICON = "mdi:star-outline" # Default icon for general points display
DEFAULT_PENALTY_ICON = "mdi:alert-outline" # Default icon for penalties
DEFAULT_POINTS_ADJUST_MINUS_ICON = "mdi:minus-circle-outline"
DEFAULT_POINTS_ADJUST_PLUS_ICON = "mdi:plus-circle-outline"
DEFAULT_POINTS_ADJUST_MINUS_MULTIPLE_ICON = "mdi:minus-circle-multiple-outline"
DEFAULT_POINTS_ADJUST_PLUS_MULTIPLE_ICON = "mdi:plus-circle-multiple-outline"
DEFAULT_POINTS_ICON = "mdi:star-outline" # Default icon for points
DEFAULT_STREAK_ICON = "mdi:blur-linear" # Default icon for streaks
DEFAULT_BONUS_ICON = "mdi:seal" # Default icon for bonuses
DEFAULT_REWARD_ICON = "mdi:gift-outline" # Default icon for rewards
DEFAULT_TROPHY_ICON = "mdi:trophy" # For highest-badge sensor fallback
DEFAULT_TROPHY_OUTLINE = "mdi:trophy-outline"
# Default Values
DEFAULT_APPLICABLE_DAYS = [] # Empty means the chore applies every day.
DEFAULT_BADGE_THRESHOLD = 50 # Default points threshold for badges
DEFAULT_MULTIPLE_CLAIMS_PER_DAY = False # Allow only one chore claim per day
DEFAULT_PARTIAL_ALLOWED = False # Partial points not allowed by default
DEFAULT_POINTS = 5 # Default points awarded for each chore
DEFAULT_POINTS_MULTIPLIER = 1 # Default points multiplier for badges
DEFAULT_POINTS_LABEL = "Points" # Default label for points displayed in UI
DEFAULT_PENALTY_POINTS = 2 # Default points deducted for each penalty
DEFAULT_BONUS_POINTS = 2 # Default points added for each bonus
DEFAULT_REMINDER_DELAY = 30 # Default reminder delay in minutes
DEFAULT_REWARD_COST = 10 # Default cost for each reward
DEFAULT_DAILY_RESET_TIME = {
"hour": 0,
"minute": 0,
"second": 0,
} # Daily reset at midnight
DEFAULT_MONTHLY_RESET_DAY = 1 # Monthly reset on the 1st day
DEFAULT_WEEKLY_RESET_DAY = 0 # Weekly reset on Monday (0 = Monday, 6 = Sunday)
DEFAULT_NOTIFY_ON_CLAIM = True
DEFAULT_NOTIFY_ON_APPROVAL = True
DEFAULT_NOTIFY_ON_DISAPPROVAL = True
# -------------------- Recurring Frequencies --------------------
FREQUENCY_BIWEEKLY = "biweekly"
FREQUENCY_CUSTOM = "custom"
FREQUENCY_DAILY = "daily"
FREQUENCY_MONTHLY = "monthly"
FREQUENCY_NONE = "none"
FREQUENCY_WEEKLY = "weekly"
# -------------------- Data Keys --------------------
# Data Keys for Coordinator and Storage
DATA_ACHIEVEMENTS = "achievements" # Key for storing achievements data
DATA_BADGES = "badges" # Key for storing badges data
DATA_CHALLENGES = "challenges" # Key for storing challenges data
DATA_CHORES = "chores" # Key for storing chores data
DATA_KIDS = "kids" # Key for storing kids data in storage
DATA_PARENTS = "parents" # Key for storing parent data
DATA_PENDING_CHORE_APPROVALS = "pending_chore_approvals" # Pending chore approvals
DATA_PENDING_REWARD_APPROVALS = "pending_reward_approvals" # Pending reward approvals
DATA_PENALTIES = "penalties" # Key for storing penalties data
DATA_REWARDS = "rewards" # Key for storing rewards data
DATA_BONUSES = "bonuses" # Key for storing bonuses data
# -------------------- States --------------------
# Badge Threshold Types
BADGE_THRESHOLD_TYPE_CHORE_COUNT = (
"chore_count" # Badges for completing a number of chores
)
BADGE_THRESHOLD_TYPE_POINTS = "points" # Badges awarded for reaching points
# Chore States
CHORE_STATE_APPROVED = "approved" # Chore fully approved
CHORE_STATE_APPROVED_IN_PART = "approved_in_part" # Chore approved for some kids
CHORE_STATE_CLAIMED = "claimed" # Chore claimed by a kid
CHORE_STATE_CLAIMED_IN_PART = "claimed_in_part" # Chore claimed by some kids
CHORE_STATE_INDEPENDENT = "independent" # Chore is not shared
CHORE_STATE_OVERDUE = "overdue" # Chore not completed before the due date
CHORE_STATE_PARTIAL = "partial" # Chore approved with partial points
CHORE_STATE_PENDING = "pending" # Default state: chore pending approval
CHORE_STATE_UNKNOWN = "unknown" # Unknown chore state
# Reward States
REWARD_STATE_APPROVED = "approved" # Reward fully approved
REWARD_STATE_CLAIMED = "claimed" # Reward claimed by a kid
REWARD_STATE_NOT_CLAIMED = "not_claimed" # Default state: reward not claimed
REWARD_STATE_UNKNOWN = "unknown" # Unknown reward state
# -------------------- Events --------------------
# Event Names
EVENT_CHORE_COMPLETED = "kidschores_chore_completed" # Event for chore completion
EVENT_REWARD_REDEEMED = "kidschores_reward_redeemed" # Event for redeeming a reward
# -------------------- Actions --------------------
# Action titles for notifications
ACTION_TITLE_APPROVE = "Approve"
ACTION_TITLE_DISAPPROVE = "Disapprove"
ACTION_TITLE_REMIND_30 = "Remind in 30 mins"
# Action identifiers
ACTION_APPROVE_CHORE = "APPROVE_CHORE"
ACTION_DISAPPROVE_CHORE = "DISAPPROVE_CHORE"
ACTION_APPROVE_REWARD = "APPROVE_REWARD"
ACTION_DISAPPROVE_REWARD = "DISAPPROVE_REWARD"
ACTION_REMIND_30 = "REMIND_30"
# -------------------- Sensors --------------------
# Sensor Attributes
ATTR_ACHIEVEMENT_NAME = "achievement_name"
ATTR_ALL_EARNED_BADGES = "all_earned_badges"
ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY = "allow_multiple_claims_per_day"
ATTR_APPLICABLE_DAYS = "applicable_days"
ATTR_AWARDED = "awarded"
ATTR_ASSIGNED_KIDS = "assigned_kids"
ATTR_ASSOCIATED_CHORE = "associated_chore"
ATTR_BADGES = "badges"
ATTR_CHALLENGE_NAME = "challenge_name"
ATTR_CHALLENGE_TYPE = "challenge_type"
ATTR_CHORE_APPROVALS_COUNT = "chore_approvals_count"
ATTR_CHORE_APPROVALS_TODAY = "chore_approvals_today"
ATTR_CHORE_CLAIMS_COUNT = "chore_claims_count"
ATTR_CHORE_CURRENT_STREAK = "chore_current_streak"
ATTR_CHORE_HIGHEST_STREAK = "chore_highest_streak"
ATTR_CHORE_NAME = "chore_name"
ATTR_CLAIMED_ON = "Claimed on"
ATTR_COST = "cost"
ATTR_CRITERIA = "criteria"
ATTR_CUSTOM_FREQUENCY_INTERVAL = "custom_frequency_interval"
ATTR_CUSTOM_FREQUENCY_UNIT = "custom_frequency_unit"
ATTR_DEFAULT_POINTS = "default_points"
ATTR_DESCRIPTION = "description"
ATTR_DUE_DATE = "due_date"
ATTR_END_DATE = "end_date"
ATTR_GLOBAL_STATE = "global_state"
ATTR_HIGHEST_BADGE_THRESHOLD_VALUE = "highest_badge_threshold_value"
ATTR_KID_NAME = "kid_name"
ATTR_KID_STATE = "kid_state"
ATTR_LABELS = "labels"
ATTR_KIDS_EARNED = "kids_earned"
ATTR_LAST_DATE = "last_date"
ATTR_PARTIAL_ALLOWED = "partial_allowed"
ATTR_PENALTY_NAME = "penalty_name"
ATTR_PENALTY_POINTS = "penalty_points"
ATTR_POINTS_MULTIPLIER = "points_multiplier"
ATTR_POINTS_TO_NEXT_BADGE = "points_to_next_badge"
ATTR_RAW_PROGRESS = "raw_progress"
ATTR_RAW_STREAK = "raw_streak"
ATTR_RECURRING_FREQUENCY = "recurring_frequency"
ATTR_REDEEMED_ON = "Redeemed on"
ATTR_REWARD_APPROVALS_COUNT = "reward_approvals_count"
ATTR_REWARD_CLAIMS_COUNT = "reward_claims_count"
ATTR_REWARD_NAME = "reward_name"
ATTR_REWARD_POINTS = "reward_points"
ATTR_BONUS_NAME = "bonus_name"
ATTR_BONUS_POINTS = "bonus_points"
ATTR_START_DATE = "start_date"
ATTR_SHARED_CHORE = "shared_chore"
ATTR_TARGET_VALUE = "target_value"
ATTR_THRESHOLD_TYPE = "threshold_type"
ATTR_TYPE = "type"
# Calendar Attributes
ATTR_CAL_SUMMARY = "summary"
ATTR_CAL_START = "start"
ATTR_CAL_END = "end"
ATTR_CAL_ALL_DAY = "all_day"
ATTR_CAL_DESCRIPTION = "description"
ATTR_CAL_MANUFACTURER = "manufacturer"
# Sensor Types
SENSOR_TYPE_BADGES = "badges" # Sensor tracking earned badges
SENSOR_TYPE_CHORE_APPROVALS = "chore_approvals" # Chore approvals sensor
SENSOR_TYPE_CHORE_CLAIMS = "chore_claims" # Chore claims sensor
SENSOR_TYPE_COMPLETED_DAILY = (
"completed_daily" # Sensor tracking daily chores completed
)
SENSOR_TYPE_COMPLETED_MONTHLY = (
"completed_monthly" # Sensor tracking monthly chores completed
)
SENSOR_TYPE_COMPLETED_WEEKLY = (
"completed_weekly" # Sensor tracking weekly chores completed
)
SENSOR_TYPE_PENALTY_APPLIES = "penalty_applies" # Penalty applies sensor
SENSOR_TYPE_POINTS = "points" # Sensor tracking total points
SENSOR_TYPE_PENDING_CHORE_APPROVALS = (
"pending_chore_approvals" # Pending chore approvals
)
SENSOR_TYPE_PENDING_REWARD_APPROVALS = (
"pending_reward_approvals" # Pending reward approvals
)
SENSOR_TYPE_REWARD_APPROVALS = "reward_approvals" # Reward approvals sensor
SENSOR_TYPE_REWARD_CLAIMS = "reward_claims" # Reward claims sensor
SENSOR_TYPE_BONUS_APPLIES = "bonus_applies" # Bonus applies sensor
# -------------------- Services --------------------
# Custom Services
SERVICE_APPLY_PENALTY = "apply_penalty" # Apply penalty service
SERVICE_APPROVE_CHORE = "approve_chore" # Approve chore service
SERVICE_APPROVE_REWARD = "approve_reward" # Approve reward service
SERVICE_CLAIM_CHORE = "claim_chore" # Claim chore service
SERVICE_DISAPPROVE_CHORE = "disapprove_chore" # Disapprove chore service
SERVICE_DISAPPROVE_REWARD = "disapprove_reward" # Disapprove reward service
SERVICE_REDEEM_REWARD = "redeem_reward" # Redeem reward service
SERVICE_RESET_ALL_CHORES = "reset_all_chores" # Reset all chores service
SERVICE_RESET_ALL_DATA = "reset_all_data" # Reset all data service
SERVICE_RESET_OVERDUE_CHORES = "reset_overdue_chores" # Reset overdue chores
SERVICE_SET_CHORE_DUE_DATE = "set_chore_due_date" # Set or reset chores due date
SERVICE_SKIP_CHORE_DUE_DATE = (
"skip_chore_due_date" # Skip chores due date and reschedule
)
SERVICE_APPLY_BONUS = "apply_bonus" # Apply bonus service
SERVICE_RESET_PENALTIES = "reset_penalties" # Reset penalties service
SERVICE_RESET_BONUSES = "reset_bonuses" # Reset bonuses service
SERVICE_RESET_REWARDS = "reset_rewards" # Reset rewards service
# Field Names (for consistency across services)
FIELD_CHORE_ID = "chore_id"
FIELD_CHORE_NAME = "chore_name"
FIELD_DUE_DATE = "due_date"
FIELD_KID_NAME = "kid_name"
FIELD_PARENT_NAME = "parent_name"
FIELD_PENALTY_NAME = "penalty_name"
FIELD_POINTS_AWARDED = "points_awarded"
FIELD_REWARD_NAME = "reward_name"
FIELD_BONUS_NAME = "bonus_name"
# -------------------- Labels --------------------
# Labels for Sensors and UI
LABEL_BADGES = "Badges"
LABEL_COMPLETED_DAILY = "Daily Completed Chores"
LABEL_COMPLETED_MONTHLY = "Monthly Completed Chores"
LABEL_COMPLETED_WEEKLY = "Weekly Completed Chores"
LABEL_POINTS = "Points"
# -------------------- Buttons --------------------
# Button Prefixes for Dynamic Creation
BUTTON_DISAPPROVE_CHORE_PREFIX = "disapprove_chore_button_" # Disapprove chore button
BUTTON_DISAPPROVE_REWARD_PREFIX = (
"disapprove_reward_button_" # Disapprove reward button
)
BUTTON_PENALTY_PREFIX = (
"penalty_button_" # Prefix for dynamically created penalty buttons
)
BUTTON_REWARD_PREFIX = "reward_button_" # Prefix for dynamically created reward buttons
BUTTON_BONUS_PREFIX = "bonus_button_" # Prefix for dynamically created bonus buttons
# -------------------- Errors and Warnings --------------------
DUE_DATE_NOT_SET = "Not Set"
ERROR_CHORE_NOT_FOUND = "Chore not found." # Error for missing chore
ERROR_CHORE_NOT_FOUND_FMT = "Chore '{}' not found" # Error format for missing chore
ERROR_INVALID_POINTS = "Invalid points." # Error for invalid points input
ERROR_KID_NOT_FOUND = "Kid not found." # Error for non-existent kid
ERROR_KID_NOT_FOUND_FMT = "Kid '{}' not found" # Error format for missing kid
ERROR_NOT_AUTHORIZED_ACTION_FMT = "Not authorized to {}." # Auth error format
ERROR_NOT_AUTHORIZED_FMT = (
"User not authorized to {} for this kid." # Auth error format
)
ERROR_PENALTY_NOT_FOUND = "Penalty not found." # Error for missing penalty
ERROR_PENALTY_NOT_FOUND_FMT = (
"Penalty '{}' not found" # Error format for missing penalty
)
ERROR_REWARD_NOT_FOUND = "Reward not found." # Error for missing reward
ERROR_REWARD_NOT_FOUND_FMT = "Reward '{}' not found" # Error format for missing reward
ERROR_BONUS_NOT_FOUND = "Bonus not found." # Error for missing bonus
ERROR_BONUS_NOT_FOUND_FMT = "Bonus '{}' not found" # Error format for missing bonus
ERROR_USER_NOT_AUTHORIZED = (
"User is not authorized to perform this action." # Auth error
)
MSG_NO_ENTRY_FOUND = "No KidsChores entry found"
# Unknown States
UNKNOWN_CHORE = "Unknown Chore" # Error for unknown chore
UNKNOWN_KID = "Unknown Kid" # Error for unknown kid
UNKNOWN_REWARD = "Unknown Reward" # Error for unknown reward
# -------------------- Parent Approval Workflow --------------------
PARENT_APPROVAL_REQUIRED = True # Enable parent approval for certain actions
HA_USERNAME_LINK_ENABLED = True # Enable linking kids to HA usernames
# ---------------------------- Weekdays -----------------------------
WEEKDAY_OPTIONS = {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday",
}
================================================
FILE: custom_components/kidschores/coordinator.py
================================================
# File: coordinator.py
"""Coordinator for the KidsChores integration.
Handles data synchronization, chore claiming and approval, badge tracking,
reward redemption, penalty application, and recurring chore handling.
Manages entities primarily using internal_id for consistency.
"""
import asyncio
import uuid
from calendar import monthrange
from datetime import datetime, timedelta
from typing import Any, Optional
from homeassistant.auth.models import User
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import (
ACHIEVEMENT_TYPE_DAILY_MIN,
ACHIEVEMENT_TYPE_STREAK,
ACHIEVEMENT_TYPE_TOTAL,
ACTION_APPROVE_CHORE,
ACTION_APPROVE_REWARD,
ACTION_DISAPPROVE_CHORE,
ACTION_DISAPPROVE_REWARD,
ACTION_REMIND_30,
ACTION_TITLE_APPROVE,
ACTION_TITLE_DISAPPROVE,
ACTION_TITLE_REMIND_30,
BADGE_THRESHOLD_TYPE_CHORE_COUNT,
BADGE_THRESHOLD_TYPE_POINTS,
CHALLENGE_TYPE_DAILY_MIN,
CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW,
CHORE_STATE_APPROVED,
CHORE_STATE_APPROVED_IN_PART,
CHORE_STATE_CLAIMED,
CHORE_STATE_CLAIMED_IN_PART,
CHORE_STATE_INDEPENDENT,
CHORE_STATE_OVERDUE,
CHORE_STATE_PARTIAL,
CHORE_STATE_PENDING,
CHORE_STATE_UNKNOWN,
CONF_ACHIEVEMENTS,
CONF_APPLICABLE_DAYS,
CONF_BADGES,
CONF_CHALLENGES,
CONF_CHORES,
CONF_ENABLE_MOBILE_NOTIFICATIONS,
CONF_ENABLE_PERSISTENT_NOTIFICATIONS,
CONF_KIDS,
CONF_MOBILE_NOTIFY_SERVICE,
CONF_NOTIFY_ON_APPROVAL,
CONF_NOTIFY_ON_CLAIM,
CONF_NOTIFY_ON_DISAPPROVAL,
CONF_PARENTS,
CONF_PENALTIES,
CONF_REWARDS,
CONF_BONUSES,
DATA_ACHIEVEMENTS,
DATA_BADGES,
DATA_CHALLENGES,
DATA_CHORES,
DATA_KIDS,
DATA_PARENTS,
DATA_PENDING_CHORE_APPROVALS,
DATA_PENDING_REWARD_APPROVALS,
DATA_PENALTIES,
DATA_REWARDS,
DATA_BONUSES,
DEFAULT_APPLICABLE_DAYS,
DEFAULT_BADGE_THRESHOLD,
DEFAULT_DAILY_RESET_TIME,
DEFAULT_ICON,
DEFAULT_MONTHLY_RESET_DAY,
DEFAULT_MULTIPLE_CLAIMS_PER_DAY,
DEFAULT_NOTIFY_ON_APPROVAL,
DEFAULT_NOTIFY_ON_CLAIM,
DEFAULT_NOTIFY_ON_DISAPPROVAL,
DEFAULT_PARTIAL_ALLOWED,
DEFAULT_PENALTY_ICON,
DEFAULT_PENALTY_POINTS,
DEFAULT_POINTS,
DEFAULT_POINTS_MULTIPLIER,
DEFAULT_REWARD_COST,
DEFAULT_REWARD_ICON,
DEFAULT_BONUS_ICON,
DEFAULT_BONUS_POINTS,
DEFAULT_WEEKLY_RESET_DAY,
DOMAIN,
FREQUENCY_BIWEEKLY,
FREQUENCY_CUSTOM,
FREQUENCY_DAILY,
FREQUENCY_MONTHLY,
FREQUENCY_NONE,
FREQUENCY_WEEKLY,
LOGGER,
UPDATE_INTERVAL,
WEEKDAY_OPTIONS,
)
from .storage_manager import KidsChoresStorageManager
from .notification_helper import async_send_notification
class KidsChoresDataCoordinator(DataUpdateCoordinator):
"""Coordinator for KidsChores integration.
Manages data primarily using internal_id for entities.
"""
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
storage_manager: KidsChoresStorageManager,
):
"""Initialize the KidsChoresDataCoordinator."""
super().__init__(
hass,
LOGGER,
name=f"{DOMAIN}_coordinator",
update_interval=timedelta(minutes=UPDATE_INTERVAL),
)
self.config_entry = config_entry
self.storage_manager = storage_manager
self._data: dict[str, Any] = {}
# -------------------------------------------------------------------------------------
# Migrate Data and Converters
# -------------------------------------------------------------------------------------
def _migrate_datetime(self, dt_str: str) -> str:
"""Convert a datetime string to a UTC-aware ISO string."""
if not isinstance(dt_str, str):
return dt_str
try:
# Try to parse using Home Assistant’s utility first:
dt_obj = dt_util.parse_datetime(dt_str)
if dt_obj is None:
# Fallback using fromisoformat
dt_obj = datetime.fromisoformat(dt_str)
# If naive, assume local time and make it aware:
if dt_obj.tzinfo is None:
dt_obj = dt_obj.replace(
tzinfo=dt_util.get_time_zone(self.hass.config.time_zone)
)
# Convert to UTC
dt_obj_utc = dt_util.as_utc(dt_obj)
return dt_obj_utc.isoformat()
except Exception as err:
LOGGER.warning("Error migrating datetime '%s': %s", dt_str, err)
return dt_str
def _migrate_stored_datetimes(self):
"""Walk through stored data and convert known datetime fields to UTC-aware ISO strings."""
# For each chore, migrate due_date, last_completed, and last_claimed
for chore in self._data.get(DATA_CHORES, {}).values():
if chore.get("due_date"):
chore["due_date"] = self._migrate_datetime(chore["due_date"])
if chore.get("last_completed"):
chore["last_completed"] = self._migrate_datetime(
chore["last_completed"]
)
if chore.get("last_claimed"):
chore["last_claimed"] = self._migrate_datetime(chore["last_claimed"])
# Also, migrate timestamps in pending approvals
for approval in self._data.get(DATA_PENDING_CHORE_APPROVALS, []):
if approval.get("timestamp"):
approval["timestamp"] = self._migrate_datetime(approval["timestamp"])
for approval in self._data.get(DATA_PENDING_REWARD_APPROVALS, []):
if approval.get("timestamp"):
approval["timestamp"] = self._migrate_datetime(approval["timestamp"])
# Migrate datetime on Challenges
for challenge in self._data.get(DATA_CHALLENGES, {}).values():
start_date = challenge.get("start_date")
if not isinstance(start_date, str) or not start_date.strip():
challenge["start_date"] = None
else:
challenge["start_date"] = self._migrate_datetime(start_date)
end_date = challenge.get("end_date")
if not isinstance(end_date, str) or not end_date.strip():
challenge["end_date"] = None
else:
challenge["end_date"] = self._migrate_datetime(end_date)
def _migrate_chore_data(self):
"""Migrate each chore's data to include new fields if missing.
This method iterates over each chore entry in the stored data and ensures
that the following keys are present:
- CONF_APPLICABLE_DAYS (defaults to DEFAULT_APPLICABLE_DAYS)
- CONF_NOTIFY_ON_CLAIM (defaults to DEFAULT_NOTIFY_ON_CLAIM)
- CONF_NOTIFY_ON_APPROVAL (defaults to DEFAULT_NOTIFY_ON_APPROVAL)
- CONF_NOTIFY_ON_DISAPPROVAL (defaults to DEFAULT_NOTIFY_ON_DISAPPROVAL)
"""
chores = self._data.get(DATA_CHORES, {})
for chore in chores.values():
chore.setdefault(CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS)
chore.setdefault(CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM)
chore.setdefault(CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL)
chore.setdefault(CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL)
LOGGER.info("Chore data migration complete.")
# -------------------------------------------------------------------------------------
# Normalize Lists
# -------------------------------------------------------------------------------------
def _normalize_kid_lists(self, kid_info: dict[str, Any]) -> None:
"Normalize lists and ensuring they are not dict"
for key in [
"claimed_chores",
"approved_chores",
"pending_rewards",
"redeemed_rewards",
]:
if not isinstance(kid_info.get(key), list):
kid_info[key] = []
# -------------------------------------------------------------------------------------
# Periodic + First Refresh
# -------------------------------------------------------------------------------------
async def _async_update_data(self):
"""Periodic update."""
try:
# Check overdue chores
await self._check_overdue_chores()
# Notify entities of changes
self.async_update_listeners()
return self._data
except Exception as err:
raise UpdateFailed(f"Error updating KidsChores data: {err}") from err
async def async_config_entry_first_refresh(self):
"""Load from storage and merge config options."""
stored_data = self.storage_manager.get_data()
if stored_data:
self._data = stored_data
# Migrate any datetime fields in stored data to UTC-aware strings
self._migrate_stored_datetimes()
# Migrate chore data and add new fields
self._migrate_chore_data()
else:
self._data = {
DATA_KIDS: {},
DATA_CHORES: {},
DATA_BADGES: {},
DATA_REWARDS: {},
DATA_PARENTS: {},
DATA_PENALTIES: {},
DATA_BONUSES: {},
DATA_ACHIEVEMENTS: {},
DATA_CHALLENGES: {},
DATA_PENDING_CHORE_APPROVALS: [],
DATA_PENDING_REWARD_APPROVALS: [],
}
if not isinstance(self._data.get(DATA_PENDING_CHORE_APPROVALS), list):
self._data[DATA_PENDING_CHORE_APPROVALS] = []
if not isinstance(self._data.get(DATA_PENDING_REWARD_APPROVALS), list):
self._data[DATA_PENDING_REWARD_APPROVALS] = []
# Register daily/weekly/monthly resets
async_track_time_change(
self.hass, self._reset_all_chore_counts, **DEFAULT_DAILY_RESET_TIME
)
# Merge config entry data (options) into the stored data
self._initialize_data_from_config()
# Normalize all kids list fields
for kid in self._data.get(DATA_KIDS, {}).values():
self._normalize_kid_lists(kid)
self._persist()
await super().async_config_entry_first_refresh()
# -------------------------------------------------------------------------------------
# Data Initialization from Config
# -------------------------------------------------------------------------------------
def _initialize_data_from_config(self):
"""Merge config_entry options with stored data structures using internal_id."""
options = self.config_entry.options
# Retrieve configuration dictionaries from config entry options
config_sections = {
DATA_KIDS: options.get(CONF_KIDS, {}),
DATA_PARENTS: options.get(CONF_PARENTS, {}),
DATA_CHORES: options.get(CONF_CHORES, {}),
DATA_BADGES: options.get(CONF_BADGES, {}),
DATA_REWARDS: options.get(CONF_REWARDS, {}),
DATA_PENALTIES: options.get(CONF_PENALTIES, {}),
DATA_BONUSES: options.get(CONF_BONUSES, {}),
DATA_ACHIEVEMENTS: options.get(CONF_ACHIEVEMENTS, {}),
DATA_CHALLENGES: options.get(CONF_CHALLENGES, {}),
}
# Ensure minimal structure
self._ensure_minimal_structure()
# Initialize each section using private helper
for section_key, data_dict in config_sections.items():
init_func = getattr(self, f"_initialize_{section_key}", None)
if init_func:
init_func(data_dict)
else:
self._data.setdefault(section_key, data_dict)
LOGGER.warning("No initializer found for section '%s'", section_key)
# Recalculate Badges on reload
self._recalculate_all_badges()
def _ensure_minimal_structure(self):
"""Ensure that all necessary data sections are present."""
for key in [
DATA_KIDS,
DATA_PARENTS,
DATA_CHORES,
DATA_BADGES,
DATA_REWARDS,
DATA_PENALTIES,
DATA_BONUSES,
DATA_ACHIEVEMENTS,
DATA_CHALLENGES,
]:
self._data.setdefault(key, {})
for key in [DATA_PENDING_CHORE_APPROVALS, DATA_PENDING_REWARD_APPROVALS]:
if not isinstance(self._data.get(key), list):
self._data[key] = []
# -------------------------------------------------------------------------------------
# Helpers to Sync Entities from config
# -------------------------------------------------------------------------------------
def _initialize_kids(self, kids_dict: dict[str, Any]):
self._sync_entities(DATA_KIDS, kids_dict, self._create_kid, self._update_kid)
def _initialize_parents(self, parents_dict: dict[str, Any]):
self._sync_entities(
DATA_PARENTS, parents_dict, self._create_parent, self._update_parent
)
def _initialize_chores(self, chores_dict: dict[str, Any]):
self._sync_entities(
DATA_CHORES, chores_dict, self._create_chore, self._update_chore
)
def _initialize_badges(self, badges_dict: dict[str, Any]):
self._sync_entities(
DATA_BADGES, badges_dict, self._create_badge, self._update_badge
)
def _initialize_rewards(self, rewards_dict: dict[str, Any]):
self._sync_entities(
DATA_REWARDS, rewards_dict, self._create_reward, self._update_reward
)
def _initialize_penalties(self, penalties_dict: dict[str, Any]):
self._sync_entities(
DATA_PENALTIES, penalties_dict, self._create_penalty, self._update_penalty
)
def _initialize_achievements(self, achievements_dict: dict[str, Any]):
self._sync_entities(
DATA_ACHIEVEMENTS,
achievements_dict,
self._create_achievement,
self._update_achievement,
)
def _initialize_challenges(self, challenges_dict: dict[str, Any]):
self._sync_entities(
DATA_CHALLENGES,
challenges_dict,
self._create_challenge,
self._update_challenge,
)
def _initialize_bonuses(self, bonuses_dict: dict[str, Any]):
self._sync_entities(
DATA_BONUSES, bonuses_dict, self._create_bonus, self._update_bonus
)
def _sync_entities(
self,
section: str,
config_data: dict[str, Any],
create_method,
update_method,
):
"""Synchronize entities in a given data section based on config_data."""
existing_ids = set(self._data[section].keys())
config_ids = set(config_data.keys())
# Identify entities to remove
entities_to_remove = existing_ids - config_ids
for entity_id in entities_to_remove:
# Remove entity from data
del self._data[section][entity_id]
# Remove entity from HA registry
self._remove_entities_in_ha(section, entity_id)
if section == DATA_CHORES:
for kid_id in self.kids_data.keys():
self._remove_kid_chore_entities(kid_id, entity_id)
# Perform general clean-up
self._cleanup_all_links()
# Remove deleted kids from parents list
self._cleanup_parent_assignments()
# Remove chore approvals on chore delete
self._cleanup_pending_chore_approvals()
# Remove reward approvals on reward delete
if section == DATA_REWARDS:
self._cleanup_pending_reward_approvals()
# Add or update entities
for entity_id, entity_body in config_data.items():
if entity_id not in self._data[section]:
create_method(entity_id, entity_body)
else:
update_method(entity_id, entity_body)
# Remove orphaned shared chore sensors.
if section == DATA_CHORES:
self.hass.async_create_task(self._remove_orphaned_shared_chore_sensors())
# Remove orphaned achievement and challenges sensors
self.hass.async_create_task(self._remove_orphaned_achievement_entities())
self.hass.async_create_task(self._remove_orphaned_challenge_entities())
def _cleanup_all_links(self) -> None:
"""Run all cross-entity cleanup routines."""
self._cleanup_deleted_kid_references()
self._cleanup_deleted_chore_references()
self._cleanup_deleted_chore_in_achievements()
self._cleanup_deleted_chore_in_challenges()
def _remove_entities_in_ha(self, section: str, item_id: str):
"""Remove all platform entities whose unique_id references the given item_id."""
ent_reg = er.async_get(self.hass)
for entity_entry in list(ent_reg.entities.values()):
if str(item_id) in str(entity_entry.unique_id):
ent_reg.async_remove(entity_entry.entity_id)
LOGGER.debug(
"Auto-removed entity '%s' with unique_id '%s' from registry",
entity_entry.entity_id,
entity_entry.unique_id,
)
async def _remove_orphaned_shared_chore_sensors(self):
"""Remove SharedChoreGlobalStateSensor entities for chores no longer marked as shared."""
ent_reg = er.async_get(self.hass)
prefix = f"{self.config_entry.entry_id}_"
suffix = "_global_state"
for entity_entry in list(ent_reg.entities.values()):
if (
entity_entry.domain == "sensor"
and entity_entry.unique_id.startswith(prefix)
and entity_entry.unique_id.endswith(suffix)
):
chore_id = entity_entry.unique_id[len(prefix) : -len(suffix)]
chore_info = self.chores_data.get(chore_id)
if not chore_info or not chore_info.get("shared_chore", False):
ent_reg.async_remove(entity_entry.entity_id)
LOGGER.debug(
"Removed orphaned SharedChoreGlobalStateSensor: %s",
entity_entry.entity_id,
)
async def _remove_orphaned_achievement_entities(self) -> None:
"""Remove achievement progress entities for kids that are no longer assigned."""
ent_reg = er.async_get(self.hass)
prefix = f"{self.config_entry.entry_id}_"
suffix = "_achievement_progress"
for entity_entry in list(ent_reg.entities.values()):
if (
entity_entry.domain == "sensor"
and entity_entry.unique_id.startswith(prefix)
and entity_entry.unique_id.endswith(suffix)
):
core_id = entity_entry.unique_id[len(prefix) : -len(suffix)]
parts = core_id.split("_", 1)
if len(parts) != 2:
continue
kid_id, achievement_id = parts
achievement = self._data.get(DATA_ACHIEVEMENTS, {}).get(achievement_id)
if not achievement or kid_id not in achievement.get(
"assigned_kids", []
):
ent_reg.async_remove(entity_entry.entity_id)
LOGGER.debug(
"Removed orphaned achievement progress sensor '%s' because kid '%s' is not assigned to achievement '%s'",
entity_entry.entity_id,
kid_id,
achievement_id,
)
async def _remove_orphaned_challenge_entities(self) -> None:
"""Remove challenge progress sensor entities for kids no longer assigned."""
ent_reg = er.async_get(self.hass)
prefix = f"{self.config_entry.entry_id}_"
suffix = "_challenge_progress"
for entity_entry in list(ent_reg.entities.values()):
if (
entity_entry.domain == "sensor"
and entity_entry.unique_id.startswith(prefix)
and entity_entry.unique_id.endswith(suffix)
):
core_id = entity_entry.unique_id[len(prefix) : -len(suffix)]
parts = core_id.split("_", 1)
if len(parts) != 2:
continue
kid_id, challenge_id = parts
challenge = self._data.get(DATA_CHALLENGES, {}).get(challenge_id)
if not challenge or kid_id not in challenge.get("assigned_kids", []):
ent_reg.async_remove(entity_entry.entity_id)
LOGGER.debug(
"Removed orphaned challenge progress sensor '%s' because kid '%s' is not assigned to challenge '%s'",
entity_entry.entity_id,
kid_id,
challenge_id,
)
def _remove_kid_chore_entities(self, kid_id: str, chore_id: str) -> None:
"""Remove all kid-specific chore entities for a given kid and chore."""
ent_reg = er.async_get(self.hass)
for entity_entry in list(ent_reg.entities.values()):
if (kid_id in entity_entry.unique_id) and (
chore_id in entity_entry.unique_id
):
ent_reg.async_remove(entity_entry.entity_id)
LOGGER.debug(
"Removed kid-specific entity '%s' for kid '%s' and chore '%s'",
entity_entry.entity_id,
kid_id,
chore_id,
)
def _cleanup_chore_from_kid(self, kid_id: str, chore_id: str) -> None:
"""Remove references to a specific chore from a kid's data."""
kid = self.kids_data.get(kid_id)
if not kid:
return
# Remove from lists if present
for key in ["claimed_chores", "approved_chores"]:
if chore_id in kid.get(key, []):
kid[key] = [c for c in kid[key] if c != chore_id]
LOGGER.debug(
"Removed chore '%s' from kid '%s' list '%s'", chore_id, kid_id, key
)
# Remove from dictionary fields if present
for dict_key in ["chore_claims", "chore_approvals"]:
if chore_id in kid.get(dict_key, {}):
kid[dict_key].pop(chore_id)
LOGGER.debug(
"Removed chore '%s' from kid '%s' dict '%s'",
chore_id,
kid_id,
dict_key,
)
# Remove from chore streaks if present
if "chore_streaks" in kid and chore_id in kid["chore_streaks"]:
kid["chore_streaks"].pop(chore_id)
LOGGER.debug(
"Removed chore streak for chore '%s' from kid '%s'", chore_id, kid_id
)
# Remove any pending chore approvals for this kid and chore
self._data[DATA_PENDING_CHORE_APPROVALS] = [
ap
for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, [])
if not (ap.get("kid_id") == kid_id and ap.get("chore_id") == chore_id)
]
def _cleanup_pending_chore_approvals(self) -> None:
"""Remove any pending chore approvals for chore IDs that no longer exist."""
valid_chore_ids = set(self._data.get(DATA_CHORES, {}).keys())
self._data[DATA_PENDING_CHORE_APPROVALS] = [
ap
for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, [])
if ap.get("chore_id") in valid_chore_ids
]
def _cleanup_pending_reward_approvals(self) -> None:
"""Remove any pending reward approvals for reward IDs that no longer exist."""
valid_reward_ids = set(self._data.get(DATA_REWARDS, {}).keys())
self._data[DATA_PENDING_REWARD_APPROVALS] = [
approval
for approval in self._data.get(DATA_PENDING_REWARD_APPROVALS, [])
if approval.get("reward_id") in valid_reward_ids
]
def _cleanup_deleted_kid_references(self) -> None:
"""Remove references to kids that no longer exist from other sections."""
valid_kid_ids = set(self.kids_data.keys())
# Remove deleted kid IDs from all chore assignments
for chore in self._data.get(DATA_CHORES, {}).values():
if "assigned_kids" in chore:
original = chore["assigned_kids"]
filtered = [kid for kid in original if kid in valid_kid_ids]
if filtered != original:
chore["assigned_kids"] = filtered
LOGGER.debug(
"Cleaned up assigned_kids in chore '%s'", chore.get("name")
)
# Remove progress in achievements and challenges
for section in [DATA_ACHIEVEMENTS, DATA_CHALLENGES]:
for entity in self._data.get(section, {}).values():
progress = entity.get("progress", {})
keys_to_remove = [kid for kid in progress if kid not in valid_kid_ids]
for kid in keys_to_remove:
del progress[kid]
LOGGER.debug(
"Removed progress for deleted kid '%s' in section '%s'",
kid,
section,
)
if "assigned_kids" in entity:
original_assigned = entity["assigned_kids"]
filtered_assigned = [
kid for kid in original_assigned if kid in valid_kid_ids
]
if filtered_assigned != original_assigned:
entity["assigned_kids"] = filtered_assigned
LOGGER.debug(
"Cleaned up assigned_kids in %s '%s'",
section,
entity.get("name"),
)
def _cleanup_deleted_chore_references(self) -> None:
"""Remove references to chores that no longer exist from kid data."""
valid_chore_ids = set(self.chores_data.keys())
for kid in self.kids_data.values():
# Clean up list fields
for key in ["claimed_chores", "approved_chores"]:
if key in kid:
original = kid[key]
filtered = [chore for chore in original if chore in valid_chore_ids]
if filtered != original:
kid[key] = filtered
# Clean up dictionary fields
for dict_key in ["chore_claims", "chore_approvals"]:
if dict_key in kid:
kid[dict_key] = {
chore: count
for chore, count in kid[dict_key].items()
if chore in valid_chore_ids
}
# Clean up chore streaks
if "chore_streaks" in kid:
for chore in list(kid["chore_streaks"].keys()):
if chore not in valid_chore_ids:
del kid["chore_streaks"][chore]
LOGGER.debug(
"Removed chore streak for deleted chore '%s'", chore
)
def _cleanup_parent_assignments(self) -> None:
"""Remove any kid IDs from parent's 'associated_kids' that no longer exist."""
valid_kid_ids = set(self.kids_data.keys())
for parent in self._data.get(DATA_PARENTS, {}).values():
original = parent.get("associated_kids", [])
filtered = [kid_id for kid_id in original if kid_id in valid_kid_ids]
if filtered != original:
parent["associated_kids"] = filtered
LOGGER.debug(
"Cleaned up associated_kids for parent '%s'. New list: %s",
parent.get("name"),
filtered,
)
def _cleanup_deleted_chore_in_achievements(self) -> None:
"""Clear selected_chore_id in achievements if the chore no longer exists."""
valid_chore_ids = set(self.chores_data.keys())
for achievement in self._data.get(DATA_ACHIEVEMENTS, {}).values():
selected = achievement.get("selected_chore_id")
if selected and selected not in valid_chore_ids:
achievement["selected_chore_id"] = ""
LOGGER.debug(
"Cleared selected_chore_id in achievement '%s'",
achievement.get("name"),
)
def _cleanup_deleted_chore_in_challenges(self) -> None:
"""Clear selected_chore_id in challenges if the chore no longer exists."""
valid_chore_ids = set(self.chores_data.keys())
for challenge in self._data.get(DATA_CHALLENGES, {}).values():
selected = challenge.get("selected_chore_id")
if selected and selected not in valid_chore_ids:
challenge["selected_chore_id"] = ""
LOGGER.debug(
"Cleared selected_chore_id in challenge '%s'", challenge.get("name")
)
# -------------------------------------------------------------------------------------
# Create/Update Entities
# (Kids, Parents, Chores, Badges, Rewards, Penalties, Achievements and Challenges)
# -------------------------------------------------------------------------------------
# -- Kids
def _create_kid(self, kid_id: str, kid_data: dict[str, Any]):
self._data[DATA_KIDS][kid_id] = {
"name": kid_data.get("name", ""),
"points": kid_data.get("points", 0.0),
"badges": kid_data.get("badges", []),
"claimed_chores": kid_data.get("claimed_chores", []),
"approved_chores": kid_data.get("approved_chores", []),
"completed_chores_today": kid_data.get("completed_chores_today", 0),
"completed_chores_weekly": kid_data.get("completed_chores_weekly", 0),
"completed_chores_monthly": kid_data.get("completed_chores_monthly", 0),
"completed_chores_total": kid_data.get("completed_chores_total", 0),
"ha_user_id": kid_data.get("ha_user_id"),
"internal_id": kid_id,
"points_multiplier": kid_data.get("points_multiplier", 1.0),
"reward_claims": kid_data.get("reward_claims", {}),
"reward_approvals": kid_data.get("reward_approvals", {}),
"chore_claims": kid_data.get("chore_claims", {}),
"chore_approvals": kid_data.get("chore_approvals", {}),
"penalty_applies": kid_data.get("penalty_applies", {}),
"bonus_applies": kid_data.get("bonus_applies", {}),
"pending_rewards": kid_data.get("pending_rewards", []),
"redeemed_rewards": kid_data.get("redeemed_rewards", []),
"points_earned_today": kid_data.get("points_earned_today", 0.0),
"points_earned_weekly": kid_data.get("points_earned_weekly", 0.0),
"points_earned_monthly": kid_data.get("points_earned_monthly", 0.0),
"max_points_ever": kid_data.get("max_points_ever", 0.0),
"enable_notifications": kid_data.get("enable_notifications", True),
"mobile_notify_service": kid_data.get("mobile_notify_service", ""),
"use_persistent_notifications": kid_data.get(
"use_persistent_notifications", True
),
"chore_streaks": {},
"overall_chore_streak": 0,
"last_chore_date": None,
"overdue_chores": [],
"overdue_notifications": {},
}
self._normalize_kid_lists(self._data[DATA_KIDS][kid_id])
LOGGER.debug(
"Added new kid '%s' with ID: %s",
self._data[DATA_KIDS][kid_id]["name"],
kid_id,
)
def _update_kid(self, kid_id: str, kid_data: dict[str, Any]):
kid_info = self._data[DATA_KIDS][kid_id]
# Overwrite or set default if not present
kid_info["name"] = kid_data.get("name", kid_info["name"])
kid_info["ha_user_id"] = kid_data.get("ha_user_id", kid_info["ha_user_id"])
kid_info.setdefault("reward_claims", kid_data.get("reward_claims", {}))
kid_info.setdefault("reward_approvals", kid_data.get("reward_approvals", {}))
kid_info.setdefault("chore_claims", kid_data.get("chore_claims", {}))
kid_info.setdefault("chore_approvals", kid_data.get("chore_approvals", {}))
kid_info.setdefault("penalty_applies", kid_data.get("penalty_applies", {}))
kid_info.setdefault("bonus_applies", kid_data.get("bonus_applies", {}))
kid_info.setdefault("pending_rewards", kid_data.get("pending_rewards", []))
kid_info.setdefault("redeemed_rewards", kid_data.get("redeemed_rewards", []))
kid_info.setdefault(
"points_earned_today", kid_data.get("points_earned_today", 0.0)
)
kid_info.setdefault(
"points_earned_weekly", kid_data.get("points_earned_weekly", 0.0)
)
kid_info.setdefault(
"points_earned_monthly", kid_data.get("points_earned_monthly", 0.0)
)
kid_info.setdefault("max_points_ever", kid_data.get("max_points_ever", 0.0))
kid_info.setdefault("points_multiplier", kid_data.get("points_multiplier", 1.0))
kid_info["enable_notifications"] = kid_data.get(
"enable_notifications", kid_info.get("enable_notifications", True)
)
kid_info["mobile_notify_service"] = kid_data.get(
"mobile_notify_service", kid_info.get("mobile_notify_service", "")
)
kid_info["use_persistent_notifications"] = kid_data.get(
"use_persistent_notifications",
kid_info.get("use_persistent_notifications", True),
)
kid_info.setdefault("chore_streaks", {})
kid_info.setdefault("overall_chore_streak", 0)
kid_info.setdefault("last_chore_date", None)
kid_info.setdefault("overdue_chores", [])
kid_info.setdefault("overdue_notifications", {})
self._normalize_kid_lists(self._data[DATA_KIDS][kid_id])
LOGGER.debug("Updated kid '%s' with ID: %s", kid_info["name"], kid_id)
# -- Parents
def _create_parent(self, parent_id: str, parent_data: dict[str, Any]):
associated_kids_ids = []
for kid_id in parent_data.get("associated_kids", []):
if kid_id in self.kids_data:
associated_kids_ids.append(kid_id)
else:
LOGGER.warning(
"Parent '%s': Kid ID '%s' not found. Skipping assignment to parent",
parent_data.get("name", parent_id),
kid_id,
)
self._data[DATA_PARENTS][parent_id] = {
"name": parent_data.get("name", ""),
"ha_user_id": parent_data.get("ha_user_id", ""),
"associated_kids": associated_kids_ids,
"enable_notifications": parent_data.get("enable_notifications", True),
"mobile_notify_service": parent_data.get("mobile_notify_service", ""),
"use_persistent_notifications": parent_data.get(
"use_persistent_notifications", True
),
"internal_id": parent_id,
}
LOGGER.debug(
"Added new parent '%s' with ID: %s",
self._data[DATA_PARENTS][parent_id]["name"],
parent_id,
)
def _update_parent(self, parent_id: str, parent_data: dict[str, Any]):
parent_info = self._data[DATA_PARENTS][parent_id]
parent_info["name"] = parent_data.get("name", parent_info["name"])
parent_info["ha_user_id"] = parent_data.get(
"ha_user_id", parent_info["ha_user_id"]
)
# Update associated_kids
updated_kids = []
for kid_id in parent_data.get("associated_kids", []):
if kid_id in self.kids_data:
updated_kids.append(kid_id)
else:
LOGGER.warning(
"Parent '%s': Kid ID '%s' not found. Skipping assignment",
parent_info["name"],
kid_id,
)
parent_info["associated_kids"] = updated_kids
parent_info["enable_notifications"] = parent_data.get(
"enable_notifications", parent_info.get("enable_notifications", True)
)
parent_info["mobile_notify_service"] = parent_data.get(
"mobile_notify_service", parent_info.get("mobile_notify_service", "")
)
parent_info["use_persistent_notifications"] = parent_data.get(
"use_persistent_notifications",
parent_info.get("use_persistent_notifications", True),
)
LOGGER.debug("Updated parent '%s' with ID: %s", parent_info["name"], parent_id)
# -- Chores
def _create_chore(self, chore_id: str, chore_data: dict[str, Any]):
assigned_kids_ids = []
for kid_name in chore_data.get("assigned_kids", []):
kid_id = self._get_kid_id_by_name(kid_name)
if kid_id:
assigned_kids_ids.append(kid_id)
else:
LOGGER.warning(
"Chore '%s': Kid name '%s' not found. Skipping assignment",
chore_data.get("name", chore_id),
kid_name,
)
# If chore is recurring, set due_date to creation date if not set
freq = chore_data.get("recurring_frequency", FREQUENCY_NONE)
if freq != FREQUENCY_NONE and not chore_data.get("due_date"):
now_local = dt_util.utcnow().astimezone(
dt_util.get_time_zone(self.hass.config.time_zone)
)
# Force the time to 23:59:00 (and zero microseconds)
default_due = now_local.replace(hour=23, minute=59, second=0, microsecond=0)
chore_data["due_date"] = default_due.isoformat()
LOGGER.debug(
"Chore '%s' has freq '%s' but no due_date. Defaulting to 23:59 local time: %s",
chore_data.get("name", chore_id),
freq,
chore_data["due_date"],
)
self._data[DATA_CHORES][chore_id] = {
"name": chore_data.get("name", ""),
"state": chore_data.get("state", CHORE_STATE_PENDING),
"default_points": chore_data.get("default_points", DEFAULT_POINTS),
"allow_multiple_claims_per_day": chore_data.get(
"allow_multiple_claims_per_day", DEFAULT_MULTIPLE_CLAIMS_PER_DAY
),
"partial_allowed": chore_data.get(
"partial_allowed", DEFAULT_PARTIAL_ALLOWED
),
"description": chore_data.get("description", ""),
"chore_labels": chore_data.get("chore_labels", []),
"icon": chore_data.get("icon", DEFAULT_ICON),
"shared_chore": chore_data.get("shared_chore", False),
"assigned_kids": assigned_kids_ids,
"recurring_frequency": chore_data.get(
"recurring_frequency", FREQUENCY_NONE
),
"custom_interval": chore_data.get("custom_interval")
if chore_data.get("recurring_frequency") == FREQUENCY_CUSTOM
else None,
"custom_interval_unit": chore_data.get("custom_interval_unit")
if chore_data.get("recurring_frequency") == FREQUENCY_CUSTOM
else None,
"due_date": chore_data.get("due_date"),
"last_completed": chore_data.get("last_completed"),
"last_claimed": chore_data.get("last_claimed"),
"applicable_days": chore_data.get("applicable_days", []),
"notify_on_claim": chore_data.get(
"notify_on_claim", DEFAULT_NOTIFY_ON_CLAIM
),
"notify_on_approval": chore_data.get(
"notify_on_approval", DEFAULT_NOTIFY_ON_APPROVAL
),
"notify_on_disapproval": chore_data.get(
"notify_on_disapproval", DEFAULT_NOTIFY_ON_DISAPPROVAL
),
"internal_id": chore_id,
}
LOGGER.debug(
"Added new chore '%s' with ID: %s",
self._data[DATA_CHORES][chore_id]["name"],
chore_id,
)
# Notify Kids of new chore
new_name = self._data[DATA_CHORES][chore_id]["name"]
due_date = self._data[DATA_CHORES][chore_id]["due_date"]
for kid_id in assigned_kids_ids:
due_str = due_date if due_date else "No due date set"
extra_data = {"kid_id": kid_id, "chore_id": chore_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: New Chore",
message=f"A new chore '{new_name}' was assigned to you! Due: {due_str}",
extra_data=extra_data,
)
)
def _update_chore(self, chore_id: str, chore_data: dict[str, Any]):
chore_info = self._data[DATA_CHORES][chore_id]
chore_info["name"] = chore_data.get("name", chore_info["name"])
chore_info["state"] = chore_data.get("state", chore_info["state"])
chore_info["default_points"] = chore_data.get(
"default_points", chore_info["default_points"]
)
chore_info["allow_multiple_claims_per_day"] = chore_data.get(
"allow_multiple_claims_per_day", chore_info["allow_multiple_claims_per_day"]
)
chore_info["partial_allowed"] = chore_data.get(
"partial_allowed", chore_info["partial_allowed"]
)
chore_info["description"] = chore_data.get(
"description", chore_info["description"]
)
chore_info["chore_labels"] = chore_data.get(
"chore_labels", chore_info.get("chore_labels", [])
)
chore_info["icon"] = chore_data.get("icon", chore_info["icon"])
chore_info["shared_chore"] = chore_data.get(
"shared_chore", chore_info["shared_chore"]
)
assigned_kids_ids = []
for kid_name in chore_data.get("assigned_kids", []):
kid_id = self._get_kid_id_by_name(kid_name)
if kid_id:
assigned_kids_ids.append(kid_id)
else:
LOGGER.warning(
"Chore '%s': Kid name '%s' not found. Skipping assignment",
chore_data.get("name", chore_id),
kid_name,
)
old_assigned = set(chore_info.get("assigned_kids", []))
new_assigned = set(assigned_kids_ids)
removed_kids = old_assigned - new_assigned
for kid in removed_kids:
self._remove_kid_chore_entities(kid, chore_id)
self._cleanup_chore_from_kid(kid, chore_id)
# Update the chore's assigned kids list with the new assignments
chore_info["assigned_kids"] = list(new_assigned)
chore_info["recurring_frequency"] = chore_data.get(
"recurring_frequency", chore_info["recurring_frequency"]
)
chore_info["due_date"] = chore_data.get("due_date", chore_info["due_date"])
chore_info["last_completed"] = chore_data.get(
"last_completed", chore_info.get("last_completed")
)
chore_info["last_claimed"] = chore_data.get(
"last_claimed", chore_info.get("last_claimed")
)
chore_info["applicable_days"] = chore_data.get(
"applicable_days", chore_info.get("applicable_days", [])
)
chore_info["notify_on_claim"] = chore_data.get(
"notify_on_claim",
chore_info.get("notify_on_claim", DEFAULT_NOTIFY_ON_CLAIM),
)
chore_info["notify_on_approval"] = chore_data.get(
"notify_on_approval",
chore_info.get("notify_on_approval", DEFAULT_NOTIFY_ON_APPROVAL),
)
chore_info["notify_on_disapproval"] = chore_data.get(
"notify_on_disapproval",
chore_info.get("notify_on_disapproval", DEFAULT_NOTIFY_ON_DISAPPROVAL),
)
if chore_info["recurring_frequency"] == FREQUENCY_CUSTOM:
chore_info["custom_interval"] = chore_data.get("custom_interval")
chore_info["custom_interval_unit"] = chore_data.get("custom_interval_unit")
else:
chore_info["custom_interval"] = None
chore_info["custom_interval_unit"] = None
LOGGER.debug("Updated chore '%s' with ID: %s", chore_info["name"], chore_id)
self.hass.async_create_task(self._check_overdue_chores())
# -- Badges
def _create_badge(self, badge_id: str, badge_data: dict[str, Any]):
self._data[DATA_BADGES][badge_id] = {
"name": badge_data.get("name", ""),
"threshold_type": badge_data.get(
"threshold_type", BADGE_THRESHOLD_TYPE_POINTS
),
"threshold_value": badge_data.get(
"threshold_value", DEFAULT_BADGE_THRESHOLD
),
"chore_count_type": badge_data.get("chore_count_type", FREQUENCY_DAILY),
"earned_by": badge_data.get("earned_by", []),
"points_multiplier": badge_data.get(
"points_multiplier", DEFAULT_POINTS_MULTIPLIER
),
"icon": badge_data.get("icon", DEFAULT_ICON),
"description": badge_data.get("description", ""),
"badge_labels": badge_data.get("badge_labels", []),
"internal_id": badge_id,
}
LOGGER.debug(
"Added new badge '%s' with ID: %s",
self._data[DATA_BADGES][badge_id]["name"],
badge_id,
)
def _update_badge(self, badge_id: str, badge_data: dict[str, Any]):
badge_info = self._data[DATA_BADGES][badge_id]
badge_info["name"] = badge_data.get("name", badge_info["name"])
badge_info["threshold_type"] = badge_data.get(
"threshold_type",
badge_info.get("threshold_type", BADGE_THRESHOLD_TYPE_POINTS),
)
badge_info["threshold_value"] = badge_data.get(
"threshold_value",
badge_info.get("threshold_value", DEFAULT_BADGE_THRESHOLD),
)
badge_info["chore_count_type"] = badge_data.get(
"chore_count_type", badge_info.get("chore_count_type", FREQUENCY_NONE)
)
badge_info["points_multiplier"] = badge_data.get(
"points_multiplier",
badge_info.get("points_multiplier", DEFAULT_POINTS_MULTIPLIER),
)
badge_info["icon"] = badge_data.get(
"icon", badge_info.get("icon", DEFAULT_ICON)
)
badge_info["description"] = badge_data.get(
"description", badge_info.get("description", "")
)
badge_info["badge_labels"] = badge_data.get(
"badge_labels", badge_info.get("badge_labels", [])
)
LOGGER.debug("Updated badge '%s' with ID: %s", badge_info["name"], badge_id)
# -- Rewards
def _create_reward(self, reward_id: str, reward_data: dict[str, Any]):
self._data[DATA_REWARDS][reward_id] = {
"name": reward_data.get("name", ""),
"cost": reward_data.get("cost", DEFAULT_REWARD_COST),
"description": reward_data.get("description", ""),
"reward_labels": reward_data.get("reward_labels", []),
"icon": reward_data.get("icon", DEFAULT_REWARD_ICON),
"internal_id": reward_id,
}
LOGGER.debug(
"Added new reward '%s' with ID: %s",
self._data[DATA_REWARDS][reward_id]["name"],
reward_id,
)
def _update_reward(self, reward_id: str, reward_data: dict[str, Any]):
reward_info = self._data[DATA_REWARDS][reward_id]
reward_info["name"] = reward_data.get("name", reward_info["name"])
reward_info["cost"] = reward_data.get("cost", reward_info["cost"])
reward_info["description"] = reward_data.get(
"description", reward_info["description"]
)
reward_info["reward_labels"] = reward_data.get(
"reward_labels", reward_info.get("reward_labels", [])
)
reward_info["icon"] = reward_data.get("icon", reward_info["icon"])
LOGGER.debug("Updated reward '%s' with ID: %s", reward_info["name"], reward_id)
# -- Penalties
def _create_penalty(self, penalty_id: str, penalty_data: dict[str, Any]):
self._data[DATA_PENALTIES][penalty_id] = {
"name": penalty_data.get("name", ""),
"points": penalty_data.get("points", -DEFAULT_PENALTY_POINTS),
"description": penalty_data.get("description", ""),
"penalty_labels": penalty_data.get("penalty_labels", []),
"icon": penalty_data.get("icon", DEFAULT_PENALTY_ICON),
"internal_id": penalty_id,
}
LOGGER.debug(
"Added new penalty '%s' with ID: %s",
self._data[DATA_PENALTIES][penalty_id]["name"],
penalty_id,
)
def _update_penalty(self, penalty_id: str, penalty_data: dict[str, Any]):
penalty_info = self._data[DATA_PENALTIES][penalty_id]
penalty_info["name"] = penalty_data.get("name", penalty_info["name"])
penalty_info["points"] = penalty_data.get("points", penalty_info["points"])
penalty_info["description"] = penalty_data.get(
"description", penalty_info["description"]
)
penalty_info["penalty_labels"] = penalty_data.get(
"penalty_labels", penalty_info.get("penalty_labels", [])
)
penalty_info["icon"] = penalty_data.get("icon", penalty_info["icon"])
LOGGER.debug(
"Updated penalty '%s' with ID: %s", penalty_info["name"], penalty_id
)
# -- Bonuses
def _create_bonus(self, bonus_id: str, bonus_data: dict[str, Any]):
self._data[DATA_BONUSES][bonus_id] = {
"name": bonus_data.get("name", ""),
"points": bonus_data.get("points", DEFAULT_BONUS_POINTS),
"description": bonus_data.get("description", ""),
"bonus_labels": bonus_data.get("bonus_labels", []),
"icon": bonus_data.get("icon", DEFAULT_BONUS_ICON),
"internal_id": bonus_id,
}
LOGGER.debug(
"Added new bonus '%s' with ID: %s",
self._data[DATA_BONUSES][bonus_id]["name"],
bonus_id,
)
def _update_bonus(self, bonus_id: str, bonus_data: dict[str, Any]):
bonus_info = self._data[DATA_BONUSES][bonus_id]
bonus_info["name"] = bonus_data.get("name", bonus_info["name"])
bonus_info["points"] = bonus_data.get("points", bonus_info["points"])
bonus_info["description"] = bonus_data.get(
"description", bonus_info["description"]
)
bonus_info["bonus_labels"] = bonus_data.get(
"bonus_labels", bonus_info.get("bonus_labels", [])
)
bonus_info["icon"] = bonus_data.get("icon", bonus_info["icon"])
LOGGER.debug("Updated bonus '%s' with ID: %s", bonus_info["name"], bonus_id)
# -- Achievements
def _create_achievement(
self, achievement_id: str, achievement_data: dict[str, Any]
):
self._data[DATA_ACHIEVEMENTS][achievement_id] = {
"name": achievement_data.get("name", ""),
"description": achievement_data.get("description", ""),
"achievement_labels": achievement_data.get("achievement_labels", []),
"icon": achievement_data.get("icon", ""),
"assigned_kids": achievement_data.get("assigned_kids", []),
"type": achievement_data.get("type", "individual"),
"selected_chore_id": achievement_data.get("selected_chore_id", ""),
"criteria": achievement_data.get("criteria", ""),
"target_value": achievement_data.get("target_value", 1),
"reward_points": achievement_data.get("reward_points", 0),
"progress": achievement_data.get("progress", {}),
"internal_id": achievement_id,
}
LOGGER.debug(
"Added new achievement '%s' with ID: %s",
self._data[DATA_ACHIEVEMENTS][achievement_id]["name"],
achievement_id,
)
def _update_achievement(
self, achievement_id: str, achievement_data: dict[str, Any]
):
achievement_info = self._data[DATA_ACHIEVEMENTS][achievement_id]
achievement_info["name"] = achievement_data.get(
"name", achievement_info["name"]
)
achievement_info["description"] = achievement_data.get(
"description", achievement_info["description"]
)
achievement_info["achievement_labels"] = achievement_data.get(
"achievement_labels", achievement_info.get("achievement_labels", [])
)
achievement_info["icon"] = achievement_data.get(
"icon", achievement_info["icon"]
)
achievement_info["assigned_kids"] = achievement_data.get(
"assigned_kids", achievement_info["assigned_kids"]
)
achievement_info["type"] = achievement_data.get(
"type", achievement_info["type"]
)
achievement_info["selected_chore_id"] = achievement_data.get(
"selected_chore_id", achievement_info.get("selected_chore_id", "")
)
achievement_info["criteria"] = achievement_data.get(
"criteria", achievement_info["criteria"]
)
achievement_info["target_value"] = achievement_data.get(
"target_value", achievement_info["target_value"]
)
achievement_info["reward_points"] = achievement_data.get(
"reward_points", achievement_info["reward_points"]
)
LOGGER.debug(
"Updated achievement '%s' with ID: %s",
achievement_info["name"],
achievement_id,
)
# -- Challenges
def _create_challenge(self, challenge_id: str, challenge_data: dict[str, Any]):
self._data[DATA_CHALLENGES][challenge_id] = {
"name": challenge_data.get("name", ""),
"description": challenge_data.get("description", ""),
"challenge_labels": challenge_data.get("challenge_labels", []),
"icon": challenge_data.get("icon", ""),
"assigned_kids": challenge_data.get("assigned_kids", []),
"type": challenge_data.get("type", "individual"),
"selected_chore_id": challenge_data.get("selected_chore_id", ""),
"criteria": challenge_data.get("criteria", ""),
"target_value": challenge_data.get("target_value", 1),
"reward_points": challenge_data.get("reward_points", 0),
"start_date": challenge_data.get("start_date")
if challenge_data.get("start_date") not in [None, {}]
else None,
"end_date": challenge_data.get("end_date")
if challenge_data.get("end_date") not in [None, {}]
else None,
"progress": challenge_data.get("progress", {}),
"internal_id": challenge_id,
}
LOGGER.debug(
"Added new challenge '%s' with ID: %s",
self._data[DATA_CHALLENGES][challenge_id]["name"],
challenge_id,
)
def _update_challenge(self, challenge_id: str, challenge_data: dict[str, Any]):
challenge_info = self._data[DATA_CHALLENGES][challenge_id]
challenge_info["name"] = challenge_data.get("name", challenge_info["name"])
challenge_info["description"] = challenge_data.get(
"description", challenge_info["description"]
)
challenge_info["challenge_labels"] = challenge_data.get(
"challenge_labels", challenge_info.get("challenge_labels", [])
)
challenge_info["icon"] = challenge_data.get("icon", challenge_info["icon"])
challenge_info["assigned_kids"] = challenge_data.get(
"assigned_kids", challenge_info["assigned_kids"]
)
challenge_info["type"] = challenge_data.get("type", challenge_info["type"])
challenge_info["selected_chore_id"] = challenge_data.get(
"selected_chore_id", challenge_info.get("selected_chore_id", "")
)
challenge_info["criteria"] = challenge_data.get(
"criteria", challenge_info["criteria"]
)
challenge_info["target_value"] = challenge_data.get(
"target_value", challenge_info["target_value"]
)
challenge_info["reward_points"] = challenge_data.get(
"reward_points", challenge_info["reward_points"]
)
challenge_info["start_date"] = (
challenge_data.get("start_date")
if challenge_data.get("start_date") not in [None, {}]
else None
)
challenge_info["end_date"] = (
challenge_data.get("end_date")
if challenge_data.get("end_date") not in [None, {}]
else None
)
LOGGER.debug(
"Updated challenge '%s' with ID: %s", challenge_info["name"], challenge_id
)
# -------------------------------------------------------------------------------------
# Properties for Easy Access
# -------------------------------------------------------------------------------------
@property
def kids_data(self) -> dict[str, Any]:
"""Return the kids data."""
return self._data.get(DATA_KIDS, {})
@property
def parents_data(self) -> dict[str, Any]:
"""Return the parents data."""
return self._data.get(DATA_PARENTS, {})
@property
def chores_data(self) -> dict[str, Any]:
"""Return the chores data."""
return self._data.get(DATA_CHORES, {})
@property
def badges_data(self) -> dict[str, Any]:
"""Return the badges data."""
return self._data.get(DATA_BADGES, {})
@property
def rewards_data(self) -> dict[str, Any]:
"""Return the rewards data."""
return self._data.get(DATA_REWARDS, {})
@property
def penalties_data(self) -> dict[str, Any]:
"""Return the penalties data."""
return self._data.get(DATA_PENALTIES, {})
@property
def achievements_data(self) -> dict[str, Any]:
"""Return the achievements data."""
return self._data.get(DATA_ACHIEVEMENTS, {}) # New
@property
def challenges_data(self) -> dict[str, Any]:
"""Return the challenges data."""
return self._data.get(DATA_CHALLENGES, {})
@property
def bonuses_data(self) -> dict[str, Any]:
"""Return the bonuses data."""
return self._data.get(DATA_BONUSES, {})
# -------------------------------------------------------------------------------------
# Parents: Add, Remove
# -------------------------------------------------------------------------------------
def add_parent(self, parent_def: dict[str, Any]):
"""Add new parent at runtime if needed."""
parent_name = parent_def.get("name")
ha_user_id = parent_def.get("ha_user_id")
kid_ids = parent_def.get("associated_kids", [])
if not parent_name or not ha_user_id:
LOGGER.warning("Add parent: Parent must have a name and ha_user_id")
return
if any(p["ha_user_id"] == ha_user_id for p in self.parents_data.values()):
LOGGER.warning(
"Add parent: Parent with ha_user_id '%s' already exists", ha_user_id
)
return
valid_kids = []
for kid_id in kid_ids:
if kid_id in self.kids_data:
valid_kids.append(kid_id)
else:
LOGGER.warning(
"Add parent: Kid ID '%s' not found. Skipping assignment to parent '%s'",
kid_id,
parent_name,
)
new_id = str(uuid.uuid4())
self.parents_data[new_id] = {
"name": parent_name,
"ha_user_id": ha_user_id,
"associated_kids": valid_kids,
"internal_id": new_id,
}
LOGGER.debug("Added new parent '%s' with ID: %s", parent_name, new_id)
self._persist()
self.async_set_updated_data(self._data)
def remove_parent(self, parent_id: str):
"""Remove a parent by ID."""
if parent_id in self.parents_data:
parent_name = self.parents_data[parent_id]["name"]
del self.parents_data[parent_id]
LOGGER.debug("Removed parent '%s' with ID: %s", parent_name, parent_id)
self._persist()
self.async_set_updated_data(self._data)
else:
LOGGER.warning("Remove parent: Parent ID '%s' not found", parent_id)
# -------------------------------------------------------------------------------------
# Chores: Claim, Approve, Disapprove, Compute Global State for Shared Chores
# -------------------------------------------------------------------------------------
def claim_chore(self, kid_id: str, chore_id: str, user_name: str):
"""Kid claims chore => state=claimed; parent must then approve."""
if chore_id not in self.chores_data:
LOGGER.warning("Chore ID '%s' not found for claim", chore_id)
raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.")
chore_info = self.chores_data[chore_id]
if kid_id not in chore_info.get("assigned_kids", []):
LOGGER.warning(
"Claim chore: Chore ID '%s' not assigned to kid ID '%s'",
chore_id,
kid_id,
)
raise HomeAssistantError(
f"Chore '{chore_info.get('name')}' is not assigned to kid '{self.kids_data[kid_id]['name']}'."
)
if kid_id not in self.kids_data:
LOGGER.warning("Kid ID '%s' not found", kid_id)
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
kid_info = self.kids_data.get(kid_id)
self._normalize_kid_lists(kid_info)
allow_multiple = chore_info.get("allow_multiple_claims_per_day", False)
if allow_multiple:
# If already approved, remove it so the new claim can trigger a new approval flow
kid_info["approved_chores"] = [
item for item in kid_info.get("approved_chores", []) if item != chore_id
]
if not allow_multiple:
if chore_id in kid_info.get(
"claimed_chores", []
) or chore_id in kid_info.get("approved_chores", []):
error_message = f"Chore '{chore_info['name']}' has already been claimed today and multiple claims are not allowed."
LOGGER.warning(error_message)
raise HomeAssistantError(error_message)
self._process_chore_state(kid_id, chore_id, CHORE_STATE_CLAIMED)
# Send a notification to the parents that a kid claimed a chore
if chore_info.get(CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM):
actions = [
{
"action": f"{ACTION_APPROVE_CHORE}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_APPROVE,
},
{
"action": f"{ACTION_DISAPPROVE_CHORE}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_DISAPPROVE,
},
{
"action": f"{ACTION_REMIND_30}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_REMIND_30,
},
]
# Pass extra context so the event handler can route the action.
extra_data = {
"kid_id": kid_id,
"chore_id": chore_id,
}
self.hass.async_create_task(
self._notify_parents(
kid_id,
title="KidsChores: Chore Claimed",
message=f"'{self.kids_data[kid_id]['name']}' claimed chore '{self.chores_data[chore_id]['name']}'",
actions=actions,
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def approve_chore(
self,
parent_name: str,
kid_id: str,
chore_id: str,
points_awarded: Optional[float] = None,
):
"""Approve a chore for kid_id if assigned."""
if chore_id not in self.chores_data:
raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.")
chore_info = self.chores_data[chore_id]
if kid_id not in chore_info.get("assigned_kids", []):
raise HomeAssistantError(
f"Chore '{chore_info.get('name')}' is not assigned to kid '{self.kids_data[kid_id]['name']}'."
)
if kid_id not in self.kids_data:
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
kid_info = self.kids_data.get(kid_id)
allow_multiple = chore_info.get("allow_multiple_claims_per_day", False)
if not allow_multiple:
if chore_id in kid_info.get("approved_chores", []):
error_message = f"Chore '{chore_info['name']}' has already been approved today; multiple approvals not allowed."
LOGGER.warning(error_message)
raise HomeAssistantError(error_message)
default_points = chore_info.get("default_points", DEFAULT_POINTS)
multiplier = kid_info.get("points_multiplier", 1.0)
awarded_points = (
points_awarded * multiplier
if points_awarded is not None
else default_points * multiplier
)
self._process_chore_state(
kid_id, chore_id, CHORE_STATE_APPROVED, points_awarded=awarded_points
)
# Remove to avoid awarding duplicated points
# old_points = float(kid_info["points"])
# new_points = old_points + awarded_points
# self.update_kid_points(kid_id, new_points)
# increment completed chores counters
kid_info["completed_chores_today"] += 1
kid_info["completed_chores_weekly"] += 1
kid_info["completed_chores_monthly"] += 1
kid_info["completed_chores_total"] += 1
# Track today’s approvals for chores that allow multiple claims.
if chore_info.get("allow_multiple_claims_per_day", False):
kid_info.setdefault("today_chore_approvals", {})
kid_info["today_chore_approvals"][chore_id] = (
kid_info["today_chore_approvals"].get(chore_id, 0) + 1
)
chore_info["last_completed"] = dt_util.utcnow().isoformat()
today = dt_util.as_local(dt_util.utcnow()).date()
self._update_chore_streak_for_kid(kid_id, chore_id, today)
self._update_overall_chore_streak(kid_id, today)
# remove from pending approvals
self._data[DATA_PENDING_CHORE_APPROVALS] = [
ap
for ap in self._data[DATA_PENDING_CHORE_APPROVALS]
if not (ap["kid_id"] == kid_id and ap["chore_id"] == chore_id)
]
# increment chore approvals
if chore_id in kid_info["chore_approvals"]:
kid_info["chore_approvals"][chore_id] += 1
else:
kid_info["chore_approvals"][chore_id] = 1
# Manage Achievements
today = dt_util.as_local(dt_util.utcnow()).date()
for achievement_id, achievement in self.achievements_data.items():
if achievement.get("type") == ACHIEVEMENT_TYPE_STREAK:
selected_chore_id = achievement.get("selected_chore_id")
if selected_chore_id == chore_id:
# Get or create the progress dict for this kid
progress = achievement.setdefault("progress", {}).setdefault(
kid_id,
{"current_streak": 0, "last_date": None, "awarded": False},
)
self._update_streak_progress(progress, today)
# Manage Challenges
today_iso = dt_util.as_local(dt_util.utcnow()).date().isoformat()
for challenge_id, challenge in self.challenges_data.items():
if challenge.get("type") == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
# (Challenge update logic for total-within-window remains here)
start_date_raw = challenge.get("start_date")
if isinstance(start_date_raw, str):
start_date = dt_util.parse_datetime(start_date_raw)
if start_date and start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=dt_util.UTC)
else:
start_date = None
end_date_raw = challenge.get("end_date")
if isinstance(end_date_raw, str):
end_date = dt_util.parse_datetime(end_date_raw)
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=dt_util.UTC)
else:
end_date = None
now = dt_util.utcnow()
if start_date and end_date and start_date <= now <= end_date:
progress = challenge.setdefault("progress", {}).setdefault(
kid_id, {"count": 0, "awarded": False}
)
progress["count"] += 1
elif challenge.get("type") == CHALLENGE_TYPE_DAILY_MIN:
# Only update if the challenge is tracking a specific chore.
selected_chore = challenge.get("selected_chore_id")
if not selected_chore:
LOGGER.warning(
"Challenge '%s' of type daily_min has no selected_chore_id set. Skipping progress update.",
challenge.get("name"),
)
continue
if selected_chore != chore_id:
continue
if kid_id in challenge.get("assigned_kids", []):
progress = challenge.setdefault("progress", {}).setdefault(
kid_id, {"daily_counts": {}, "awarded": False}
)
progress["daily_counts"][today_iso] = (
progress["daily_counts"].get(today_iso, 0) + 1
)
# Send a notification to the kid that chore was approved
if chore_info.get(CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL):
extra_data = {"kid_id": kid_id, "chore_id": chore_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Chore Approved",
message=f"Your chore '{chore_info['name']}' was approved. You earned {awarded_points} points.",
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def disapprove_chore(self, parent_name: str, kid_id: str, chore_id: str):
"""Disapprove a chore for kid_id."""
chore_info = self.chores_data.get(chore_id)
if not chore_info:
raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.")
kid_info = self.kids_data.get(kid_id)
if not kid_info:
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING)
# Send a notification to the kid that chore was disapproved
if chore_info.get(CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL):
extra_data = {"kid_id": kid_id, "chore_id": chore_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Chore Disapproved",
message=f"Your chore '{chore_info['name']}' was disapproved.",
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def update_chore_state(self, chore_id: str, state: str):
"""Manually override a chore's state."""
chore_info = self.chores_data.get(chore_id)
if not chore_info:
LOGGER.warning("Update chore state: Chore ID '%s' not found", chore_id)
return
# Set state for all kids assigned to the chore:
for kid_id in chore_info.get("assigned_kids", []):
if kid_id:
self._process_chore_state(kid_id, chore_id, state)
self._persist()
self.async_set_updated_data(self._data)
LOGGER.debug(f"Chore ID '{chore_id}' state manually updated to '{state}'")
# -------------------------------------------------------------------------------------
# Chore State Processing: Centralized Function
# The most critical thing to understand when working on this function is that
# chore_info["state"] is actually the global state of the chore. The individual chore
# state per kid is always calculated based on whether they have any claimed, approved, or
# overdue chores listed for them.
#
# Global state will only match if a single kid is assigned to the chore, or all kids
# assigned are in the same state.
# -------------------------------------------------------------------------------------
def _process_chore_state(
self,
kid_id: str,
chore_id: str,
new_state: str,
*,
points_awarded: Optional[float] = None,
) -> None:
LOGGER.debug(
"Entering _process_chore_state with kid_id=%s, chore_id=%s, new_state=%s, points_awarded=%s",
kid_id,
chore_id,
new_state,
points_awarded,
)
"""Centralized function to update a chore’s state for a given kid."""
kid_info = self.kids_data.get(kid_id)
chore_info = self.chores_data.get(chore_id)
if not kid_info or not chore_info:
LOGGER.warning(
"State change skipped: Kid '%s' or Chore '%s' not found",
kid_id,
chore_id,
)
return
# Clear any overdue tracking.
kid_info.setdefault("overdue_chores", [])
kid_info.setdefault("overdue_notifications", {})
# Remove all instances of the chore from overdue lists.
kid_info["overdue_chores"] = [
entry for entry in kid_info.get("overdue_chores", []) if entry != chore_id
]
if chore_id in kid_info["overdue_notifications"]:
kid_info["overdue_notifications"].pop(chore_id)
if new_state == CHORE_STATE_CLAIMED:
# Remove all previous approvals in case of duplicate, add to claimed.
kid_info["approved_chores"] = [
item for item in kid_info.get("approved_chores", []) if item != chore_id
]
kid_info.setdefault("claimed_chores", [])
if chore_id not in kid_info["claimed_chores"]:
kid_info["claimed_chores"].append(chore_id)
chore_info["last_claimed"] = dt_util.utcnow().isoformat()
self._data.setdefault(DATA_PENDING_CHORE_APPROVALS, []).append(
{
"kid_id": kid_id,
"chore_id": chore_id,
"timestamp": dt_util.utcnow().isoformat(),
}
)
elif new_state == CHORE_STATE_APPROVED:
# Remove all claims for chores in case of duplicates, add to approvals.
kid_info["claimed_chores"] = [
item for item in kid_info.get("claimed_chores", []) if item != chore_id
]
kid_info.setdefault("approved_chores", [])
if chore_id not in kid_info["approved_chores"]:
kid_info["approved_chores"].append(chore_id)
chore_info["last_completed"] = dt_util.utcnow().isoformat()
if points_awarded is not None:
current_points = float(kid_info.get("points", 0))
self.update_kid_points(kid_id, current_points + points_awarded)
today = dt_util.as_local(dt_util.utcnow()).date()
self._update_chore_streak_for_kid(kid_id, chore_id, today)
self._update_overall_chore_streak(kid_id, today)
self._data[DATA_PENDING_CHORE_APPROVALS] = [
ap
for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, [])
if not (ap.get("kid_id") == kid_id and ap.get("chore_id") == chore_id)
]
elif new_state == CHORE_STATE_PENDING:
# Remove the chore from both claimed and approved lists.
for field in ["claimed_chores", "approved_chores"]:
if chore_id in kid_info.get(field, []):
kid_info[field] = [c for c in kid_info[field] if c != chore_id]
# Remove from pending approvals.
self._data[DATA_PENDING_CHORE_APPROVALS] = [
ap
for ap in self._data.get(DATA_PENDING_CHORE_APPROVALS, [])
if not (ap.get("kid_id") == kid_id and ap.get("chore_id") == chore_id)
]
elif new_state == CHORE_STATE_OVERDUE:
# Mark as overdue.
kid_info.setdefault("overdue_chores", [])
if chore_id not in kid_info["overdue_chores"]:
kid_info["overdue_chores"].append(chore_id)
kid_info.setdefault("overdue_notifications", {})
kid_info["overdue_notifications"][chore_id] = dt_util.utcnow().isoformat()
# Compute and update the chore's global state.
# Given the process above is handling everything properly for each kid, computing the global state straightforward.
# This process needs run every time a chore state changes, so it no longer warrants a separate function.
assigned_kids = chore_info.get("assigned_kids", [])
if len(assigned_kids) == 1:
# if only one kid is assigned to the chore, update the chore state to new state 1:1
chore_info["state"] = new_state
elif len(assigned_kids) > 1:
# For chores assigned to multiple kids, you have to figure out the global state
count_pending = count_claimed = count_approved = count_overdue = 0
for kid_id in assigned_kids:
kid_info = self.kids_data.get(kid_id, {})
if chore_id in kid_info.get("overdue_chores", []):
count_overdue += 1
elif chore_id in kid_info.get("approved_chores", []):
count_approved += 1
elif chore_id in kid_info.get("claimed_chores", []):
count_claimed += 1
else:
count_pending += 1
total = len(assigned_kids)
# If all kids are in the same state, update the chore state to new state 1:1
if (
count_pending == total
or count_claimed == total
or count_approved == total
or count_overdue == total
):
chore_info["state"] = new_state
# For shared chores, recompute global state of a partial if they aren't all in the same state as checked above
elif chore_info.get("shared_chore", False):
if count_overdue > 0:
chore_info["state"] = CHORE_STATE_OVERDUE
elif count_approved > 0:
chore_info["state"] = CHORE_STATE_APPROVED_IN_PART
elif count_claimed > 0:
chore_info["state"] = CHORE_STATE_CLAIMED_IN_PART
else:
chore_info["state"] = CHORE_STATE_UNKNOWN
# For non-shared chores multiple assign it will be independent if they aren't all in the same state as checked above.
elif chore_info.get("shared_chore", False) is False:
chore_info["state"] = CHORE_STATE_INDEPENDENT
else:
chore_info["state"] = CHORE_STATE_UNKNOWN
LOGGER.debug(
"Chore '%s' global state computed as '%s'",
chore_id,
chore_info["state"],
)
# -------------------------------------------------------------------------------------
# Kids: Update Points
# -------------------------------------------------------------------------------------
def update_kid_points(self, kid_id: str, new_points: float):
"""Set a kid's points to 'new_points', updating daily/weekly/monthly counters."""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.warning("Update kid points: Kid ID '%s' not found", kid_id)
return
old_points = float(kid_info["points"])
delta = new_points - old_points
if delta == 0:
LOGGER.debug("No change in points for kid '%s'. Skipping updates", kid_id)
return
kid_info["points"] = new_points
kid_info["points_earned_today"] += delta
kid_info["points_earned_weekly"] += delta
kid_info["points_earned_monthly"] += delta
# Update Max Points Ever
if new_points > kid_info.get("max_points_ever", 0):
kid_info["max_points_ever"] = new_points
# Check Badges
self._check_badges_for_kid(kid_id)
self._check_achievements_for_kid(kid_id)
self._check_challenges_for_kid(kid_id)
self._persist()
self.async_set_updated_data(self._data)
LOGGER.debug(
"update_kid_points: Kid '%s' changed from %.2f to %.2f (delta=%.2f)",
kid_id,
old_points,
new_points,
delta,
)
# -------------------------------------------------------------------------------------
# Rewards: Redeem, Approve, Disapprove
# -------------------------------------------------------------------------------------
def redeem_reward(self, parent_name: str, kid_id: str, reward_id: str):
"""Kid claims a reward => mark as pending approval (no deduction yet)."""
reward = self.rewards_data.get(reward_id)
if not reward:
raise HomeAssistantError(f"Reward with ID '{reward_id}' not found.")
kid_info = self.kids_data.get(kid_id)
if not kid_info:
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
cost = reward.get("cost", 0.0)
if kid_info["points"] < cost:
raise HomeAssistantError(
f"'{kid_info['name']}' does not have enough points ({cost} needed)."
)
kid_info.setdefault("pending_rewards", []).append(reward_id)
kid_info.setdefault("redeemed_rewards", [])
# Add to pending approvals
self._data[DATA_PENDING_REWARD_APPROVALS].append(
{
"kid_id": kid_id,
"reward_id": reward_id,
"timestamp": dt_util.utcnow().isoformat(),
}
)
# increment reward_claims counter
if reward_id in kid_info["reward_claims"]:
kid_info["reward_claims"][reward_id] += 1
else:
kid_info["reward_claims"][reward_id] = 1
# Send a notification to the parents that a kid claimed a reward
actions = [
{
"action": f"{ACTION_APPROVE_REWARD}|{kid_id}|{reward_id}",
"title": ACTION_TITLE_APPROVE,
},
{
"action": f"{ACTION_DISAPPROVE_REWARD}|{kid_id}|{reward_id}",
"title": ACTION_TITLE_DISAPPROVE,
},
{
"action": f"{ACTION_REMIND_30}|{kid_id}|{reward_id}",
"title": ACTION_TITLE_REMIND_30,
},
]
extra_data = {"kid_id": kid_id, "reward_id": reward_id}
self.hass.async_create_task(
self._notify_parents(
kid_id,
title="KidsChores: Reward Claimed",
message=f"'{kid_info['name']}' claimed reward '{reward['name']}'",
actions=actions,
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def approve_reward(self, parent_name: str, kid_id: str, reward_id: str):
"""Parent approves the reward => deduct points."""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
reward = self.rewards_data.get(reward_id)
if not reward:
raise HomeAssistantError(f"Reward with ID '{reward_id}' not found.")
cost = reward.get("cost", 0.0)
if reward_id in kid_info.get("pending_rewards", []):
if kid_info["points"] < cost:
raise HomeAssistantError(
f"'{kid_info['name']}' does not have enough points to redeem '{reward['name']}'."
)
# Deduct
new_points = float(kid_info["points"]) - cost
self.update_kid_points(kid_id, new_points)
kid_info["pending_rewards"].remove(reward_id)
kid_info["redeemed_rewards"].append(reward_id)
else:
# direct approval scenario
if kid_info["points"] < cost:
raise HomeAssistantError(
f"'{kid_info['name']}' does not have enough points to redeem '{reward['name']}'."
)
kid_info["points"] -= cost
kid_info["redeemed_rewards"].append(reward_id)
self._check_badges_for_kid(kid_id)
# remove 1 claim from pending approvals
approvals = self._data[DATA_PENDING_REWARD_APPROVALS]
for i, ap in enumerate(approvals):
if ap["kid_id"] == kid_id and ap["reward_id"] == reward_id:
del approvals[i] # Remove only the first match
break # Stop after the first removal
# increment reward_approvals
if reward_id in kid_info["reward_approvals"]:
kid_info["reward_approvals"][reward_id] += 1
else:
kid_info["reward_approvals"][reward_id] = 1
# Send a notification to the kid that reward was approved
extra_data = {"kid_id": kid_id, "reward_id": reward_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Reward Approved",
message=f"Your reward '{reward['name']}' was approved.",
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def disapprove_reward(self, parent_name: str, kid_id: str, reward_id: str):
"""Disapprove a reward for kid_id."""
reward = self.rewards_data.get(reward_id)
if not reward:
raise HomeAssistantError(f"Reward with ID '{reward_id}' not found.")
# remove from pending approvals
self._data[DATA_PENDING_REWARD_APPROVALS] = [
ap
for ap in self._data[DATA_PENDING_REWARD_APPROVALS]
if not (ap["kid_id"] == kid_id and ap["reward_id"] == reward_id)
]
kid_info = self.kids_data.get(kid_id)
if kid_info and reward_id in kid_info.get("pending_rewards", []):
kid_info["pending_rewards"].remove(reward_id)
# Send a notification to the kid that reward was disapproved
extra_data = {"kid_id": kid_id, "reward_id": reward_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Reward Disapproved",
message=f"Your reward '{reward['name']}' was disapproved.",
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
# -------------------------------------------------------------------------------------
# Badges: Add, Check, Award
# -------------------------------------------------------------------------------------
def add_badge(self, badge_def: dict[str, Any]):
"""Add new badge at runtime if needed."""
badge_name = badge_def.get("name")
if not badge_name:
LOGGER.warning("Add badge: Badge must have a name")
return
if any(b["name"] == badge_name for b in self.badges_data.values()):
LOGGER.warning("Add badge: Badge '%s' already exists", badge_name)
return
internal_id = str(uuid.uuid4())
self.badges_data[internal_id] = {
"name": badge_name,
"threshold_type": badge_def.get(
"threshold_type", BADGE_THRESHOLD_TYPE_POINTS
),
"threshold_value": badge_def.get(
"threshold_value", DEFAULT_BADGE_THRESHOLD
),
"chore_count_type": badge_def.get("chore_count_type", FREQUENCY_DAILY),
"earned_by": [],
"points_multiplier": badge_def.get(
"points_multiplier", DEFAULT_POINTS_MULTIPLIER
),
"icon": badge_def.get("icon", DEFAULT_ICON),
"description": badge_def.get("description", ""),
"internal_id": internal_id,
}
LOGGER.debug("Added new badge '%s' with ID: %s", badge_name, internal_id)
self._persist()
self.async_set_updated_data(self._data)
def _check_badges_for_kid(self, kid_id: str):
"""Evaluate all badge thresholds for kid."""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
return
for badge_id, badge_data in self.badges_data.items():
if kid_id in badge_data.get("earned_by", []):
continue # already earned
threshold_type = badge_data.get("threshold_type")
threshold_val = badge_data.get("threshold_value", 0)
if threshold_type == BADGE_THRESHOLD_TYPE_POINTS:
if kid_info["points"] >= threshold_val:
self._award_badge(kid_id, badge_id)
elif threshold_type == BADGE_THRESHOLD_TYPE_CHORE_COUNT:
ctype = badge_data.get("chore_count_type", FREQUENCY_DAILY)
if ctype == "total":
ccount = kid_info.get("completed_chores_total", 0)
else:
ccount = kid_info.get(f"completed_chores_{ctype}", 0)
if ccount >= threshold_val:
self._award_badge(kid_id, badge_id)
def _award_badge(self, kid_id: str, badge_id: str):
"""Add the badge to kid's 'earned_by' and kid's 'badges' list."""
badge = self.badges_data.get(badge_id)
if not badge:
LOGGER.error(
"Attempted to award non-existent badge ID '%s' to kid ID '%s'",
badge_id,
kid_id,
)
return
if kid_id in badge.get("earned_by", []):
return # already earned
badge.setdefault("earned_by", []).append(kid_id)
kid_info = self.kids_data.get(kid_id, {})
if badge["name"] not in kid_info.get("badges", []):
kid_info.setdefault("badges", []).append(badge["name"])
self._update_kid_multiplier(kid_id)
badge_name = badge["name"]
kid_name = kid_info["name"]
# Send a notification to the kid and parents that a new badge was earned
extra_data = {"kid_id": kid_id, "badge_id": badge_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Badge Earned",
message=f"You earned a new badge: '{badge_name}'!",
extra_data=extra_data,
)
)
self.hass.async_create_task(
self._notify_parents(
kid_id,
title="KidsChores: Badge Earned",
message=f"'{kid_name}' earned a new badge: '{badge_name}'.",
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def _update_kid_multiplier(self, kid_id: str):
"""Update the kid's points multiplier based on highest badge achieved."""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
return
earned_badges = [
b for b in self.badges_data.values() if kid_id in b.get("earned_by", [])
]
if not earned_badges:
kid_info["points_multiplier"] = 1.0
return
highest_mult = max(b.get("points_multiplier", 1.0) for b in earned_badges)
kid_info["points_multiplier"] = highest_mult
def _recalculate_all_badges(self):
"""Global re-check of all badges for all kids."""
LOGGER.info("Starting global badge recalculation")
## Clear current references
# for _, badge_info in self.badges_data.items():
# badge_info["earned_by"] = []
# for _, kid_info in self.kids_data.items():
# kid_info["badges"] = []
# Re-check thresholds
for badge_id, badge_info in self.badges_data.items():
ttype = badge_info.get("threshold_type", BADGE_THRESHOLD_TYPE_POINTS)
tval = badge_info.get("threshold_value", 0)
for kid_id, kid_info in self.kids_data.items():
if ttype == BADGE_THRESHOLD_TYPE_POINTS:
if kid_info.get("max_points_ever", 0.0) >= tval:
self._award_badge(kid_id, badge_id)
elif ttype == BADGE_THRESHOLD_TYPE_CHORE_COUNT:
ctype = badge_info.get("chore_count_type", FREQUENCY_DAILY)
if ctype == "total":
ccount = kid_info.get("completed_chores_total", 0)
else:
ccount = kid_info.get(f"completed_chores_{ctype}", 0)
if ccount >= tval:
self._award_badge(kid_id, badge_id)
self._persist()
self.async_set_updated_data(self._data)
LOGGER.info("Badge recalculation complete")
# -------------------------------------------------------------------------------------
# Penalties: Apply, Add
# -------------------------------------------------------------------------------------
def apply_penalty(self, parent_name: str, kid_id: str, penalty_id: str):
"""Apply penalty => negative points to reduce kid's points."""
penalty = self.penalties_data.get(penalty_id)
if not penalty:
raise HomeAssistantError(f"Penalty with ID '{penalty_id}' not found.")
kid_info = self.kids_data.get(kid_id)
if not kid_info:
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
penalty_pts = penalty.get("points", 0)
new_points = float(kid_info["points"]) + penalty_pts
self.update_kid_points(kid_id, new_points)
# increment penalty_applies
if penalty_id in kid_info["penalty_applies"]:
kid_info["penalty_applies"][penalty_id] += 1
else:
kid_info["penalty_applies"][penalty_id] = 1
# Send a notification to the kid that a penalty was applied
extra_data = {"kid_id": kid_id, "penalty_id": penalty_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Penalty Applied",
message=f"A '{penalty['name']}' penalty was applied. Your points changed by {penalty_pts}.",
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def add_penalty(self, penalty_def: dict[str, Any]):
"""Add new penalty at runtime if needed."""
penalty_name = penalty_def.get("name")
if not penalty_name:
LOGGER.warning("Add penalty: Penalty must have a name")
return
if any(p["name"] == penalty_name for p in self.penalties_data.values()):
LOGGER.warning("Add penalty: Penalty '%s' already exists", penalty_name)
return
internal_id = str(uuid.uuid4())
self.penalties_data[internal_id] = {
"name": penalty_name,
"points": penalty_def.get("points", -DEFAULT_PENALTY_POINTS),
"description": penalty_def.get("description", ""),
"icon": penalty_def.get("icon", DEFAULT_PENALTY_ICON),
"internal_id": internal_id,
}
LOGGER.debug("Added new penalty '%s' with ID: %s", penalty_name, internal_id)
self._persist()
self.async_set_updated_data(self._data)
# -------------------------------------------------------------------------
# Bonuses: Apply, Add
# -------------------------------------------------------------------------
def apply_bonus(self, parent_name: str, kid_id: str, bonus_id: str):
"""Apply bonus => positive points to increase kid's points."""
bonus = self.bonuses_data.get(bonus_id)
if not bonus:
raise HomeAssistantError(f"Bonus with ID '{bonus_id}' not found.")
kid_info = self.kids_data.get(kid_id)
if not kid_info:
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
bonus_pts = bonus.get("points", 0)
new_points = float(kid_info["points"]) + bonus_pts
self.update_kid_points(kid_id, new_points)
# increment bonus_applies
if bonus_id in kid_info["bonus_applies"]:
kid_info["bonus_applies"][bonus_id] += 1
else:
kid_info["bonus_applies"][bonus_id] = 1
# Send a notification to the kid that a bonus was applied
extra_data = {"kid_id": kid_id, "bonus_id": bonus_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Bonus Applied",
message=f"A '{bonus['name']}' bonus was applied. Your points changed by {bonus_pts}.",
extra_data=extra_data,
)
)
self._persist()
self.async_set_updated_data(self._data)
def add_bonus(self, bonus_def: dict[str, Any]):
"""Add new bonus at runtime if needed."""
bonus_name = bonus_def.get("name")
if not bonus_name:
LOGGER.warning("Add bonus: Bonus must have a name")
return
if any(s["name"] == bonus_name for s in self.bonuses_data.values()):
LOGGER.warning("Add bonus: Bonus '%s' already exists", bonus_name)
return
internal_id = str(uuid.uuid4())
self.bonuses_data[internal_id] = {
"name": bonus_name,
"points": bonus_def.get("points", DEFAULT_BONUS_POINTS),
"description": bonus_def.get("description", ""),
"icon": bonus_def.get("icon", DEFAULT_BONUS_ICON),
"internal_id": internal_id,
}
LOGGER.debug("Added new bonus '%s' with ID: %s", bonus_name, internal_id)
self._persist()
self.async_set_updated_data(self._data)
# -------------------------------------------------------------------------
# Achievements: Check, Award
# -------------------------------------------------------------------------
def _check_achievements_for_kid(self, kid_id: str):
"""Evaluate all achievement criteria for a given kid.
For each achievement not already awarded, check its type and update progress accordingly.
"""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
return
now_date = dt_util.as_local(dt_util.utcnow()).date()
for achievement_id, achievement in self._data[DATA_ACHIEVEMENTS].items():
progress = achievement.setdefault("progress", {})
if kid_id in progress and progress[kid_id].get("awarded", False):
continue
ach_type = achievement.get("type")
target = achievement.get("target_value", 1)
# For a streak achievement, update a streak counter:
if ach_type == ACHIEVEMENT_TYPE_STREAK:
progress = progress.setdefault(
kid_id, {"current_streak": 0, "last_date": None, "awarded": False}
)
self._update_streak_progress(progress, now_date)
if progress["current_streak"] >= target:
self._award_achievement(kid_id, achievement_id)
# For a total achievement, simply compare total completed chores:
elif ach_type == ACHIEVEMENT_TYPE_TOTAL:
# Get per–kid progress for this achievement.
progress = achievement.setdefault("progress", {}).setdefault(
kid_id, {"baseline": None, "current_value": 0, "awarded": False}
)
# Set the baseline so that we only count chores done after deployment.
if "baseline" not in progress or progress["baseline"] is None:
progress["baseline"] = kid_info.get("completed_chores_total", 0)
# Calculate progress as (current total minus baseline)
current_total = kid_info.get("completed_chores_total", 0)
progress["current_value"] = current_total
effective_target = progress["baseline"] + target
if current_total >= effective_target:
self._award_achievement(kid_id, achievement_id)
# For daily minimum achievement, compare total daily chores:
elif ach_type == ACHIEVEMENT_TYPE_DAILY_MIN:
# Initialize progress for this achievement if missing.
progress = achievement.setdefault("progress", {}).setdefault(
kid_id, {"last_awarded_date": None, "awarded": False}
)
today = dt_util.as_local(dt_util.utcnow()).date().isoformat()
# Only award bonus if not awarded today AND the kid's daily count meets the threshold.
if (
progress.get("last_awarded_date") != today
and kid_info.get("completed_chores_today", 0) >= target
):
self._award_achievement(kid_id, achievement_id)
progress["last_awarded_date"] = today
def _award_achievement(self, kid_id: str, achievement_id: str):
"""Award the achievement to the kid.
Update the achievement progress to indicate it is earned,
and send notifications to both the kid and their parents.
"""
achievement = self.achievements_data.get(achievement_id)
if not achievement:
LOGGER.error(
"Attempted to award non-existent achievement '%s'", achievement_id
)
return
# Get or create the existing progress dictionary for this kid
progress_for_kid = achievement.setdefault("progress", {}).get(kid_id)
if progress_for_kid is None:
# If it doesn't exist, initialize it with baseline from the kid's current total.
kid_info = self.kids_data.get(kid_id, {})
progress_dict = {
"baseline": kid_info.get("completed_chores_total", 0),
"current_value": 0,
"awarded": False,
}
achievement["progress"][kid_id] = progress_dict
progress_for_kid = progress_dict
# Mark achievement as earned for the kid by storing progress (e.g. set to target)
progress_for_kid["awarded"] = True
progress_for_kid["current_value"] = achievement.get("target_value", 1)
# Award the extra reward points defined in the achievement
extra_points = achievement.get("reward_points", 0)
kid_info = self.kids_data.get(kid_id)
if kid_info is not None:
new_points = float(kid_info["points"]) + extra_points
self.update_kid_points(kid_id, new_points)
# Notify kid and parents
extra_data = {"kid_id": kid_id, "achievement_id": achievement_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Achievement Earned",
message=f"You have earned the achievement: '{achievement.get('name')}'.",
extra_data=extra_data,
)
)
self.hass.async_create_task(
self._notify_parents(
kid_id,
title="KidsChores: Achievement Earned",
message=f"{self.kids_data[kid_id]['name']} has earned the achievement: '{achievement.get('name')}'.",
extra_data=extra_data,
)
)
LOGGER.info(
"Awarded achievement '%s' to kid '%s'", achievement.get("name"), kid_id
)
self._persist()
self.async_set_updated_data(self._data)
# -------------------------------------------------------------------------
# Challenges: Check, Award
# -------------------------------------------------------------------------
def _check_challenges_for_kid(self, kid_id: str):
"""Evaluate all challenge criteria for a given kid.
Checks that the challenge is active and then updates progress.
"""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
return
now = dt_util.utcnow()
for challenge_id, challenge in self.challenges_data.items():
progress = challenge.setdefault("progress", {})
if kid_id in progress and progress[kid_id].get("awarded", False):
continue
# Check challenge window
start_date_raw = challenge.get("start_date")
if isinstance(start_date_raw, str):
start = dt_util.parse_datetime(start_date_raw)
else:
start = None
end_date_raw = challenge.get("end_date")
if isinstance(end_date_raw, str):
end = dt_util.parse_datetime(end_date_raw)
else:
end = None
if start and now < start:
continue
if end and now > end:
continue
target = challenge.get("target_value", 1)
challenge_type = challenge.get("type")
# For a total count challenge:
if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
progress = progress.setdefault(kid_id, {"count": 0, "awarded": False})
if progress["count"] >= target:
self._award_challenge(kid_id, challenge_id)
# For a daily minimum challenge, you might store per-day counts:
elif challenge_type == CHALLENGE_TYPE_DAILY_MIN:
progress = progress.setdefault(
kid_id, {"daily_counts": {}, "awarded": False}
)
required_daily = challenge.get("required_daily", 1)
start = dt_util.parse_datetime(challenge.get("start_date"))
end = dt_util.parse_datetime(challenge.get("end_date"))
if start and end:
num_days = (end - start).days + 1
# Verify for each day:
success = True
for n in range(num_days):
day = (start + timedelta(days=n)).date().isoformat()
if progress["daily_counts"].get(day, 0) < required_daily:
success = False
break
if success:
self._award_challenge(kid_id, challenge_id)
def _award_challenge(self, kid_id: str, challenge_id: str):
"""Award the challenge to the kid.
Update progress and notify kid/parents.
"""
challenge = self.challenges_data.get(challenge_id)
if not challenge:
LOGGER.error("Attempted to award non-existent challenge '%s'", challenge_id)
return
# Get or create the existing progress dictionary for this kid
progress_for_kid = challenge.setdefault("progress", {}).setdefault(
kid_id, {"count": 0, "awarded": False}
)
# Mark challenge as earned for the kid by storing progress
progress_for_kid["awarded"] = True
progress_for_kid["count"] = challenge.get("target_value", 1)
# Award extra reward points from the challenge
extra_points = challenge.get("reward_points", 0)
kid_info = self.kids_data.get(kid_id)
if kid_info is not None:
new_points = float(kid_info["points"]) + extra_points
self.update_kid_points(kid_id, new_points)
# Notify kid and parents
extra_data = {"kid_id": kid_id, "challenge_id": challenge_id}
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Challenge Completed",
message=f"You have completed the challenge: '{challenge.get('name')}'.",
extra_data=extra_data,
)
)
self.hass.async_create_task(
self._notify_parents(
kid_id,
title="KidsChores: Challenge Completed",
message=f"{self.kids_data[kid_id]['name']} has completed the challenge: '{challenge.get('name')}'.",
extra_data=extra_data,
)
)
LOGGER.info("Awarded challenge '%s' to kid '%s'", challenge.get("name"), kid_id)
self._persist()
self.async_set_updated_data(self._data)
def _update_streak_progress(self, progress: dict, today: datetime.date):
"""Update a streak progress dict.
If the last approved date was yesterday, increment the streak.
Otherwise, reset to 1.
"""
last_date = None
if progress.get("last_date"):
# Parse the stored ISO string using Home Assistant's dt_util
last_dt = dt_util.parse_datetime(progress["last_date"])
if last_dt:
# Convert to local time and get the date portion
last_date = dt_util.as_local(last_dt).date()
if last_date == today:
# Already updated today – do nothing
return
elif last_date == today - timedelta(days=1):
progress["current_streak"] += 1
else:
progress["current_streak"] = 1
progress["last_date"] = today.isoformat()
def _update_chore_streak_for_kid(
self, kid_id: str, chore_id: str, completion_date: datetime.date
):
"""Update (or initialize) the streak for a specific chore for a kid, and update the max streak achieved so far."""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
return
# Ensure a streak dictionary exists
if "chore_streaks" not in kid_info:
kid_info["chore_streaks"] = {}
# Initialize the streak record if not already present
streak = kid_info["chore_streaks"].get(
chore_id, {"current_streak": 0, "max_streak": 0, "last_date": None}
)
last_date = None
if streak["last_date"]:
try:
last_date = datetime.fromisoformat(streak["last_date"]).date()
except Exception:
pass
if last_date == completion_date - timedelta(days=1):
streak["current_streak"] += 1
else:
streak["current_streak"] = 1
streak["last_date"] = completion_date.isoformat()
# Update the maximum streak if the current streak is higher.
if streak["current_streak"] > streak.get("max_streak", 0):
streak["max_streak"] = streak["current_streak"]
kid_info["chore_streaks"][chore_id] = streak
def _update_overall_chore_streak(self, kid_id: str, completion_date: datetime.date):
"""Update the overall streak for a kid (days in a row with at least one approved chore)."""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
return
last_date = None
if "last_chore_date" in kid_info and kid_info["last_chore_date"]:
try:
last_date = datetime.fromisoformat(kid_info["last_chore_date"]).date()
except Exception:
pass
if last_date == completion_date - timedelta(days=1):
kid_info["overall_chore_streak"] = (
kid_info.get("overall_chore_streak", 0) + 1
)
else:
kid_info["overall_chore_streak"] = 1
kid_info["last_chore_date"] = completion_date.isoformat()
# -------------------------------------------------------------------------------------
# Recurring / Reset / Overdue
# -------------------------------------------------------------------------------------
async def _check_overdue_chores(self):
"""Check and mark overdue chores if due date is passed.
Send an overdue notification only if not sent in the last 24 hours.
"""
now = dt_util.utcnow()
LOGGER.debug("Starting overdue check at %s", now.isoformat())
for chore_id, chore_info in self.chores_data.items():
# LOGGER.debug("Checking chore '%s' id '%s' (state=%s)", chore_info.get("name"), chore_id, chore_info.get("state"))
# Get the list of assigned kids
assigned_kids = chore_info.get("assigned_kids", [])
# LOGGER.debug("Chore '%s' id '%s' assigned to kids: %s", chore_info.get("name"), chore_id, assigned_kids,)
# Check if all assigned kids have either claimed or approved the chore
all_kids_claimed_or_approved = all(
chore_id in self.kids_data.get(kid_id, {}).get("claimed_chores", [])
or chore_id in self.kids_data.get(kid_id, {}).get("approved_chores", [])
for kid_id in assigned_kids
)
# Debugging: Log the claim/approval status of each assigned kid
for kid_id in assigned_kids:
kid_info = self.kids_data.get(kid_id, {})
has_claimed = chore_id in kid_info.get("claimed_chores", [])
has_approved = chore_id in kid_info.get("approved_chores", [])
# LOGGER.debug("Kid '%s': claimed=%s, approved=%s", kid_id, has_claimed, has_approved
# Log the overall result of the check
# LOGGER.debug("Chore '%s': all_kids_claimed_or_approved=%s", chore_id, all_kids_claimed_or_approved)
# Only skip the chore if ALL assigned kids have acted on it
if all_kids_claimed_or_approved:
# LOGGER.debug("Skipping chore '%s': all assigned kids have claimed or approved", chore_id,)
continue
due_str = chore_info.get("due_date")
if not due_str:
LOGGER.debug(
"Chore '%s' has no due_date; checking to confirm it isn't overdue; then skipping if not",
chore_id,
)
# If it has no due date, but is overdue, it should be marked as pending
if chore_info.get("state") == CHORE_STATE_OVERDUE:
self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING)
continue
try:
due_date = dt_util.parse_datetime(due_str)
if due_date is None:
raise ValueError("Parsed datetime is None")
due_date = dt_util.as_utc(due_date)
# LOGGER.debug("Chore '%s' due_date parsed as %s", chore_id, due_date.isoformat())
except Exception as err:
LOGGER.error(
"Error parsing due_date '%s' for chore '%s': %s",
due_str,
chore_id,
err,
)
continue
# Check for applicable day is no longer required; the scheduling function ensures due_date matches applicable day criteria.
# LOGGER.debug("Chore '%s': now=%s, due_date=%s", chore_id, now.isoformat(), due_date.isoformat()
if now < due_date:
# Not past due date, but before resetting the state back to pending, check if global state is currently overdue
for kid_id in assigned_kids:
if chore_id in kid_info.get("overdue_chores", []):
self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING)
LOGGER.debug(
"Chore '%s' status is overdue but not yet due; cleared overdue flags",
chore_id,
)
continue
# Handling for overdue is the same for shared and non-shared chores
# Status and global status will be determined by the chore state processor
assigned_kids = chore_info.get("assigned_kids", [])
for kid_id in assigned_kids:
kid_info = self.kids_data.get(kid_id, {})
# Skip if kid already claimed/approved on the chore.
if chore_id in kid_info.get(
"claimed_chores", []
) or chore_id in kid_info.get("approved_chores", []):
continue
# Mark chore as overdue for this kid.
self._process_chore_state(kid_id, chore_id, CHORE_STATE_OVERDUE)
LOGGER.debug(
"Marking chore '%s' as overdue for kid '%s'", chore_id, kid_id
)
# Check notification timestamp.
last_notif_str = kid_info["overdue_notifications"].get(chore_id)
notify = False
if last_notif_str:
try:
last_dt = dt_util.parse_datetime(last_notif_str)
if (
(not last_dt)
or (last_dt < due_date)
or ((now - last_dt) >= timedelta(hours=24))
):
notify = True
else:
LOGGER.debug(
"Chore '%s' for kid '%s' already notified within 24 hours",
chore_id,
kid_id,
)
except Exception as err:
LOGGER.error(
"Error parsing overdue notification '%s' for chore '%s', kid '%s': %s",
last_notif_str,
chore_id,
kid_id,
err,
)
notify = True
else:
notify = True
if notify:
kid_info["overdue_notifications"][chore_id] = now.isoformat()
extra_data = {"kid_id": kid_id, "chore_id": chore_id}
actions = [
{
"action": f"{ACTION_APPROVE_CHORE}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_APPROVE,
},
{
"action": f"{ACTION_DISAPPROVE_CHORE}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_DISAPPROVE,
},
{
"action": f"{ACTION_REMIND_30}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_REMIND_30,
},
]
LOGGER.debug(
"Sending overdue notification for chore '%s' to kid '%s'",
chore_id,
kid_id,
)
self.hass.async_create_task(
self._notify_kid(
kid_id,
title="KidsChores: Chore Overdue",
message=f"Your chore '{chore_info.get('name', 'Unnamed Chore')}' is overdue",
extra_data=extra_data,
)
)
self.hass.async_create_task(
self._notify_parents(
kid_id,
title="KidsChores: Chore Overdue",
message=f"{self._get_kid_name_by_id(kid_id)}'s chore '{chore_info.get('name', 'Unnamed Chore')}' is overdue",
actions=actions,
extra_data=extra_data,
)
)
LOGGER.debug("Overdue check completed")
async def _reset_all_chore_counts(self, now: datetime):
"""Trigger resets based on the current time for all frequencies."""
await self._handle_recurring_chore_resets(now)
await self._reset_daily_reward_statuses()
await self._check_overdue_chores()
for kid in self.kids_data.values():
kid["today_chore_approvals"] = {}
async def _handle_recurring_chore_resets(self, now: datetime):
"""Handle recurring resets for daily, weekly, and monthly frequencies."""
await self._reschedule_recurring_chores(now)
# Daily
if now.hour == DEFAULT_DAILY_RESET_TIME.get("hour", 0):
await self._reset_chore_counts(FREQUENCY_DAILY, now)
# Weekly
if now.weekday() == DEFAULT_WEEKLY_RESET_DAY:
await self._reset_chore_counts(FREQUENCY_WEEKLY, now)
# Monthly
days_in_month = monthrange(now.year, now.month)[1]
reset_day = min(DEFAULT_MONTHLY_RESET_DAY, days_in_month)
if now.day == reset_day:
await self._reset_chore_counts(FREQUENCY_MONTHLY, now)
async def _reset_chore_counts(self, frequency: str, now: datetime):
"""Reset chore counts and statuses based on the recurring frequency."""
# Reset counters on kids
for kid_info in self.kids_data.values():
if frequency == FREQUENCY_DAILY:
kid_info["completed_chores_today"] = 0
kid_info["points_earned_today"] = 0.0
elif frequency == FREQUENCY_WEEKLY:
kid_info["completed_chores_weekly"] = 0
kid_info["points_earned_weekly"] = 0.0
elif frequency == FREQUENCY_MONTHLY:
kid_info["completed_chores_monthly"] = 0
kid_info["points_earned_monthly"] = 0.0
LOGGER.info(f"{frequency.capitalize()} chore counts have been reset")
# If daily reset -> reset statuses
if frequency == FREQUENCY_DAILY:
await self._reset_daily_chore_statuses([frequency])
elif frequency == FREQUENCY_WEEKLY:
await self._reset_daily_chore_statuses([frequency, FREQUENCY_WEEKLY])
async def _reschedule_recurring_chores(self, now: datetime):
"""For chores with the given recurring frequency, reschedule due date if they are approved and past due."""
for chore_id, chore_info in self.chores_data.items():
# Only consider chores with a recurring frequency (any of the three) and a defined due_date:
if chore_info.get("recurring_frequency") not in (
FREQUENCY_DAILY,
FREQUENCY_WEEKLY,
FREQUENCY_BIWEEKLY,
FREQUENCY_MONTHLY,
FREQUENCY_CUSTOM,
):
continue
if not chore_info.get("due_date"):
continue
try:
due_date = dt_util.parse_datetime(
chore_info["due_date"]
) or datetime.fromisoformat(chore_info["due_date"])
except Exception as e:
LOGGER.warning("Error parsing due_date for chore '%s': %s", chore_id, e)
continue
# If the due date is in the past and the chore is approved or approved_in_part
if now > due_date and chore_info.get("state") in [
CHORE_STATE_APPROVED,
CHORE_STATE_APPROVED_IN_PART,
]:
# Reschedule the chore
self._reschedule_next_due_date(chore_info)
LOGGER.debug(
"Rescheduled recurring chore '%s'", chore_info.get("name", chore_id)
)
self._persist()
self.async_set_updated_data(self._data)
LOGGER.debug("Daily rescheduling of recurring chores complete")
async def _reset_daily_chore_statuses(self, target_freqs: list[str]):
"""Reset chore statuses and clear approved/claimed chores for chores with these freq."""
LOGGER.info("Executing _reset_daily_chore_statuses")
now = dt_util.utcnow()
for chore_id, chore_info in self.chores_data.items():
frequency = chore_info.get("recurring_frequency", FREQUENCY_NONE)
# Only consider chores whose frequency is either in target_freqs or FREQUENCY_NONE.
if frequency in target_freqs or frequency == FREQUENCY_NONE:
due_date_str = chore_info.get("due_date")
if due_date_str:
try:
due_date = dt_util.parse_datetime(
due_date_str
) or datetime.fromisoformat(due_date_str)
# If the due date has not yet been reached, skip resetting this chore.
if now < due_date:
continue
except Exception as e:
LOGGER.warning(
"Error parsing due_date '%s' for chore '%s': %s",
due_date_str,
chore_id,
e,
)
# If no due date or the due date has passed, then reset the chore state
if chore_info["state"] not in [
CHORE_STATE_PENDING,
CHORE_STATE_OVERDUE,
]:
previous_state = chore_info["state"]
for kid_id in chore_info.get("assigned_kids", []):
if kid_id:
self._process_chore_state(
kid_id, chore_id, CHORE_STATE_PENDING
)
LOGGER.debug(
"Resetting chore '%s' from '%s' to '%s'",
chore_id,
previous_state,
CHORE_STATE_PENDING,
)
# clear pending chore approvals
target_chore_ids = [
chore_id
for chore_id, chore_info in self.chores_data.items()
if chore_info.get("recurring_frequency") in target_freqs
]
self._data[DATA_PENDING_CHORE_APPROVALS] = [
ap
for ap in self._data[DATA_PENDING_CHORE_APPROVALS]
if ap["chore_id"] not in target_chore_ids
]
self._persist()
async def _reset_daily_reward_statuses(self):
"""Reset all kids' reward states daily."""
# Remove from global pending reward approvals
self._data[DATA_PENDING_REWARD_APPROVALS] = []
LOGGER.debug("Cleared all pending reward approvals globally")
# For each kid, clear pending/approved reward lists to reflect daily reset
for kid_id, kid_info in self.kids_data.items():
kid_info["pending_rewards"] = []
kid_info["redeemed_rewards"] = []
LOGGER.debug(
"Cleared daily reward statuses for kid ID '%s' (%s)",
kid_id,
kid_info.get("name", "Unknown"),
)
self._persist()
self.async_set_updated_data(self._data)
LOGGER.info("Daily reward statuses have been reset")
def _reschedule_next_due_date(self, chore_info: dict[str, Any]):
"""Reschedule the next due date based on the recurring frequency."""
freq = chore_info.get("recurring_frequency", FREQUENCY_NONE)
if freq == FREQUENCY_CUSTOM:
custom_interval = chore_info.get("custom_interval")
custom_unit = chore_info.get("custom_interval_unit")
if custom_interval is None or custom_unit not in [
"days",
"weeks",
"months",
]:
LOGGER.warning(
"Custom frequency set but custom_interval or unit invalid for chore '%s'",
chore_info.get("name"),
)
return
due_date_str = chore_info.get("due_date")
if not freq or freq == FREQUENCY_NONE or not due_date_str:
LOGGER.debug(
"Skipping reschedule: recurring_frequency=%s, due_date=%s",
freq,
due_date_str,
)
return
try:
original_due = dt_util.parse_datetime(due_date_str)
if not original_due:
original_due = datetime.fromisoformat(due_date_str)
except ValueError:
LOGGER.warning("Unable to parse due_date '%s'", due_date_str)
return
applicable_days = chore_info.get(CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS)
weekday_mapping = {i: key for i, key in enumerate(WEEKDAY_OPTIONS.keys())}
# Convert next_due to local time for proper weekday checking
now = dt_util.utcnow()
now_local = dt_util.as_local(now)
next_due = original_due
next_due_local = dt_util.as_local(next_due)
# Track first iteration to allow one advancement for future dates
first_iteration = True
# Ensure the next due date is advanced even if it's already scheduled in the future
# Handle past due_date by looping until we find a future date that is also on an applicable day
while (
first_iteration
or next_due_local <= now_local
or (
applicable_days
and weekday_mapping[next_due_local.weekday()] not in applicable_days
)
):
# If next_due is still in the past, increment by the full frequency period
if first_iteration or (next_due_local <= now_local):
if freq == FREQUENCY_DAILY:
next_due += timedelta(days=1)
elif freq == FREQUENCY_WEEKLY:
next_due += timedelta(weeks=1)
elif freq == FREQUENCY_BIWEEKLY:
next_due += timedelta(weeks=2)
elif freq == FREQUENCY_MONTHLY:
next_due = self._add_months(next_due, 1)
elif freq == FREQUENCY_CUSTOM:
if custom_unit == "days":
next_due += timedelta(days=custom_interval)
elif custom_unit == "weeks":
next_due += timedelta(weeks=custom_interval)
elif custom_unit == "months":
next_due = self._add_months(next_due, custom_interval)
else:
# Next due is in the future but not on an applicable day,
# so just add one day until it falls on an applicable day.
next_due += timedelta(days=1)
# After first loop, only move forward if necessary
first_iteration = False
# Update the local time reference for the new next_due
next_due_local = dt_util.as_local(next_due)
LOGGER.debug(
"Rescheduling chore: Original Due: %s, New Attempt: %s (Local: %s), Now: %s (Local: %s), Weekday: %s, Applicable Days: %s",
original_due,
next_due,
next_due_local,
now,
now_local,
weekday_mapping[next_due_local.weekday()],
applicable_days,
)
chore_info["due_date"] = next_due.isoformat()
chore_id = chore_info.get("internal_id")
# Update config_entry.options for this chore so that the new due_date is visible in Options
self.hass.async_create_task(
self._update_chore_due_date_in_config(
chore_id, chore_info["due_date"], None, None, None
)
)
# Reset the chore state to Pending
for kid_id in chore_info.get("assigned_kids", []):
if kid_id:
self._process_chore_state(kid_id, chore_id, CHORE_STATE_PENDING)
LOGGER.info(
"Chore '%s' rescheduled: Original due date %s, Final new due date (local) %s",
chore_info.get("name", chore_id),
dt_util.as_local(original_due).isoformat(),
next_due_local.isoformat(),
)
# Removed the _add_one_month method since _add_months method will handle all cases including adding one month.
def _add_months(self, dt_in: datetime, months: int) -> datetime:
"""Add a specified number of months to a datetime, preserving the day if possible."""
total_month = dt_in.month + months
year = dt_in.year + (total_month - 1) // 12
month = ((total_month - 1) % 12) + 1
day = dt_in.day
days_in_new_month = monthrange(year, month)[1]
if day > days_in_new_month:
day = days_in_new_month
return dt_in.replace(year=year, month=month, day=day)
# Set Chore Due Date
def set_chore_due_date(self, chore_id: str, due_date: Optional[datetime]) -> None:
"""Set the due date of a chore."""
# Retrieve the chore data; raise error if not found.
chore_info = self.chores_data.get(chore_id)
if chore_info is None:
raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.")
# Convert the due_date to an ISO-formatted string if provided; otherwise use None.
new_due_date = due_date.isoformat() if due_date else None
# Update the chore's due date. If the key is missing, add it.
try:
chore_info["due_date"] = new_due_date
except KeyError as err:
raise HomeAssistantError(
f"Missing 'due_date' key in chore data for '{chore_id}': {err}"
)
# If the due date is cleared (None), then remove any recurring frequency
# and custom interval settings unless the frequency is none, daily, or weekly.
if new_due_date is None:
# FREQUENCY_DAILY, FREQUENCY_WEEKLY, and FREQUENCY_NONE are all OK without a due_date
current_frequency = chore_info.get("recurring_frequency")
if chore_info.get("recurring_frequency") not in (
FREQUENCY_NONE,
FREQUENCY_DAILY,
FREQUENCY_WEEKLY,
):
LOGGER.debug(
"Removing frequency for chore '%s': current frequency '%s' is does not work with a due date of None",
chore_id,
current_frequency,
)
chore_info["recurring_frequency"] = FREQUENCY_NONE
chore_info.pop("custom_interval", None)
chore_info.pop("custom_interval_unit", None)
# Update config_entry.options so that the new due date is visible in Options.
# Use new_due_date here to ensure we’re passing the updated value.
self.hass.async_create_task(
self._update_chore_due_date_in_config(
chore_id,
chore_info.get("due_date"),
chore_info.get("recurring_frequency"),
chore_info.get("custom_interval"),
chore_info.get("custom_interval_unit"),
)
)
self._persist()
self.async_set_updated_data(self._data)
# Skip Chore Due Date
def skip_chore_due_date(self, chore_id: str) -> None:
"""Skip the current due date of a recurring chore and reschedule it."""
chore = self.chores_data.get(chore_id)
if not chore:
raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.")
if chore.get("recurring_frequency", FREQUENCY_NONE) == FREQUENCY_NONE:
raise HomeAssistantError(
f"Chore '{chore.get('name', chore_id)}' does not have a recurring frequency."
)
if not chore.get("due_date"):
raise HomeAssistantError(
f"Chore '{chore.get('name', chore_id)}' does not have a due date set."
)
# Compute the next due date and update the chore options/config.
self._reschedule_next_due_date(chore)
self._persist()
self.async_set_updated_data(self._data)
# Reset Overdue Chores
def reset_overdue_chores(
self, chore_id: Optional[str] = None, kid_id: Optional[str] = None
) -> None:
"""Reset overdue chore(s) to Pending state and reschedule."""
if chore_id:
# Specific chore reset (with or without kid_id)
chore = self.chores_data.get(chore_id)
if not chore:
raise HomeAssistantError(f"Chore with ID '{chore_id}' not found.")
# Reschedule happens at the chore level, so it is not necessary to check for kid_id
# _rescheduled_next_due_date will also handle setting the status to Pending
self._reschedule_next_due_date(chore)
elif kid_id:
# Kid-only reset: reset all overdue chores for the specified kid.
# Note that reschedule happens at the chore level, so it chores assigned to this kid that are multi assigned
# will show as reset for those other kids
kid = self.kids_data.get(kid_id)
if not kid:
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
for cid, chore in self.chores_data.items():
if kid_id in chore.get("assigned_kids", []):
if cid in kid.get("overdue_chores", []):
# Reschedule chore which will also set status to Pending
self._reschedule_next_due_date(chore)
else:
# Global reset: Reset all chores that are overdue.
for kid_id, kid in self.kids_data.items():
for cid, chore in self.chores_data.items():
if kid_id in chore.get("assigned_kids", []):
if cid in kid.get("overdue_chores", []):
# Reschedule chore which will also set status to Pending
self._reschedule_next_due_date(chore)
self._persist()
self.async_set_updated_data(self._data)
# -------------------------------------------------------------------------------------
# Penalties: Reset
# -------------------------------------------------------------------------------------
def reset_penalties(
self, kid_id: Optional[str] = None, penalty_id: Optional[str] = None
) -> None:
"""Reset penalties based on provided kid_id and penalty_id."""
if penalty_id and kid_id:
# Reset a specific penalty for a specific kid
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.error("Reset Penalties: Kid with ID '%s' not found.", kid_id)
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
if penalty_id not in kid_info.get("penalty_applies", {}):
LOGGER.error(
"Reset Penalties: Penalty '%s' does not apply to kid '%s'.",
penalty_id,
kid_id,
)
raise HomeAssistantError(
f"Penalty '{penalty_id}' does not apply to kid '{kid_id}'."
)
kid_info["penalty_applies"].pop(penalty_id, None)
elif penalty_id:
# Reset a specific penalty for all kids
found = False
for kid_info in self.kids_data.values():
if penalty_id in kid_info.get("penalty_applies", {}):
found = True
kid_info["penalty_applies"].pop(penalty_id, None)
if not found:
LOGGER.warning(
"Reset Penalties: Penalty '%s' not found in any kid's data.",
penalty_id,
)
elif kid_id:
# Reset all penalties for a specific kid
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.error("Reset Penalties: Kid with ID '%s' not found.", kid_id)
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
kid_info["penalty_applies"].clear()
else:
# Reset all penalties for all kids
LOGGER.info("Reset Penalties: Resetting all penalties for all kids.")
for kid_info in self.kids_data.values():
kid_info["penalty_applies"].clear()
LOGGER.debug(
"Penalties reset completed (kid_id=%s, penalty_id=%s)", kid_id, penalty_id
)
self._persist()
self.async_set_updated_data(self._data)
# -------------------------------------------------------------------------------------
# Bonuses: Reset
# -------------------------------------------------------------------------------------
def reset_bonuses(
self, kid_id: Optional[str] = None, bonus_id: Optional[str] = None
) -> None:
"""Reset bonuses based on provided kid_id and bonus_id."""
if bonus_id and kid_id:
# Reset a specific bonus for a specific kid
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.error("Reset Bonuses: Kid with ID '%s' not found.", kid_id)
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
if bonus_id not in kid_info.get("bonus_applies", {}):
LOGGER.error(
"Reset Bonuses: Bonus '%s' does not apply to kid '%s'.",
bonus_id,
kid_id,
)
raise HomeAssistantError(
f"Bonus '{bonus_id}' does not apply to kid '{kid_id}'."
)
kid_info["bonus_applies"].pop(bonus_id, None)
elif bonus_id:
# Reset a specific bonus for all kids
found = False
for kid_info in self.kids_data.values():
if bonus_id in kid_info.get("bonus_applies", {}):
found = True
kid_info["bonus_applies"].pop(bonus_id, None)
if not found:
LOGGER.warning(
"Reset Bonuses: Bonus '%s' not found in any kid's data.", bonus_id
)
elif kid_id:
# Reset all bonuses for a specific kid
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.error("Reset Bonuses: Kid with ID '%s' not found.", kid_id)
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
kid_info["bonus_applies"].clear()
else:
# Reset all bonuses for all kids
LOGGER.info("Reset Bonuses: Resetting all bonuses for all kids.")
for kid_info in self.kids_data.values():
kid_info["bonus_applies"].clear()
LOGGER.debug(
"Bonuses reset completed (kid_id=%s, bonus_id=%s)", kid_id, bonus_id
)
self._persist()
self.async_set_updated_data(self._data)
# -------------------------------------------------------------------------------------
# Rewards: Reset
# This function resets reward-related data for a specified kid and/or reward by
# clearing claims, approvals, redeemed and pending rewards, and removing associated
# pending reward approvals from the global data.
# -------------------------------------------------------------------------------------
def reset_rewards(
self, kid_id: Optional[str] = None, reward_id: Optional[str] = None
) -> None:
"""Reset rewards based on provided kid_id and reward_id."""
if reward_id and kid_id:
# Reset a specific reward for a specific kid
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.error("Reset Rewards: Kid with ID '%s' not found.", kid_id)
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
kid_info["reward_claims"].pop(reward_id, None)
kid_info["reward_approvals"].pop(reward_id, None)
kid_info["redeemed_rewards"] = [
reward for reward in kid_info["redeemed_rewards"] if reward != reward_id
]
kid_info["pending_rewards"] = [
reward for reward in kid_info["pending_rewards"] if reward != reward_id
]
# Remove open claims from pending approvals for this kid and reward.
self._data[DATA_PENDING_REWARD_APPROVALS] = [
ap
for ap in self._data[DATA_PENDING_REWARD_APPROVALS]
if not (ap["kid_id"] == kid_id and ap["reward_id"] == reward_id)
]
elif reward_id:
# Reset a specific reward for all kids
found = False
for kid_info in self.kids_data.values():
if reward_id in kid_info.get("reward_claims", {}):
found = True
kid_info["reward_claims"].pop(reward_id, None)
if reward_id in kid_info.get("reward_approvals", {}):
found = True
kid_info["reward_approvals"].pop(reward_id, None)
kid_info["redeemed_rewards"] = [
reward
for reward in kid_info["redeemed_rewards"]
if reward != reward_id
]
kid_info["pending_rewards"] = [
reward
for reward in kid_info["pending_rewards"]
if reward != reward_id
]
# Remove open claims from pending approvals for this reward (all kids).
self._data[DATA_PENDING_REWARD_APPROVALS] = [
ap
for ap in self._data[DATA_PENDING_REWARD_APPROVALS]
if ap["reward_id"] != reward_id
]
if not found:
LOGGER.warning(
"Reset Rewards: Reward '%s' not found in any kid's data.",
reward_id,
)
elif kid_id:
# Reset all rewards for a specific kid
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.error("Reset Rewards: Kid with ID '%s' not found.", kid_id)
raise HomeAssistantError(f"Kid with ID '{kid_id}' not found.")
kid_info["reward_claims"].clear()
kid_info["reward_approvals"].clear()
kid_info["redeemed_rewards"].clear()
kid_info["pending_rewards"].clear()
# Remove open claims from pending approvals for that kid.
self._data[DATA_PENDING_REWARD_APPROVALS] = [
ap
for ap in self._data[DATA_PENDING_REWARD_APPROVALS]
if ap["kid_id"] != kid_id
]
else:
# Reset all rewards for all kids
LOGGER.info("Reset Rewards: Resetting all rewards for all kids.")
for kid_info in self.kids_data.values():
kid_info["reward_claims"].clear()
kid_info["reward_approvals"].clear()
kid_info["redeemed_rewards"].clear()
kid_info["pending_rewards"].clear()
# Clear all pending reward approvals.
self._data[DATA_PENDING_REWARD_APPROVALS].clear()
LOGGER.debug(
"Rewards reset completed (kid_id=%s, reward_id=%s)", kid_id, reward_id
)
self._persist()
self.async_set_updated_data(self._data)
# Persist new due dates on config entries
# This is not being used currently, but was refactored so it calls a new function _update_chore_due_date_in_config
# which can be used to update a single chore's due date and frequency. New function can be used in multiple places.
async def _update_all_chore_due_dates_in_config(self) -> None:
"""Update due dates for all chores in config_entry.options."""
tasks = []
for chore_id, chore_info in self.chores_data.items():
if "due_date" in chore_info:
tasks.append(
self._update_chore_due_date_in_config(
chore_id,
chore_info.get("due_date"),
recurring_frequency=chore_info.get("recurring_frequency"),
custom_interval=chore_info.get("custom_interval"),
custom_interval_unit=chore_info.get("custom_interval_unit"),
)
)
# Run all updates concurrently
if tasks:
await asyncio.gather(*tasks)
# Persist new due dates on config entries
async def _update_chore_due_date_in_config(
self,
chore_id: str,
due_date: Optional[str],
recurring_frequency: Optional[str] = None,
custom_interval: Optional[int] = None,
custom_interval_unit: Optional[str] = None,
) -> None:
"""Update the due date and frequency fields for a specific chore in config_entry.options.
- due_date should be an ISO-formatted string (or None).
- If a frequency is passed, then that value is set.
If the frequency is FREQUENCY_CUSTOM, custom_interval and custom_interval_unit are required.
If the frequency is not custom, any custom interval settings are cleared.
- If no frequency is passed, then do not change the frequency or custom interval settings.
"""
updated_options = dict(self.config_entry.options)
chores_conf = dict(updated_options.get(DATA_CHORES, {}))
# Get existing options for the chore.
existing_options = dict(chores_conf.get(chore_id, {}))
# Update due_date: set if provided; otherwise remove.
if due_date is not None:
existing_options["due_date"] = due_date
else:
existing_options.pop("due_date", None)
# If a frequency is passed, update it.
if recurring_frequency is not None:
existing_options["recurring_frequency"] = recurring_frequency
if recurring_frequency == FREQUENCY_CUSTOM:
# For custom frequency, custom_interval and custom_interval_unit are required.
if custom_interval is None or custom_interval_unit is None:
raise HomeAssistantError(
"For custom frequency, both custom_interval and custom_interval_unit are required."
)
existing_options["custom_interval"] = custom_interval
existing_options["custom_interval_unit"] = custom_interval_unit
else:
# For non-custom frequencies, clear any custom interval settings.
existing_options.pop("custom_interval", None)
existing_options.pop("custom_interval_unit", None)
# If no frequency is passed, leave the frequency and custom fields unchanged.
chores_conf[chore_id] = existing_options
updated_options[DATA_CHORES] = chores_conf
new_data = dict(self.config_entry.data)
new_data["last_change"] = dt_util.utcnow().isoformat()
update_result = self.hass.config_entries.async_update_entry(
self.config_entry, data=new_data, options=updated_options
)
if asyncio.iscoroutine(update_result):
await update_result
# -------------------------------------------------------------------------------------
# Notifications
# -------------------------------------------------------------------------------------
async def send_kc_notification(
self,
user_id: Optional[str],
title: str,
message: str,
notification_id: str,
) -> None:
"""Send a persistent notification to a user if possible; fallback to a general persistent notification if the user is not found or not set."""
hass = self.hass
if not user_id:
# If no user_id is provided, use a general notification
LOGGER.debug(
"No user_id provided. Sending a general persistent notification"
)
await hass.services.async_call(
"persistent_notification",
"create",
{
"title": title,
"message": message,
"notification_id": notification_id,
},
blocking=True,
)
return
try:
user_obj: User = await hass.auth.async_get_user(user_id)
if not user_obj:
LOGGER.warning(
"User with ID '%s' not found. Sending fallback persistent notification",
user_id,
)
await hass.services.async_call(
"persistent_notification",
"create",
{
"title": title,
"message": message,
"notification_id": notification_id,
},
blocking=True,
)
return
await hass.services.async_call(
"persistent_notification",
"create",
{
"title": title,
"message": message,
"notification_id": notification_id,
},
blocking=True,
)
except Exception as err:
LOGGER.warning(
"Failed to send user-specific notification to user_id='%s': %s. Fallback to persistent_notification",
user_id,
err,
)
await hass.services.async_call(
"persistent_notification",
"create",
{
"title": title,
"message": message,
"notification_id": notification_id,
},
blocking=True,
)
async def _notify_kid(
self,
kid_id: str,
title: str,
message: str,
actions: Optional[list[dict[str, str]]] = None,
extra_data: Optional[dict] = None,
) -> None:
"""Notify a kid using their configured notification settings."""
kid_info = self.kids_data.get(kid_id)
if not kid_info:
return
if not kid_info.get("enable_notifications", True):
LOGGER.debug("Notifications disabled for kid '%s'", kid_id)
return
mobile_enabled = kid_info.get(CONF_ENABLE_MOBILE_NOTIFICATIONS, True)
persistent_enabled = kid_info.get(CONF_ENABLE_PERSISTENT_NOTIFICATIONS, True)
mobile_notify_service = kid_info.get(CONF_MOBILE_NOTIFY_SERVICE, "")
if mobile_enabled and mobile_notify_service:
await async_send_notification(
self.hass,
mobile_notify_service,
title,
message,
actions=actions,
extra_data=extra_data,
use_persistent=persistent_enabled,
)
elif persistent_enabled:
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"title": title,
"message": message,
"notification_id": f"kid_{kid_id}",
},
blocking=True,
)
else:
LOGGER.debug("No notification method configured for kid '%s'", kid_id)
async def _notify_parents(
self,
kid_id: str,
title: str,
message: str,
actions: Optional[list[dict[str, str]]] = None,
extra_data: Optional[dict] = None,
) -> None:
"""Notify all parents associated with a kid using their settings."""
for parent_id, parent_info in self.parents_data.items():
if kid_id not in parent_info.get("associated_kids", []):
continue
if not parent_info.get("enable_notifications", True):
LOGGER.debug("Notifications disabled for parent '%s'", parent_id)
continue
mobile_enabled = parent_info.get(CONF_ENABLE_MOBILE_NOTIFICATIONS, True)
persistent_enabled = parent_info.get(
CONF_ENABLE_PERSISTENT_NOTIFICATIONS, True
)
mobile_notify_service = parent_info.get(CONF_MOBILE_NOTIFY_SERVICE, "")
if mobile_enabled and mobile_notify_service:
await async_send_notification(
self.hass,
mobile_notify_service,
title,
message,
actions=actions,
extra_data=extra_data,
use_persistent=persistent_enabled,
)
elif persistent_enabled:
await self.hass.services.async_call(
"persistent_notification",
"create",
{
"title": title,
"message": message,
"notification_id": f"parent_{parent_id}",
},
blocking=True,
)
else:
LOGGER.debug(
"No notification method configured for parent '%s'", parent_id
)
async def remind_in_minutes(
self,
kid_id: str,
minutes: int,
*,
chore_id: Optional[str] = None,
reward_id: Optional[str] = None,
) -> None:
"""
Wait for the specified number of minutes and then resend the parent's
notification if the chore or reward is still pending approval.
If a chore_id is provided, the method checks the corresponding chore’s state.
If a reward_id is provided, it checks whether that reward is still pending.
"""
LOGGER.info(
"Scheduling reminder for kid '%s', chore '%s', reward '%s' in %d minutes",
kid_id,
chore_id,
reward_id,
minutes,
)
await asyncio.sleep(minutes * 60)
kid_info = self.kids_data.get(kid_id)
if not kid_info:
LOGGER.warning("Kid with ID '%s' not found during reminder check", kid_id)
return
if chore_id:
chore_info = self.chores_data.get(chore_id)
if not chore_info:
LOGGER.warning(
"Chore with ID '%s' not found during reminder check", chore_id
)
return
# Only resend if the chore is still in a pending-like state.
if chore_info.get("state") not in [
CHORE_STATE_PENDING,
CHORE_STATE_CLAIMED,
CHORE_STATE_OVERDUE,
]:
LOGGER.info(
"Chore '%s' is no longer pending approval; no reminder sent",
chore_id,
)
return
actions = [
{
"action": f"{ACTION_APPROVE_CHORE}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_APPROVE,
},
{
"action": f"{ACTION_DISAPPROVE_CHORE}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_DISAPPROVE,
},
{
"action": f"{ACTION_REMIND_30}|{kid_id}|{chore_id}",
"title": ACTION_TITLE_REMIND_30,
},
]
extra_data = {"kid_id": kid_id, "chore_id": chore_id}
await self._notify_parents(
kid_id,
title="KidsChores: Reminder for Pending Chore",
message=f"Reminder: {kid_info.get('name', 'A kid')} has '{chore_info.get('name', 'Unnamed Chore')}' chore pending approval.",
actions=actions,
extra_data=extra_data,
)
LOGGER.info("Resent reminder for chore '%s' for kid '%s'", chore_id, kid_id)
elif reward_id:
# Check if the reward is still pending approval.
if reward_id not in kid_info.get("pending_rewards", []):
LOGGER.info(
"Reward '%s' is no longer pending approval for kid '%s'; no reminder sent",
reward_id,
kid_id,
)
return
actions = [
{
"action": f"{ACTION_APPROVE_REWARD}|{kid_id}|{reward_id}",
"title": ACTION_TITLE_APPROVE,
},
{
"action": f"{ACTION_DISAPPROVE_REWARD}|{kid_id}|{reward_id}",
"title": ACTION_TITLE_DISAPPROVE,
},
{
"action": f"{ACTION_REMIND_30}|{kid_id}|{reward_id}",
"title": ACTION_TITLE_REMIND_30,
},
]
extra_data = {"kid_id": kid_id, "reward_id": reward_id}
reward = self.rewards_data.get(reward_id, {})
reward_name = reward.get("name", "the reward")
await self._notify_parents(
kid_id,
title="KidsChores: Reminder for Pending Reward",
message=f"Reminder: {kid_info.get('name', 'A kid')} has '{reward_name}' reward pending approval.",
actions=actions,
extra_data=extra_data,
)
LOGGER.info(
"Resent reminder for reward '%s' for kid '%s'", reward_id, kid_id
)
else:
LOGGER.warning("No chore_id or reward_id provided for reminder action")
# -------------------------------------------------------------------------------------
# Storage
# -------------------------------------------------------------------------------------
def _persist(self):
"""Save to persistent storage."""
self.storage_manager.set_data(self._data)
self.hass.add_job(self.storage_manager.async_save)
# -------------------------------------------------------------------------------------
# Internal Helper for kid <-> name lookups
# -------------------------------------------------------------------------------------
def _get_kid_id_by_name(self, kid_name: str) -> Optional[str]:
"""Help function to get kid_id by kid_name."""
for kid_id, k_info in self.kids_data.items():
if k_info.get("name") == kid_name:
return kid_id
return None
def _get_kid_name_by_id(self, kid_id: str) -> Optional[str]:
"""Help function to get kid_name by kid_id."""
kid_info = self.kids_data.get(kid_id)
if kid_info:
return kid_info.get("name")
return None
================================================
FILE: custom_components/kidschores/flow_helpers.py
================================================
# File: flow_helpers.py
"""Helpers for the KidsChores integration's Config and Options flow.
Provides schema builders and input-processing logic for internal_id-based management.
"""
import datetime
import uuid
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import selector, config_validation as cv
from homeassistant.util import dt as dt_util
from .const import (
ACHIEVEMENT_TYPE_DAILY_MIN,
ACHIEVEMENT_TYPE_STREAK,
ACHIEVEMENT_TYPE_TOTAL,
CHALLENGE_TYPE_DAILY_MIN,
CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW,
CONF_APPLICABLE_DAYS,
CONF_ENABLE_MOBILE_NOTIFICATIONS,
CONF_ENABLE_PERSISTENT_NOTIFICATIONS,
CONF_MOBILE_NOTIFY_SERVICE,
CONF_NOTIFY_ON_APPROVAL,
CONF_NOTIFY_ON_CLAIM,
CONF_NOTIFY_ON_DISAPPROVAL,
CONF_POINTS_LABEL,
CONF_POINTS_ICON,
DEFAULT_APPLICABLE_DAYS,
DEFAULT_NOTIFY_ON_APPROVAL,
DEFAULT_NOTIFY_ON_CLAIM,
DEFAULT_NOTIFY_ON_DISAPPROVAL,
DEFAULT_POINTS_MULTIPLIER,
DEFAULT_POINTS_LABEL,
DEFAULT_POINTS_ICON,
DOMAIN,
FREQUENCY_BIWEEKLY,
FREQUENCY_CUSTOM,
FREQUENCY_DAILY,
FREQUENCY_MONTHLY,
FREQUENCY_NONE,
FREQUENCY_WEEKLY,
WEEKDAY_OPTIONS,
)
def build_points_schema(
default_label=DEFAULT_POINTS_LABEL, default_icon=DEFAULT_POINTS_ICON
):
"""Build a schema for points label & icon."""
return vol.Schema(
{
vol.Required(CONF_POINTS_LABEL, default=default_label): str,
vol.Optional(
CONF_POINTS_ICON, default=default_icon
): selector.IconSelector(),
}
)
def build_kid_schema(
hass,
users,
default_kid_name="",
default_ha_user_id=None,
internal_id=None,
default_enable_mobile_notifications=False,
default_mobile_notify_service=None,
default_enable_persistent_notifications=False,
):
"""Build a Voluptuous schema for adding/editing a Kid, keyed by internal_id in the dict."""
user_options = [{"value": "", "label": "None"}] + [
{"value": user.id, "label": user.name} for user in users
]
notify_options = [{"value": "", "label": "None"}] + _get_notify_services(hass)
return vol.Schema(
{
vol.Required("kid_name", default=default_kid_name): str,
vol.Optional(
"ha_user", default=default_ha_user_id or ""
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=user_options,
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=False,
)
),
vol.Required(
CONF_ENABLE_MOBILE_NOTIFICATIONS,
default=default_enable_mobile_notifications,
): selector.BooleanSelector(),
vol.Optional(
CONF_MOBILE_NOTIFY_SERVICE, default=default_mobile_notify_service or ""
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=notify_options,
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=False,
)
),
vol.Required(
CONF_ENABLE_PERSISTENT_NOTIFICATIONS,
default=default_enable_persistent_notifications,
): selector.BooleanSelector(),
vol.Required("internal_id", default=internal_id or str(uuid.uuid4())): str,
}
)
def build_parent_schema(
hass,
users,
kids_dict,
default_parent_name="",
default_ha_user_id=None,
default_associated_kids=None,
default_enable_mobile_notifications=False,
default_mobile_notify_service=None,
default_enable_persistent_notifications=False,
internal_id=None,
):
"""Build a Voluptuous schema for adding/editing a Parent, keyed by internal_id in the dict."""
user_options = [{"value": "", "label": "None"}] + [
{"value": user.id, "label": user.name} for user in users
]
kid_options = [
{"value": kid_id, "label": kid_name} for kid_name, kid_id in kids_dict.items()
]
notify_options = [{"value": "", "label": "None"}] + _get_notify_services(hass)
return vol.Schema(
{
vol.Required("parent_name", default=default_parent_name): str,
vol.Optional(
"ha_user_id", default=default_ha_user_id or ""
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=user_options,
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=False,
)
),
vol.Optional(
"associated_kids", default=default_associated_kids or []
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=kid_options,
translation_key="associated_kids",
multiple=True,
)
),
vol.Required(
CONF_ENABLE_MOBILE_NOTIFICATIONS,
default=default_enable_mobile_notifications,
): selector.BooleanSelector(),
vol.Optional(
CONF_MOBILE_NOTIFY_SERVICE, default=default_mobile_notify_service or ""
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=notify_options,
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=False,
)
),
vol.Required(
CONF_ENABLE_PERSISTENT_NOTIFICATIONS,
default=default_enable_persistent_notifications,
): selector.BooleanSelector(),
vol.Required("internal_id", default=internal_id or str(uuid.uuid4())): str,
}
)
def build_chore_schema(kids_dict, default=None):
"""Build a schema for chores, referencing existing kids by name.
Uses internal_id for entity management.
"""
default = default or {}
chore_name_default = default.get("name", "")
internal_id_default = default.get("internal_id", str(uuid.uuid4()))
kid_choices = {k: k for k in kids_dict}
return vol.Schema(
{
vol.Required("chore_name", default=chore_name_default): str,
vol.Optional(
"chore_description", default=default.get("description", "")
): str,
vol.Optional(
"chore_labels", default=default.get("chore_labels", [])
): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)),
vol.Required(
"default_points", default=default.get("default_points", 5)
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Required(
"assigned_kids", default=default.get("assigned_kids", [])
): cv.multi_select(kid_choices),
vol.Required(
"shared_chore", default=default.get("shared_chore", False)
): selector.BooleanSelector(),
vol.Required(
"allow_multiple_claims_per_day",
default=default.get("allow_multiple_claims_per_day", False),
): selector.BooleanSelector(),
vol.Required(
"partial_allowed", default=default.get("partial_allowed", False)
): selector.BooleanSelector(),
vol.Optional(
"icon", default=default.get("icon", "")
): selector.IconSelector(),
vol.Required(
"recurring_frequency",
default=default.get("recurring_frequency", FREQUENCY_NONE),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
FREQUENCY_NONE,
FREQUENCY_DAILY,
FREQUENCY_WEEKLY,
FREQUENCY_BIWEEKLY,
FREQUENCY_MONTHLY,
FREQUENCY_CUSTOM,
],
translation_key="recurring_frequency",
)
),
vol.Optional(
"custom_interval", default=default.get("custom_interval", None)
): vol.Any(
None,
selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, min=1, step=1
)
),
),
vol.Optional(
"custom_interval_unit",
default=default.get("custom_interval_unit", None),
): vol.Any(
None,
selector.SelectSelector(
selector.SelectSelectorConfig(
options=["", "days", "weeks", "months"],
translation_key="custom_interval_unit",
multiple=False,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
),
vol.Optional(
CONF_APPLICABLE_DAYS,
default=default.get(CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
{"value": key, "label": WEEKDAY_OPTIONS[key]}
for key in WEEKDAY_OPTIONS
],
multiple=True,
translation_key="applicable_days",
)
),
vol.Optional("due_date", default=default.get("due_date")): vol.Any(
None, selector.DateTimeSelector()
),
vol.Optional(
CONF_NOTIFY_ON_CLAIM,
default=default.get(CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_ON_APPROVAL,
default=default.get(
CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL
),
): selector.BooleanSelector(),
vol.Optional(
CONF_NOTIFY_ON_DISAPPROVAL,
default=default.get(
CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL
),
): selector.BooleanSelector(),
vol.Required("internal_id", default=internal_id_default): str,
}
)
def build_badge_schema(default=None):
"""Build a schema for badges, keyed by internal_id in the dict."""
default = default or {}
badge_name_default = default.get("name", "")
internal_id_default = default.get("internal_id", str(uuid.uuid4()))
points_multiplier_default = default.get(
"points_multiplier", DEFAULT_POINTS_MULTIPLIER
)
return vol.Schema(
{
vol.Required("badge_name", default=badge_name_default): str,
vol.Optional(
"badge_description", default=default.get("description", "")
): str,
vol.Optional(
"badge_labels", default=default.get("badge_labels", [])
): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)),
vol.Required(
"threshold_type",
default=default.get("threshold_type", "points"),
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=["points", "chore_count"],
translation_key="threshold_type",
)
),
vol.Required(
"threshold_value", default=default.get("threshold_value", 10)
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Required(
"points_multiplier",
default=points_multiplier_default,
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, step=0.01, min=1.0
)
),
vol.Optional(
"icon", default=default.get("icon", "")
): selector.IconSelector(),
vol.Required("internal_id", default=internal_id_default): str,
}
)
def build_reward_schema(default=None):
"""Build a schema for rewards, keyed by internal_id in the dict."""
default = default or {}
reward_name_default = default.get("name", "")
internal_id_default = default.get("internal_id", str(uuid.uuid4()))
return vol.Schema(
{
vol.Required("reward_name", default=reward_name_default): str,
vol.Optional(
"reward_description", default=default.get("description", "")
): str,
vol.Optional(
"reward_labels", default=default.get("reward_labels", [])
): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)),
vol.Required(
"reward_cost", default=default.get("cost", 10.0)
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Optional(
"icon", default=default.get("icon", "")
): selector.IconSelector(),
vol.Required("internal_id", default=internal_id_default): str,
}
)
def build_achievement_schema(kids_dict, chores_dict, default=None):
"""Build a schema for achievements, keyed by internal_id."""
default = default or {}
achievement_name_default = default.get("name", "")
internal_id_default = default.get("internal_id", str(uuid.uuid4()))
kid_options = [
{"value": kid_id, "label": kid_name} for kid_name, kid_id in kids_dict.items()
]
chore_options = [{"value": "", "label": "None"}]
for chore_id, chore_data in chores_dict.items():
chore_name = chore_data.get("name", f"Chore {chore_id[:6]}")
chore_options.append({"value": chore_id, "label": chore_name})
default_selected_chore = default.get("selected_chore_id", "")
if not default_selected_chore or default_selected_chore not in [
option["value"] for option in chore_options
]:
pass
default_criteria = default.get("criteria", "")
default_assigned_kids = default.get("assigned_kids", [])
if not isinstance(default_assigned_kids, list):
default_assigned_kids = [default_assigned_kids]
return vol.Schema(
{
vol.Required("name", default=achievement_name_default): str,
vol.Optional("description", default=default.get("description", "")): str,
vol.Optional(
"achievement_labels", default=default.get("achievement_labels", [])
): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)),
vol.Optional(
"icon", default=default.get("icon", "")
): selector.IconSelector(),
vol.Required(
"assigned_kids", default=default_assigned_kids
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=kid_options,
translation_key="assigned_kids",
multiple=True,
)
),
vol.Required(
"type", default=default.get("type", ACHIEVEMENT_TYPE_STREAK)
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
{"value": ACHIEVEMENT_TYPE_STREAK, "label": "Chore Streak"},
{"value": ACHIEVEMENT_TYPE_TOTAL, "label": "Chore Total"},
{
"value": ACHIEVEMENT_TYPE_DAILY_MIN,
"label": "Daily Minimum Chores",
},
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
# If type == "chore_streak", let the user choose the chore to track:
vol.Optional(
"selected_chore_id", default=default_selected_chore
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=chore_options,
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=False,
)
),
# For non-streak achievements the user can type criteria freely:
vol.Optional("criteria", default=default_criteria): str,
vol.Required(
"target_value", default=default.get("target_value", 1)
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Required(
"reward_points", default=default.get("reward_points", 0)
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Required("internal_id", default=internal_id_default): str,
}
)
def build_challenge_schema(kids_dict, chores_dict, default=None):
"""Build a schema for challenges, keyed by internal_id."""
default = default or {}
challenge_name_default = default.get("name", "")
internal_id_default = default.get("internal_id", str(uuid.uuid4()))
kid_options = [
{"value": kid_id, "label": kid_name} for kid_name, kid_id in kids_dict.items()
]
chore_options = [{"value": "", "label": ""}]
for chore_id, chore_data in chores_dict.items():
chore_name = chore_data.get("name", f"Chore {chore_id[:6]}")
chore_options.append({"value": chore_id, "label": chore_name})
default_selected_chore = default.get("selected_chore_id", "")
available_values = [option["value"] for option in chore_options]
if default_selected_chore not in available_values:
default_selected_chore = ""
default_criteria = default.get("criteria", "")
default_assigned_kids = default.get("assigned_kids", [])
if not isinstance(default_assigned_kids, list):
default_assigned_kids = [default_assigned_kids]
return vol.Schema(
{
vol.Required("name", default=challenge_name_default): str,
vol.Optional("description", default=default.get("description", "")): str,
vol.Optional(
"challenge_labels", default=default.get("challenge_labels", [])
): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)),
vol.Optional(
"icon", default=default.get("icon", "")
): selector.IconSelector(),
vol.Required(
"assigned_kids", default=default_assigned_kids
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=kid_options,
translation_key="assigned_kids",
multiple=True,
)
),
vol.Required(
"type", default=default.get("type", CHALLENGE_TYPE_DAILY_MIN)
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
{
"value": CHALLENGE_TYPE_DAILY_MIN,
"label": "Minimum Chores per Day",
},
{
"value": CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW,
"label": "Total Chores within Period",
},
],
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
# If type == "chore_streak", let the user choose the chore to track:
vol.Optional(
"selected_chore_id", default=default_selected_chore
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=chore_options,
mode=selector.SelectSelectorMode.DROPDOWN,
multiple=False,
)
),
# For non-streak achievements the user can type criteria freely:
vol.Optional("criteria", default=default_criteria): str,
vol.Required(
"target_value", default=default.get("target_value", 1)
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Required(
"reward_points", default=default.get("reward_points", 0)
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Required(
"start_date", default=default.get("start_date")
): selector.DateTimeSelector(),
vol.Required(
"end_date", default=default.get("end_date")
): selector.DateTimeSelector(),
vol.Required("internal_id", default=internal_id_default): str,
}
)
def build_penalty_schema(default=None):
"""Build a schema for penalties, keyed by internal_id in the dict.
Stores penalty_points as positive in the form, converted to negative internally.
"""
default = default or {}
penalty_name_default = default.get("name", "")
internal_id_default = default.get("internal_id", str(uuid.uuid4()))
# Display penalty points as positive for user input
display_points = abs(default.get("points", 1)) if default else 1
return vol.Schema(
{
vol.Required("penalty_name", default=penalty_name_default): str,
vol.Optional(
"penalty_description", default=default.get("description", "")
): str,
vol.Optional(
"penalty_labels", default=default.get("penalty_labels", [])
): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)),
vol.Required(
"penalty_points", default=display_points
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Optional(
"icon", default=default.get("icon", "")
): selector.IconSelector(),
vol.Required("internal_id", default=internal_id_default): str,
}
)
def build_bonus_schema(default=None):
"""Build a schema for bonuses, keyed by internal_id in the dict.
Stores bonus_points as positive in the form, converted to negative internally.
"""
default = default or {}
bonus_name_default = default.get("name", "")
internal_id_default = default.get("internal_id", str(uuid.uuid4()))
# Display bonus points as positive for user input
display_points = abs(default.get("points", 1)) if default else 1
return vol.Schema(
{
vol.Required("bonus_name", default=bonus_name_default): str,
vol.Optional(
"bonus_description", default=default.get("description", "")
): str,
vol.Optional(
"bonus_labels", default=default.get("bonus_labels", [])
): selector.LabelSelector(selector.LabelSelectorConfig(multiple=True)),
vol.Required(
"bonus_points", default=display_points
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX,
min=0,
step=0.1,
)
),
vol.Optional(
"icon", default=default.get("icon", "")
): selector.IconSelector(),
vol.Required("internal_id", default=internal_id_default): str,
}
)
# ----------------- HELPERS -----------------
# Penalty points are stored as negative internally, but displayed as positive in the form.
def process_penalty_form_input(user_input: dict) -> dict:
"""Ensure penalty points are negative internally."""
data = dict(user_input)
data["points"] = -abs(data["penalty_points"])
return data
# Get notify services from HA
def _get_notify_services(hass: HomeAssistant) -> list[dict[str, str]]:
"""Return a list of all notify.* services as [{'value': 'notify.foo', 'label': 'notify.foo'}, ...]."""
services_list = []
all_services = hass.services.async_services()
if "notify" in all_services:
for service_name in all_services["notify"].keys():
fullname = f"notify.{service_name}"
services_list.append({"value": fullname, "label": fullname})
return services_list
# Ensure aware datetime objects
def ensure_utc_datetime(hass: HomeAssistant, dt_value: any) -> str:
"""Convert a datetime input (or a datetime string) into an ISO string that is timezone aware (in UTC).
If dt_value is naive, assume it is in the local timezone.
"""
# Convert dt_value to a datetime object if necessary
if not isinstance(dt_value, datetime.datetime):
dt_value = dt_util.parse_datetime(dt_value)
if dt_value is None:
raise ValueError(f"Unable to parse datetime from {dt_value}")
# If the datetime is naive, assume local time using hass.config.time_zone
if dt_value.tzinfo is None:
local_tz = dt_util.get_time_zone(hass.config.time_zone)
dt_value = dt_value.replace(tzinfo=local_tz)
# Convert to UTC and return the ISO string
return dt_util.as_utc(dt_value).isoformat()
================================================
FILE: custom_components/kidschores/kc_helpers.py
================================================
# File: kc_helpers.py
"""KidsChores helper functions and shared logic."""
from homeassistant.core import HomeAssistant
from homeassistant.auth.models import User
from homeassistant.helpers.label_registry import async_get
from typing import Optional
from .const import LOGGER, DOMAIN
from .coordinator import KidsChoresDataCoordinator
# -------- Get Coordinator --------
def _get_kidschores_coordinator(
hass: HomeAssistant,
) -> KidsChoresDataCoordinator | None:
"""Retrieve KidsChores coordinator from hass.data."""
domain_entries = hass.data.get(DOMAIN, {})
if not domain_entries:
return None
entry_id = next(iter(domain_entries), None)
if not entry_id:
return None
data = domain_entries.get(entry_id)
if not data or "coordinator" not in data:
return None
return data["coordinator"]
# -------- Authorization for General Actions --------
async def is_user_authorized_for_global_action(
hass: HomeAssistant,
user_id: str,
action: str,
) -> bool:
"""Check if the user is allowed to do a global action (penalty, reward, points adjust) that doesn't require a specific kid_id.
By default:
- Admin users => authorized
- Everyone else => not authorized
"""
if not user_id:
return False # no user context => not authorized
user: User = await hass.auth.async_get_user(user_id)
if not user:
LOGGER.warning("%s: Invalid user ID '%s'", action, user_id)
return False
if user.is_admin:
return True
# Allow non-admin users if they are registered as a parent in KidsChores.
coordinator = _get_kidschores_coordinator(hass)
if coordinator:
for parent in coordinator.parents_data.values():
if parent.get("ha_user_id") == user.id:
return True
LOGGER.warning(
"%s: Non-admin user '%s' is not authorized in this logic", action, user.name
)
return False
# -------- Authorization for Kid-Specific Actions --------
async def is_user_authorized_for_kid(
hass: HomeAssistant,
user_id: str,
kid_id: str,
) -> bool:
"""Check if user is authorized to manage chores/rewards/etc. for the given kid.
By default:
- Admin => authorized
- If kid_info['ha_user_id'] == user.id => authorized
- Otherwise => not authorized
"""
if not user_id:
return False
user: User = await hass.auth.async_get_user(user_id)
if not user:
LOGGER.warning("Authorization: Invalid user ID '%s'", user_id)
return False
# Admin => automatically allowed
if user.is_admin:
return True
# Allow non-admin users if they are registered as a parent in KidsChores.
coordinator = _get_kidschores_coordinator(hass)
if coordinator:
for parent in coordinator.parents_data.values():
if parent.get("ha_user_id") == user.id:
return True
coordinator: KidsChoresDataCoordinator = _get_kidschores_coordinator(hass)
if not coordinator:
LOGGER.warning("Authorization: No KidsChores coordinator found")
return False
kid_info = coordinator.kids_data.get(kid_id)
if not kid_info:
LOGGER.warning(
"Authorization: Kid ID '%s' not found in coordinator data", kid_id
)
return False
linked_ha_id = kid_info.get("ha_user_id")
if linked_ha_id and linked_ha_id == user.id:
return True
LOGGER.warning(
"Authorization: Non-admin user '%s' attempted to manage kid '%s' but is not linked",
user.name,
kid_info.get("name"),
)
return False
# ------------------ Helper Functions ------------------
def _get_kid_id_by_name(self, kid_name: str) -> Optional[str]:
"""Help function to get kid_id by kid_name."""
for kid_id, kid_info in self.kids_data.items():
if kid_info.get("name") == kid_name:
return kid_id
return None
def _get_kid_name_by_id(self, kid_id: str) -> Optional[str]:
"""Help function to get kid_name by kid_id."""
kid_info = self.kids_data.get(kid_id)
if kid_info:
return kid_info.get("name")
return None
def get_friendly_label(hass, label_name: str) -> str:
registry = async_get(hass)
entries = registry.async_list_labels()
label_entry = registry.async_get_label(label_name)
return label_entry.name if label_entry else label_name
================================================
FILE: custom_components/kidschores/manifest.json
================================================
{
"domain": "kidschores",
"name": "KidsChores",
"codeowners": ["@ad-ha"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/ad-ha/kidschores-ha",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/ad-ha/kidschores-ha/issues",
"requirements": [],
"version": "0.3.0"
}
================================================
FILE: custom_components/kidschores/notification_action_handler.py
================================================
# File: notification_action_handler.py
"""Handle notification actions from HA companion notifications."""
from homeassistant.core import HomeAssistant, Event
from homeassistant.exceptions import HomeAssistantError
from .const import (
NOTIFICATION_EVENT,
ACTION_APPROVE_CHORE,
ACTION_APPROVE_REWARD,
ACTION_DISAPPROVE_CHORE,
ACTION_DISAPPROVE_REWARD,
ACTION_REMIND_30,
DEFAULT_REMINDER_DELAY,
LOGGER,
)
from .coordinator import KidsChoresDataCoordinator
async def async_handle_notification_action(hass: HomeAssistant, event: Event) -> None:
"""Handle notification actions from HA companion notifications."""
action_field = event.data.get("action")
if not action_field:
LOGGER.error("No action found in event data: %s", event.data)
return
parts = action_field.split("|")
base_action = parts[0]
kid_id = None
chore_id = None
reward_id = None
# Decide what to expect based on the base action.
if base_action in (ACTION_APPROVE_REWARD, ACTION_DISAPPROVE_REWARD):
if len(parts) < 3:
LOGGER.error("Not enough context in reward action field: %s", action_field)
return
kid_id = parts[1]
reward_id = parts[2]
elif base_action in (
ACTION_APPROVE_CHORE,
ACTION_DISAPPROVE_CHORE,
ACTION_REMIND_30,
):
if len(parts) < 3:
LOGGER.error("Not enough context in chore action field: %s", action_field)
return
kid_id = parts[1]
chore_id = parts[2]
else:
LOGGER.error("Unknown base action: %s", base_action)
return
# Parent name may be provided in the event data or use a default.
parent_name = event.data.get("parent_name", "ParentOrAdmin")
if not kid_id or not base_action:
LOGGER.error("Notification action event missing required data: %s", event.data)
return
# Retrieve the coordinator.
domain_data = hass.data.get("kidschores", {})
if not domain_data:
LOGGER.error("No KidsChores data found in hass.data")
return
entry_id = next(iter(domain_data))
coordinator: KidsChoresDataCoordinator = domain_data[entry_id].get("coordinator")
if not coordinator:
LOGGER.error("No coordinator found in KidsChores data")
return
try:
if base_action == ACTION_APPROVE_CHORE:
await coordinator.approve_chore(
parent_name=parent_name,
kid_id=kid_id,
chore_id=chore_id,
)
elif base_action == ACTION_DISAPPROVE_CHORE:
await coordinator.disapprove_chore(
parent_name=parent_name,
kid_id=kid_id,
chore_id=chore_id,
)
elif base_action == ACTION_APPROVE_REWARD:
await coordinator.approve_reward(
parent_name=parent_name,
kid_id=kid_id,
reward_id=reward_id,
)
elif base_action == ACTION_DISAPPROVE_REWARD:
await coordinator.disapprove_reward(
parent_name=parent_name,
kid_id=kid_id,
reward_id=reward_id,
)
elif base_action == ACTION_REMIND_30:
await coordinator.remind_in_minutes(
kid_id=kid_id,
chore_id=chore_id,
reward_id=reward_id,
minutes=DEFAULT_REMINDER_DELAY,
)
else:
LOGGER.error("Received unknown notification action: %s", base_action)
except HomeAssistantError as err:
LOGGER.error("Error processing notification action %s: %s", base_action, err)
================================================
FILE: custom_components/kidschores/notification_helper.py
================================================
# File: notification_helper.py
"""Sends notifications using Home Assistant's notify services.
This module implements a helper for sending notifications in the KidsChores integration.
It supports sending notifications via Home Assistant’s notify services (HA Companion notifications)
and includes an optional payload of actions. For actionable notifications, you must encode extra
context (like kid_id and chore_id) directly into the action string.
All texts and labels are referenced from constants.
"""
from __future__ import annotations
from typing import Optional
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN, LOGGER
async def async_send_notification(
hass: HomeAssistant,
notify_service: str,
title: str,
message: str,
actions: Optional[list[dict[str, str]]] = None,
extra_data: Optional[dict[str, str]] = None,
use_persistent: bool = False,
) -> None:
"""Send a notification using the specified notify service."""
payload = {"title": title, "message": message}
if actions:
payload.setdefault("data", {})["actions"] = actions
if extra_data:
payload.setdefault("data", {}).update(extra_data)
try:
if "." not in notify_service:
domain = "notify"
service = notify_service
else:
domain, service = notify_service.split(".", 1)
await hass.services.async_call(domain, service, payload, blocking=True)
LOGGER.debug("Notification sent via '%s': %s", notify_service, payload)
except Exception as err:
LOGGER.error(
"Failed to send notification via '%s': %s. Payload: %s",
notify_service,
err,
payload,
)
raise HomeAssistantError(
f"Failed to send notification via '{notify_service}': {err}"
) from err
================================================
FILE: custom_components/kidschores/options_flow.py
================================================
# File: options_flow.py
"""Options Flow for the KidsChores integration, managing entities by internal_id.
Handles add/edit/delete operations with entities referenced internally by internal_id.
Ensures consistency and reloads the integration upon changes.
"""
import datetime
import uuid
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.helpers import selector
from homeassistant.util import dt as dt_util
from .const import (
ACHIEVEMENT_TYPE_STREAK,
CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW,
CONF_APPLICABLE_DAYS,
CONF_ACHIEVEMENTS,
CONF_BADGES,
CONF_CHALLENGES,
CONF_CHORES,
CONF_KIDS,
CONF_NOTIFY_ON_APPROVAL,
CONF_NOTIFY_ON_CLAIM,
CONF_NOTIFY_ON_DISAPPROVAL,
CONF_PARENTS,
CONF_PENALTIES,
CONF_POINTS_ICON,
CONF_POINTS_LABEL,
CONF_REWARDS,
CONF_BONUSES,
DEFAULT_APPLICABLE_DAYS,
DEFAULT_NOTIFY_ON_APPROVAL,
DEFAULT_NOTIFY_ON_CLAIM,
DEFAULT_NOTIFY_ON_DISAPPROVAL,
DEFAULT_POINTS_ICON,
DEFAULT_POINTS_LABEL,
FREQUENCY_CUSTOM,
DOMAIN,
LOGGER,
)
from .flow_helpers import (
build_points_schema,
build_kid_schema,
build_parent_schema,
build_chore_schema,
build_badge_schema,
build_reward_schema,
build_penalty_schema,
build_achievement_schema,
build_challenge_schema,
ensure_utc_datetime,
build_bonus_schema,
)
def _ensure_str(value):
"""Convert anything to string safely."""
if isinstance(value, dict):
# Attempt to get a known key or fallback
return str(value.get("value", next(iter(value.values()), "")))
return str(value)
class KidsChoresOptionsFlowHandler(config_entries.OptionsFlow):
"""Options Flow for adding/editing/deleting kids, chores, badges, rewards, penalties, and bonuses.
Manages entities via internal_id for consistency and historical data preservation.
"""
def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize the options flow."""
self._entry_options = {}
self._action = None
self._entity_type = None
async def async_step_init(self, user_input=None):
"""Display the main menu for the Options Flow.
Add/Edit/Delete kid, chore, badge, reward, penalty, or done.
"""
self._entry_options = dict(self.config_entry.options)
if user_input is not None:
selection = user_input["menu_selection"]
if selection.startswith("manage_"):
self._entity_type = selection.replace("manage_", "")
# If user chose manage_points
if self._entity_type == "points":
return await self.async_step_manage_points()
# Else manage other entities
return await self.async_step_manage_entity()
elif selection == "done":
return self.async_abort(reason="setup_complete")
main_menu = [
"manage_points",
"manage_kid",
"manage_parent",
"manage_chore",
"manage_badge",
"manage_reward",
"manage_penalty",
"manage_bonus",
"manage_achievement",
"manage_challenge",
"done",
]
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required("menu_selection"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=main_menu,
mode=selector.SelectSelectorMode.LIST,
translation_key="main_menu",
)
)
}
),
)
async def async_step_manage_points(self, user_input=None):
"""Let user edit the points label/icon after initial setup."""
if user_input is not None:
new_label = user_input.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL)
new_icon = user_input.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON)
self._entry_options = dict(self.config_entry.options)
self._entry_options[CONF_POINTS_LABEL] = new_label
self._entry_options[CONF_POINTS_ICON] = new_icon
LOGGER.debug(
"Before saving points, entry_options = %s", self._entry_options
)
await self._update_and_reload()
return await self.async_step_init()
# Get existing values from entry options
current_label = self._entry_options.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL)
current_icon = self._entry_options.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON)
# Build the form
points_schema = build_points_schema(
default_label=current_label, default_icon=current_icon
)
return self.async_show_form(
step_id="manage_points",
data_schema=points_schema,
description_placeholders={},
)
async def async_step_manage_entity(self, user_input=None):
"""Handle the management actions for a selected entity type.
Presents add/edit/delete options for the selected entity.
"""
if user_input is not None:
self._action = user_input["manage_action"]
# Route to the corresponding step based on action
if self._action == "add":
return await getattr(self, f"async_step_add_{self._entity_type}")()
elif self._action in ["edit", "delete"]:
return await self.async_step_select_entity()
elif self._action == "back":
return await self.async_step_init()
# Define manage action choices
manage_action_choices = [
"add",
"edit",
"delete",
"back", # Option to go back to the main menu
]
return self.async_show_form(
step_id="manage_entity",
data_schema=vol.Schema(
{
vol.Required("manage_action"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=manage_action_choices,
mode=selector.SelectSelectorMode.LIST,
translation_key="manage_actions",
)
)
}
),
description_placeholders={"entity_type": self._entity_type},
)
async def async_step_select_entity(self, user_input=None):
"""Select an entity (kid, chore, etc.) to edit or delete based on internal_id."""
if self._action not in ["edit", "delete"]:
LOGGER.error("Invalid action '%s' for select_entity step", self._action)
return self.async_abort(reason="invalid_action")
entity_dict = self._get_entity_dict()
entity_names = [data["name"] for data in entity_dict.values()]
if user_input is not None:
selected_name = _ensure_str(user_input["entity_name"])
internal_id = next(
(
eid
for eid, data in entity_dict.items()
if data["name"] == selected_name
),
None,
)
if not internal_id:
LOGGER.error("Selected entity '%s' not found", selected_name)
return self.async_abort(reason="invalid_entity")
# Store internal_id in context for later use
self.context["internal_id"] = internal_id
# Route to the corresponding edit/delete step
return await getattr(
self, f"async_step_{self._action}_{self._entity_type}"
)()
if not entity_names:
return self.async_abort(reason=f"no_{self._entity_type}s")
return self.async_show_form(
step_id="select_entity",
data_schema=vol.Schema(
{
vol.Required("entity_name"): selector.SelectSelector(
selector.SelectSelectorConfig(
options=entity_names,
mode=selector.SelectSelectorMode.DROPDOWN,
sort=True,
)
)
}
),
description_placeholders={
"entity_type": self._entity_type,
"action": self._action,
},
)
def _get_entity_dict(self):
"""Retrieve the appropriate entity dictionary based on entity_type."""
entity_type_to_conf = {
"kid": CONF_KIDS,
"parent": CONF_PARENTS,
"chore": CONF_CHORES,
"badge": CONF_BADGES,
"reward": CONF_REWARDS,
"penalty": CONF_PENALTIES,
"achievement": CONF_ACHIEVEMENTS,
"challenge": CONF_CHALLENGES,
"bonus": CONF_BONUSES,
}
key = entity_type_to_conf.get(self._entity_type)
if key is None:
LOGGER.error(
"Unknown entity_type '%s'. Cannot retrieve entity dictionary",
self._entity_type,
)
return {}
return self._entry_options.get(key, {})
# ------------------ ADD ENTITY ------------------
async def async_step_add_kid(self, user_input=None):
"""Add a new kid."""
self._entry_options = dict(self.config_entry.options)
errors = {}
kids_dict = self._entry_options.setdefault(CONF_KIDS, {})
if user_input is not None:
kid_name = user_input["kid_name"].strip()
ha_user_id = user_input.get("ha_user") or ""
enable_mobile_notifications = user_input.get(
"enable_mobile_notifications", True
)
notify_service = user_input.get("mobile_notify_service") or ""
enable_persist = user_input.get("enable_persistent_notifications", True)
if any(kid_data["name"] == kid_name for kid_data in kids_dict.values()):
errors["kid_name"] = "duplicate_kid"
else:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
kids_dict[internal_id] = {
"name": kid_name,
"ha_user_id": ha_user_id,
"enable_notifications": enable_mobile_notifications,
"mobile_notify_service": notify_service,
"use_persistent_notifications": enable_persist,
"internal_id": internal_id,
}
self._entry_options[CONF_KIDS] = kids_dict
LOGGER.debug("Added kid '%s' with ID: %s", kid_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
# Retrieve HA users for linking
users = await self.hass.auth.async_get_users()
schema = build_kid_schema(
self.hass,
users=users,
default_kid_name="",
default_ha_user_id=None,
default_enable_mobile_notifications=False,
default_mobile_notify_service=None,
default_enable_persistent_notifications=False,
)
return self.async_show_form(
step_id="add_kid", data_schema=schema, errors=errors
)
async def async_step_add_parent(self, user_input=None):
"""Add a new parent."""
self._entry_options = dict(self.config_entry.options)
errors = {}
parents_dict = self._entry_options.setdefault(CONF_PARENTS, {})
if user_input is not None:
parent_name = user_input["parent_name"].strip()
ha_user_id = user_input.get("ha_user_id") or ""
associated_kids = user_input.get("associated_kids", [])
enable_mobile_notifications = user_input.get(
"enable_mobile_notifications", True
)
notify_service = user_input.get("mobile_notify_service") or ""
enable_persist = user_input.get("enable_persistent_notifications", True)
if any(
parent_data["name"] == parent_name
for parent_data in parents_dict.values()
):
errors["parent_name"] = "duplicate_parent"
else:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
parents_dict[internal_id] = {
"name": parent_name,
"ha_user_id": ha_user_id,
"associated_kids": associated_kids,
"enable_notifications": enable_mobile_notifications,
"mobile_notify_service": notify_service,
"use_persistent_notifications": enable_persist,
"internal_id": internal_id,
}
self._entry_options[CONF_PARENTS] = parents_dict
LOGGER.debug("Added parent '%s' with ID: %s", parent_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
# Retrieve HA users and existing kids for linking
users = await self.hass.auth.async_get_users()
kids_dict = {
kid_data["name"]: kid_id
for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items()
}
parent_schema = build_parent_schema(
self.hass,
users=users,
kids_dict=kids_dict,
default_parent_name="",
default_ha_user_id=None,
default_associated_kids=[],
default_enable_mobile_notifications=False,
default_mobile_notify_service=None,
default_enable_persistent_notifications=False,
internal_id=None,
)
return self.async_show_form(
step_id="add_parent", data_schema=parent_schema, errors=errors
)
async def async_step_add_chore(self, user_input=None):
"""Add a new chore."""
self._entry_options = dict(self.config_entry.options)
errors = {}
chores_dict = self._entry_options.setdefault(CONF_CHORES, {})
if user_input is not None:
chore_name = user_input["chore_name"].strip()
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if user_input.get("due_date"):
raw_due = user_input["due_date"]
try:
due_date_str = ensure_utc_datetime(self.hass, raw_due)
due_dt = dt_util.parse_datetime(due_date_str)
if due_dt and due_dt < dt_util.utcnow():
errors["due_date"] = "due_date_in_past"
except ValueError:
errors["due_date"] = "invalid_due_date"
due_date_str = None
else:
due_date_str = None
if any(
chore_data["name"] == chore_name for chore_data in chores_dict.values()
):
errors["chore_name"] = "duplicate_chore"
if errors:
kids_dict = {
data["name"]: eid
for eid, data in self._entry_options.get(CONF_KIDS, {}).items()
}
schema = build_chore_schema(kids_dict, default=user_input)
return self.async_show_form(
step_id="add_chore", data_schema=schema, errors=errors
)
if user_input.get("recurring_frequency") != FREQUENCY_CUSTOM:
user_input.pop("custom_interval", None)
user_input.pop("custom_interval_unit", None)
chores_dict[internal_id] = {
"name": chore_name,
"default_points": user_input["default_points"],
"partial_allowed": user_input["partial_allowed"],
"shared_chore": user_input["shared_chore"],
"allow_multiple_claims_per_day": user_input[
"allow_multiple_claims_per_day"
],
"assigned_kids": user_input["assigned_kids"],
"description": user_input.get("chore_description", ""),
"chore_labels": user_input.get("chore_labels", []),
"icon": user_input.get("icon", ""),
"recurring_frequency": user_input.get("recurring_frequency", "none"),
"custom_interval": user_input.get("custom_interval"),
"custom_interval_unit": user_input.get("custom_interval_unit"),
"due_date": due_date_str,
"applicable_days": user_input.get(
CONF_APPLICABLE_DAYS, DEFAULT_APPLICABLE_DAYS
),
"notify_on_claim": user_input.get(
CONF_NOTIFY_ON_CLAIM, DEFAULT_NOTIFY_ON_CLAIM
),
"notify_on_approval": user_input.get(
CONF_NOTIFY_ON_APPROVAL, DEFAULT_NOTIFY_ON_APPROVAL
),
"notify_on_disapproval": user_input.get(
CONF_NOTIFY_ON_DISAPPROVAL, DEFAULT_NOTIFY_ON_DISAPPROVAL
),
"internal_id": internal_id,
}
self._entry_options[CONF_CHORES] = chores_dict
LOGGER.debug("Added chore '%s' with ID: %s", chore_name, internal_id)
LOGGER.debug(
"Final stored 'due_date' for chore '%s': %s",
chore_name,
due_date_str,
)
await self._update_and_reload()
return await self.async_step_init()
# Use flow_helpers.build_chore_schema, passing current kids
kids_dict = {
data["name"]: eid
for eid, data in self._entry_options.get(CONF_KIDS, {}).items()
}
schema = build_chore_schema(kids_dict)
return self.async_show_form(
step_id="add_chore", data_schema=schema, errors=errors
)
async def async_step_add_badge(self, user_input=None):
"""Add a new badge."""
self._entry_options = dict(self.config_entry.options)
errors = {}
badges_dict = self._entry_options.setdefault(CONF_BADGES, {})
if user_input is not None:
badge_name = user_input["badge_name"].strip()
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if any(
badge_data["name"] == badge_name for badge_data in badges_dict.values()
):
errors["badge_name"] = "duplicate_badge"
else:
badges_dict[internal_id] = {
"name": badge_name,
"threshold_type": user_input["threshold_type"],
"threshold_value": user_input["threshold_value"],
"points_multiplier": user_input["points_multiplier"],
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
"description": user_input.get("badge_description", ""),
"badge_labels": user_input.get("badge_labels", []),
}
self._entry_options[CONF_BADGES] = badges_dict
LOGGER.debug("Added badge '%s' with ID: %s", badge_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
schema = build_badge_schema()
return self.async_show_form(
step_id="add_badge", data_schema=schema, errors=errors
)
async def async_step_add_reward(self, user_input=None):
"""Add a new reward."""
self._entry_options = dict(self.config_entry.options)
errors = {}
rewards_dict = self._entry_options.setdefault(CONF_REWARDS, {})
if user_input is not None:
reward_name = user_input["reward_name"].strip()
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if any(
reward_data["name"] == reward_name
for reward_data in rewards_dict.values()
):
errors["reward_name"] = "duplicate_reward"
else:
rewards_dict[internal_id] = {
"name": reward_name,
"cost": user_input["reward_cost"],
"description": user_input.get("reward_description", ""),
"reward_labels": user_input.get("reward_labels", []),
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
}
self._entry_options[CONF_REWARDS] = rewards_dict
LOGGER.debug("Added reward '%s' with ID: %s", reward_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
schema = build_reward_schema()
return self.async_show_form(
step_id="add_reward", data_schema=schema, errors=errors
)
async def async_step_add_penalty(self, user_input=None):
"""Add a new penalty."""
self._entry_options = dict(self.config_entry.options)
errors = {}
penalties_dict = self._entry_options.setdefault(CONF_PENALTIES, {})
if user_input is not None:
penalty_name = user_input["penalty_name"].strip()
penalty_points = user_input["penalty_points"]
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if any(
penalty_data["name"] == penalty_name
for penalty_data in penalties_dict.values()
):
errors["penalty_name"] = "duplicate_penalty"
else:
penalties_dict[internal_id] = {
"name": penalty_name,
"description": user_input.get("penalty_description", ""),
"penalty_labels": user_input.get("penalty_labels", []),
"points": -abs(penalty_points), # Ensure points are negative
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
}
self._entry_options[CONF_PENALTIES] = penalties_dict
LOGGER.debug(
"Added penalty '%s' with ID: %s", penalty_name, internal_id
)
await self._update_and_reload()
return await self.async_step_init()
schema = build_penalty_schema()
return self.async_show_form(
step_id="add_penalty", data_schema=schema, errors=errors
)
async def async_step_add_bonus(self, user_input=None):
"""Add a new bonus."""
self._entry_options = dict(self.config_entry.options)
errors = {}
bonuses_dict = self._entry_options.setdefault(CONF_BONUSES, {})
if user_input is not None:
bonus_name = user_input["bonus_name"].strip()
bonus_points = user_input["bonus_points"]
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
if any(
bonus_data["name"] == bonus_name for bonus_data in bonuses_dict.values()
):
errors["bonus_name"] = "duplicate_bonus"
else:
bonuses_dict[internal_id] = {
"name": bonus_name,
"description": user_input.get("bonus_description", ""),
"bonus_labels": user_input.get("bonus_labels", []),
"points": abs(bonus_points), # Ensure points are positive
"icon": user_input.get("icon", ""),
"internal_id": internal_id,
}
self._entry_options[CONF_BONUSES] = bonuses_dict
LOGGER.debug("Added bonus '%s' with ID: %s", bonus_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
schema = build_bonus_schema()
return self.async_show_form(
step_id="add_bonus", data_schema=schema, errors=errors
)
async def async_step_add_achievement(self, user_input=None):
"""Add a new achievement."""
self._entry_options = dict(self.config_entry.options)
errors = {}
achievements_dict = self._entry_options.setdefault(CONF_ACHIEVEMENTS, {})
chores_dict = self._entry_options.get(CONF_CHORES, {})
if user_input is not None:
achievement_name = user_input["name"].strip()
if any(
data["name"] == achievement_name for data in achievements_dict.values()
):
errors["name"] = "duplicate_achievement"
else:
_type = user_input["type"]
chore_id = ""
if _type == ACHIEVEMENT_TYPE_STREAK:
c = user_input.get("selected_chore_id") or ""
if not c or c == "None":
errors["selected_chore_id"] = "a_chore_must_be_selected"
chore_id = c
if not errors:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
achievements_dict[internal_id] = {
"name": achievement_name,
"description": user_input.get("description", ""),
"achievement_labels": user_input.get("achievement_labels", []),
"icon": user_input.get("icon", ""),
"assigned_kids": user_input["assigned_kids"],
"type": _type,
"selected_chore_id": chore_id,
"criteria": user_input.get("criteria", "").strip(),
"target_value": user_input["target_value"],
"reward_points": user_input["reward_points"],
"internal_id": internal_id,
"progress": {},
}
self._entry_options["achievements"] = achievements_dict
LOGGER.debug(
"Added achievement '%s' with ID: %s",
achievement_name,
internal_id,
)
await self._update_and_reload()
return await self.async_step_init()
kids_dict = {
kid_data["name"]: kid_id
for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items()
}
achievement_schema = build_achievement_schema(
kids_dict=kids_dict, chores_dict=chores_dict, default=None
)
return self.async_show_form(
step_id="add_achievement", data_schema=achievement_schema, errors=errors
)
async def async_step_add_challenge(self, user_input=None):
"""Add a new challenge."""
self._entry_options = dict(self.config_entry.options)
errors = {}
challenges_dict = self._entry_options.setdefault(CONF_CHALLENGES, {})
chores_dict = self._entry_options.get(CONF_CHORES, {})
if user_input is not None:
challenge_name = user_input["name"].strip()
if any(data["name"] == challenge_name for data in challenges_dict.values()):
errors["name"] = "duplicate_challenge"
else:
_type = user_input["type"]
chore_id = ""
if _type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
c = user_input.get("selected_chore_id") or ""
if not c or c == "None":
errors["selected_chore_id"] = "a_chore_must_be_selected"
chore_id = c
# Process start_date and end_date using the helper:
start_date_input = user_input.get("start_date")
end_date_input = user_input.get("end_date")
if start_date_input:
try:
start_date = ensure_utc_datetime(self.hass, start_date_input)
start_dt = dt_util.parse_datetime(start_date)
if start_dt and start_dt < dt_util.utcnow():
errors["start_date"] = "start_date_in_past"
except Exception:
errors["start_date"] = "invalid_start_date"
start_date = None
else:
start_date = None
if end_date_input:
try:
end_date = ensure_utc_datetime(self.hass, end_date_input)
end_dt = dt_util.parse_datetime(end_date)
if end_dt and end_dt <= dt_util.utcnow():
errors["end_date"] = "end_date_in_past"
if start_date:
sdt = dt_util.parse_datetime(start_date)
if sdt and end_dt and end_dt <= sdt:
errors["end_date"] = "end_date_not_after_start_date"
except Exception:
errors["end_date"] = "invalid_end_date"
end_date = None
else:
end_date = None
if not errors:
internal_id = user_input.get("internal_id", str(uuid.uuid4()))
challenges_dict[internal_id] = {
"name": challenge_name,
"description": user_input.get("description", ""),
"challenge_labels": user_input.get("challenge_labels", []),
"icon": user_input.get("icon", ""),
"assigned_kids": user_input["assigned_kids"],
"type": _type,
"selected_chore_id": chore_id,
"criteria": user_input.get("criteria", "").strip(),
"target_value": user_input["target_value"],
"reward_points": user_input["reward_points"],
"start_date": start_date,
"end_date": end_date,
"internal_id": internal_id,
"progress": {},
}
self._entry_options[CONF_CHALLENGES] = challenges_dict
LOGGER.debug(
"Added challenge '%s' with ID: %s", challenge_name, internal_id
)
await self._update_and_reload()
return await self.async_step_init()
kids_dict = {
kid_data["name"]: kid_id
for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items()
}
challenge_schema = build_challenge_schema(
kids_dict=kids_dict, chores_dict=chores_dict, default=user_input
)
return self.async_show_form(
step_id="add_challenge", data_schema=challenge_schema, errors=errors
)
# ------------------ EDIT ENTITY ------------------
async def async_step_edit_kid(self, user_input=None):
"""Edit an existing kid."""
self._entry_options = dict(self.config_entry.options)
errors = {}
kids_dict = self._entry_options.get(CONF_KIDS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in kids_dict:
LOGGER.error("Edit kid: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_kid")
kid_data = kids_dict[internal_id]
if user_input is not None:
new_name = user_input["kid_name"].strip()
ha_user_id = user_input.get("ha_user") or ""
enable_notifications = user_input.get("enable_mobile_notifications", True)
mobile_notify_service = user_input.get("mobile_notify_service") or ""
use_persistent = user_input.get("enable_persistent_notifications", True)
# Check for duplicate names excluding current kid
if any(
data["name"] == new_name and eid != internal_id
for eid, data in kids_dict.items()
):
errors["kid_name"] = "duplicate_kid"
else:
kid_data["name"] = new_name
kid_data["ha_user_id"] = ha_user_id
kid_data["enable_notifications"] = enable_notifications
kid_data["mobile_notify_service"] = mobile_notify_service
kid_data["use_persistent_notifications"] = use_persistent
self._entry_options[CONF_KIDS] = kids_dict
LOGGER.debug("Edited kid '%s' with ID: %s", new_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
# Retrieve HA users for linking
users = await self.hass.auth.async_get_users()
schema = build_kid_schema(
self.hass,
users=users,
default_kid_name=kid_data["name"],
default_ha_user_id=kid_data.get("ha_user_id"),
default_enable_mobile_notifications=kid_data.get(
"enable_notifications", True
),
default_mobile_notify_service=kid_data.get("mobile_notify_service"),
default_enable_persistent_notifications=kid_data.get(
"use_persistent_notifications", True
),
internal_id=internal_id,
)
return self.async_show_form(
step_id="edit_kid", data_schema=schema, errors=errors
)
async def async_step_edit_parent(self, user_input=None):
"""Edit an existing parent."""
self._entry_options = dict(self.config_entry.options)
errors = {}
parents_dict = self._entry_options.get(CONF_PARENTS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in parents_dict:
LOGGER.error("Edit parent: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_parent")
parent_data = parents_dict[internal_id]
if user_input is not None:
new_name = user_input["parent_name"].strip()
ha_user_id = user_input.get("ha_user_id") or ""
associated_kids = user_input.get("associated_kids", [])
enable_notifications = user_input.get("enable_mobile_notifications", True)
mobile_notify_service = user_input.get("mobile_notify_service") or ""
use_persistent = user_input.get("enable_persistent_notifications", True)
# Check for duplicate names excluding current parent
if any(
data["name"] == new_name and eid != internal_id
for eid, data in parents_dict.items()
):
errors["parent_name"] = "duplicate_parent"
else:
parent_data["name"] = new_name
parent_data["ha_user_id"] = ha_user_id
parent_data["associated_kids"] = associated_kids
parent_data["enable_notifications"] = enable_notifications
parent_data["mobile_notify_service"] = mobile_notify_service
parent_data["use_persistent_notifications"] = use_persistent
self._entry_options[CONF_PARENTS] = parents_dict
LOGGER.debug("Edited parent '%s' with ID: %s", new_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
# Retrieve HA users and existing kids for linking
users = await self.hass.auth.async_get_users()
kids_dict = {
kid_data["name"]: kid_id
for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items()
}
parent_schema = build_parent_schema(
self.hass,
users=users,
kids_dict=kids_dict,
default_parent_name=parent_data["name"],
default_ha_user_id=parent_data.get("ha_user_id"),
default_associated_kids=parent_data.get("associated_kids", []),
default_enable_mobile_notifications=parent_data.get(
"enable_notifications", True
),
default_mobile_notify_service=parent_data.get("mobile_notify_service"),
default_enable_persistent_notifications=parent_data.get(
"use_persistent_notifications", True
),
internal_id=internal_id,
)
return self.async_show_form(
step_id="edit_parent", data_schema=parent_schema, errors=errors
)
async def async_step_edit_chore(self, user_input=None):
"""Edit an existing chore."""
self._entry_options = dict(self.config_entry.options)
errors = {}
chores_dict = self._entry_options.get(CONF_CHORES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in chores_dict:
LOGGER.error("Edit chore: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_chore")
chore_data = chores_dict[internal_id]
if user_input is not None:
new_name = user_input["chore_name"].strip()
raw_due = user_input.get("due_date")
# Check for duplicate names excluding current chore
if any(
data["name"] == new_name and eid != internal_id
for eid, data in chores_dict.items()
):
errors["chore_name"] = "duplicate_chore"
else:
if user_input.get("recurring_frequency") != FREQUENCY_CUSTOM:
user_input.pop("custom_interval", None)
user_input.pop("custom_interval_unit", None)
chore_data["name"] = new_name
chore_data["description"] = user_input.get("chore_description", "")
chore_data["chore_labels"] = user_input.get("chore_labels", [])
chore_data["default_points"] = user_input["default_points"]
chore_data["shared_chore"] = user_input["shared_chore"]
chore_data["partial_allowed"] = user_input["partial_allowed"]
chore_data["allow_multiple_claims_per_day"] = user_input[
"allow_multiple_claims_per_day"
]
chore_data["assigned_kids"] = user_input["assigned_kids"]
chore_data["icon"] = user_input.get("icon", "")
chore_data["recurring_frequency"] = user_input.get(
"recurring_frequency", "none"
)
chore_data["custom_interval"] = user_input.get("custom_interval")
chore_data["custom_interval_unit"] = user_input.get(
"custom_interval_unit"
)
if raw_due:
try:
if isinstance(raw_due, datetime.datetime):
parsed_due = raw_due
else:
parsed_due = dt_util.parse_datetime(
raw_due
) or datetime.datetime.fromisoformat(raw_due)
due_utc = dt_util.as_utc(parsed_due)
if due_utc < dt_util.utcnow():
errors["due_date"] = "due_date_in_past"
else:
chore_data["due_date"] = due_utc.isoformat()
except Exception:
errors["due_date"] = "invalid_due_date"
else:
chore_data["due_date"] = None
LOGGER.debug("No date/time provided; defaulting to None")
chore_data["applicable_days"] = user_input.get("applicable_days", [])
chore_data["notify_on_claim"] = user_input.get("notify_on_claim", True)
chore_data["notify_on_approval"] = user_input.get(
"notify_on_approval", True
)
chore_data["notify_on_disapproval"] = user_input.get(
"notify_on_disapproval", True
)
if errors:
kids_dict = {
data["name"]: eid
for eid, data in self._entry_options.get(CONF_KIDS, {}).items()
}
default_data = user_input.copy()
return self.async_show_form(
step_id="edit_chore",
data_schema=build_chore_schema(
kids_dict, default={**chore_data, **default_data}
),
errors=errors,
)
self._entry_options[CONF_CHORES] = chores_dict
LOGGER.debug("Edited chore '%s' with ID: %s", new_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
# Use flow_helpers.build_chore_schema, passing current kids
kids_dict = {
data["name"]: eid
for eid, data in self._entry_options.get(CONF_KIDS, {}).items()
}
# Convert stored string to datetime for DateTimeSelector
existing_due_str = chore_data.get("due_date")
existing_due_date = None
if existing_due_str:
try:
# Attempt to parse using dt_util or fallback to fromisoformat
parsed_date = dt_util.parse_datetime(
existing_due_str
) or datetime.datetime.fromisoformat(existing_due_str)
# Convert to the required format for DateTimeSelector
existing_due_date = dt_util.as_local(parsed_date).strftime(
"%Y-%m-%d %H:%M:%S"
)
LOGGER.debug(
"Processed existing_due_date for DateTimeSelector: %s",
existing_due_date,
)
except ValueError as e:
LOGGER.error(
"Failed to parse existing_due_date '%s': %s", existing_due_str, e
)
schema = build_chore_schema(
kids_dict, default={**chore_data, "due_date": existing_due_date}
)
return self.async_show_form(
step_id="edit_chore", data_schema=schema, errors=errors
)
async def async_step_edit_badge(self, user_input=None):
"""Edit an existing badge."""
self._entry_options = dict(self.config_entry.options)
errors = {}
badges_dict = self._entry_options.get(CONF_BADGES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in badges_dict:
LOGGER.error("Edit badge: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_badge")
badge_data = badges_dict[internal_id]
if user_input is not None:
new_name = user_input["badge_name"].strip()
# Check for duplicate names excluding current badge
if any(
data["name"] == new_name and eid != internal_id
for eid, data in badges_dict.items()
):
errors["badge_name"] = "duplicate_badge"
else:
badge_data["name"] = new_name
badge_data["threshold_type"] = user_input["threshold_type"]
badge_data["threshold_value"] = user_input["threshold_value"]
badge_data["points_multiplier"] = user_input["points_multiplier"]
badge_data["icon"] = user_input.get("icon", "")
badge_data["description"] = user_input["badge_description"]
badge_data["badge_labels"] = user_input.get("badge_labels", [])
self._entry_options[CONF_BADGES] = badges_dict
LOGGER.debug("Edited badge '%s' with ID: %s", new_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
schema = build_badge_schema(default=badge_data)
return self.async_show_form(
step_id="edit_badge", data_schema=schema, errors=errors
)
async def async_step_edit_reward(self, user_input=None):
"""Edit an existing reward."""
self._entry_options = dict(self.config_entry.options)
errors = {}
rewards_dict = self._entry_options.get(CONF_REWARDS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in rewards_dict:
LOGGER.error("Edit reward: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_reward")
reward_data = rewards_dict[internal_id]
if user_input is not None:
new_name = user_input["reward_name"].strip()
# Check for duplicate names excluding current reward
if any(
data["name"] == new_name and eid != internal_id
for eid, data in rewards_dict.items()
):
errors["reward_name"] = "duplicate_reward"
else:
reward_data["name"] = new_name
reward_data["cost"] = user_input["reward_cost"]
reward_data["description"] = user_input.get("reward_description", "")
reward_data["reward_labels"] = user_input.get("reward_labels", [])
reward_data["icon"] = user_input.get("icon", "")
self._entry_options[CONF_REWARDS] = rewards_dict
LOGGER.debug("Edited reward '%s' with ID: %s", new_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
schema = build_reward_schema(default=reward_data)
return self.async_show_form(
step_id="edit_reward", data_schema=schema, errors=errors
)
async def async_step_edit_penalty(self, user_input=None):
"""Edit an existing penalty."""
self._entry_options = dict(self.config_entry.options)
errors = {}
penalties_dict = self._entry_options.get(CONF_PENALTIES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in penalties_dict:
LOGGER.error("Edit penalty: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_penalty")
penalty_data = penalties_dict[internal_id]
if user_input is not None:
new_name = user_input["penalty_name"].strip()
penalty_points = user_input["penalty_points"]
# Check for duplicate names excluding current penalty
if any(
data["name"] == new_name and eid != internal_id
for eid, data in penalties_dict.items()
):
errors["penalty_name"] = "duplicate_penalty"
else:
penalty_data["name"] = new_name
penalty_data["description"] = user_input.get("penalty_description", "")
penalty_data["penalty_labels"] = user_input.get("penalty_labels", [])
penalty_data["points"] = -abs(
penalty_points
) # Ensure points are negative
penalty_data["icon"] = user_input.get("icon", "")
self._entry_options[CONF_PENALTIES] = penalties_dict
LOGGER.debug("Edited penalty '%s' with ID: %s", new_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
# Prepare data for schema (convert points to positive for display)
display_data = dict(penalty_data)
display_data["penalty_points"] = abs(display_data["points"])
schema = build_penalty_schema(default=display_data)
return self.async_show_form(
step_id="edit_penalty", data_schema=schema, errors=errors
)
async def async_step_edit_bonus(self, user_input=None):
"""Edit an existing bonus."""
self._entry_options = dict(self.config_entry.options)
errors = {}
bonuses_dict = self._entry_options.get(CONF_BONUSES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in bonuses_dict:
LOGGER.error("Edit bonus: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_bonus")
bonus_data = bonuses_dict[internal_id]
if user_input is not None:
new_name = user_input["bonus_name"].strip()
bonus_points = user_input["bonus_points"]
# Check for duplicate names excluding current bonus
if any(
data["name"] == new_name and eid != internal_id
for eid, data in bonuses_dict.items()
):
errors["bonus_name"] = "duplicate_bonus"
else:
bonus_data["name"] = new_name
bonus_data["description"] = user_input.get("bonus_description", "")
bonus_data["bonus_labels"] = user_input.get("bonus_labels", [])
bonus_data["points"] = abs(bonus_points) # Ensure points are positive
bonus_data["icon"] = user_input.get("icon", "")
self._entry_options[CONF_BONUSES] = bonuses_dict
LOGGER.debug("Edited bonus '%s' with ID: %s", new_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
# Prepare data for schema (convert points to positive for display)
display_data = dict(bonus_data)
display_data["bonus_points"] = abs(display_data["points"])
schema = build_bonus_schema(default=display_data)
return self.async_show_form(
step_id="edit_bonus", data_schema=schema, errors=errors
)
async def async_step_edit_achievement(self, user_input=None):
"""Edit an existing achievement."""
self._entry_options = dict(self.config_entry.options)
errors = {}
achievements_dict = self._entry_options.get(CONF_ACHIEVEMENTS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in achievements_dict:
LOGGER.error("Edit achievement: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_achievement")
achievement_data = achievements_dict[internal_id]
if user_input is not None:
new_name = user_input["name"].strip()
if any(
data["name"] == new_name and eid != internal_id
for eid, data in achievements_dict.items()
):
errors["name"] = "duplicate_achievement"
else:
_type = user_input["type"]
chore_id = ""
if _type == ACHIEVEMENT_TYPE_STREAK:
c = user_input.get("selected_chore_id") or ""
if not c or c == "None":
errors["selected_chore_id"] = "a_chore_must_be_selected"
chore_id = c
if not errors:
achievement_data["name"] = new_name
achievement_data["description"] = user_input.get("description", "")
achievement_data["achievement_labels"] = user_input.get(
"achievement_labels", []
)
achievement_data["icon"] = user_input.get("icon", "")
achievement_data["assigned_kids"] = user_input["assigned_kids"]
achievement_data["type"] = _type
achievement_data["selected_chore_id"] = chore_id
achievement_data["criteria"] = user_input.get(
"criteria", ""
).strip()
achievement_data["target_value"] = user_input["target_value"]
achievement_data["reward_points"] = user_input["reward_points"]
achievements_dict[internal_id] = achievement_data
self._entry_options[CONF_ACHIEVEMENTS] = achievements_dict
LOGGER.debug(
"Edited achievement '%s' with ID: %s", new_name, internal_id
)
await self._update_and_reload()
return await self.async_step_init()
kids_dict = {
kid_data["name"]: kid_id
for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items()
}
chores_dict = self._entry_options.get(CONF_CHORES, {})
achievement_schema = build_achievement_schema(
kids_dict=kids_dict, chores_dict=chores_dict, default=achievement_data
)
return self.async_show_form(
step_id="edit_achievement", data_schema=achievement_schema, errors=errors
)
async def async_step_edit_challenge(self, user_input=None):
"""Edit an existing challenge."""
self._entry_options = dict(self.config_entry.options)
errors = {}
challenges_dict = self._entry_options.get(CONF_CHALLENGES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in challenges_dict:
LOGGER.error("Edit challenge: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_challenge")
challenge_data = challenges_dict[internal_id]
if user_input is None:
kids_dict = {
data["name"]: kid_id
for kid_id, data in self._entry_options.get(CONF_KIDS, {}).items()
}
chores_dict = self._entry_options.get(CONF_CHORES, {})
# Convert stored start/end dates to a display format (e.g. local time string)
default_data = {
**challenge_data,
"start_date": challenge_data.get("start_date")
and dt_util.as_local(
dt_util.parse_datetime(challenge_data["start_date"])
).strftime("%Y-%m-%d %H:%M:%S"),
"end_date": challenge_data.get("end_date")
and dt_util.as_local(
dt_util.parse_datetime(challenge_data["end_date"])
).strftime("%Y-%m-%d %H:%M:%S"),
}
schema = build_challenge_schema(
kids_dict=kids_dict, chores_dict=chores_dict, default=default_data
)
return self.async_show_form(
step_id="edit_challenge", data_schema=schema, errors=errors
)
start_date_input = user_input.get("start_date")
if start_date_input:
try:
new_start_date = ensure_utc_datetime(self.hass, start_date_input)
start_dt = dt_util.parse_datetime(new_start_date)
if start_dt and start_dt < dt_util.utcnow():
errors["start_date"] = "start_date_in_past"
except Exception:
errors["start_date"] = "invalid_start_date"
new_start_date = None
else:
new_start_date = None
end_date_input = user_input.get("end_date")
if end_date_input:
try:
new_end_date = ensure_utc_datetime(self.hass, end_date_input)
end_dt = dt_util.parse_datetime(new_end_date)
if end_dt and end_dt <= dt_util.utcnow():
errors["end_date"] = "end_date_in_past"
if new_start_date:
sdt = dt_util.parse_datetime(new_start_date)
if sdt and end_dt and end_dt <= sdt:
errors["end_date"] = "end_date_not_after_start_date"
except Exception:
errors["end_date"] = "invalid_end_date"
new_end_date = None
else:
new_end_date = None
if user_input is not None:
new_name = user_input["name"].strip()
if any(
data["name"] == new_name and eid != internal_id
for eid, data in challenges_dict.items()
):
errors["name"] = "duplicate_challenge"
else:
_type = user_input["type"]
chore_id = ""
if _type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
c = user_input.get("selected_chore_id") or ""
if not c or c == "None":
errors["selected_chore_id"] = "a_chore_must_be_selected"
chore_id = c
if not errors:
challenge_data["name"] = new_name
challenge_data["description"] = user_input.get("description", "")
challenge_data["challenge_labels"] = user_input.get(
"challenge_labels", []
)
challenge_data["icon"] = user_input.get("icon", "")
challenge_data["assigned_kids"] = user_input["assigned_kids"]
challenge_data["type"] = _type
challenge_data["selected_chore_id"] = chore_id
challenge_data["criteria"] = user_input.get("criteria", "").strip()
challenge_data["target_value"] = user_input["target_value"]
challenge_data["reward_points"] = user_input["reward_points"]
challenge_data["start_date"] = new_start_date
challenge_data["end_date"] = new_end_date
LOGGER.debug(
"Edited challenge '%s' with ID: %s", new_name, internal_id
)
await self._update_and_reload()
return await self.async_step_init()
kids_dict = {
kid_data["name"]: kid_id
for kid_id, kid_data in self._entry_options.get(CONF_KIDS, {}).items()
}
chores_dict = self._entry_options.get(CONF_CHORES, {})
default_data = {
**challenge_data,
"start_date": new_start_date,
"end_date": new_end_date,
}
challenge_schema = build_challenge_schema(
kids_dict=kids_dict, chores_dict=chores_dict, default=default_data
)
return self.async_show_form(
step_id="edit_challenge", data_schema=challenge_schema, errors=errors
)
# ------------------ DELETE ENTITY ------------------
async def async_step_delete_kid(self, user_input=None):
"""Delete a kid."""
self._entry_options = dict(self.config_entry.options)
kids_dict = self._entry_options.get(CONF_KIDS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in kids_dict:
LOGGER.error("Delete kid: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_kid")
kid_name = kids_dict[internal_id]["name"]
if user_input is not None:
kids_dict.pop(internal_id, None)
self._entry_options[CONF_KIDS] = kids_dict
LOGGER.debug("Deleted kid '%s' with ID: %s", kid_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_kid",
data_schema=vol.Schema({}),
description_placeholders={"kid_name": kid_name},
)
async def async_step_delete_parent(self, user_input=None):
"""Delete a parent."""
self._entry_options = dict(self.config_entry.options)
parents_dict = self._entry_options.get(CONF_PARENTS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in parents_dict:
LOGGER.error("Delete parent: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_parent")
parent_name = parents_dict[internal_id]["name"]
if user_input is not None:
parents_dict.pop(internal_id, None)
self._entry_options[CONF_PARENTS] = parents_dict
LOGGER.debug("Deleted parent '%s' with ID: %s", parent_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_parent",
data_schema=vol.Schema({}),
description_placeholders={"parent_name": parent_name},
)
async def async_step_delete_chore(self, user_input=None):
"""Delete a chore."""
self._entry_options = dict(self.config_entry.options)
chores_dict = self._entry_options.get(CONF_CHORES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in chores_dict:
LOGGER.error("Delete chore: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_chore")
chore_name = chores_dict[internal_id]["name"]
if user_input is not None:
chores_dict.pop(internal_id, None)
self._entry_options[CONF_CHORES] = chores_dict
LOGGER.debug("Deleted chore '%s' with ID: %s", chore_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_chore",
data_schema=vol.Schema({}),
description_placeholders={"chore_name": chore_name},
)
async def async_step_delete_badge(self, user_input=None):
"""Delete a badge."""
self._entry_options = dict(self.config_entry.options)
badges_dict = self._entry_options.get(CONF_BADGES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in badges_dict:
LOGGER.error("Delete badge: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_badge")
badge_name = badges_dict[internal_id]["name"]
if user_input is not None:
badges_dict.pop(internal_id, None)
self._entry_options[CONF_BADGES] = badges_dict
LOGGER.debug("Deleted badge '%s' with ID: %s", badge_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_badge",
data_schema=vol.Schema({}),
description_placeholders={"badge_name": badge_name},
)
async def async_step_delete_reward(self, user_input=None):
"""Delete a reward."""
self._entry_options = dict(self.config_entry.options)
rewards_dict = self._entry_options.get(CONF_REWARDS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in rewards_dict:
LOGGER.error("Delete reward: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_reward")
reward_name = rewards_dict[internal_id]["name"]
if user_input is not None:
rewards_dict.pop(internal_id, None)
self._entry_options[CONF_REWARDS] = rewards_dict
LOGGER.debug("Deleted reward '%s' with ID: %s", reward_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_reward",
data_schema=vol.Schema({}),
description_placeholders={"reward_name": reward_name},
)
async def async_step_delete_penalty(self, user_input=None):
"""Delete a penalty."""
self._entry_options = dict(self.config_entry.options)
penalties_dict = self._entry_options.get(CONF_PENALTIES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in penalties_dict:
LOGGER.error("Delete penalty: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_penalty")
penalty_name = penalties_dict[internal_id]["name"]
if user_input is not None:
penalties_dict.pop(internal_id, None)
self._entry_options[CONF_PENALTIES] = penalties_dict
LOGGER.debug("Deleted penalty '%s' with ID: %s", penalty_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_penalty",
data_schema=vol.Schema({}),
description_placeholders={"penalty_name": penalty_name},
)
async def async_step_delete_achievement(self, user_input=None):
"""Delete an achievement."""
self._entry_options = dict(self.config_entry.options)
achievements_dict = self._entry_options.get(CONF_ACHIEVEMENTS, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in achievements_dict:
LOGGER.error("Delete achievement: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_achievement")
achievement_name = achievements_dict[internal_id]["name"]
if user_input is not None:
achievements_dict.pop(internal_id, None)
self._entry_options[CONF_ACHIEVEMENTS] = achievements_dict
LOGGER.debug(
"Deleted achievement '%s' with ID: %s", achievement_name, internal_id
)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_achievement",
data_schema=vol.Schema({}),
description_placeholders={"achievement_name": achievement_name},
)
async def async_step_delete_challenge(self, user_input=None):
"""Delete a challenge."""
self._entry_options = dict(self.config_entry.options)
challenges_dict = self._entry_options.get(CONF_CHALLENGES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in challenges_dict:
LOGGER.error("Delete challenge: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_challenge")
challenge_name = challenges_dict[internal_id]["name"]
if user_input is not None:
challenges_dict.pop(internal_id, None)
self._entry_options[CONF_CHALLENGES] = challenges_dict
LOGGER.debug(
"Deleted challenge '%s' with ID: %s", challenge_name, internal_id
)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_challenge",
data_schema=vol.Schema({}),
description_placeholders={"challenge_name": challenge_name},
)
async def async_step_delete_bonus(self, user_input=None):
"""Delete a bonus."""
self._entry_options = dict(self.config_entry.options)
bonuses_dict = self._entry_options.get(CONF_BONUSES, {})
internal_id = self.context.get("internal_id")
if not internal_id or internal_id not in bonuses_dict:
LOGGER.error("Delete bonus: Invalid internal_id '%s'", internal_id)
return self.async_abort(reason="invalid_bonus")
bonus_name = bonuses_dict[internal_id]["name"]
if user_input is not None:
bonuses_dict.pop(internal_id, None)
self._entry_options[CONF_BONUSES] = bonuses_dict
LOGGER.debug("Deleted bonus '%s' with ID: %s", bonus_name, internal_id)
await self._update_and_reload()
return await self.async_step_init()
return self.async_show_form(
step_id="delete_bonus",
data_schema=vol.Schema({}),
description_placeholders={"bonus_name": bonus_name},
)
# ------------------ HELPER METHODS ------------------
async def _update_and_reload(self):
"""Update the config entry options and reload the integration."""
new_data = dict(self.config_entry.data)
new_data["last_change"] = dt_util.utcnow().isoformat()
self.hass.config_entries.async_update_entry(
self.config_entry, data=new_data, options=self._entry_options
)
LOGGER.debug(
"Called update_entry. Now reloading entry: %s", self.config_entry.entry_id
)
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
LOGGER.debug("Options updated and integration reloaded")
================================================
FILE: custom_components/kidschores/select.py
================================================
# File: select.py
"""Select entities for the KidsChores integration.
Allows the user to pick from all chores, all rewards, or all penalties
in a global manner. This is useful for automations or scripts where a
user wishes to select a chore/reward/penalty dynamically.
"""
from __future__ import annotations
from typing import Optional
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, LOGGER
from .coordinator import KidsChoresDataCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the KidsChores select entities from a config entry.
Creates three global selects:
1) ChoresSelect: lists all chore names
2) RewardsSelect: lists all reward names
3) PenaltiesSelect: lists all penalty names
"""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: KidsChoresDataCoordinator = data["coordinator"]
# Create one global select entity for each category
selects = [
ChoresSelect(coordinator, entry),
RewardsSelect(coordinator, entry),
PenaltiesSelect(coordinator, entry),
]
for kid_id in coordinator.kids_data.keys():
selects.append(ChoresKidSelect(coordinator, entry, kid_id))
async_add_entities(selects)
class KidsChoresSelectBase(CoordinatorEntity, SelectEntity):
"""Base class for the KidsChores select entities."""
_attr_has_entity_name = True
_attr_translation_key = "kc_select_base"
def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry):
"""Initialize the base select entity."""
super().__init__(coordinator)
self._entry = entry
self._selected_option: Optional[str] = None
@property
def current_option(self) -> Optional[str]:
"""Return the currently selected option (chore/reward/penalty name).
None if nothing has been selected.
"""
return self._selected_option
async def async_select_option(self, option: str) -> None:
"""When the user selects an option from the dropdown, store it.
By default, no further action is taken.
"""
self._selected_option = option
LOGGER.debug(
"%s: User selected option '%s'",
self._attr_name,
option,
)
self.async_write_ha_state()
class ChoresSelect(KidsChoresSelectBase):
"""Global select entity listing all defined chores by name."""
_attr_has_entity_name = True
_attr_translation_key = "chores_select"
def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry):
"""Initialize the Chores select entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_chores_select"
self._attr_name = "KidsChores: All Chores"
self.entity_id = f"select.kc_all_chores"
@property
def options(self) -> list[str]:
"""Return a list of chore names from the coordinator.
If no chores exist, returns an empty list.
"""
return [
chore_info.get("name", f"Chore {chore_id}")
for chore_id, chore_info in self.coordinator.chores_data.items()
]
class RewardsSelect(KidsChoresSelectBase):
"""Global select entity listing all defined rewards by name."""
_attr_has_entity_name = True
_attr_translation_key = "rewards_select"
def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry):
"""Initialize the Rewards select entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_rewards_select"
self._attr_name = "KidsChores: All Rewards"
self.entity_id = f"select.kc_all_rewards"
@property
def options(self) -> list[str]:
"""Return a list of reward names from the coordinator.
If no rewards exist, returns an empty list.
"""
return [
reward_info.get("name", f"Reward {reward_id}")
for reward_id, reward_info in self.coordinator.rewards_data.items()
]
class PenaltiesSelect(KidsChoresSelectBase):
"""Global select entity listing all defined penalties by name."""
_attr_has_entity_name = True
_attr_translation_key = "penalties_select"
def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry):
"""Initialize the Penalties select entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_penalties_select"
self._attr_name = "KidsChores: All Penalties"
self.entity_id = f"select.kc_all_penalties"
@property
def options(self) -> list[str]:
"""Return a list of penalty names from the coordinator.
If no penalties exist, returns an empty list.
"""
return [
penalty_info.get("name", f"Penalty {penalty_id}")
for penalty_id, penalty_info in self.coordinator.penalties_data.items()
]
class ChoresKidSelect(KidsChoresSelectBase):
"""Select entity listing only the chores assigned to a specific kid."""
_attr_has_entity_name = True
_attr_translation_key = "chores_kid_select"
def __init__(
self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry, kid_id: str
):
"""Initialize the ChoresKidSelect."""
super().__init__(coordinator, entry)
self._kid_id = kid_id
kid_name = coordinator.kids_data.get(kid_id, {}).get("name", f"Kid {kid_id}")
self._attr_unique_id = f"{entry.entry_id}_chores_select_{kid_id}"
self._attr_name = f"KidsChores: Chores for {kid_name}"
self.entity_id = f"select.kc_{kid_name}_chore_list"
@property
def options(self) -> list[str]:
"""Return a list of chore names assigned to this kid, with a 'None' option."""
# Start with a "None" entry
options = ["None"]
for chore_id, chore in self.coordinator.chores_data.items():
if self._kid_id in chore.get("assigned_kids", []):
options.append(chore.get("name", f"Chore {chore_id}"))
return options
class BonusesSelect(KidsChoresSelectBase):
"""Global select entity listing all defined bonuses by name."""
_attr_has_entity_name = True
_attr_translation_key = "bonuses_select"
def __init__(self, coordinator: KidsChoresDataCoordinator, entry: ConfigEntry):
"""Initialize the Bonuses select entity."""
super().__init__(coordinator, entry)
self._attr_unique_id = f"{entry.entry_id}_bonuses_select"
self._attr_name = "KidsChores: All Bonuses"
self.entity_id = f"select.kc_all_bonuses"
@property
def options(self) -> list[str]:
"""Return a list of bonus names from the coordinator.
If no bonuses exist, returns an empty list.
"""
return [
bonus_info.get("name", f"Bonus {bonus_id}")
for bonus_id, bonus_info in self.coordinator.bonuses_data.items()
]
================================================
FILE: custom_components/kidschores/sensor.py
================================================
# File: sensor.py
"""Sensors for the KidsChores integration.
This file defines all sensor entities for each Kid, Chore, Reward, and Badge.
Available Sensors:
01. KidPointsSensor .................... Kid's current total points
02. KidPointsEarnedDailySensor ......... Points earned by the kid today
03. KidPointsEarnedWeeklySensor ........ Points earned by the kid this week
04. KidPointsEarnedMonthlySensor ....... Points earned by the kid this month
05. KidMaxPointsEverSensor ............. The highest points total the kid has ever reached
06. CompletedChoresDailySensor ......... Chores completed by the kid today
07. CompletedChoresWeeklySensor ........ Chores completed by the kid this week
08. CompletedChoresMonthlySensor ....... Chores completed by the kid this month
09. CompletedChoresTotalSensor ......... Total chores completed by the kid
10.* KidBadgesSensor .................... Number of badges the kid currently has - DEPRECATE
11. KidHighestBadgeSensor .............. The highest (threshold) badge the kid holds
12. BadgeSensor ........................ One sensor per badge, showing its threshold & who earned it
13. ChoreStatusSensor .................. Shows current state (pending/claimed/approved, etc.) for each (kid, chore)
14. SharedChoreGlobalStateSensor ....... Shows current global state for shared chores
15. RewardStatusSensor ................. Shows current state (not claimed/claimed/approved) for each (kid, reward)
16. PenaltyAppliesSensor ............... Tracks how many times each penalty was applied for each kid
17.* RewardClaimsSensor ................. Number of times a reward was claimed by a kid - DEPRECATE
18.* RewardApprovalsSensor .............. Number of times a reward was approved for a kid - DEPRECATE
19.* ChoreClaimsSensor .................. Number of times a chore was claimed by a kid - DEPRECATE
20.* ChoreApprovalsSensor ............... Number of times a chore was approved for a kid - DEPRECATE
21. PendingChoreApprovalsSensor ........ Lists chores that are awaiting approval
22. PendingRewardApprovalsSensor ....... Lists rewards that are awaiting approval
23. AchievementSensor .................. Shows the achievement name, target value, reward points, and number of kids that have earned it
24. ChallengeSensor .................... Shows the challenge name, target, reward, and number of kids that have completed it
25. AchievementProgressSensor .......... Progress (in %) toward an achievement per kid
26. ChallengeProgressSensor ............ Progress (in %) toward a challenge per kid
27. KidHighestStreakSensor ............. The highest current streak (in days) among streak-type achievements for a kid
28.* ChoreStreakSensor .................. Current streak (in days) for a kid for a specific chore - DEPRECATE
"""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.components.sensor import SensorEntity
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import (
ACHIEVEMENT_TYPE_DAILY_MIN,
ACHIEVEMENT_TYPE_STREAK,
ACHIEVEMENT_TYPE_TOTAL,
ATTR_ACHIEVEMENT_NAME,
ATTR_ALL_EARNED_BADGES,
ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY,
ATTR_APPLICABLE_DAYS,
ATTR_ASSIGNED_KIDS,
ATTR_ASSOCIATED_CHORE,
ATTR_AWARDED,
ATTR_BADGES,
ATTR_CHALLENGE_NAME,
ATTR_CHALLENGE_TYPE,
ATTR_CLAIMED_ON,
ATTR_CHORE_APPROVALS_COUNT,
ATTR_CHORE_APPROVALS_TODAY,
ATTR_CHORE_CLAIMS_COUNT,
ATTR_CHORE_CURRENT_STREAK,
ATTR_CHORE_HIGHEST_STREAK,
ATTR_CHORE_NAME,
ATTR_COST,
ATTR_CRITERIA,
ATTR_CUSTOM_FREQUENCY_INTERVAL,
ATTR_CUSTOM_FREQUENCY_UNIT,
ATTR_DEFAULT_POINTS,
ATTR_DESCRIPTION,
ATTR_DUE_DATE,
ATTR_END_DATE,
ATTR_HIGHEST_BADGE_THRESHOLD_VALUE,
ATTR_GLOBAL_STATE,
ATTR_KID_NAME,
ATTR_KIDS_EARNED,
ATTR_LABELS,
ATTR_LAST_DATE,
ATTR_PARTIAL_ALLOWED,
ATTR_PENALTY_NAME,
ATTR_PENALTY_POINTS,
ATTR_POINTS_MULTIPLIER,
ATTR_POINTS_TO_NEXT_BADGE,
ATTR_RECURRING_FREQUENCY,
ATTR_RAW_PROGRESS,
ATTR_RAW_STREAK,
ATTR_REDEEMED_ON,
ATTR_REWARD_APPROVALS_COUNT,
ATTR_REWARD_CLAIMS_COUNT,
ATTR_REWARD_NAME,
ATTR_REWARD_POINTS,
ATTR_START_DATE,
ATTR_SHARED_CHORE,
ATTR_BONUS_NAME,
ATTR_BONUS_POINTS,
ATTR_TARGET_VALUE,
ATTR_THRESHOLD_TYPE,
ATTR_TYPE,
CHALLENGE_TYPE_DAILY_MIN,
CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW,
CHORE_STATE_APPROVED,
CHORE_STATE_CLAIMED,
CHORE_STATE_OVERDUE,
CHORE_STATE_PENDING,
CHORE_STATE_UNKNOWN,
CONF_POINTS_ICON,
CONF_POINTS_LABEL,
DATA_PENDING_CHORE_APPROVALS,
DATA_PENDING_REWARD_APPROVALS,
DEFAULT_ACHIEVEMENTS_ICON,
DEFAULT_BADGE_ICON,
DEFAULT_CHALLENGES_ICON,
DEFAULT_CHORE_SENSOR_ICON,
DEFAULT_PENALTY_ICON,
DEFAULT_PENALTY_POINTS,
DEFAULT_POINTS_ICON,
DEFAULT_POINTS_LABEL,
DEFAULT_REWARD_COST,
DEFAULT_REWARD_ICON,
DEFAULT_BONUS_ICON,
DEFAULT_BONUS_POINTS,
DEFAULT_STREAK_ICON,
DEFAULT_TROPHY_ICON,
DEFAULT_TROPHY_OUTLINE,
DOMAIN,
DUE_DATE_NOT_SET,
FREQUENCY_CUSTOM,
LABEL_POINTS,
REWARD_STATE_APPROVED,
REWARD_STATE_CLAIMED,
REWARD_STATE_NOT_CLAIMED,
UNKNOWN_CHORE,
UNKNOWN_KID,
UNKNOWN_REWARD,
)
from .coordinator import KidsChoresDataCoordinator
from .kc_helpers import get_friendly_label
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities
):
"""Set up sensors for KidsChores integration."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator: KidsChoresDataCoordinator = data["coordinator"]
points_label = entry.options.get(CONF_POINTS_LABEL, DEFAULT_POINTS_LABEL)
points_icon = entry.options.get(CONF_POINTS_ICON, DEFAULT_POINTS_ICON)
entities = []
# Sensor to detail number of Chores pending approval
entities.append(PendingChoreApprovalsSensor(coordinator, entry))
# Sensor to detail number of Rewards pending approval
entities.append(PendingRewardApprovalsSensor(coordinator, entry))
# For each kid, add standard sensors
for kid_id, kid_info in coordinator.kids_data.items():
kid_name = kid_info.get("name", f"Kid {kid_id}")
# Points counter sensor
entities.append(
KidPointsSensor(
coordinator, entry, kid_id, kid_name, points_label, points_icon
)
)
entities.append(
CompletedChoresTotalSensor(coordinator, entry, kid_id, kid_name)
)
# Chores completed by each Kid during the day
entities.append(
CompletedChoresDailySensor(coordinator, entry, kid_id, kid_name)
)
# Chores completed by each Kid during the week
entities.append(
CompletedChoresWeeklySensor(coordinator, entry, kid_id, kid_name)
)
# Chores completed by each Kid during the month
entities.append(
CompletedChoresMonthlySensor(coordinator, entry, kid_id, kid_name)
)
# Badges Obtained by each Kid
entities.append(KidBadgesSensor(coordinator, entry, kid_id, kid_name))
# Kid Highest Badge
entities.append(KidHighestBadgeSensor(coordinator, entry, kid_id, kid_name))
# Poimts obtained per Kid during the day
entities.append(
KidPointsEarnedDailySensor(
coordinator, entry, kid_id, kid_name, points_label, points_icon
)
)
# Poimts obtained per Kid during the week
entities.append(
KidPointsEarnedWeeklySensor(
coordinator, entry, kid_id, kid_name, points_label, points_icon
)
)
# Poimts obtained per Kid during the month
entities.append(
KidPointsEarnedMonthlySensor(
coordinator, entry, kid_id, kid_name, points_label, points_icon
)
)
# Maximum Points ever obtained ny a kid
entities.append(
KidMaxPointsEverSensor(
coordinator, entry, kid_id, kid_name, points_label, points_icon
)
)
# Reward Claims and Approvals
for reward_id, reward_info in coordinator.rewards_data.items():
reward_name = reward_info.get("name", f"Reward {reward_id}")
entities.append(
RewardClaimsSensor(
coordinator, entry, kid_id, kid_name, reward_id, reward_name
)
)
# Rewards Approval Sensor
entities.append(
RewardApprovalsSensor(
coordinator, entry, kid_id, kid_name, reward_id, reward_name
)
)
# Chore Claims and Approvals
for chore_id, chore_info in coordinator.chores_data.items():
if kid_id not in chore_info.get("assigned_kids", []):
continue
chore_name = chore_info.get("name", f"Chore {chore_id}")
entities.append(
ChoreClaimsSensor(
coordinator, entry, kid_id, kid_name, chore_id, chore_name
)
)
# Chore Approvals Sensor
entities.append(
ChoreApprovalsSensor(
coordinator, entry, kid_id, kid_name, chore_id, chore_name
)
)
# Chore Streak per Kid
entities.append(
ChoreStreakSensor(
coordinator, entry, kid_id, kid_name, chore_id, chore_name
)
)
# Penalty Applies
for penalty_id, penalty_info in coordinator.penalties_data.items():
penalty_name = penalty_info.get("name", f"Penalty {penalty_id}")
entities.append(
PenaltyAppliesSensor(
coordinator, entry, kid_id, kid_name, penalty_id, penalty_name
)
)
# Bonus Applies
for bonus_id, bonus_info in coordinator.bonuses_data.items():
bonus_name = bonus_info.get("name", f"Bonus {bonus_id}")
entities.append(
BonusAppliesSensor(
coordinator, entry, kid_id, kid_name, bonus_id, bonus_name
)
)
# Achivement Progress per Kid
for achievement_id, achievement in coordinator.achievements_data.items():
if kid_id in achievement.get("assigned_kids", []):
achievement_name = achievement.get(
"name", f"Achievement {achievement_id}"
)
entities.append(
AchievementProgressSensor(
coordinator,
entry,
kid_id,
kid_name,
achievement_id,
achievement_name,
)
)
# Challenge Progress per Kid
for challenge_id, challenge in coordinator.challenges_data.items():
if kid_id in challenge.get("assigned_kids", []):
challenge_name = challenge.get("name", f"Challenge {challenge_id}")
entities.append(
ChallengeProgressSensor(
coordinator,
entry,
kid_id,
kid_name,
challenge_id,
challenge_name,
)
)
# Highest Streak Sensor per Kid
entities.append(KidHighestStreakSensor(coordinator, entry, kid_id, kid_name))
# For each chore assigned to each kid, add a ChoreStatusSensor
for chore_id, chore_info in coordinator.chores_data.items():
chore_name = chore_info.get("name", f"Chore {chore_id}")
assigned_kids_ids = chore_info.get("assigned_kids", [])
for kid_id in assigned_kids_ids:
kid_name = coordinator._get_kid_name_by_id(kid_id) or f"Kid {kid_id}"
entities.append(
ChoreStatusSensor(
coordinator, entry, kid_id, kid_name, chore_id, chore_name
)
)
# For each shared chore, add a global state sensor
for chore_id, chore_info in coordinator.chores_data.items():
if chore_info.get("shared_chore", False):
chore_name = chore_info.get("name", f"Chore {chore_id}")
entities.append(
SharedChoreGlobalStateSensor(coordinator, entry, chore_id, chore_name)
)
# For each Reward, add a RewardStatusSensor
for reward_id, reward_info in coordinator.rewards_data.items():
reward_name = reward_info.get("name", f"Reward {reward_id}")
# For each kid, create the reward status sensor
for kid_id, kid_info in coordinator.kids_data.items():
kid_name = kid_info.get("name", f"Kid {kid_id}")
entities.append(
RewardStatusSensor(
coordinator, entry, kid_id, kid_name, reward_id, reward_name
)
)
# For each Badge, add a BadgeSensor
for badge_id, badge_info in coordinator.badges_data.items():
badge_name = badge_info.get("name", f"Badge {badge_id}")
entities.append(BadgeSensor(coordinator, entry, badge_id, badge_name))
# For each Achievement, add an AchievementSensor
for achievement_id, achievement in coordinator.achievements_data.items():
achievement_name = achievement.get("name", f"Achievement {achievement_id}")
entities.append(
AchievementSensor(coordinator, entry, achievement_id, achievement_name)
)
# For each Challenge, add a ChallengeSensor
for challenge_id, challenge in coordinator.challenges_data.items():
challenge_name = challenge.get("name", f"Challenge {challenge_id}")
entities.append(
ChallengeSensor(coordinator, entry, challenge_id, challenge_name)
)
async_add_entities(entities)
# ------------------------------------------------------------------------------------------
class ChoreStatusSensor(CoordinatorEntity, SensorEntity):
"""Sensor for chore status: pending/claimed/approved/etc."""
_attr_has_entity_name = True
_attr_translation_key = "chore_status_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, chore_id, chore_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._chore_id = chore_id
self._chore_name = chore_name
self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_status"
self.entity_id = f"sensor.kc_{kid_name}_chore_status_{chore_name}"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"chore_name": chore_name,
}
@property
def native_value(self):
"""Return the chore's state based on shared or individual tracking."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
# The status of the kids chore should always be their own status, it's only global status that would show independent or in-part
if self._chore_id in kid_info.get("approved_chores", []):
return CHORE_STATE_APPROVED
elif self._chore_id in kid_info.get("claimed_chores", []):
return CHORE_STATE_CLAIMED
elif self._chore_id in kid_info.get("overdue_chores", []):
return CHORE_STATE_OVERDUE
else:
return CHORE_STATE_PENDING
@property
def extra_state_attributes(self):
"""Include points, description, etc."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
shared = chore_info.get("shared_chore", False)
global_state = chore_info.get("state", CHORE_STATE_UNKNOWN)
assigned_kids_ids = chore_info.get("assigned_kids", [])
assigned_kids_names = [
self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}"
for k_id in assigned_kids_ids
]
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
chore_streak_data = kid_info.get("chore_streaks", {}).get(self._chore_id, {})
current_streak = chore_streak_data.get("current_streak", 0)
highest_streak = chore_streak_data.get("max_streak", 0)
stored_labels = chore_info.get("chore_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_KID_NAME: self._kid_name,
ATTR_CHORE_NAME: self._chore_name,
ATTR_DESCRIPTION: chore_info.get("description", ""),
ATTR_CHORE_CLAIMS_COUNT: kid_info.get("chore_claims", {}).get(
self._chore_id, 0
),
ATTR_CHORE_APPROVALS_COUNT: kid_info.get("chore_approvals", {}).get(
self._chore_id, 0
),
ATTR_CHORE_CURRENT_STREAK: current_streak,
ATTR_CHORE_HIGHEST_STREAK: highest_streak,
ATTR_SHARED_CHORE: shared,
ATTR_GLOBAL_STATE: global_state,
ATTR_RECURRING_FREQUENCY: chore_info.get("recurring_frequency", "None"),
ATTR_APPLICABLE_DAYS: chore_info.get("applicable_days", []),
ATTR_DUE_DATE: chore_info.get("due_date", DUE_DATE_NOT_SET),
ATTR_DEFAULT_POINTS: chore_info.get("default_points", 0),
ATTR_PARTIAL_ALLOWED: chore_info.get("partial_allowed", False),
ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY: chore_info.get(
"allow_multiple_claims_per_day", False
),
ATTR_ASSIGNED_KIDS: assigned_kids_names,
ATTR_LABELS: friendly_labels,
}
if chore_info.get("allow_multiple_claims_per_day", False):
today_approvals = kid_info.get("today_chore_approvals", {}).get(
self._chore_id, 0
)
attributes[ATTR_CHORE_APPROVALS_TODAY] = today_approvals
if chore_info.get("recurring_frequency") == FREQUENCY_CUSTOM:
attributes[ATTR_CUSTOM_FREQUENCY_INTERVAL] = chore_info.get(
"custom_interval"
)
attributes[ATTR_CUSTOM_FREQUENCY_UNIT] = chore_info.get(
"custom_interval_unit"
)
return attributes
@property
def icon(self):
"""Use the chore's custom icon if set, else fallback."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON)
# ------------------------------------------------------------------------------------------
class KidPointsSensor(CoordinatorEntity, SensorEntity):
"""Sensor for a kid's total points balance."""
_attr_has_entity_name = True
_attr_translation_key = "kid_points_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._points_label = points_label
self._points_icon = points_icon
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points"
self._attr_state_class = "measurement"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"points": self._points_label,
}
self.entity_id = f"sensor.kc_{kid_name}_points"
@property
def native_value(self):
"""Return the kid's total points."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("points", 0)
@property
def native_unit_of_measurement(self):
"""Return the points label."""
return self._points_label or LABEL_POINTS
@property
def icon(self):
"""Use the points' custom icon if set, else fallback."""
return self._points_icon or DEFAULT_POINTS_ICON
# ------------------------------------------------------------------------------------------
class KidMaxPointsEverSensor(CoordinatorEntity, SensorEntity):
"""Sensor showing the maximum points a kid has ever reached."""
_attr_has_entity_name = True
_attr_translation_key = "kid_max_points_ever_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._points_label = points_label
self._points_icon = points_icon
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_max_points_ever"
self._entry = entry
self._attr_translation_placeholders = {"kid_name": kid_name}
self.entity_id = f"sensor.kc_{kid_name}_points_max_ever"
@property
def native_value(self):
"""Return the highest points total the kid has ever reached."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("max_points_ever", 0)
@property
def icon(self):
"""Use the same icon as points or any custom icon you prefer."""
return self._points_icon or DEFAULT_POINTS_ICON
@property
def native_unit_of_measurement(self):
"""Optionally display the same points label for consistency."""
return self._points_label or LABEL_POINTS
# ------------------------------------------------------------------------------------------
class CompletedChoresTotalSensor(CoordinatorEntity, SensorEntity):
"""Sensor tracking the total number of chores a kid has completed since integration start."""
_attr_has_entity_name = True
_attr_translation_key = "chores_completed_total_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_total"
self._attr_native_unit_of_measurement = "chores"
self._attr_icon = "mdi:clipboard-check-outline"
self._attr_translation_placeholders = {"kid_name": kid_name}
self.entity_id = f"sensor.kc_{kid_name}_chores_completed_total"
@property
def native_value(self):
"""Return the total number of chores completed by the kid."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("completed_chores_total", 0)
# ------------------------------------------------------------------------------------------
class CompletedChoresDailySensor(CoordinatorEntity, SensorEntity):
"""How many chores kid completed today."""
_attr_has_entity_name = True
_attr_translation_key = "chores_completed_daily_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_daily"
self._attr_native_unit_of_measurement = "chores"
self._attr_translation_placeholders = {"kid_name": kid_name}
self.entity_id = f"sensor.kc_{kid_name}_chores_completed_daily"
@property
def native_value(self):
"""Return the number of chores completed today."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("completed_chores_today", 0)
# ------------------------------------------------------------------------------------------
class CompletedChoresWeeklySensor(CoordinatorEntity, SensorEntity):
"""How many chores kid completed this week."""
_attr_has_entity_name = True
_attr_translation_key = "chores_completed_weekly_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_weekly"
self._attr_native_unit_of_measurement = "chores"
self._attr_translation_placeholders = {"kid_name": kid_name}
self.entity_id = f"sensor.kc_{kid_name}_chores_completed_weekly"
@property
def native_value(self):
"""Return the number of chores completed this week."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("completed_chores_weekly", 0)
# ------------------------------------------------------------------------------------------
class CompletedChoresMonthlySensor(CoordinatorEntity, SensorEntity):
"""How many chores kid completed this month."""
_attr_has_entity_name = True
_attr_translation_key = "chores_completed_monthly_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_completed_monthly"
self._attr_native_unit_of_measurement = "chores"
self._attr_translation_placeholders = {"kid_name": kid_name}
self.entity_id = f"sensor.kc_{kid_name}_chores_completed_monthly"
@property
def native_value(self):
"""Return the number of chores completed this month."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("completed_chores_monthly", 0)
# DEPRECATE --------------------------------------------------------------------------------
class KidBadgesSensor(CoordinatorEntity, SensorEntity):
"""Sensor: number of badges earned + attribute with the list."""
_attr_has_entity_name = True
_attr_translation_key = "kid_badges_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_badges"
self._attr_translation_placeholders = {"kid_name": kid_name}
self.entity_id = f"sensor.kc_{kid_name}_badges"
@property
def native_value(self):
"""Return the number of badges the kid has earned."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return len(kid_info.get("badges", []))
@property
def extra_state_attributes(self):
"""Include the list of badges the kid has earned."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return {ATTR_BADGES: kid_info.get("badges", [])}
# ------------------------------------------------------------------------------------------
class KidHighestBadgeSensor(CoordinatorEntity, SensorEntity):
"""Sensor that returns the "highest" badge the kid currently has."""
_attr_has_entity_name = True
_attr_translation_key = "kids_highest_badge_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_highest_badge"
self._attr_translation_placeholders = {"kid_name": kid_name}
self.entity_id = f"sensor.kc_{kid_name}_highest_badge"
def _find_highest_badge(self):
"""Determine which badge has the highest ranking."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
earned_badge_names = kid_info.get("badges", [])
highest_badge = None
highest_value = -1
for badge_name in earned_badge_names:
# Find badge by name
badge_data = next(
(
info
for bid, info in self.coordinator.badges_data.items()
if info.get("name") == badge_name
),
None,
)
if not badge_data:
continue # skip if not found or invalid
threshold_val = badge_data.get("threshold_value", 0)
if threshold_val > highest_value:
highest_value = threshold_val
highest_badge = badge_name
return highest_badge, highest_value
@property
def native_value(self) -> str:
"""Return the badge name of the highest-threshold badge the kid has earned.
If the kid has none, return "None".
"""
highest_badge, _ = self._find_highest_badge()
return highest_badge if highest_badge else "None"
@property
def icon(self):
"""Return the icon for the highest badge. Fall back if none found."""
highest_badge, _ = self._find_highest_badge()
if highest_badge:
badge_data = next(
(
info
for bid, info in self.coordinator.badges_data.items()
if info.get("name") == highest_badge
),
{},
)
return badge_data.get("icon", DEFAULT_TROPHY_ICON)
return DEFAULT_TROPHY_OUTLINE
@property
def extra_state_attributes(self):
"""Provide additional details."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
highest_badge, highest_val = self._find_highest_badge()
current_multiplier = 1.0
friendly_labels = []
if highest_badge:
badge_data = next(
(
info
for bid, info in self.coordinator.badges_data.items()
if info.get("name") == highest_badge
),
{},
)
current_multiplier = badge_data.get("points_multiplier", 1.0)
stored_labels = badge_data.get("badge_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
# Compute points needed for next badge:
current_points = kid_info.get("points", 0)
# Gather thresholds for badges that are higher than current points
thresholds = [
badge.get("threshold_value", 0)
for badge in self.coordinator.badges_data.values()
if badge.get("threshold_value", 0) > current_points
]
if thresholds:
next_threshold = min(thresholds)
points_to_next_badge = next_threshold - current_points
else:
points_to_next_badge = 0
return {
ATTR_KID_NAME: self._kid_name,
ATTR_ALL_EARNED_BADGES: kid_info.get("badges", []),
ATTR_HIGHEST_BADGE_THRESHOLD_VALUE: highest_val if highest_badge else 0,
ATTR_POINTS_MULTIPLIER: current_multiplier,
ATTR_POINTS_TO_NEXT_BADGE: points_to_next_badge,
ATTR_LABELS: friendly_labels,
}
# ------------------------------------------------------------------------------------------
class BadgeSensor(CoordinatorEntity, SensorEntity):
"""Sensor representing a single badge in KidsChores."""
_attr_has_entity_name = True
_attr_translation_key = "badge_sensor"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
badge_id: str,
badge_name: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._badge_id = badge_id
self._badge_name = badge_name
self._attr_unique_id = f"{entry.entry_id}_{badge_id}_badge_sensor"
self._attr_translation_placeholders = {"badge_name": badge_name}
self.entity_id = f"sensor.kc_{badge_name}_badge"
@property
def native_value(self) -> float:
"""The sensor state is the threshold_value for the badge."""
badge_info = self.coordinator.badges_data.get(self._badge_id, {})
return badge_info.get("threshold_value", 0)
@property
def extra_state_attributes(self):
"""Provide additional badge data, including which kids currently have it."""
badge_info = self.coordinator.badges_data.get(self._badge_id, {})
threshold_type = badge_info.get("threshold_type", "points")
points_multiplier = badge_info.get("points_multiplier", 1.0)
description = badge_info.get("description", "")
kids_earned_ids = badge_info.get("earned_by", [])
stored_labels = badge_info.get("badge_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
# Convert each kid_id to kid_name
kids_earned_names = []
for kid_id in kids_earned_ids:
kid = self.coordinator.kids_data.get(kid_id)
if kid is not None:
kids_earned_names.append(kid.get("name", f"Kid {kid_id}"))
else:
kids_earned_names.append(f"Kid {kid_id} (not found)")
return {
ATTR_DESCRIPTION: description,
ATTR_THRESHOLD_TYPE: threshold_type,
ATTR_POINTS_MULTIPLIER: points_multiplier,
ATTR_KIDS_EARNED: kids_earned_names,
ATTR_LABELS: friendly_labels,
}
@property
def icon(self) -> str:
"""Return the badge's custom icon if set, else default."""
badge_info = self.coordinator.badges_data.get(self._badge_id, {})
return badge_info.get("icon", DEFAULT_BADGE_ICON)
# ------------------------------------------------------------------------------------------
class PendingChoreApprovalsSensor(CoordinatorEntity, SensorEntity):
"""Sensor listing all pending chore approvals."""
_attr_has_entity_name = True
_attr_translation_key = "pending_chores_approvals_sensor"
def __init__(self, coordinator, entry):
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_pending_chore_approvals"
self._attr_icon = "mdi:clipboard-check-outline"
self.entity_id = f"sensor.kc_global_chore_pending_approvals"
@property
def native_value(self):
"""Return a summary of pending chore approvals."""
approvals = self.coordinator._data.get(DATA_PENDING_CHORE_APPROVALS, [])
return f"{len(approvals)} pending chores"
@property
def extra_state_attributes(self):
"""Return detailed pending chores."""
approvals = self.coordinator._data.get(DATA_PENDING_CHORE_APPROVALS, [])
grouped_by_kid = {}
for approval in approvals:
kid_name = (
self.coordinator._get_kid_name_by_id(approval["kid_id"]) or UNKNOWN_KID
)
chore_info = self.coordinator.chores_data.get(approval["chore_id"], {})
chore_name = chore_info.get("name", UNKNOWN_CHORE)
timestamp = approval["timestamp"]
if kid_name not in grouped_by_kid:
grouped_by_kid[kid_name] = []
grouped_by_kid[kid_name].append(
{
ATTR_CHORE_NAME: chore_name,
ATTR_CLAIMED_ON: timestamp,
}
)
return grouped_by_kid
# ------------------------------------------------------------------------------------------
class PendingRewardApprovalsSensor(CoordinatorEntity, SensorEntity):
"""Sensor listing all pending reward approvals."""
_attr_has_entity_name = True
_attr_translation_key = "pending_rewards_approvals_sensor"
def __init__(self, coordinator, entry):
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_pending_reward_approvals"
self._attr_icon = "mdi:gift-open-outline"
self.entity_id = f"sensor.kc_global_reward_pending_approvals"
@property
def native_value(self):
"""Return a summary of pending reward approvals."""
approvals = self.coordinator._data.get(DATA_PENDING_REWARD_APPROVALS, [])
return f"{len(approvals)} pending rewards"
@property
def extra_state_attributes(self):
"""Return detailed pending rewards."""
approvals = self.coordinator._data.get(DATA_PENDING_REWARD_APPROVALS, [])
grouped_by_kid = {}
for approval in approvals:
kid_name = (
self.coordinator._get_kid_name_by_id(approval["kid_id"]) or UNKNOWN_KID
)
reward_info = self.coordinator.rewards_data.get(approval["reward_id"], {})
reward_name = reward_info.get("name", UNKNOWN_REWARD)
timestamp = approval["timestamp"]
if kid_name not in grouped_by_kid:
grouped_by_kid[kid_name] = []
grouped_by_kid[kid_name].append(
{
ATTR_REWARD_NAME: reward_name,
ATTR_REDEEMED_ON: timestamp,
}
)
return grouped_by_kid
# DEPRECATE --------------------------------------------------------------------------------
class RewardClaimsSensor(CoordinatorEntity, SensorEntity):
"""Sensor tracking how many times each reward has been claimed by a kid."""
_attr_has_entity_name = True
_attr_translation_key = "reward_claims_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, reward_id, reward_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._reward_id = reward_id
self._reward_name = reward_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_reward_claims"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"reward_name": reward_name,
}
self.entity_id = f"sensor.kc_{kid_name}_reward_claims_{reward_name}"
@property
def native_value(self):
"""Return the number of times the reward has been claimed."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("reward_claims", {}).get(self._reward_id, 0)
@property
def icon(self):
"""Return the chore's custom icon if set, else fallback."""
reward_info = self.coordinator.rewards_data.get(self._reward_id, {})
return reward_info.get("icon", DEFAULT_REWARD_ICON)
# DEPRECATE --------------------------------------------------------------------------------
class RewardApprovalsSensor(CoordinatorEntity, SensorEntity):
"""Sensor tracking how many times each reward has been approved for a kid."""
_attr_has_entity_name = True
_attr_translation_key = "reward_approvals_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, reward_id, reward_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._reward_id = reward_id
self._reward_name = reward_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_reward_approvals"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"reward_name": reward_name,
}
self.entity_id = f"sensor.kc_{kid_name}_reward_approvals_{reward_name}"
@property
def native_value(self):
"""Return the number of times the reward has been approved."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("reward_approvals", {}).get(self._reward_id, 0)
@property
def icon(self):
"""Return the chore's custom icon if set, else fallback."""
reward_info = self.coordinator.rewards_data.get(self._reward_id, {})
return reward_info.get("icon", DEFAULT_REWARD_ICON)
# ------------------------------------------------------------------------------------------
class SharedChoreGlobalStateSensor(CoordinatorEntity, SensorEntity):
"""Sensor that shows the global state of a shared chore."""
_attr_has_entity_name = True
_attr_translation_key = "shared_chore_global_status_sensor"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
chore_id: str,
chore_name: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._chore_id = chore_id
self._chore_name = chore_name
self._attr_unique_id = f"{entry.entry_id}_{chore_id}_global_state"
self._attr_translation_placeholders = {
"chore_name": chore_name,
}
self.entity_id = f"sensor.kc_global_chore_status_{chore_name}"
@property
def native_value(self) -> str:
"""Return the global state for the chore."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
return chore_info.get("state", CHORE_STATE_UNKNOWN)
@property
def extra_state_attributes(self) -> dict:
"""Return additional attributes for the chore."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
assigned_kids_ids = chore_info.get("assigned_kids", [])
assigned_kids_names = [
self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}"
for k_id in assigned_kids_ids
]
stored_labels = chore_info.get("chore_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
total_approvals_today = 0
for kid_id in assigned_kids_ids:
kid_data = self.coordinator.kids_data.get(kid_id, {})
total_approvals_today += kid_data.get("today_chore_approvals", {}).get(
self._chore_id, 0
)
attributes = {
ATTR_CHORE_NAME: self._chore_name,
ATTR_DESCRIPTION: chore_info.get("description", ""),
ATTR_RECURRING_FREQUENCY: chore_info.get("recurring_frequency", "None"),
ATTR_APPLICABLE_DAYS: chore_info.get("applicable_days", []),
ATTR_DUE_DATE: chore_info.get("due_date", "Not set"),
ATTR_DEFAULT_POINTS: chore_info.get("default_points", 0),
ATTR_PARTIAL_ALLOWED: chore_info.get("partial_allowed", False),
ATTR_ALLOW_MULTIPLE_CLAIMS_PER_DAY: chore_info.get(
"allow_multiple_claims_per_day", False
),
ATTR_CHORE_APPROVALS_TODAY: total_approvals_today,
ATTR_ASSIGNED_KIDS: assigned_kids_names,
ATTR_LABELS: friendly_labels,
}
if chore_info.get("recurring_frequency") == FREQUENCY_CUSTOM:
attributes[ATTR_CUSTOM_FREQUENCY_INTERVAL] = chore_info.get(
"custom_interval"
)
attributes[ATTR_CUSTOM_FREQUENCY_UNIT] = chore_info.get(
"custom_interval_unit"
)
return attributes
@property
def icon(self) -> str:
"""Return the icon for the chore sensor."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON)
# ------------------------------------------------------------------------------------------
class RewardStatusSensor(CoordinatorEntity, SensorEntity):
"""Shows the status of a reward for a particular kid."""
_attr_has_entity_name = True
_attr_translation_key = "reward_status_sensor"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
reward_id: str,
reward_name: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._reward_id = reward_id
self._reward_name = reward_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{reward_id}_reward_status"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"reward_name": reward_name,
}
self.entity_id = f"sensor.kc_{kid_name}_reward_status_{reward_name}"
@property
def native_value(self) -> str:
"""Return the current reward status: 'Not Claimed', 'Claimed', or 'Approved'."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
if self._reward_id in kid_info.get("pending_rewards", []):
return REWARD_STATE_CLAIMED
if self._reward_id in kid_info.get("redeemed_rewards", []):
return REWARD_STATE_APPROVED
return REWARD_STATE_NOT_CLAIMED
@property
def extra_state_attributes(self) -> dict:
"""Provide extra attributes about the reward."""
reward_info = self.coordinator.rewards_data.get(self._reward_id, {})
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
stored_labels = reward_info.get("reward_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
attributes = {
ATTR_KID_NAME: self._kid_name,
ATTR_REWARD_NAME: self._reward_name,
ATTR_DESCRIPTION: reward_info.get("description", ""),
ATTR_COST: reward_info.get("cost", DEFAULT_REWARD_COST),
ATTR_REWARD_CLAIMS_COUNT: kid_info.get("reward_claims", {}).get(
self._reward_id, 0
),
ATTR_REWARD_APPROVALS_COUNT: kid_info.get("reward_approvals", {}).get(
self._reward_id, 0
),
ATTR_LABELS: friendly_labels,
}
return attributes
@property
def icon(self) -> str:
"""Use the reward's custom icon if set, else fallback."""
reward_info = self.coordinator.rewards_data.get(self._reward_id, {})
return reward_info.get("icon", DEFAULT_REWARD_ICON)
# DEPRECATE --------------------------------------------------------------------------------
class ChoreClaimsSensor(CoordinatorEntity, SensorEntity):
"""Sensor tracking how many times each chore has been claimed by a kid."""
_attr_has_entity_name = True
_attr_translation_key = "chore_claims_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, chore_id, chore_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._chore_id = chore_id
self._chore_name = chore_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_chore_claims"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"chore_name": chore_name,
}
self.entity_id = f"sensor.kc_{kid_name}_chore_claims_{chore_name}"
@property
def native_value(self):
"""Return the number of times the chore has been claimed."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("chore_claims", {}).get(self._chore_id, 0)
@property
def icon(self):
"""Return the chore's custom icon if set, else fallback."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON)
# DEPRECATE --------------------------------------------------------------------------------
class ChoreApprovalsSensor(CoordinatorEntity, SensorEntity):
"""Sensor tracking how many times each chore has been approved for a kid."""
_attr_has_entity_name = True
_attr_translation_key = "chore_approvals_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, chore_id, chore_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._chore_id = chore_id
self._chore_name = chore_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_chore_approvals"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"chore_name": chore_name,
}
self.entity_id = f"sensor.kc_{kid_name}_chore_approvals_{chore_name}"
@property
def native_value(self):
"""Return the number of times the chore has been approved."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("chore_approvals", {}).get(self._chore_id, 0)
@property
def icon(self):
"""Return the chore's custom icon if set, else fallback."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON)
# ------------------------------------------------------------------------------------------
class PenaltyAppliesSensor(CoordinatorEntity, SensorEntity):
"""Sensor tracking how many times each penalty has been applied to a kid."""
_attr_has_entity_name = True
_attr_translation_key = "penalty_applies_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, penalty_id, penalty_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._penalty_id = penalty_id
self._penalty_name = penalty_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{penalty_id}_penalty_applies"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"penalty_name": penalty_name,
}
self.entity_id = f"sensor.kc_{kid_name}_penalties_applied_{penalty_name}"
@property
def native_value(self):
"""Return the number of times the penalty has been applied."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("penalty_applies", {}).get(self._penalty_id, 0)
@property
def extra_state_attributes(self):
"""Expose additional details like penalty points and description."""
penalty_info = self.coordinator.penalties_data.get(self._penalty_id, {})
stored_labels = penalty_info.get("penalty_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
return {
ATTR_KID_NAME: self._kid_name,
ATTR_PENALTY_NAME: self._penalty_name,
ATTR_DESCRIPTION: penalty_info.get("description", ""),
ATTR_PENALTY_POINTS: penalty_info.get("points", DEFAULT_PENALTY_POINTS),
ATTR_LABELS: friendly_labels,
}
@property
def icon(self):
"""Return the chore's custom icon if set, else fallback."""
penalty_info = self.coordinator.penalties_data.get(self._penalty_id, {})
return penalty_info.get("icon", DEFAULT_PENALTY_ICON)
# ------------------------------------------------------------------------------------------
class KidPointsEarnedDailySensor(CoordinatorEntity, SensorEntity):
"""Sensor for how many net points a kid earned today."""
_attr_has_entity_name = True
_attr_translation_key = "kid_points_earned_daily_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._points_label = points_label
self._points_icon = points_icon
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points_earned_daily"
self._attr_translation_placeholders = {
"kid_name": kid_name,
}
self.entity_id = f"sensor.kc_{kid_name}_points_earned_daily"
@property
def native_value(self):
"""Return how many net points the kid has earned so far today."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("points_earned_today", 0)
@property
def native_unit_of_measurement(self):
"""Return the points label."""
return self._points_label or LABEL_POINTS
@property
def icon(self):
"""Use the points' custom icon if set, else fallback."""
return self._points_icon or DEFAULT_POINTS_ICON
# ------------------------------------------------------------------------------------------
class KidPointsEarnedWeeklySensor(CoordinatorEntity, SensorEntity):
"""Sensor for how many net points a kid earned this week."""
_attr_has_entity_name = True
_attr_translation_key = "kid_points_earned_weekly_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._points_label = points_label
self._points_icon = points_icon
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points_earned_weekly"
self._attr_translation_placeholders = {
"kid_name": kid_name,
}
self.entity_id = f"sensor.kc_{kid_name}_points_earned_weekly"
@property
def native_value(self):
"""Return how many net points the kid has earned this week."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("points_earned_weekly", 0)
@property
def native_unit_of_measurement(self):
"""Return the points label."""
return self._points_label or LABEL_POINTS
@property
def icon(self):
"""Use the points' custom icon if set, else fallback."""
return self._points_icon or DEFAULT_POINTS_ICON
# ------------------------------------------------------------------------------------------
class KidPointsEarnedMonthlySensor(CoordinatorEntity, SensorEntity):
"""Sensor for how many net points a kid earned this month."""
_attr_has_entity_name = True
_attr_translation_key = "kid_points_earned_monthly_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, points_label, points_icon):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._points_label = points_label
self._points_icon = points_icon
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_points_earned_monthly"
self._attr_translation_placeholders = {
"kid_name": kid_name,
}
self.entity_id = f"sensor.kc_{kid_name}_points_earned_monthly"
@property
def native_value(self):
"""Return how many net points the kid has earned this month."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("points_earned_monthly", 0)
@property
def native_unit_of_measurement(self):
"""Return the points label."""
return self._points_label or LABEL_POINTS
@property
def icon(self):
"""Use the points' custom icon if set, else fallback."""
return self._points_icon or DEFAULT_POINTS_ICON
# ------------------------------------------------------------------------------------------
class AchievementSensor(CoordinatorEntity, SensorEntity):
"""Sensor representing an achievement."""
_attr_has_entity_name = True
_attr_translation_key = "achievement_state_sensor"
def __init__(self, coordinator, entry, achievement_id, achievement_name):
"""Initialize the AchievementSensor."""
super().__init__(coordinator)
self._entry = entry
self._achievement_id = achievement_id
self._achievement_name = achievement_name
self._attr_unique_id = f"{entry.entry_id}_{achievement_id}_achievement"
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_translation_placeholders = {
"achievement_name": achievement_name,
}
self.entity_id = f"sensor.kc_achievement_status_{achievement_name}"
@property
def native_value(self):
"""Return the overall progress percentage toward the achievement."""
achievement = self.coordinator.achievements_data.get(self._achievement_id, {})
target = achievement.get("target_value", 1)
assigned_kids = achievement.get("assigned_kids", [])
if not assigned_kids:
return 0
ach_type = achievement.get("type")
if ach_type == ACHIEVEMENT_TYPE_TOTAL:
total_current = 0
total_effective_target = 0
for kid_id in assigned_kids:
progress_data = achievement.get("progress", {}).get(kid_id, {})
baseline = (
progress_data.get("baseline", 0)
if isinstance(progress_data, dict)
else 0
)
current_total = self.coordinator.kids_data.get(kid_id, {}).get(
"completed_chores_total", 0
)
total_current += current_total
total_effective_target += baseline + target
percent = (
(total_current / total_effective_target * 100)
if total_effective_target > 0
else 0
)
elif ach_type == ACHIEVEMENT_TYPE_STREAK:
total_current = 0
for kid_id in assigned_kids:
progress_data = achievement.get("progress", {}).get(kid_id, {})
total_current += (
progress_data.get("current_streak", 0)
if isinstance(progress_data, dict)
else 0
)
global_target = target * len(assigned_kids)
percent = (total_current / global_target * 100) if global_target > 0 else 0
elif ach_type == ACHIEVEMENT_TYPE_DAILY_MIN:
total_progress = 0
for kid_id in assigned_kids:
daily = self.coordinator.kids_data.get(kid_id, {}).get(
"completed_chores_today", 0
)
kid_progress = (
100
if daily >= target
else (daily / target * 100)
if target > 0
else 0
)
total_progress += kid_progress
percent = total_progress / len(assigned_kids)
else:
percent = 0
return min(100, round(percent, 1))
@property
def extra_state_attributes(self):
"""Return extra attributes for this achievement."""
achievement = self.coordinator.achievements_data.get(self._achievement_id, {})
progress = achievement.get("progress", {})
kids_progress = {}
earned_by = []
for kid_id, data in progress.items():
if data.get("awarded", False):
kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id
earned_by.append(kid_name)
associated_chore = ""
selected_chore_id = achievement.get("selected_chore_id")
if selected_chore_id:
associated_chore = self.coordinator.chores_data.get(
selected_chore_id, {}
).get("name", "")
assigned_kids_ids = achievement.get("assigned_kids", [])
assigned_kids_names = [
self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}"
for k_id in assigned_kids_ids
]
ach_type = achievement.get("type")
for kid_id in assigned_kids_ids:
kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id
progress_data = achievement.get("progress", {}).get(kid_id, {})
if ach_type == ACHIEVEMENT_TYPE_TOTAL:
kids_progress[kid_name] = progress_data.get("current_value", 0)
elif ach_type == ACHIEVEMENT_TYPE_STREAK:
kids_progress[kid_name] = progress_data.get("current_streak", 0)
elif achievement.get("type") == ACHIEVEMENT_TYPE_DAILY_MIN:
kids_progress[kid_name] = self.coordinator.kids_data.get(
kid_id, {}
).get("completed_chores_today", 0)
else:
kids_progress[kid_name] = 0
stored_labels = achievement.get("achievement_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
return {
ATTR_ACHIEVEMENT_NAME: self._achievement_name,
ATTR_DESCRIPTION: achievement.get("description", ""),
ATTR_ASSIGNED_KIDS: assigned_kids_names,
ATTR_TYPE: ach_type,
ATTR_ASSOCIATED_CHORE: associated_chore,
ATTR_CRITERIA: achievement.get("criteria", ""),
ATTR_TARGET_VALUE: achievement.get("target_value"),
ATTR_REWARD_POINTS: achievement.get("reward_points"),
ATTR_KIDS_EARNED: earned_by,
ATTR_LABELS: friendly_labels,
}
@property
def icon(self):
"""Return an icon; you could choose a trophy icon."""
achievement_info = self.coordinator.achievements_data.get(
self._achievement_id, {}
)
return achievement_info.get("icon", DEFAULT_ACHIEVEMENTS_ICON)
# ------------------------------------------------------------------------------------------
class ChallengeSensor(CoordinatorEntity, SensorEntity):
"""Sensor representing a challenge."""
_attr_has_entity_name = True
_attr_translation_key = "challenge_state_sensor"
def __init__(self, coordinator, entry, challenge_id, challenge_name):
"""Initialize the ChallengeSensor."""
super().__init__(coordinator)
self._entry = entry
self._challenge_id = challenge_id
self._challenge_name = challenge_name
self._attr_unique_id = f"{entry.entry_id}_{challenge_id}_challenge"
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_translation_placeholders = {
"challenge_name": challenge_name,
}
self.entity_id = f"sensor.kc_challenge_status_{challenge_name}"
@property
def native_value(self):
"""Return the overall progress percentage toward the challenge."""
challenge = self.coordinator.challenges_data.get(self._challenge_id, {})
target = challenge.get("target_value", 1)
assigned_kids = challenge.get("assigned_kids", [])
if not assigned_kids:
return 0
challenge_type = challenge.get("type")
total_progress = 0
for kid_id in assigned_kids:
progress_data = challenge.get("progress", {}).get(kid_id, {})
if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
total_progress += progress_data.get("count", 0)
elif challenge_type == CHALLENGE_TYPE_DAILY_MIN:
if isinstance(progress_data, dict):
daily_counts = progress_data.get("daily_counts", {})
total_progress += sum(daily_counts.values())
else:
total_progress += 0
else:
total_progress += 0
global_target = target * len(assigned_kids)
percent = (total_progress / global_target * 100) if global_target > 0 else 0
return min(100, round(percent, 1))
@property
def extra_state_attributes(self):
"""Return extra attributes for this challenge."""
challenge = self.coordinator.challenges_data.get(self._challenge_id, {})
progress = challenge.get("progress", {})
kids_progress = {}
challenge_type = challenge.get("type")
earned_by = []
for kid_id, data in progress.items():
if data.get("awarded", False):
kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id
earned_by.append(kid_name)
associated_chore = ""
selected_chore_id = challenge.get("selected_chore_id")
if selected_chore_id:
associated_chore = self.coordinator.chores_data.get(
selected_chore_id, {}
).get("name", "")
assigned_kids_ids = challenge.get("assigned_kids", [])
assigned_kids_names = [
self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}"
for k_id in assigned_kids_ids
]
for kid_id in assigned_kids_ids:
kid_name = self.coordinator._get_kid_name_by_id(kid_id) or kid_id
progress_data = challenge.get("progress", {}).get(kid_id, {})
if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
kids_progress[kid_name] = progress_data.get("count", 0)
elif challenge_type == CHALLENGE_TYPE_DAILY_MIN:
if isinstance(progress_data, dict):
kids_progress[kid_name] = sum(
progress_data.get("daily_counts", {}).values()
)
else:
kids_progress[kid_name] = 0
else:
kids_progress[kid_name] = 0
stored_labels = challenge.get("challenge_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
return {
ATTR_CHALLENGE_NAME: self._challenge_name,
ATTR_DESCRIPTION: challenge.get("description", ""),
ATTR_ASSIGNED_KIDS: assigned_kids_names,
ATTR_TYPE: challenge_type,
ATTR_ASSOCIATED_CHORE: associated_chore,
ATTR_CRITERIA: challenge.get("criteria", ""),
ATTR_TARGET_VALUE: challenge.get("target_value"),
ATTR_REWARD_POINTS: challenge.get("reward_points"),
ATTR_START_DATE: challenge.get("start_date"),
ATTR_END_DATE: challenge.get("end_date"),
ATTR_KIDS_EARNED: earned_by,
ATTR_LABELS: friendly_labels,
}
@property
def icon(self):
"""Return an icon for challenges (you might want to choose one that fits your theme)."""
challenge_info = self.coordinator.challenges_data.get(self._challenge_id, {})
return challenge_info.get("icon", DEFAULT_ACHIEVEMENTS_ICON)
# ------------------------------------------------------------------------------------------
class AchievementProgressSensor(CoordinatorEntity, SensorEntity):
"""Sensor representing a kid's progress toward a specific achievement."""
_attr_has_entity_name = True
_attr_translation_key = "achievement_progress_sensor"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
achievement_id: str,
achievement_name: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._achievement_id = achievement_id
self._achievement_name = achievement_name
self._attr_unique_id = (
f"{entry.entry_id}_{kid_id}_{achievement_id}_achievement_progress"
)
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_translation_placeholders = {
"kid_name": kid_name,
"achievement_name": achievement_name,
}
self.entity_id = f"sensor.kc_{kid_name}_achievement_status_{achievement_name}"
@property
def native_value(self) -> float:
"""Return the progress percentage toward the achievement."""
achievement = self.coordinator.achievements_data.get(self._achievement_id, {})
target = achievement.get("target_value", 1)
ach_type = achievement.get("type")
if ach_type == ACHIEVEMENT_TYPE_TOTAL:
progress_data = achievement.get("progress", {}).get(self._kid_id, {})
baseline = (
progress_data.get("baseline", 0)
if isinstance(progress_data, dict)
else 0
)
current_total = self.coordinator.kids_data.get(self._kid_id, {}).get(
"completed_chores_total", 0
)
effective_target = baseline + target
percent = (
(current_total / effective_target * 100) if effective_target > 0 else 0
)
elif ach_type == ACHIEVEMENT_TYPE_STREAK:
progress_data = achievement.get("progress", {}).get(self._kid_id, {})
progress = (
progress_data.get("current_streak", 0)
if isinstance(progress_data, dict)
else 0
)
percent = (progress / target * 100) if target > 0 else 0
elif ach_type == ACHIEVEMENT_TYPE_DAILY_MIN:
daily = self.coordinator.kids_data.get(self._kid_id, {}).get(
"completed_chores_today", 0
)
percent = (daily / target * 100) if target > 0 else 0
else:
percent = 0
return min(100, round(percent, 1))
@property
def extra_state_attributes(self) -> dict:
"""Return extra attributes for the achievement progress."""
achievement = self.coordinator.achievements_data.get(self._achievement_id, {})
target = achievement.get("target_value", 1)
progress_data = achievement.get("progress", {}).get(self._kid_id, {})
awarded = (
progress_data.get("awarded", False)
if isinstance(progress_data, dict)
else False
)
if achievement.get("type") == ACHIEVEMENT_TYPE_TOTAL:
raw_progress = (
progress_data.get("current_value", 0)
if isinstance(progress_data, dict)
else 0
)
elif achievement.get("type") == ACHIEVEMENT_TYPE_STREAK:
raw_progress = (
progress_data.get("current_streak", 0)
if isinstance(progress_data, dict)
else 0
)
elif achievement.get("type") == ACHIEVEMENT_TYPE_DAILY_MIN:
raw_progress = self.coordinator.kids_data.get(self._kid_id, {}).get(
"completed_chores_today", 0
)
associated_chore = ""
selected_chore_id = achievement.get("selected_chore_id")
if selected_chore_id:
associated_chore = self.coordinator.chores_data.get(
selected_chore_id, {}
).get("name", "")
assigned_kids_ids = achievement.get("assigned_kids", [])
assigned_kids_names = [
self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}"
for k_id in assigned_kids_ids
]
stored_labels = achievement.get("achievement_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
return {
ATTR_ACHIEVEMENT_NAME: self._achievement_name,
ATTR_DESCRIPTION: achievement.get("description", ""),
ATTR_ASSIGNED_KIDS: assigned_kids_names,
ATTR_TYPE: achievement.get("type"),
ATTR_ASSOCIATED_CHORE: associated_chore,
ATTR_CRITERIA: achievement.get("criteria", ""),
ATTR_TARGET_VALUE: target,
ATTR_REWARD_POINTS: achievement.get("reward_points"),
ATTR_RAW_PROGRESS: raw_progress,
ATTR_AWARDED: awarded,
ATTR_LABELS: friendly_labels,
}
@property
def icon(self) -> str:
"""Return the icon for the achievement.
Use the icon provided in the achievement data if set, else fallback to default.
"""
achievement = self.coordinator.achievements_data.get(self._achievement_id, {})
return achievement.get("icon", DEFAULT_ACHIEVEMENTS_ICON)
# ------------------------------------------------------------------------------------------
class ChallengeProgressSensor(CoordinatorEntity, SensorEntity):
"""Sensor representing a kid's progress toward a specific challenge."""
_attr_has_entity_name = True
_attr_translation_key = "challenge_progress_sensor"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
challenge_id: str,
challenge_name: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._challenge_id = challenge_id
self._challenge_name = challenge_name
self._attr_unique_id = (
f"{entry.entry_id}_{kid_id}_{challenge_id}_challenge_progress"
)
self._attr_native_unit_of_measurement = PERCENTAGE
self._attr_translation_placeholders = {
"kid_name": kid_name,
"challenge_name": challenge_name,
}
self.entity_id = f"sensor.kc_{kid_name}_challenge_status_{challenge_name}"
@property
def native_value(self) -> float:
"""Return the challenge progress percentage."""
challenge = self.coordinator.challenges_data.get(self._challenge_id, {})
target = challenge.get("target_value", 1)
challenge_type = challenge.get("type")
progress_data = challenge.get("progress", {}).get(self._kid_id)
if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
raw_progress = (
progress_data.get("count", 0) if isinstance(progress_data, dict) else 0
)
elif challenge_type == CHALLENGE_TYPE_DAILY_MIN:
if isinstance(progress_data, dict):
daily_counts = progress_data.get("daily_counts", {})
raw_progress = sum(daily_counts.values())
# Optionally, compute target as required_daily * number_of_days:
start_date = dt_util.parse_datetime(challenge.get("start_date"))
end_date = dt_util.parse_datetime(challenge.get("end_date"))
if start_date and end_date:
num_days = (end_date.date() - start_date.date()).days + 1
else:
num_days = 1
required_daily = challenge.get("required_daily", 1)
target = required_daily * num_days
else:
raw_progress = 0
else:
raw_progress = 0
percent = (raw_progress / target * 100) if target > 0 else 0
return min(100, round(percent, 1))
@property
def extra_state_attributes(self) -> dict:
"""Return extra attributes for the challenge progress."""
challenge = self.coordinator.challenges_data.get(self._challenge_id, {})
target = challenge.get("target_value", 1)
challenge_type = challenge.get("type")
progress_data = challenge.get("progress", {}).get(self._kid_id, {})
awarded = (
progress_data.get("awarded", False)
if isinstance(progress_data, dict)
else False
)
if challenge_type == CHALLENGE_TYPE_TOTAL_WITHIN_WINDOW:
raw_progress = (
progress_data.get("count", 0) if isinstance(progress_data, dict) else 0
)
elif challenge_type == CHALLENGE_TYPE_DAILY_MIN:
if isinstance(progress_data, dict):
daily_counts = progress_data.get("daily_counts", {})
raw_progress = sum(daily_counts.values())
else:
raw_progress = 0
else:
raw_progress = 0
associated_chore = ""
selected_chore_id = challenge.get("selected_chore_id")
if selected_chore_id:
associated_chore = self.coordinator.chores_data.get(
selected_chore_id, {}
).get("name", "")
assigned_kids_ids = challenge.get("assigned_kids", [])
assigned_kids_names = [
self.coordinator._get_kid_name_by_id(k_id) or f"Kid {k_id}"
for k_id in assigned_kids_ids
]
stored_labels = challenge.get("challenge_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
return {
ATTR_CHALLENGE_NAME: self._challenge_name,
ATTR_DESCRIPTION: challenge.get("description", ""),
ATTR_ASSIGNED_KIDS: assigned_kids_names,
ATTR_TYPE: challenge_type,
ATTR_ASSOCIATED_CHORE: associated_chore,
ATTR_CRITERIA: challenge.get("criteria", ""),
ATTR_TARGET_VALUE: target,
ATTR_REWARD_POINTS: challenge.get("reward_points"),
ATTR_START_DATE: challenge.get("start_date"),
ATTR_END_DATE: challenge.get("end_date"),
ATTR_RAW_PROGRESS: raw_progress,
ATTR_AWARDED: awarded,
ATTR_LABELS: friendly_labels,
}
@property
def icon(self) -> str:
"""Return the icon for the challenge.
Use the icon provided in the challenge data if set, else fallback to default.
"""
challenge = self.coordinator.challenges_data.get(self._challenge_id, {})
return challenge.get("icon", DEFAULT_CHALLENGES_ICON)
# ------------------------------------------------------------------------------------------
class KidHighestStreakSensor(CoordinatorEntity, SensorEntity):
"""Sensor returning the highest current streak among streak-type achievements for a kid."""
_attr_has_entity_name = True
_attr_translation_key = "kid_highest_streak_sensor"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_highest_streak"
self._attr_native_unit_of_measurement = UnitOfTime.DAYS
self._attr_translation_placeholders = {
"kid_name": kid_name,
}
self.entity_id = f"sensor.kc_{kid_name}_highest_streak"
@property
def native_value(self) -> int:
"""Return the highest current streak among all streak achievements for the kid."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("overall_chore_streak", 0)
@property
def extra_state_attributes(self) -> dict:
"""Return extra attributes including individual streaks per achievement."""
streaks = {}
for achievement in self.coordinator.achievements_data.values():
if achievement.get("type") == ACHIEVEMENT_TYPE_STREAK:
achievement_name = achievement.get("name", "Unnamed Achievement")
progress_for_kid = achievement.get("progress", {}).get(self._kid_id)
if isinstance(progress_for_kid, dict):
streaks[achievement_name] = progress_for_kid.get(
"current_streak", 0
)
elif isinstance(progress_for_kid, int):
streaks[achievement_name] = progress_for_kid
return {"streaks_by_achievement": streaks}
@property
def icon(self) -> str:
"""Return an icon for 'highest streak'. You can choose any default or allow config overrides."""
return DEFAULT_STREAK_ICON
# ------------------------------------------------------------------------------------------
class ChoreStreakSensor(CoordinatorEntity, SensorEntity):
"""Sensor returning the current streak for a specific chore for a given kid."""
_attr_has_entity_name = True
_attr_translation_key = "chore_streak_sensor"
def __init__(
self,
coordinator: KidsChoresDataCoordinator,
entry: ConfigEntry,
kid_id: str,
kid_name: str,
chore_id: str,
chore_name: str,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._entry = entry
self._kid_id = kid_id
self._kid_name = kid_name
self._chore_id = chore_id
self._chore_name = chore_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{chore_id}_streak"
self._attr_native_unit_of_measurement = UnitOfTime.DAYS
self._attr_translation_placeholders = {
"kid_name": kid_name,
"chore_name": chore_name,
}
self.entity_id = f"sensor.kc_{kid_name}_chore_streak_{chore_name}"
@property
def native_value(self) -> int:
"""Return the current streak (in days) for this kid and chore."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
streaks = kid_info.get("chore_streaks", {})
streak_info = streaks.get(self._chore_id, {})
return streak_info.get("current_streak", 0)
@property
def extra_state_attributes(self) -> dict:
"""Return extra attributes such as the last approved date for this streak."""
attributes = {}
for achievement in self.coordinator.achievements_data.values():
if (
achievement.get("type") == ACHIEVEMENT_TYPE_STREAK
and achievement.get("selected_chore_id") == self._chore_id
):
progress_for_kid = achievement.get("progress", {}).get(self._kid_id)
if isinstance(progress_for_kid, dict):
attributes[ATTR_LAST_DATE] = progress_for_kid.get("last_date")
attributes[ATTR_RAW_STREAK] = progress_for_kid.get(
"current_streak", 0
)
elif isinstance(progress_for_kid, int):
attributes[ATTR_LAST_DATE] = None
attributes[ATTR_RAW_STREAK] = progress_for_kid
break
return attributes
@property
def icon(self) -> str:
"""Return the chore's custom icon if set, else fallback."""
chore_info = self.coordinator.chores_data.get(self._chore_id, {})
return chore_info.get("icon", DEFAULT_CHORE_SENSOR_ICON)
# ------------------------------------------------------------------------------------------
class BonusAppliesSensor(CoordinatorEntity, SensorEntity):
"""Sensor tracking how many times each bonus has been applied to a kid."""
_attr_has_entity_name = True
_attr_translation_key = "bonus_applies_sensor"
def __init__(self, coordinator, entry, kid_id, kid_name, bonus_id, bonus_name):
"""Initialize the sensor."""
super().__init__(coordinator)
self._kid_id = kid_id
self._kid_name = kid_name
self._bonus_id = bonus_id
self._bonus_name = bonus_name
self._attr_unique_id = f"{entry.entry_id}_{kid_id}_{bonus_id}_bonus_applies"
self._attr_translation_placeholders = {
"kid_name": kid_name,
"bonus_name": bonus_name,
}
self.entity_id = f"sensor.kc_{kid_name}_bonuses_applied_{bonus_name}"
@property
def native_value(self):
"""Return the number of times the bonus has been applied."""
kid_info = self.coordinator.kids_data.get(self._kid_id, {})
return kid_info.get("bonus_applies", {}).get(self._bonus_id, 0)
@property
def extra_state_attributes(self):
"""Expose additional details like bonus points and description."""
bonus_info = self.coordinator.bonuses_data.get(self._bonus_id, {})
stored_labels = bonus_info.get("bonus_labels", [])
friendly_labels = [
get_friendly_label(self.hass, label) for label in stored_labels
]
return {
ATTR_KID_NAME: self._kid_name,
ATTR_BONUS_NAME: self._bonus_name,
ATTR_DESCRIPTION: bonus_info.get("description", ""),
ATTR_BONUS_POINTS: bonus_info.get("points", DEFAULT_BONUS_POINTS),
ATTR_LABELS: friendly_labels,
}
@property
def icon(self):
"""Return the bonus's custom icon if set, else fallback."""
bonus_info = self.coordinator.bonuses_data.get(self._bonus_id, {})
return bonus_info.get("icon", DEFAULT_BONUS_ICON)
================================================
FILE: custom_components/kidschores/services.py
================================================
# File: services.py
"""Defines custom services for the KidsChores integration.
These services allow direct actions through scripts or automations.
Includes UI editor support with selectors for dropdowns and text inputs.
"""
import asyncio
import voluptuous as vol
from typing import Optional
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .const import (
CHORE_STATE_OVERDUE,
CHORE_STATE_PENDING,
DATA_CHORES,
DATA_PENDING_CHORE_APPROVALS,
DOMAIN,
ERROR_CHORE_NOT_FOUND_FMT,
ERROR_KID_NOT_FOUND_FMT,
ERROR_NOT_AUTHORIZED_FMT,
FIELD_CHORE_ID,
FIELD_CHORE_NAME,
FIELD_DUE_DATE,
FIELD_KID_NAME,
FIELD_PARENT_NAME,
FIELD_PENALTY_NAME,
FIELD_POINTS_AWARDED,
FIELD_REWARD_NAME,
FIELD_BONUS_NAME,
LOGGER,
MSG_NO_ENTRY_FOUND,
SERVICE_APPLY_PENALTY,
SERVICE_APPLY_BONUS,
SERVICE_APPROVE_CHORE,
SERVICE_APPROVE_REWARD,
SERVICE_CLAIM_CHORE,
SERVICE_DISAPPROVE_CHORE,
SERVICE_DISAPPROVE_REWARD,
SERVICE_REDEEM_REWARD,
SERVICE_RESET_ALL_CHORES,
SERVICE_RESET_ALL_DATA,
SERVICE_RESET_OVERDUE_CHORES,
SERVICE_RESET_PENALTIES,
SERVICE_RESET_BONUSES,
SERVICE_RESET_REWARDS,
SERVICE_SET_CHORE_DUE_DATE,
SERVICE_SKIP_CHORE_DUE_DATE,
)
from .coordinator import KidsChoresDataCoordinator
from .kc_helpers import is_user_authorized_for_global_action, is_user_authorized_for_kid
from .flow_helpers import ensure_utc_datetime
# --- Service Schemas ---
CLAIM_CHORE_SCHEMA = vol.Schema(
{
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_CHORE_NAME): cv.string,
}
)
APPROVE_CHORE_SCHEMA = vol.Schema(
{
vol.Required(FIELD_PARENT_NAME): cv.string,
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_CHORE_NAME): cv.string,
vol.Optional(FIELD_POINTS_AWARDED): vol.Coerce(float),
}
)
DISAPPROVE_CHORE_SCHEMA = vol.Schema(
{
vol.Required(FIELD_PARENT_NAME): cv.string,
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_CHORE_NAME): cv.string,
}
)
REDEEM_REWARD_SCHEMA = vol.Schema(
{
vol.Required(FIELD_PARENT_NAME): cv.string,
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_REWARD_NAME): cv.string,
}
)
APPROVE_REWARD_SCHEMA = vol.Schema(
{
vol.Required(FIELD_PARENT_NAME): cv.string,
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_REWARD_NAME): cv.string,
}
)
DISAPPROVE_REWARD_SCHEMA = vol.Schema(
{
vol.Required(FIELD_PARENT_NAME): cv.string,
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_REWARD_NAME): cv.string,
}
)
APPLY_PENALTY_SCHEMA = vol.Schema(
{
vol.Required(FIELD_PARENT_NAME): cv.string,
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_PENALTY_NAME): cv.string,
}
)
APPLY_BONUS_SCHEMA = vol.Schema(
{
vol.Required(FIELD_PARENT_NAME): cv.string,
vol.Required(FIELD_KID_NAME): cv.string,
vol.Required(FIELD_BONUS_NAME): cv.string,
}
)
RESET_OVERDUE_CHORES_SCHEMA = vol.Schema(
{
vol.Optional(FIELD_CHORE_ID): cv.string,
vol.Optional(FIELD_CHORE_NAME): cv.string,
vol.Optional(FIELD_KID_NAME): cv.string,
}
)
RESET_PENALTIES_SCHEMA = vol.Schema(
{
vol.Optional(FIELD_KID_NAME): cv.string,
vol.Optional(FIELD_PENALTY_NAME): cv.string,
}
)
RESET_BONUSES_SCHEMA = vol.Schema(
{
vol.Optional(FIELD_KID_NAME): cv.string,
vol.Optional(FIELD_BONUS_NAME): cv.string,
}
)
RESET_REWARDS_SCHEMA = vol.Schema(
{
vol.Optional(FIELD_KID_NAME): cv.string,
vol.Optional(FIELD_REWARD_NAME): cv.string,
}
)
RESET_ALL_DATA_SCHEMA = vol.Schema({})
RESET_ALL_CHORES_SCHEMA = vol.Schema({})
SET_CHORE_DUE_DATE_SCHEMA = vol.Schema(
{
vol.Required(FIELD_CHORE_NAME): cv.string,
vol.Optional(FIELD_DUE_DATE): vol.Any(cv.string, None),
}
)
SKIP_CHORE_DUE_DATE_SCHEMA = vol.Schema(
{
vol.Optional(FIELD_CHORE_ID): cv.string,
vol.Optional(FIELD_CHORE_NAME): cv.string,
}
)
def async_setup_services(hass: HomeAssistant):
"""Register KidsChores services."""
async def handle_claim_chore(call: ServiceCall):
"""Handle claiming a chore."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Claim Chore: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
user_id = call.context.user_id
kid_name = call.data[FIELD_KID_NAME]
chore_name = call.data[FIELD_CHORE_NAME]
# Map kid_name and chore_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Claim Chore: " + ERROR_KID_NOT_FOUND_FMT, kid_name)
raise HomeAssistantError(ERROR_KID_NOT_FOUND_FMT.format(kid_name))
chore_id = _get_chore_id_by_name(coordinator, chore_name)
if not chore_id:
LOGGER.warning("Claim Chore: " + ERROR_CHORE_NOT_FOUND_FMT, chore_name)
raise HomeAssistantError(ERROR_CHORE_NOT_FOUND_FMT.format(chore_name))
# Check if user is authorized
if user_id and not await is_user_authorized_for_kid(hass, user_id, kid_id):
LOGGER.warning("Claim Chore: %s", ERROR_NOT_AUTHORIZED_FMT)
raise HomeAssistantError(ERROR_NOT_AUTHORIZED_FMT.format("claim chores"))
# Process chore claim
coordinator.claim_chore(
kid_id=kid_id, chore_id=chore_id, user_name=f"user:{user_id}"
)
LOGGER.info(
"Chore '%s' claimed by kid '%s' by user '%s'",
chore_name,
kid_name,
user_id,
)
await coordinator.async_request_refresh()
async def handle_approve_chore(call: ServiceCall):
"""Handle approving a claimed chore."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Approve Chore: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
user_id = call.context.user_id
parent_name = call.data[FIELD_PARENT_NAME]
kid_name = call.data[FIELD_KID_NAME]
chore_name = call.data[FIELD_CHORE_NAME]
points_awarded = call.data.get(FIELD_POINTS_AWARDED)
# Map kid_name and chore_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Approve Chore: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found")
chore_id = _get_chore_id_by_name(coordinator, chore_name)
if not chore_id:
LOGGER.warning("Approve Chore: Chore '%s' not found", chore_name)
raise HomeAssistantError(f"Chore '{chore_name}' not found")
# Check if user is authorized
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Approve Chore: User not authorized")
raise HomeAssistantError(
"You are not authorized to approve chores for this kid."
)
# Approve chore and assign points
try:
coordinator.approve_chore(
parent_name=parent_name,
kid_id=kid_id,
chore_id=chore_id,
points_awarded=points_awarded,
)
LOGGER.info(
"Chore '%s' approved for kid '%s' by parent '%s'. Points Awarded: %s",
chore_name,
kid_name,
parent_name,
points_awarded,
)
await coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error("Approve Chore: %s", e)
raise
except Exception as e:
LOGGER.error(
"Approve Chore: Failed to approve chore '%s' for kid '%s': %s",
chore_name,
kid_name,
e,
)
raise HomeAssistantError(
f"Failed to approve chore '{chore_name}' for kid '{kid_name}'."
)
async def handle_disapprove_chore(call: ServiceCall):
"""Handle disapproving a chore."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Disapprove Chore: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
parent_name = call.data[FIELD_PARENT_NAME]
kid_name = call.data[FIELD_KID_NAME]
chore_name = call.data[FIELD_CHORE_NAME]
# Map kid_name and chore_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Disapprove Chore: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found")
chore_id = _get_chore_id_by_name(coordinator, chore_name)
if not chore_id:
LOGGER.warning("Disapprove Chore: Chore '%s' not found", chore_name)
raise HomeAssistantError(f"Chore '{chore_name}' not found")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Disapprove Chore: User not authorized")
raise HomeAssistantError(
"You are not authorized to disapprove chores for this kid."
)
# Disapprove the chore
coordinator.disapprove_chore(
parent_name=parent_name,
kid_id=kid_id,
chore_id=chore_id,
)
LOGGER.info(
"Chore '%s' disapproved for kid '%s' by parent '%s'",
chore_name,
kid_name,
parent_name,
)
await coordinator.async_request_refresh()
async def handle_redeem_reward(call: ServiceCall):
"""Handle redeeming a reward (claiming without deduction)."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Redeem Reward: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
parent_name = call.data[FIELD_PARENT_NAME]
kid_name = call.data[FIELD_KID_NAME]
reward_name = call.data[FIELD_REWARD_NAME]
# Map kid_name and reward_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Redeem Reward: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found")
reward_id = _get_reward_id_by_name(coordinator, reward_name)
if not reward_id:
LOGGER.warning("Redeem Reward: Reward '%s' not found", reward_name)
raise HomeAssistantError(f"Reward '{reward_name}' not found")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_kid(hass, user_id, kid_id):
LOGGER.warning("Redeem Reward: User not authorized")
raise HomeAssistantError(
"You are not authorized to redeem rewards for this kid."
)
# Check if kid has enough points
kid_info = coordinator.kids_data.get(kid_id)
reward = coordinator.rewards_data.get(reward_id)
if not kid_info or not reward:
LOGGER.warning("Redeem Reward: Invalid kid or reward")
raise HomeAssistantError("Invalid kid or reward")
if kid_info["points"] < reward.get("cost", 0):
LOGGER.warning(
"Redeem Reward: Kid '%s' does not have enough points to redeem reward '%s'",
kid_name,
reward_name,
)
raise HomeAssistantError(
f"Kid '{kid_name}' does not have enough points to redeem '{reward_name}'."
)
# Process reward claim without deduction
try:
coordinator.redeem_reward(
parent_name=parent_name, kid_id=kid_id, reward_id=reward_id
)
LOGGER.info(
"Reward '%s' claimed by kid '%s' and pending approval by parent '%s'",
reward_name,
kid_name,
parent_name,
)
await coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error("Redeem Reward: %s", e)
raise
except Exception as e:
LOGGER.error(
"Redeem Reward: Failed to claim reward '%s' for kid '%s': %s",
reward_name,
kid_name,
e,
)
raise HomeAssistantError(
f"Failed to claim reward '{reward_name}' for kid '{kid_name}'."
)
async def handle_approve_reward(call: ServiceCall):
"""Handle approving a reward claimed by a kid."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Approve Reward: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
user_id = call.context.user_id
parent_name = call.data[FIELD_PARENT_NAME]
kid_name = call.data[FIELD_KID_NAME]
reward_name = call.data[FIELD_REWARD_NAME]
# Map kid_name and reward_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Approve Reward: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found")
reward_id = _get_reward_id_by_name(coordinator, reward_name)
if not reward_id:
LOGGER.warning("Approve Reward: Reward '%s' not found", reward_name)
raise HomeAssistantError(f"Reward '{reward_name}' not found")
# Check if user is authorized
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Approve Reward: User not authorized")
raise HomeAssistantError(
"You are not authorized to approve rewards for this kid."
)
# Approve reward redemption and deduct points
try:
coordinator.approve_reward(
parent_name=parent_name, kid_id=kid_id, reward_id=reward_id
)
LOGGER.info(
"Reward '%s' approved for kid '%s' by parent '%s'",
reward_name,
kid_name,
parent_name,
)
await coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error("Approve Reward: %s", e)
raise
except Exception as e:
LOGGER.error(
"Approve Reward: Failed to approve reward '%s' for kid '%s': %s",
reward_name,
kid_name,
e,
)
raise HomeAssistantError(
f"Failed to approve reward '{reward_name}' for kid '{kid_name}'."
)
async def handle_disapprove_reward(call: ServiceCall):
"""Handle disapproving a reward."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Disapprove Reward: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
parent_name = call.data[FIELD_PARENT_NAME]
kid_name = call.data[FIELD_KID_NAME]
reward_name = call.data[FIELD_REWARD_NAME]
# Map kid_name and reward_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Disapprove Reward: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found")
reward_id = _get_reward_id_by_name(coordinator, reward_name)
if not reward_id:
LOGGER.warning("Disapprove Reward: Reward '%s' not found", reward_name)
raise HomeAssistantError(f"Reward '{reward_name}' not found")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Disapprove Reward: User not authorized")
raise HomeAssistantError(
"You are not authorized to disapprove rewards for this kid."
)
# Disapprove the reward
coordinator.disapprove_reward(
parent_name=parent_name,
kid_id=kid_id,
reward_id=reward_id,
)
LOGGER.info(
"Reward '%s' disapproved for kid '%s' by parent '%s'",
reward_name,
kid_name,
parent_name,
)
await coordinator.async_request_refresh()
async def handle_apply_penalty(call: ServiceCall):
"""Handle applying a penalty."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Apply Penalty: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
parent_name = call.data[FIELD_PARENT_NAME]
kid_name = call.data[FIELD_KID_NAME]
penalty_name = call.data[FIELD_PENALTY_NAME]
# Map kid_name and penalty_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Apply Penalty: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found")
penalty_id = _get_penalty_id_by_name(coordinator, penalty_name)
if not penalty_id:
LOGGER.warning("Apply Penalty: Penalty '%s' not found", penalty_name)
raise HomeAssistantError(f"Penalty '{penalty_name}' not found")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Apply Penalty: User not authorized")
raise HomeAssistantError(
"You are not authorized to apply penalties for this kid."
)
# Apply penalty
try:
coordinator.apply_penalty(
parent_name=parent_name, kid_id=kid_id, penalty_id=penalty_id
)
LOGGER.info(
"Penalty '%s' applied for kid '%s' by parent '%s'",
penalty_name,
kid_name,
parent_name,
)
await coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error("Apply Penalty: %s", e)
raise
except Exception as e:
LOGGER.error(
"Apply Penalty: Failed to apply penalty '%s' for kid '%s': %s",
penalty_name,
kid_name,
e,
)
raise HomeAssistantError(
f"Failed to apply penalty '{penalty_name}' for kid '{kid_name}'."
)
async def handle_reset_penalties(call: ServiceCall):
"""Handle resetting penalties."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Reset Penalties: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
kid_name = call.data.get(FIELD_KID_NAME)
penalty_name = call.data.get(FIELD_PENALTY_NAME)
kid_id = _get_kid_id_by_name(coordinator, kid_name) if kid_name else None
penalty_id = (
_get_penalty_id_by_name(coordinator, penalty_name) if penalty_name else None
)
if kid_name and not kid_id:
LOGGER.warning("Reset Penalties: Kid '%s' not found.", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found.")
if penalty_name and not penalty_id:
LOGGER.warning("Reset Penalties: Penalty '%s' not found.", penalty_name)
raise HomeAssistantError(f"Penalty '{penalty_name}' not found.")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Reset Penalties: User not authorized.")
raise HomeAssistantError("You are not authorized to reset penalties.")
# Log action based on parameters provided
if kid_id is None and penalty_id is None:
LOGGER.info("Resetting all penalties for all kids.")
elif kid_id is None:
LOGGER.info("Resetting penalty '%s' for all kids.", penalty_name)
elif penalty_id is None:
LOGGER.info("Resetting all penalties for kid '%s'.", kid_name)
else:
LOGGER.info("Resetting penalty '%s' for kid '%s'.", penalty_name, kid_name)
# Reset penalties
coordinator.reset_penalties(kid_id=kid_id, penalty_id=penalty_id)
await coordinator.async_request_refresh()
async def handle_reset_bonuses(call: ServiceCall):
"""Handle resetting bonuses."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Reset Bonuses: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
kid_name = call.data.get(FIELD_KID_NAME)
bonus_name = call.data.get(FIELD_BONUS_NAME)
kid_id = _get_kid_id_by_name(coordinator, kid_name) if kid_name else None
bonus_id = (
_get_bonus_id_by_name(coordinator, bonus_name) if bonus_name else None
)
if kid_name and not kid_id:
LOGGER.warning("Reset Bonuses: Kid '%s' not found.", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found.")
if bonus_name and not bonus_id:
LOGGER.warning("Reset Bonuses: Bonus '%s' not found.", bonus_name)
raise HomeAssistantError(f"Bonus '{bonus_name}' not found.")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Reset Bonuses: User not authorized.")
raise HomeAssistantError("You are not authorized to reset bonuses.")
# Log action based on parameters provided
if kid_id is None and bonus_id is None:
LOGGER.info("Resetting all bonuses for all kids.")
elif kid_id is None:
LOGGER.info("Resetting bonus '%s' for all kids.", bonus_name)
elif bonus_id is None:
LOGGER.info("Resetting all bonuses for kid '%s'.", kid_name)
else:
LOGGER.info("Resetting bonus '%s' for kid '%s'.", bonus_name, kid_name)
# Reset bonuses
coordinator.reset_bonuses(kid_id=kid_id, bonus_id=bonus_id)
await coordinator.async_request_refresh()
async def handle_reset_rewards(call: ServiceCall):
"""Handle resetting rewards counts."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Reset Rewards: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
kid_name = call.data.get(FIELD_KID_NAME)
reward_name = call.data.get(FIELD_REWARD_NAME)
kid_id = _get_kid_id_by_name(coordinator, kid_name) if kid_name else None
reward_id = (
_get_reward_id_by_name(coordinator, reward_name) if reward_name else None
)
if kid_name and not kid_id:
LOGGER.warning("Reset Rewards: Kid '%s' not found.", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found.")
if reward_name and not reward_id:
LOGGER.warning("Reset Rewards: Reward '%s' not found.", reward_name)
raise HomeAssistantError(f"Reward '{reward_name}' not found.")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Reset Rewards: User not authorized.")
raise HomeAssistantError("You are not authorized to reset rewards.")
# Log action based on parameters provided
if kid_id is None and reward_id is None:
LOGGER.info("Resetting all rewards for all kids.")
elif kid_id is None:
LOGGER.info("Resetting reward '%s' for all kids.", reward_name)
elif reward_id is None:
LOGGER.info("Resetting all rewards for kid '%s'.", kid_name)
else:
LOGGER.info("Resetting reward '%s' for kid '%s'.", reward_name, kid_name)
# Reset rewards
coordinator.reset_rewards(kid_id=kid_id, reward_id=reward_id)
await coordinator.async_request_refresh()
async def handle_apply_bonus(call: ServiceCall):
"""Handle applying a bonus."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Apply Bonus: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
parent_name = call.data[FIELD_PARENT_NAME]
kid_name = call.data[FIELD_KID_NAME]
bonus_name = call.data[FIELD_BONUS_NAME]
# Map kid_name and bonus_name to internal_ids
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Apply Bonus: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found")
bonus_id = _get_bonus_id_by_name(coordinator, bonus_name)
if not bonus_id:
LOGGER.warning("Apply Bonus: Bonus '%s' not found", bonus_name)
raise HomeAssistantError(f"Bonus '{bonus_name}' not found")
# Check if user is authorized
user_id = call.context.user_id
if user_id and not await is_user_authorized_for_global_action(
hass, user_id, kid_id
):
LOGGER.warning("Apply Bonus: User not authorized")
raise HomeAssistantError(
"You are not authorized to apply bonuses for this kid."
)
# Apply bonus
try:
coordinator.apply_bonus(
parent_name=parent_name, kid_id=kid_id, bonus_id=bonus_id
)
LOGGER.info(
"Bonus '%s' applied for kid '%s' by parent '%s'",
bonus_name,
kid_name,
parent_name,
)
await coordinator.async_request_refresh()
except HomeAssistantError as e:
LOGGER.error("Apply Bonus: %s", e)
raise
except Exception as e:
LOGGER.error(
"Apply Bonus: Failed to apply bonus '%s' for kid '%s': %s",
bonus_name,
kid_name,
e,
)
raise HomeAssistantError(
f"Failed to apply bonus '{bonus_name}' for kid '{kid_name}'."
)
async def handle_reset_all_data(call: ServiceCall):
"""Handle manually resetting ALL data in KidsChores."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Reset All Data: No KidsChores entry found")
return
data = hass.data[DOMAIN].get(entry_id)
if not data:
LOGGER.warning("Reset All Data: No coordinator data found")
return
coordinator: KidsChoresDataCoordinator = data["coordinator"]
# Clear everything from storage
await coordinator.storage_manager.async_clear_data()
# Re-init the coordinator with reload config entry
await hass.config_entries.async_reload(entry_id)
coordinator.async_set_updated_data(coordinator._data)
LOGGER.info("Manually reset all KidsChores data. Integration is now cleared")
async def handle_reset_all_chores(call: ServiceCall):
"""Handle manually resetting all chores to pending, clearing claims/approvals."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Reset All Chores: No KidsChores entry found")
return
data = hass.data[DOMAIN].get(entry_id)
if not data:
LOGGER.warning("Reset All Chores: No coordinator data found")
return
coordinator: KidsChoresDataCoordinator = data["coordinator"]
# Loop over all chores, reset them to pending
for chore_id, chore_info in coordinator.chores_data.items():
chore_info["state"] = CHORE_STATE_PENDING
# Remove all chore approvals/claims for each kid
for kid_id, kid_info in coordinator.kids_data.items():
kid_info["claimed_chores"] = []
kid_info["approved_chores"] = []
kid_info["overdue_chores"] = []
kid_info["overdue_notifications"] = {}
# Clear the pending approvals queue
coordinator._data[DATA_PENDING_CHORE_APPROVALS] = []
# Persist & notify
coordinator._persist()
coordinator.async_set_updated_data(coordinator._data)
LOGGER.info("Manually reset all chores to pending, removed claims/approvals")
async def handle_reset_overdue_chores(call: ServiceCall) -> None:
"""Handle resetting overdue chores."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Reset Overdue Chores: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
# Get parameters
chore_id = call.data.get(FIELD_CHORE_ID)
chore_name = call.data.get(FIELD_CHORE_NAME)
kid_name = call.data.get(FIELD_KID_NAME)
# If chore_id not provided but chore_name is, map it to chore_id.
if not chore_id and chore_name:
chore_id = _get_chore_id_by_name(coordinator, chore_name)
if not chore_id:
LOGGER.warning("Reset Overdue Chores: Chore '%s' not found", chore_name)
raise HomeAssistantError(f"Chore '{chore_name}' not found.")
# If kid_name provided, map it to kid_id.
kid_id: Optional[str] = None
if kid_name:
kid_id = _get_kid_id_by_name(coordinator, kid_name)
if not kid_id:
LOGGER.warning("Reset Overdue Chores: Kid '%s' not found", kid_name)
raise HomeAssistantError(f"Kid '{kid_name}' not found.")
coordinator.reset_overdue_chores(chore_id=chore_id, kid_id=kid_id)
LOGGER.info("Reset overdue chores (chore_id=%s, kid_id=%s)", chore_id, kid_id)
await coordinator.async_request_refresh()
await coordinator._check_overdue_chores()
async def handle_set_chore_due_date(call: ServiceCall):
"""Handle setting (or clearing) the due date of a chore."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Set Chore Due Date: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
chore_name = call.data[FIELD_CHORE_NAME]
due_date_input = call.data.get(FIELD_DUE_DATE)
# Look up the chore by name:
chore_id = _get_chore_id_by_name(coordinator, chore_name)
if not chore_id:
LOGGER.warning("Set Chore Due Date: Chore '%s' not found", chore_name)
raise HomeAssistantError(ERROR_CHORE_NOT_FOUND_FMT.format(chore_name))
if due_date_input:
try:
# Convert the provided date
due_date_str = ensure_utc_datetime(hass, due_date_input)
due_dt = dt_util.parse_datetime(due_date_str)
if due_dt and due_dt < dt_util.utcnow():
raise HomeAssistantError("Due date cannot be set in the past.")
except Exception as err:
LOGGER.error(
"Set Chore Due Date: Invalid due date '%s': %s", due_date_input, err
)
raise HomeAssistantError("Invalid due date provided.")
# Update the chore’s due_date:
coordinator.set_chore_due_date(chore_id, due_dt)
LOGGER.info(
"Set due date for chore '%s' (ID: %s) to %s",
chore_name,
chore_id,
due_date_str,
)
else:
# Clear the due date by setting it to None
coordinator.set_chore_due_date(chore_id, None)
LOGGER.info(
"Cleared due date for chore '%s' (ID: %s)", chore_name, chore_id
)
await coordinator.async_request_refresh()
async def handle_skip_chore_due_date(call: ServiceCall) -> None:
"""Handle skipping the due date on a chore by rescheduling it to the next due date."""
entry_id = _get_first_kidschores_entry(hass)
if not entry_id:
LOGGER.warning("Skip Chore Due Date: %s", MSG_NO_ENTRY_FOUND)
return
coordinator: KidsChoresDataCoordinator = hass.data[DOMAIN][entry_id][
"coordinator"
]
# Get parameters: either chore_id or chore_name must be provided.
chore_id = call.data.get(FIELD_CHORE_ID)
chore_name = call.data.get(FIELD_CHORE_NAME)
if not chore_id and chore_name:
chore_id = _get_chore_id_by_name(coordinator, chore_name)
if not chore_id:
LOGGER.warning("Skip Chore Due Date: Chore '%s' not found", chore_name)
raise HomeAssistantError(f"Chore '{chore_name}' not found.")
if not chore_id:
raise HomeAssistantError(
"You must provide either a chore_id or chore_name."
)
coordinator.skip_chore_due_date(chore_id)
LOGGER.info("Skipped due date for chore (chore_id=%s)", chore_id)
await coordinator.async_request_refresh()
# --- Register Services ---
hass.services.async_register(
DOMAIN, SERVICE_CLAIM_CHORE, handle_claim_chore, schema=CLAIM_CHORE_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_APPROVE_CHORE, handle_approve_chore, schema=APPROVE_CHORE_SCHEMA
)
hass.services.async_register(
DOMAIN,
SERVICE_DISAPPROVE_CHORE,
handle_disapprove_chore,
schema=DISAPPROVE_CHORE_SCHEMA,
)
hass.services.async_register(
DOMAIN, SERVICE_REDEEM_REWARD, handle_redeem_reward, schema=REDEEM_REWARD_SCHEMA
)
hass.services.async_register(
DOMAIN,
SERVICE_APPROVE_REWARD,
handle_approve_reward,
schema=APPROVE_REWARD_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_DISAPPROVE_REWARD,
handle_disapprove_reward,
schema=DISAPPROVE_REWARD_SCHEMA,
)
hass.services.async_register(
DOMAIN, SERVICE_APPLY_PENALTY, handle_apply_penalty, schema=APPLY_PENALTY_SCHEMA
)
hass.services.async_register(
DOMAIN,
SERVICE_RESET_ALL_DATA,
handle_reset_all_data,
schema=RESET_ALL_DATA_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RESET_ALL_CHORES,
handle_reset_all_chores,
schema=RESET_ALL_CHORES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RESET_OVERDUE_CHORES,
handle_reset_overdue_chores,
schema=RESET_OVERDUE_CHORES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RESET_PENALTIES,
handle_reset_penalties,
schema=RESET_PENALTIES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RESET_BONUSES,
handle_reset_bonuses,
schema=RESET_BONUSES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_RESET_REWARDS,
handle_reset_rewards,
schema=RESET_REWARDS_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_SET_CHORE_DUE_DATE,
handle_set_chore_due_date,
schema=SET_CHORE_DUE_DATE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_SKIP_CHORE_DUE_DATE,
handle_skip_chore_due_date,
schema=SKIP_CHORE_DUE_DATE_SCHEMA,
)
hass.services.async_register(
DOMAIN, SERVICE_APPLY_BONUS, handle_apply_bonus, schema=APPLY_BONUS_SCHEMA
)
LOGGER.info("KidsChores services have been registered successfully")
async def async_unload_services(hass: HomeAssistant):
"""Unregister KidsChores services when unloading the integration."""
services = [
SERVICE_CLAIM_CHORE,
SERVICE_APPROVE_CHORE,
SERVICE_DISAPPROVE_CHORE,
SERVICE_REDEEM_REWARD,
SERVICE_DISAPPROVE_REWARD,
SERVICE_APPLY_PENALTY,
SERVICE_APPLY_BONUS,
SERVICE_APPROVE_REWARD,
SERVICE_RESET_ALL_DATA,
SERVICE_RESET_ALL_CHORES,
SERVICE_RESET_OVERDUE_CHORES,
SERVICE_RESET_PENALTIES,
SERVICE_RESET_BONUSES,
SERVICE_RESET_REWARDS,
SERVICE_SET_CHORE_DUE_DATE,
SERVICE_SKIP_CHORE_DUE_DATE,
]
for service in services:
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
LOGGER.info("KidsChores services have been unregistered")
def _get_first_kidschores_entry(hass: HomeAssistant) -> Optional[str]:
"""Retrieve the first KidsChores config entry ID."""
domain_entries = hass.data.get(DOMAIN)
if not domain_entries:
return None
return next(iter(domain_entries.keys()), None)
def _get_kid_id_by_name(
coordinator: KidsChoresDataCoordinator, kid_name: str
) -> Optional[str]:
"""Help function to get kid_id by kid_name."""
for kid_id, kid_info in coordinator.kids_data.items():
if kid_info.get("name") == kid_name:
return kid_id
return None
def _get_chore_id_by_name(
coordinator: KidsChoresDataCoordinator, chore_name: str
) -> Optional[str]:
"""Help function to get chore_id by chore_name."""
for chore_id, chore_info in coordinator.chores_data.items():
if chore_info.get("name") == chore_name:
return chore_id
return None
def _get_reward_id_by_name(
coordinator: KidsChoresDataCoordinator, reward_name: str
) -> Optional[str]:
"""Help function to get reward_id by reward_name."""
for reward_id, reward_info in coordinator.rewards_data.items():
if reward_info.get("name") == reward_name:
return reward_id
return None
def _get_penalty_id_by_name(
coordinator: KidsChoresDataCoordinator, penalty_name: str
) -> Optional[str]:
"""Help function to get penalty_id by penalty_name."""
for penalty_id, penalty_info in coordinator.penalties_data.items():
if penalty_info.get("name") == penalty_name:
return penalty_id
return None
def _get_bonus_id_by_name(
coordinator: KidsChoresDataCoordinator, bonus_name: str
) -> Optional[str]:
"""Help function to get bonus_id by bonus_name."""
for bonus_id, bonus_info in coordinator.bonuses_data.items():
if bonus_info.get("name") == bonus_name:
return bonus_id
return None
================================================
FILE: custom_components/kidschores/services.yaml
================================================
# File: services.yaml
#
# Custom services documentation for the KidsChores integration.
# These services allow direct actions through scripts or automations.
# Includes UI editor support with selectors for text inputs and numbers.
claim_chore:
name: "Claim Chore"
description: "A kid claims a chore, marking it as 'claimed' for parental approval."
fields:
kid_name:
name: "Kid Name"
description: "The name of the kid claiming the chore."
example: "Alice"
required: true
selector:
text:
chore_name:
name: "Chore Name"
description: "The name of the chore to claim."
example: "Wash Dishes"
required: true
selector:
text:
approve_chore:
name: "Approve Chore"
description: "Parent approves a chore, awarding points (full or partial)."
fields:
parent_name:
name: "Parent Name"
description: "The parent approving the chore."
example: "Mom"
required: true
selector:
text:
kid_name:
name: "Kid Name"
description: "The name of the kid who performed the chore."
example: "Alice"
required: true
selector:
text:
chore_name:
name: "Chore Name"
description: "The name of the chore being approved."
example: "Wash Dishes"
required: true
selector:
text:
points_awarded:
name: "Points Awarded"
description: "Points to award (optional; defaults to the chore's points)."
example: 3
required: false
selector:
number:
min: 0
max: 1000
mode: box
disapprove_chore:
name: "Disapprove Chore"
description: "Parent disapproves a chore for a kid, reverting its status."
fields:
parent_name:
name: "Parent Name"
description: "The parent disapproving the chore."
example: "Mom"
required: true
selector:
text:
kid_name:
name: "Kid Name"
description: "The name of the kid whose chore is being disapproved."
example: "Bob"
required: true
selector:
text:
chore_name:
name: "Chore Name"
description: "The name of the chore being disapproved."
example: "Clean Room"
required: true
selector:
text:
redeem_reward:
name: "Redeem Reward"
description: "A kid redeems a reward, marking it as 'pending' for parental approval."
fields:
parent_name:
name: "Parent Name"
description: "The parent authorizing the reward redemption."
example: "Mom"
required: true
selector:
text:
kid_name:
name: "Kid Name"
description: "The kid redeeming the reward."
example: "Alice"
required: true
selector:
text:
reward_name:
name: "Reward Name"
description: "The name of the reward to redeem."
example: "Extra Screen Time"
required: true
selector:
text:
approve_reward:
name: "Approve Reward"
description: "Parent approves a reward claimed by a kid, deducting points."
fields:
parent_name:
name: "Parent Name"
description: "The parent approving the reward."
example: "Mom"
required: true
selector:
text:
kid_name:
name: "Kid Name"
description: "The kid who is redeeming the reward."
example: "Alice"
required: true
selector:
text:
reward_name:
name: "Reward Name"
description: "The name of the reward being approved."
example: "Extra Screen Time"
required: true
selector:
text:
disapprove_reward:
name: "Disapprove Reward"
description: "Parent disapproves a reward redemption for a kid."
fields:
parent_name:
name: "Parent Name"
description: "The parent disapproving the reward."
example: "Dad"
required: true
selector:
text:
kid_name:
name: "Kid Name"
description: "The kid whose reward redemption is being disapproved."
example: "Bob"
required: true
selector:
text:
reward_name:
name: "Reward Name"
description: "The name of the reward being disapproved."
example: "Extra Screen Time"
required: true
selector:
text:
apply_penalty:
name: "Apply Penalty"
description: "A parent applies a penalty, deducting points from a kid."
fields:
parent_name:
name: "Parent Name"
description: "The parent applying the penalty."
example: "Dad"
required: true
selector:
text:
kid_name:
name: "Kid Name"
description: "The kid receiving the penalty."
example: "Bob"
required: true
selector:
text:
penalty_name:
name: "Penalty Name"
description: "The name of the penalty to apply."
example: "Yelling"
required: true
selector:
text:
apply_bonus:
name: "Apply Bonus"
description: "A parent applies a bonus to award extra points."
fields:
parent_name:
name: "Parent Name"
description: "The parent applying the bonus."
example: "Dad"
required: true
selector:
text:
kid_name:
name: "Kid Name"
description: "The kid receiving the bonus."
example: "Bob"
required: true
selector:
text:
bonus_name:
name: "Bonus Name"
description: "The name of the bonus to apply."
example: "Extra Helpful"
required: true
selector:
text:
reset_all_data:
name: "Reset All Data"
description: "Completely clears the KidsChores data from storage."
fields: {}
reset_all_chores:
name: "Reset All Chores"
description: "Manually reset chores to pending state, removing claims and approvals."
fields: {}
reset_overdue_chores:
name: "Reset Overdue Chores"
description: >
Reset overdue chore(s) back to the Pending state and reschedule them based on
their recurring frequency and previous due date. You may optionally provide a
chore_id (or chore_name) to reset a specific chore and optionally a kid_name
to reset the chore only for that kid.
fields:
chore_id:
name: "Chore ID"
description: "The internal ID of the chore to reset (optional if chore_name is provided)."
example: "abc123"
required: false
selector:
text:
chore_name:
name: "Chore Name"
description: "The name of the chore to reset (optional if chore_id is provided)."
example: "Wash Dishes"
required: false
selector:
text:
kid_name:
name: "Kid Name"
description: "If provided, reset the chore only for this kid."
example: "Alice"
required: false
selector:
text:
set_chore_due_date:
name: "Set Chore Due Date"
description: >
Set (or clear) the due date for a chore. Provide the chore name and, if desired,
a new due date. If no due date is provided the existing due date will be cleared.
The service will reject due dates set in the past.
fields:
chore_name:
name: "Chore Name"
description: "The name of the chore to update."
example: "Wash Dishes"
required: true
selector:
text:
due_date:
name: "Due Date"
description: >
The new due date for the chore. Use the date/time selector to choose a valid
date and time (in your local timezone). Leave empty to clear the due date.
example: "2025-03-01T23:59:00Z"
required: false
selector:
datetime: {}
skip_chore_due_date:
name: "Skip Chore Due Date"
description: >
Skip the current due date of a recurring chore. This service immediately
reschedules the chore's due date based on its recurring frequency and resets
its state to pending. Any pending claims or approvals will be removed.
fields:
chore_id:
name: "Chore ID"
description: >
The internal ID of the chore to update. Optional if you provide a chore name.
example: "abc123"
required: false
selector:
text:
chore_name:
name: "Chore Name"
description: >
The name of the chore to update. Optional if you provide a chore ID.
example: "Weekly Laundry"
required: false
selector:
text:
reset_penalties:
name: "Reset Penalties"
description: >
Reset all applied penalties for all kids. Optionally, provide penalty_name to reset a
specific penalty across all kids. Use kid_name to reset all penalties for a specific kid.
Combine both to reset a specific penalty for a specific kid.
fields:
kid_name:
name: "Kid Name"
description: "The kid penalites will be reset for."
example: "Bob"
required: false
selector:
text:
penalty_name:
name: "Penalty Name"
description: "The name of the penalty to reset."
example: "Yelling"
required: false
selector:
text:
reset_bonuses:
name: "Reset Bonuses"
description: >
Reset all applied bonuses for all kids. Optionally, provide bonus_name to reset a
specific bonus across all kids. Use kid_name to reset all bonuses for a specific kid.
Combine both to reset a specific bonus for a specific kid.
fields:
kid_name:
name: "Kid Name"
description: "The kid bonuses will be reset for."
example: "Bob"
required: false
selector:
text:
bonus_name:
name: "Bonus Name"
description: "The name of the bonus to reset."
example: "Helping"
required: false
selector:
text:
reset_rewards:
name: "Reset Rewards"
description: >
Reset all reward claim and approval counts for all kids. Optionally, provide reward_name to reset a
specific reward counts across all kids. Use kid_name to reset all reward counts for a specific kid.
Combine both to reset a specific reward for a specific kid.
fields:
kid_name:
name: "Kid Name"
description: "The kid reward counts will be reset for."
example: "Bob"
required: false
selector:
text:
reward_name:
name: "Reward Name"
description: "The name of the reward to reset count."
example: "Ice Cream"
required: false
selector:
text:
================================================
FILE: custom_components/kidschores/storage_manager.py
================================================
# File: storage_manager.py
"""Handles persistent data storage for the KidsChores integration.
Uses Home Assistant's Storage helper to save and load chore-related data, ensuring
the state is preserved across restarts. This includes data for kids, chores,
badges, rewards, penalties, and their statuses.
"""
import os
from homeassistant.helpers.storage import Store
from .const import (
DATA_ACHIEVEMENTS,
DATA_BADGES,
DATA_BONUSES,
DATA_CHALLENGES,
DATA_CHORES,
DATA_KIDS,
DATA_PARENTS,
DATA_PENALTIES,
DATA_PENDING_CHORE_APPROVALS,
DATA_PENDING_REWARD_APPROVALS,
DATA_REWARDS,
LOGGER,
STORAGE_KEY,
STORAGE_VERSION,
)
class KidsChoresStorageManager:
"""Manages loading, saving, and accessing data from Home Assistant's storage.
Utilizes internal_id as the primary key for all entities.
"""
def __init__(self, hass, storage_key=STORAGE_KEY):
"""Initialize the storage manager.
Args:
hass: Home Assistant core object.
storage_key: Key to identify storage location (default: STORAGE_KEY).
"""
self.hass = hass
self._storage_key = storage_key
self._store = Store(hass, STORAGE_VERSION, storage_key)
self._data = {} # In-memory data cache for quick access.
async def async_initialize(self):
"""Load data from storage during startup.
If no data exists, initializes with an empty structure.
"""
LOGGER.debug("KidsChoresStorageManager: Loading data from storage")
existing_data = await self._store.async_load()
if existing_data is None:
# No existing data, create a new default structure.
LOGGER.info("No existing storage found; initializing new data")
self._data = {
DATA_KIDS: {}, # Dictionary of kids keyed by internal_id.
DATA_CHORES: {}, # Dictionary of chores keyed by internal_id.
DATA_BADGES: {}, # Dictionary of badges keyed by internal_id.
DATA_REWARDS: {}, # Dictionary of rewards keyed by internal_id.
DATA_PENALTIES: {}, # Dictionary of penalties keyed by internal_id.
DATA_BONUSES: {}, # Dictionary of bonuses keyed by internal_id.
DATA_PARENTS: {}, # Dictionary of parents keyed by internal_id.
DATA_ACHIEVEMENTS: {}, # Dictionary of achievements keyed by internal_id.
DATA_CHALLENGES: {}, # Dictionary of challenges keyed by internal_id.
DATA_PENDING_CHORE_APPROVALS: [], # List of pending chore approvals keyed by internal_id.
DATA_PENDING_REWARD_APPROVALS: [], # List of pending rewar approvals keyed by internal_id.
}
else:
# Load existing data into memory.
self._data = existing_data
LOGGER.info("Storage data loaded successfully")
@property
def data(self):
"""Retrieve the in-memory data cache."""
return self._data
def get_data(self):
"""Retrieve the data structure (alternative getter)."""
return self._data
def set_data(self, new_data: dict):
"""Replace the entire in-memory data structure."""
self._data = new_data
def get_kids(self):
"""Retrieve the kids data."""
return self._data.get(DATA_KIDS, {})
def get_parents(self):
"""Retrieve the parents data."""
return self._data.get(DATA_PARENTS, {})
def get_chores(self):
"""Retrieve the chores data."""
return self._data.get(DATA_CHORES, {})
def get_badges(self):
"""Retrieve the badges data."""
return self._data.get(DATA_BADGES, {})
def get_rewards(self):
"""Retrieve the rewards data."""
return self._data.get(DATA_REWARDS, {})
def get_penalties(self):
"""Retrieve the penalties data."""
return self._data.get(DATA_PENALTIES, {})
def get_bonuses(self):
"""Retrieve the bonuses data."""
return self._data.get(DATA_BONUSES, {})
def get_achievements(self):
"""Retrieve the achievements data."""
return self._data.get(DATA_ACHIEVEMENTS, {})
def get_challenges(self):
"""Retrieve the challenges data."""
return self._data.get(DATA_CHALLENGES, {})
def get_pending_chore_approvals(self):
"""Retrieve the pending chore approvals data."""
return self._data.get(DATA_PENDING_CHORE_APPROVALS, [])
def get_pending_reward_aprovals(self):
"""Retrieve the pending reward approvals data."""
return self._data.get(DATA_PENDING_REWARD_APPROVALS, [])
async def link_user_to_kid(self, user_id, kid_id):
"""Link a Home Assistant user ID to a specific kid by internal_id."""
if "linked_users" not in self._data:
self._data["linked_users"] = {}
self._data["linked_users"][user_id] = kid_id
await self._save()
async def unlink_user(self, user_id):
"""Unlink a Home Assistant user ID from any kid."""
if "linked_users" in self._data and user_id in self._data["linked_users"]:
del self._data["linked_users"][user_id]
await self._save()
async def get_linked_kids(self):
"""Get all linked users and their associated kids."""
return self._data.get("linked_users", {})
async def async_save(self):
"""Save the current data structure to storage asynchronously."""
try:
await self._store.async_save(self._data)
LOGGER.info("Data saved successfully to storage")
except Exception as e:
LOGGER.error("Failed to save data to storage: %s", e)
async def async_clear_data(self):
"""Clear all stored data and reset to default structure."""
LOGGER.warning("Clearing all KidsChores data and resetting storage")
self._data = {
DATA_KIDS: {},
DATA_CHORES: {},
DATA_BADGES: {},
DATA_REWARDS: {},
DATA_PARENTS: {},
DATA_PENALTIES: {},
DATA_BONUSES: {},
DATA_ACHIEVEMENTS: {},
DATA_CHALLENGES: {},
DATA_PENDING_REWARD_APPROVALS: [],
DATA_PENDING_CHORE_APPROVALS: [],
}
await self.async_save()
async def async_delete_storage(self) -> None:
"""Delete the storage file completely from disk."""
# First clear in-memory data
await self.async_clear_data()
# Remove the file if it exists
if os.path.isfile(self._store._path):
try:
os.remove(self._store._path)
LOGGER.info("Storage file removed: %s", self._store._path)
except Exception as e:
LOGGER.error("Failed to remove storage file: %s", e)
else:
LOGGER.info("Storage file not found: %s", self._store._path)
async def async_update_data(self, key, value):
"""Update a specific section of the data structure."""
if key in self._data:
LOGGER.debug("Updating data for key: %s", key)
self._data[key] = value
await self.async_save()
else:
LOGGER.warning("Attempted to update unknown data key: %s", key)
================================================
FILE: custom_components/kidschores/translations/en.json
================================================
{
"title": "KidsChores",
"config": {
"step": {
"intro": {
"title": "Welcome to KidsChores",
"description": "This wizard will guide you through setting up KidsChores."
},
"points_label": {
"title": "Points Label",
"description": "Choose a label and icon for points.",
"data": {
"points_label": "Points Label",
"points_icon": "Points Icon"
}
},
"kid_count": {
"title": "Number of Kids",
"description": "How many kids do you want to manage?",
"data": {
"kid_count": "Number of Kids"
}
},
"kids": {
"title": "Define Kid",
"description": "Enter the name for each kid.",
"data": {
"kid_name": "Kid Name",
"internal_id": "Internal ID",
"ha_user": "Home Assistant User",
"enable_mobile_notifications": "Enable Mobile Notifications",
"mobile_notify_service": "Notify Service",
"enable_persistent_notifications": "Enable Persistent Notifications"
}
},
"parent_count": {
"title": "Number of Parents",
"description": "How many parents do you want to define initially?",
"data": {
"parent_count": "Number of Parents"
}
},
"parents": {
"title": "Define Parent",
"description": "Enter details for each parent.",
"data": {
"parent_name": "Parent Name",
"ha_user_id": "Home Assistant User",
"associated_kids": "Associated Kids",
"enable_mobile_notifications": "Enable Mobile Notifications",
"mobile_notify_service": "Notify Service",
"enable_persistent_notifications": "Enable Persistent Notifications",
"internal_id": "Internal ID"
}
},
"chore_count": {
"title": "Number of Chores",
"description": "How many chores do you want to define?",
"data": {
"chore_count": "Number of Chores"
}
},
"chores": {
"title": "Define Chore",
"description": "Enter details for each chore.",
"data": {
"chore_name": "Chore Name",
"internal_id": "Internal ID",
"default_points": "Default Points",
"allow_multiple_claims_per_day": "Allow Multiple Claims per Day?",
"partial_allowed": "Allow Partial Points?",
"shared_chore": "Shared Chore?",
"assigned_kids": "Assigned Kids",
"chore_description": "Description (optional)",
"chore_labels": "Chore Labels",
"icon": "Icon (mdi:xxx)",
"recurring_frequency": "Recurring Frequency",
"custom_interval": "Custom Recurring Frequency Interval (only use if Custom Recurring Frequency is set)",
"custom_interval_unit": "Custom Recurring Frequency Period",
"applicable_days": "Applicable Days",
"due_date": "Due Date",
"notify_on_claim": "Notify on Claim",
"notify_on_approval": "Notify on Approval",
"notify_on_disapproval": "Notify on Disapproval"
}
},
"badge_count": {
"title": "Number of Badges",
"description": "How many badges do you want to define?",
"data": {
"badge_count": "Badge Count"
}
},
"badges": {
"title": "Define Badge",
"description": "Enter details for each badge.",
"data": {
"badge_name": "Badge Name",
"internal_id": "Internal ID",
"threshold_type": "Threshold Type",
"threshold_value": "Threshold Value",
"points_multiplier": "Points Multiplier",
"icon": "Icon (mdi:xxx)",
"badge_description": "Description (optional)",
"badge_labels": "Badge Labels"
}
},
"reward_count": {
"title": "Number of Rewards",
"description": "How many rewards do you want to define?",
"data": {
"reward_count": "Reward Count"
}
},
"rewards": {
"title": "Define Reward",
"description": "Enter details for each reward.",
"data": {
"reward_name": "Reward Name",
"internal_id": "Internal ID",
"reward_cost": "Reward Cost",
"reward_description": "Description (optional)",
"reward_labels": "Reward Labels",
"icon": "Icon (mdi:xxx)"
}
},
"penalty_count": {
"title": "Number of Penalties",
"description": "How many penalties do you want to define?",
"data": {
"penalty_count": "Penalty Count"
}
},
"penalties": {
"title": "Define Penalty",
"description": "Enter details for each penalty.",
"data": {
"penalty_name": "Penalty Name",
"penalty_description": "Description (optional)",
"penalty_labels": "Penalty Labels",
"internal_id": "Internal ID",
"penalty_points": "Penalty Points (negative)",
"icon": "Icon (mdi:xxx)"
}
},
"bonus_count": {
"title": "Number of Bonuses",
"description": "How many bonuses do you want to define?",
"data": {
"bonus_count": "Bonuses Count"
}
},
"bonuses": {
"title": "Define Bonus",
"description": "Enter details for each bonus.",
"data": {
"bonus_name": "Bonus Name",
"bonus_description": "Description (optional)",
"bonus_labels": "Bonus Labels",
"internal_id": "Internal ID",
"bonus_points": "Bonus Points",
"icon": "Icon (mdi:xxx)"
}
},
"achievement_count": {
"title": "Number of Achievements",
"description": "How many achievements do you want to define?",
"data": {
"achievement_count": "Achievement Count"
}
},
"achievements": {
"title": "Define Achievement",
"description": "Enter details for each achievement.",
"data": {
"name": "Achievement Name",
"description": "Description (optional)",
"achievement_labels": "Achievement Labels",
"icon": "Icon (mdi:xxx)",
"assigned_kids": "Assigned Kids",
"type": "Type of Achievement",
"selected_chore_id": "Select Chore Associated",
"criteria": "Criteria (optional)",
"target_value": "Achievement Target",
"reward_points": "Extra Points for Completing Achievement",
"internal_id": "Internal ID"
}
},
"challenge_count": {
"title": "Number of Challenges",
"description": "How many challenges do you want to define?",
"data": {
"challenge_count": "Challenge Count"
}
},
"challenges": {
"title": "Define Challenge",
"description": "Enter details for each challenge.",
"data": {
"name": "Challenge Name",
"description": "Description (optional)",
"challenge_labels": "Challenge Labels",
"icon": "Icon (mdi:xxx)",
"assigned_kids": "Assigned Kids",
"type": "Type of Challenge",
"selected_chore_id": "Select Chore Associated with Challenge (Optional)",
"criteria": "Criteria (optional)",
"target_value": "Challenges Target",
"reward_points": "Extra Points for Completing Challenge",
"start_date": "Start Date",
"end_date": "End Date",
"internal_id": "Internal ID"
}
},
"finish": {
"title": "Review & Finish",
"description": "Review the setup:\n{summary}\nClick Submit to finalize."
}
},
"error": {
"a_chore_must_be_selected": "A chore must be selected",
"duplicate_achievement": "An achievement with this name already exists",
"duplicate_badge": "A badge with this name already exists",
"duplicate_challenge": "A challenge with this name already exists",
"duplicate_chore": "A chore with this name already exists",
"duplicate_kid": "A kid with this name already exists",
"duplicate_parent": "A parent with this name already exists",
"duplicate_penalty": "A penalty with this name already exists",
"duplicate_reward": "A reward with this name already exists",
"duplicate_bonus": "A bonus with this name already exists",
"due_date_in_past": "Due date must be in the future.",
"end_date_in_past": "End Date must be in the future.",
"end_date_not_after_start_date": "End date must be later than start date",
"invalid_achievement_count": "Invalid achievement count",
"invalid_achievement_name": "Invalid achievement name",
"invalid_badge": "Invalid badge",
"invalid_badge_count": "Invalid badge count",
"invalid_badge_name": "Invalid badge name",
"invalid_challenge_count": "Invalid challenge count",
"invalid_challenge_name": "Invalid challenge name",
"invalid_chore": "Invalid chore",
"invalid_chore_count": "Invalid chore count",
"invalid_chore_name": "Invalid chore name",
"invalid_due_date": "Invalid due date",
"invalid_end_date": "Invalid end date.",
"invalid_kid_count": "Invalid kid count",
"invalid_kid_name": "Invalid kid name",
"invalid_parent_count": "Invalid parent count",
"invalid_parent_name": "Invalid parent name",
"invalid_penalty": "Invalid penalty",
"invalid_penalty_count": "Invalid penalty count",
"invalid_penalty_name": "Invalid penalty name",
"invalid_reward": "Invalid reward",
"invalid_reward_count": "Invalid reward count",
"invalid_reward_name": "Invalid reward name",
"invalid_bonus": "Invalid bonus",
"invalid_bonus_count": "Invalid bonus count",
"invalid_bonus_name": "Invalid bonus name",
"invalid_start_date": "Invalid start date.",
"start_date_in_past": "Start Date must be in the future."
},
"abort": {
"single_instance_allowed": "Only a single KidsChores instance can be configured."
}
},
"options": {
"step": {
"init": {
"title": "KidsChores Options",
"description": "Manage kids, chores, badges, rewards, penalties, bonuses, or finish.",
"data": {
"menu_selection": "Select an Option"
}
},
"manage_entity": {
"title": "Select Action",
"description": "Add, edit or delete options.",
"data": {
"manage_action": "Select an Action"
}
},
"select_entity": {
"title": "Select {entity_type}",
"description": "Select the {entity_type} you want to {action}.",
"data": {
"entity_name": "Name"
}
},
"manage_points": {
"title": "Edit Points Label & Icon",
"description": "Change the label and icon used to represent points.",
"data": {
"points_label": "Points Label",
"points_icon": "Points Icon"
}
},
"add_kid": {
"title": "Add Kid",
"description": "Provide the details for the new kid.",
"data": {
"kid_name": "Kid Name",
"ha_user": "Home Assistant User",
"enable_mobile_notifications": "Enable Mobile Notifications",
"mobile_notify_service": "Notify Service",
"enable_persistent_notifications": "Enable Persistent Notifications",
"internal_id": "Internal ID"
}
},
"add_parent": {
"title": "Add Parent",
"description": "Provide the details for the new parent.",
"data": {
"parent_name": "Parent Name",
"ha_user_id": "Home Assistant User",
"associated_kids": "Associated Kids",
"enable_mobile_notifications": "Enable Mobile Notifications",
"mobile_notify_service": "Notify Service",
"enable_persistent_notifications": "Enable Persistent Notifications",
"internal_id": "Internal ID"
}
},
"add_chore": {
"title": "Add Chore",
"description": "Provide the details for the new chore.",
"data": {
"chore_name": "Chore Name",
"internal_id": "Internal ID",
"default_points": "Default Points",
"allow_multiple_claims_per_day": "Allow Multiple Claims per Day?",
"partial_allowed": "Allow Partial Points?",
"shared_chore": "Shared Chore?",
"assigned_kids": "Assigned Kids",
"chore_description": "Description (optional)",
"chore_labels": "Chore Labels",
"icon": "Icon (mdi:xxx)",
"recurring_frequency": "Recurring Frequency",
"custom_interval": "Custom Recurring Frequency Interval (only use if Custom Recurring Frequency is set)",
"custom_interval_unit": "Custom Recurring Frequency Period",
"applicable_days": "Applicable Days",
"due_date": "Due Date",
"notify_on_claim": "Notify on Claim",
"notify_on_approval": "Notify on Approval",
"notify_on_disapproval": "Notify on Disapproval"
}
},
"add_badge": {
"title": "Add Badge",
"description": "Provide the details for the new badge.",
"data": {
"badge_name": "Badge Name",
"internal_id": "Internal ID",
"threshold_type": "Threshold Type",
"threshold_value": "Threshold Value",
"points_multiplier": "Points Multiplier",
"icon": "Icon (mdi:xxx)",
"badge_description": "Description (optional)",
"badge_labels": "Badge Labels"
}
},
"add_reward": {
"title": "Add Reward",
"description": "Provide the details for the new reward.",
"data": {
"reward_name": "Reward Name",
"internal_id": "Internal ID",
"reward_cost": "Reward Cost",
"reward_description": "Description (optional)",
"reward_labels": "Reward Labels",
"icon": "Icon (mdi:xxx)"
}
},
"add_penalty": {
"title": "Add Penalty",
"description": "Provide the details for the new penalty.",
"data": {
"penalty_name": "Penalty Name",
"penalty_description": "Description (optional)",
"penalty_labels": "Penalty Labels",
"internal_id": "Internal ID",
"penalty_points": "Penalty Points (negative)",
"icon": "Icon (mdi:xxx)"
}
},
"add_bonus": {
"title": "Add Bonus",
"description": "Provide the details for the new bonus.",
"data": {
"bonus_name": "Bonus Name",
"bonus_description": "Description (optional)",
"bonus_labels": "Bonus Labels",
"internal_id": "Internal ID",
"bonus_points": "Bonus Points",
"icon": "Icon (mdi:xxx)"
}
},
"add_achievement": {
"title": "Define Achievement",
"description": "Enter details for each achievement.",
"data": {
"name": "Achievement Name",
"description": "Description (optional)",
"achievement_labels": "Achievement Labels",
"icon": "Icon (mdi:xxx)",
"assigned_kids": "Assigned Kids",
"type": "Type of Achievement",
"selected_chore_id": "Select Chore Associated",
"criteria": "Criteria (optional)",
"target_value": "Achievement Target",
"reward_points": "Extra Points for Completing Achievement",
"internal_id": "Internal ID"
}
},
"add_challenge": {
"title": "Define Challenge",
"description": "Enter details for each challenge.",
"data": {
"name": "Challenge Name",
"description": "Description (optional)",
"challenge_labels": "Challenge Labels",
"icon": "Icon (mdi:xxx)",
"assigned_kids": "Assigned Kids",
"type": "Type of Challenge",
"selected_chore_id": "Select Chore Associated with Challenge (Optional)",
"criteria": "Criteria (optional)",
"target_value": "Challenges Target",
"reward_points": "Extra Points for Completing Challenge",
"start_date": "Start Date",
"end_date": "End Date",
"internal_id": "Internal ID"
}
},
"edit_kid": {
"title": "Edit Kid",
"description": "Modify the details of the selected kid.",
"data": {
"kid_name": "Kid Name",
"ha_user": "Home Assistant User",
"enable_mobile_notifications": "Enable Mobile Notifications",
"mobile_notify_service": "Notify Service",
"enable_persistent_notifications": "Enable Persistent Notifications",
"internal_id": "Internal ID"
}
},
"edit_parent": {
"title": "Edit Parent",
"description": "Modify the details of the selected parent.",
"data": {
"parent_name": "Parent Name",
"ha_user_id": "Home Assistant User",
"associated_kids": "Associated Kids",
"enable_mobile_notifications": "Enable Mobile Notifications",
"mobile_notify_service": "Notify Service",
"enable_persistent_notifications": "Enable Persistent Notifications",
"internal_id": "Internal ID"
}
},
"edit_chore": {
"title": "Edit Chore",
"description": "Modify the details of the selected chore.",
"data": {
"chore_name": "Chore Name",
"internal_id": "Internal ID",
"default_points": "Default Points",
"allow_multiple_claims_per_day": "Allow Multiple Claims per Day?",
"partial_allowed": "Allow Partial Points?",
"shared_chore": "Shared Chore?",
"assigned_kids": "Assigned Kids",
"chore_description": "Description (optional)",
"chore_labels": "Chore Labels",
"icon": "Icon (mdi:xxx)",
"recurring_frequency": "Recurring Frequency",
"custom_interval": "Custom Recurring Frequency Interval (only use if Custom Recurring Frequency is set)",
"custom_interval_unit": "Custom Recurring Frequency Period",
"applicable_days": "Applicable Days",
"due_date": "Due Date",
"notify_on_claim": "Notify on Claim",
"notify_on_approval": "Notify on Approval",
"notify_on_disapproval": "Notify on Disapproval"
}
},
"edit_badge": {
"title": "Edit Badge",
"description": "Modify the details of the selected badge.",
"data": {
"badge_name": "Badge Name",
"internal_id": "Internal ID",
"threshold_type": "Threshold Type",
"threshold_value": "Threshold Value",
"points_multiplier": "Points Multiplier",
"icon": "Icon (mdi:xxx)",
"badge_description": "Description (optional)",
"badge_labels": "Badge Labels"
}
},
"edit_reward": {
"title": "Edit Reward",
"description": "Modify the details of the selected reward.",
"data": {
"reward_name": "Reward Name",
"internal_id": "Internal ID",
"reward_cost": "Reward Cost",
"reward_description": "Description (optional)",
"reward_labels": "Reward Labels",
"icon": "Icon (mdi:xxx)"
}
},
"edit_penalty": {
"title": "Edit Penalty",
"description": "Modify the details of the selected penalty.",
"data": {
"penalty_name": "Penalty Name",
"penalty_description": "Description (optional)",
"penalty_labels": "Penalty Labels",
"internal_id": "Internal ID",
"penalty_points": "Penalty Points (negative)",
"icon": "Icon (mdi:xxx)"
}
},
"edit_bonus": {
"title": "Edit Bonus",
"description": "Modify the details of the selected bonus.",
"data": {
"bonus_name": "Bonus Name",
"bonus_description": "Description (optional)",
"bonus_labels": "Bonus Labels",
"internal_id": "Internal ID",
"bonus_points": "Bonus Points",
"icon": "Icon (mdi:xxx)"
}
},
"edit_achievement": {
"title": "Define Achievement",
"description": "Enter details for each achievement.",
"data": {
"name": "Achievement Name",
"description": "Description (optional)",
"achievement_labels": "Achievement Labels",
"icon": "Icon (mdi:xxx)",
"assigned_kids": "Assigned Kids",
"type": "Type of Achievement",
"selected_chore_id": "Select Chore Associated",
"criteria": "Criteria (optional)",
"target_value": "Achievement Target",
"reward_points": "Extra Points for Completing Achievement",
"internal_id": "Internal ID"
}
},
"edit_challenge": {
"title": "Define Challenge",
"description": "Enter details for each challenge.",
"data": {
"name": "Challenge Name",
"description": "Description (optional)",
"challenge_labels": "Challenge Labels",
"icon": "Icon (mdi:xxx)",
"assigned_kids": "Assigned Kids",
"type": "Type of Challenge",
"selected_chore_id": "Select Chore Associated with Challenge (Optional)",
"criteria": "Criteria (optional)",
"target_value": "Challenges Target",
"reward_points": "Extra Points for Completing Challenge",
"start_date": "Start Date",
"end_date": "End Date",
"internal_id": "Internal ID"
}
},
"delete_kid": {
"title": "Delete Kid",
"description": "Are you sure you want to delete the kid {kid_name}?",
"data": {}
},
"delete_parent": {
"title": "Delete Parent",
"description": "Are you sure you want to delete the parent {parent_name}?",
"data": {}
},
"delete_chore": {
"title": "Delete Chore",
"description": "Are you sure you want to delete the chore {chore_name}?",
"data": {}
},
"delete_badge": {
"title": "Delete Badge",
"description": "Are you sure you want to delete the badge {badge_name}?",
"data": {}
},
"delete_reward": {
"title": "Delete Reward",
"description": "Are you sure you want to delete the reward {reward_name}?",
"data": {}
},
"delete_penalty": {
"title": "Delete Penalty",
"description": "Are you sure you want to delete the penalty {penalty_name}?",
"data": {}
},
"delete_bonus": {
"title": "Delete Bonus",
"description": "Are you sure you want to delete the bonus {bonus_name}?",
"data": {}
},
"delete_achievement": {
"title": "Delete Achievement",
"description": "Are you sure you want to delete the achievement {achievement_name}?",
"data": {}
},
"delete_challenge": {
"title": "Delete Challenge",
"description": "Are you sure you want to delete the challenge {challenge_name}?",
"data": {}
}
},
"error": {
"a_chore_must_be_selected": "A chore must be selected",
"duplicate_achievement": "An achievement with this name already exists",
"duplicate_badge": "A badge with this name already exists",
"duplicate_challenge": "A challenge with this name already exists",
"duplicate_chore": "A chore with this name already exists",
"duplicate_kid": "A kid with this name already exists",
"duplicate_parent": "A parent with this name already exists",
"duplicate_penalty": "A penalty with this name already exists",
"duplicate_reward": "A reward with this name already exists",
"duplicate_bonus": "A bonus with this name already exists",
"due_date_in_past": "Due date must be in the future.",
"end_date_in_past": "End Date must be in the future.",
"end_date_not_after_start_date": "End date must be later than start date",
"invalid_badge": "Invalid badge",
"invalid_badge_count": "Invalid badge count",
"invalid_chore": "Invalid chore",
"invalid_chore_count": "Invalid chore count",
"invalid_due_date": "Invalid due date",
"invalid_end_date": "Invalid end date.",
"invalid_kid_count": "Invalid kid count",
"invalid_kid_name": "Invalid kid name",
"invalid_penalty": "Invalid penalty",
"invalid_penalty_count": "Invalid penalty count",
"invalid_bonus": "Invalid bonus",
"invalid_bonus_count": "Invalid bonus count",
"invalid_reward": "Invalid reward",
"invalid_reward_count": "Invalid reward count",
"invalid_start_date": "Invalid start date.",
"start_date_in_past": "Start Date must be in the future."
},
"abort": {
"invalid_action": "Invalid Action",
"invalid_achievement": "Invalid Achievement",
"invalid_badge": "Invalid Badge",
"invalid_challenge": "Invalid Challenge",
"invalid_chore": "Invalid Chore",
"invalid_entity": "Invalid Entity",
"invalid_kid": "Invalid Kid",
"invalid_parent": "Invalid Parent",
"invalid_penalty": "Invalid Penalty",
"invalid_reward": "Invalid Reward",
"invalid_bonus": "Invalid Bonus",
"no_kid": "No Kids are setup for edit. Add one entry first.",
"no_parent": "No Parents are setup for edit. Add one entry first.",
"no_chore": "No Chores are setup for edit. Add one entry first.",
"no_badge": "No Badges are setup for edit. Add one entry first.",
"no_reward": "No Rewards are setup for edit. Add one entry first.",
"no_penalty": "No Penalties are setup for edit. Add one entry first.",
"no_bonus": "No Bonuses are setup for edit. Add one entry first.",
"no_achievement": "No Achievements are setup for edit. Add one entry first.",
"no_challenge": "No Challenges are setup for edit. Add one entry first.",
"setup_complete": "Setup Complete"
}
},
"selector": {
"main_menu": {
"options": {
"manage_points": "Manage Points",
"manage_kid": "Manage Kid",
"manage_parent": "Manage Parent",
"manage_chore": "Manage Chore",
"manage_badge": "Manage Badge",
"manage_reward": "Manage Reward",
"manage_penalty": "Manage Penalty",
"manage_bonus": "Manage Bonus",
"manage_achievement": "Manage Achievement",
"manage_challenge": "Manage Challenge",
"done": "Finish Setup"
}
},
"manage_actions": {
"options": {
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"back": "Back to Main Menu"
}
},
"recurring_frequency": {
"options": {
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"biweekly": "Biweekly",
"monthly": "Monthly",
"custom": "Custom"
}
},
"custom_interval_unit": {
"options": {
"days": "Days",
"weeks": "Weeks",
"months": "Months"
}
},
"applicable_days": {
"options": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
}
},
"threshold_type": {
"options": {
"points": "Points",
"chore_count": "Chore Count"
}
}
},
"services": {
"claim_chore": {
"name": "Claim Chore",
"description": "A kid claims a chore, marking it as 'claimed' for parental approval.",
"fields": {
"kid_name": {
"name": "Kid Name",
"description": "The name of the kid claiming the chore.",
"example": "Alice"
},
"chore_name": {
"name": "Chore Name",
"description": "The name of the chore to claim.",
"example": "Wash Dishes"
}
}
},
"approve_chore": {
"name": "Approve Chore",
"description": "Parent approves the chore, awarding points.",
"fields": {
"parent_name": {
"name": "Parent Name",
"description": "The parent approving the chore.",
"example": "Mom"
},
"kid_name": {
"name": "Kid Name",
"description": "The name of the kid who performed the chore.",
"example": "Alice"
},
"chore_name": {
"name": "Chore Name",
"description": "The name of the chore being approved.",
"example": "Wash Dishes"
},
"points_awarded": {
"name": "Points Awarded",
"description": "Points to award (optional; defaults to the chore's points).",
"example": 3
}
}
},
"disapprove_chore": {
"name": "Disapprove Chore",
"description": "Parent disapproves a chore for a kid, reverting its status.",
"fields": {
"parent_name": {
"name": "Parent Name",
"description": "The parent disapproving the chore.",
"example": "Mom"
},
"kid_name": {
"name": "Kid Name",
"description": "The name of the kid whose chore is being disapproved.",
"example": "Alice"
},
"chore_name": {
"name": "Chore Name",
"description": "The name of the chore being disapproved.",
"example": "Clean Room"
}
}
},
"redeem_reward": {
"name": "Redeem Reward",
"description": "A parent redeems a reward for a kid, deducting points.",
"fields": {
"parent_name": {
"name": "Parent Name",
"description": "The parent authorizing the reward redemption.",
"example": "Mom"
},
"kid_name": {
"name": "Kid Name",
"description": "The kid redeeming the reward.",
"example": "Alice"
},
"reward_name": {
"name": "Reward Name",
"description": "The name of the reward to redeem.",
"example": "Extra Screen Time"
}
}
},
"approve_reward": {
"name": "Approve Reward",
"description": "Parent approves a reward claimed by a kid.",
"fields": {
"parent_name": {
"name": "Parent Name",
"description": "The parent approving the reward.",
"example": "Mom"
},
"kid_name": {
"name": "Kid Name",
"description": "The kid who is redeeming the reward.",
"example": "Alice"
},
"reward_name": {
"name": "Reward Name",
"description": "The name of the reward being approved.",
"example": "Extra Screen Time"
}
}
},
"disapprove_reward": {
"name": "Disapprove Reward",
"description": "Parent disapproves a reward redemption for a kid.",
"fields": {
"parent_name": {
"name": "Parent Name",
"description": "The parent disapproving the reward.",
"example": "Dad"
},
"kid_name": {
"name": "Kid Name",
"description": "The kid whose reward redemption is being disapproved.",
"example": "Alice"
},
"reward_name": {
"name": "Reward Name",
"description": "The name of the reward being disapproved.",
"example": "Extra Screen Time"
}
}
},
"apply_penalty": {
"name": "Apply Penalty",
"description": "A parent applies a penalty to deduct points.",
"fields": {
"parent_name": {
"name": "Parent Name",
"description": "The parent applying the penalty.",
"example": "Dad"
},
"kid_name": {
"name": "Kid Name",
"description": "The kid receiving the penalty.",
"example": "Alice"
},
"penalty_name": {
"name": "Penalty Name",
"description": "The name of the penalty to apply.",
"example": "Yelling"
}
}
},
"apply_bonus": {
"name": "Apply Bonus",
"description": "A parent applies a bonus to a kid, awarding points.",
"fields": {
"parent_name": {
"name": "Parent Name",
"description": "The parent applying the bonus.",
"example": "Mom"
},
"kid_name": {
"name": "Kid Name",
"description": "The kid receiving the bonus.",
"example": "Alice"
},
"bonus_name": {
"name": "Bonus Name",
"description": "The name of the bonus to apply.",
"example": "Extra Helpful"
}
}
},
"reset_all_data": {
"name": "Reset All Data",
"description": "Completely clears the KidsChores data from storage."
},
"reset_all_chores": {
"name": "Reset All Chores",
"description": "Manually reset chores to pending state, removing claims and approvals."
},
"reset_overdue_chores": {
"name": "Reset Overdue Chores",
"description": "Reset overdue chore(s) back to the Pending state and reschedule them based on their recurring frequency and previous due date. You may optionally provide a chore_id (or chore_name) to reset a specific chore and optionally a kid_name to reset the chore only for that kid.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The internal ID of the chore to reset (optional if chore_name is provided).",
"example": "abc123"
},
"chore_name": {
"name": "Chore Name",
"description": "The name of the chore to reset (optional if chore_id is provided).",
"example": "Wash Dishes"
},
"kid_name": {
"name": "Kid Name",
"description": "The kid receiving the penalty.",
"example": "Alice"
}
}
},
"set_chore_due_date": {
"name": "Set/Reset Chore Due Date",
"description": "Set (or clear) the due date for a chore. Provide the chore name and, if desired, a new due date. If no due date is provided the existing due date will be cleared. The service will reject due dates set in the past.",
"fields": {
"chore_name": {
"name": "Chore Name",
"description": "The name of the chore to update",
"example": "Wash Dishes"
},
"due_date": {
"name": "Due Date",
"description": "The new due date for the chore. Use the date/time selector to choose a valid date and time (in your local timezone). Leave empty to clear the due date.",
"example": "2025-03-01T23:59:00Z"
}
}
},
"skip_chore_due_date": {
"name": "Skip Chore Due Date",
"description": "Skip the current due date of a recurring chore. This service immediately reschedules the chore's due date based on its recurring frequency and resets its state to pending. Any pending claims or approvals will be removed.",
"fields": {
"chore_id": {
"name": "Chore ID",
"description": "The internal ID of the chore to reset (optional if chore_name is provided).",
"example": "abc123"
},
"chore_name": {
"name": "Chore Name",
"description": "The name of the chore to reset (optional if chore_id is provided).",
"example": "Wash Dishes"
}
}
},
"reset_penalties": {
"name": "Reset Penalties",
"description": "Reset all applied penalties for all kids. Optionally, provide penalty name to reset a specific penalty across all kids. Use kid name to reset all penalties for a specific kid. Combine both to reset a specific penalty for a specific kid.",
"fields": {
"kid_name": {
"name": "Kid Name",
"description": "The kid for which penalties will be reset.",
"example": "Alice"
},
"penalty_name": {
"name": "Penalty Name",
"description": "The name of the penalty to reset.",
"example": "Yelling"
}
}
},
"reset_bonuses": {
"name": "Reset Bonuses",
"description": "Reset all applied bonuses for all kids. Optionally, provide bonus name to reset a specific bonus across all kids. Use kid name to reset all bonuses for a specific kid. Combine both to reset a specific bonus for a specific kid.",
"fields": {
"kid_name": {
"name": "Kid Name",
"description": "The kid for which bonuses will be reset.",
"example": "Alice"
},
"bonus_name": {
"name": "Bonus Name",
"description": "The name of the bonus to reset.",
"example": "Helping"
}
}
},
"reset_rewards": {
"name": "Reset Rewards",
"description": "Reset all reward claim and approval counts for all kids. Optionally, provide reward name to reset a specific reward counts across all kids. Use kid name to reset all reward counts for a specific kid. Combine both to reset a specific reward for a specific kid.",
"fields": {
"kid_name": {
"name": "Kid Name",
"description": "The kid for which reward counts will be reset.",
"example": "Alice"
},
"reward_name": {
"name": "Reward Name",
"description": "The name of the reward to reset.",
"example": "Ice Cream"
}
}
}
},
"entity": {
"sensor": {
"chore_status_sensor": {
"name": "{kid_name} - Status - {chore_name}",
"state": {
"pending": "Pending",
"approved": "Approved",
"claimed": "Claimed",
"overdue": "Overdue",
"unknown": "Unknown",
"none": "None",
"approved_in_part": "Approved (in-part)",
"claimed_in_part": "Claimed (in-part)"
},
"state_attributes": {
"kid_name": {
"name": "Kid Name"
},
"chore_name": {
"name": "Chore Name"
},
"shared_chore": {
"name": "Shared Chore",
"state": {
"true": "Yes",
"false": "No"
}
},
"recurring_frequency": {
"name": "Recurring Frequency",
"state": {
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"biweekly": "Biweekly",
"monthly": "Monthly",
"custom": "Custom"
}
},
"applicable_days": {
"name": "Applicable Days",
"state": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
}
},
"due_date": {
"name": "Due Date"
},
"default_points": {
"name": "Default Points"
},
"description": {
"name": "Description"
},
"chore_claims_count": {
"name": "Chore Claims Count"
},
"chore_approvals_count": {
"name": "Chore Approvals Count"
},
"chore_current_streak": {
"name": "Chore Current Streak"
},
"chore_highest_streak": {
"name": "Chore Highest Streak"
},
"global_state": {
"name": "Global State",
"state": {
"pending": "Pending",
"approved": "Approved",
"claimed": "Claimed",
"overdue": "Overdue",
"unknown": "Unknown",
"none": "None",
"approved_in_part": "Approved (in-part)",
"claimed_in_part": "Claimed (in-part)",
"independent": "Independent"
}
},
"partial_allowed": {
"name": "Partially Allowed",
"state": {
"true": "Yes",
"false": "No"
}
},
"allow_multiple_claims_per_day": {
"name": "Allow Multiple Claims per Day",
"state": {
"true": "Yes",
"false": "No"
}
},
"assigned_kids": {
"name": "Assigned Kids"
},
"custom_frequency_interval": {
"name": "Custom Frequency"
},
"custom_frequency_unit": {
"name": "Custom Frequency Period",
"state": {
"days": "Days",
"weeks": "Weeks",
"months": "Months"
}
},
"chore_approvals_today": {
"name": "Chore Approvals Today"
},
"labels": {
"name": "Labels"
}
}
},
"kid_points_sensor": {
"name": "{kid_name} - {points}"
},
"kid_max_points_ever_sensor": {
"name": "{kid_name} - Maximum Points Ever"
},
"chores_completed_total_sensor": {
"name": "{kid_name} - Chores Completed - Total"
},
"chores_completed_daily_sensor": {
"name": "{kid_name} - Chores Completed - Daily"
},
"chores_completed_weekly_sensor": {
"name": "{kid_name} - Chores Completed - Weekly"
},
"chores_completed_monthly_sensor": {
"name": "{kid_name} - Chores Completed - Monthly"
},
"kid_badges_sensor": {
"name": "{kid_name} - Badges Earned"
},
"kids_highest_badge_sensor": {
"name": "{kid_name} - Badge",
"state_attributes": {
"kid_name": {
"name": "Kid Name"
},
"all_earned_badges": {
"name": "All Earned Badges"
},
"highest_badge_threshold_value": {
"name": "Highest Badge Threshold"
},
"points_multiplier": {
"name": "Points Multiplier"
},
"points_to_next_badge": {
"name": "Points to Next Badge"
},
"labels": {
"name": "Labels"
}
}
},
"badge_sensor": {
"name": "Badge - {badge_name}",
"state_attributes": {
"threshold_type": {
"name": "Threshold Type",
"state": {
"points": "Points",
"chore_count": "Chore Count"
}
},
"points_multiplier": {
"name": "Points Multiplier"
},
"description": {
"name": "Description"
},
"kids_earned": {
"name": "Kids Earned"
},
"labels": {
"name": "Labels"
}
}
},
"pending_chores_approvals_sensor": {
"name": "Pending Chore Approvals"
},
"pending_rewards_approvals_sensor": {
"name": "Pending Reward Approvals"
},
"reward_claims_sensor": {
"name": "{kid_name} - Claims - {reward_name}"
},
"reward_approvals_sensor": {
"name": "{kid_name} - Approvals - {reward_name}"
},
"shared_chore_global_status_sensor": {
"name": "{chore_name} - Global Status",
"state": {
"pending": "Pending",
"approved": "Approved",
"claimed": "Claimed",
"overdue": "Overdue",
"unknown": "Unknown",
"none": "None",
"approved_in_part": "Approved (in-part)",
"claimed_in_part": "Claimed (in-part)",
"independent": "Independent"
},
"state_attributes": {
"chore_name": {
"name": "Chore Name"
},
"description": {
"name": "Description"
},
"recurring_frequency": {
"name": "Recurring Frequency",
"state": {
"none": "None",
"daily": "Daily",
"weekly": "Weekly",
"biweekly": "Biweekly",
"monthly": "Monthly",
"custom": "Custom"
}
},
"applicable_days": {
"name": "Applicable Days",
"state": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
}
},
"due_date": {
"name": "Due Date"
},
"default_points": {
"name": "Default Points"
},
"partial_allowed": {
"name": "Partially Allowed",
"state": {
"true": "Yes",
"false": "No"
}
},
"allow_multiple_claims_per_day": {
"name": "Allow Multiple Claims per Day",
"state": {
"true": "Yes",
"false": "No"
}
},
"assigned_kids": {
"name": "Assigned Kids"
},
"chore_approvals_today": {
"name": "Chore Approvals Today"
},
"labels": {
"name": "Labels"
}
}
},
"reward_status_sensor": {
"name": "{kid_name} - Reward Status - {reward_name}",
"state": {
"not_claimed": "Not Claimed",
"approved": "Approved",
"claimed": "Claimed",
"unknown": "Unknown",
"none": "None"
},
"state_attributes": {
"kid_name": {
"name": "Kid Name"
},
"reward_name": {
"name": "Reward Name"
},
"reward_cost": {
"name": "Reward Cost"
},
"description": {
"name": "Description"
},
"labels": {
"name": "Labels"
}
}
},
"chore_claims_sensor": {
"name": "{kid_name} - Claims - {chore_name}"
},
"chore_approvals_sensor": {
"name": "{kid_name} - Approvals - {chore_name}"
},
"penalty_applies_sensor": {
"name": "{kid_name} - Penalties Applied - {penalty_name}",
"state_attributes": {
"kid_name": {
"name": "Kid Name"
},
"penalty_name": {
"name": "Penalty Name"
},
"description": {
"name": "Description"
},
"penalty_points": {
"name": "Penalty Points"
},
"labels": {
"name": "Labels"
}
}
},
"bonus_applies_sensor": {
"name": "{kid_name} - Bonus Applies - {bonus_name}",
"state_attributes": {
"kid_name": {
"name": "Kid Name"
},
"bonus_name": {
"name": "Bonus Name"
},
"description": {
"name": "Description"
},
"bonus_points": {
"name": "Bonus Points"
},
"labels": {
"name": "Labels"
}
}
},
"kid_points_earned_daily_sensor": {
"name": "{kid_name} - Points Earned - Daily"
},
"kid_points_earned_weekly_sensor": {
"name": "{kid_name} - Points Earned - Weekly"
},
"kid_points_earned_monthly_sensor": {
"name": "{kid_name} - Points Earned - Monthly"
},
"achievement_state_sensor": {
"name": "Achievement - {achievement_name}",
"state_attributes": {
"achievement_name": {
"name": "Achievement Name"
},
"description": {
"name": "Description"
},
"assigned_kids": {
"name": "Assigned Kids"
},
"type": {
"name": "Type",
"state": {
"chore_total": "Chore Total",
"chore_streak": "Chore Streak",
"daily_minimum": "Daily Minimum"
}
},
"associated_chore": {
"name": "Associated Chore"
},
"critera": {
"name": "Criteria"
},
"target_value": {
"name": "Target"
},
"reward_points": {
"name": "Reward Points"
},
"kids_earned": {
"name": "Earned by"
},
"labels": {
"name": "Labels"
}
}
},
"challenge_state_sensor": {
"name": "Challenge - {challenge_name}",
"state_attributes": {
"challenge_name": {
"name": "Challenge Name"
},
"description": {
"name": "Description"
},
"assigned_kids": {
"name": "Assigned Kids"
},
"type": {
"name": "Type",
"state": {
"total_within_window": "Total Within Window",
"daily_minimum": "Daily Minimum"
}
},
"associated_chore": {
"name": "Associated Chore"
},
"critera": {
"name": "Criteria"
},
"target_value": {
"name": "Target"
},
"reward_points": {
"name": "Reward Points"
},
"start_date": {
"name": "Start Date"
},
"end_date": {
"name": "End Date"
},
"kids_earned": {
"name": "Earned by"
},
"labels": {
"name": "Labels"
}
}
},
"achievement_progress_sensor": {
"name": "{kid_name} - Progress - {achievement_name}",
"state_attributes": {
"achievement_name": {
"name": "Achievement Name"
},
"description": {
"name": "Description"
},
"assigned_kids": {
"name": "Assigned Kids"
},
"type": {
"name": "Type",
"state": {
"chore_total": "Chore Total",
"chore_streak": "Chore Streak",
"daily_minimum": "Daily Minimum"
}
},
"associated_chore": {
"name": "Associated Chore"
},
"critera": {
"name": "Criteria"
},
"target_value": {
"name": "Target"
},
"reward_points": {
"name": "Reward Points"
},
"raw_progress": {
"name": "Progress"
},
"awarded": {
"name": "Awarded",
"state": {
"true": "Yes",
"false": "No"
}
},
"labels": {
"name": "Labels"
}
}
},
"challenge_progress_sensor": {
"name": "{kid_name} - Progress - {challenge_name}",
"state_attributes": {
"challenge_name": {
"name": "Challenge Name"
},
"description": {
"name": "Description"
},
"assigned_kids": {
"name": "Assigned Kids"
},
"type": {
"name": "Type",
"state": {
"total_within_window": "Total Within Window",
"daily_minimum": "Daily Minimum"
}
},
"associated_chore": {
"name": "Associated Chore"
},
"critera": {
"name": "Criteria"
},
"target_value": {
"name": "Target"
},
"reward_points": {
"name": "Reward Points"
},
"start_date": {
"name": "Start Date"
},
"end_date": {
"name": "End Date"
},
"raw_progress": {
"name": "Progress"
},
"awarded": {
"name": "Awarded",
"state": {
"true": "Yes",
"false": "No"
}
},
"labels": {
"name": "Labels"
}
}
},
"kid_highest_streak_sensor": {
"name": "{kid_name} - Highest Streak",
"state_attributes": {
"streaks_by_achievement": {
"name": "Streaks by Achievement"
}
}
},
"chore_streak_sensor": {
"name": "{kid_name} - Streak - {chore_name}",
"state_attributes": {
"last_date": {
"name": "Last Date"
},
"raw_streak": {
"name": "Current Streak"
}
}
}
},
"button": {
"claim_chore_button": {
"name": "{kid_name} - Claim Chore - {chore_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"approve_chore_button": {
"name": "{kid_name} - Approve Chore - {chore_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"disapprove_chore_button": {
"name": "{kid_name} - Disapprove Chore - {chore_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"claim_reward_button": {
"name": "{kid_name} - Claim Reward - {reward_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"approve_reward_button": {
"name": "{kid_name} - Approve Reward - {reward_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"disapprove_reward_button": {
"name": "{kid_name} - Disapprove Reward - {reward_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"penalty_button": {
"name": "{kid_name} - Apply Penalty - {penalty_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"bonus_button": {
"name": "{kid_name} - Apply Bonus - {bonus_name}",
"state_attributes": {
"labels": {
"name": "Labels"
}
}
},
"manual_adjustment_button": {
"name": "{kid_name} {sign_label} {points_label}"
}
}
}
}
================================================
FILE: custom_components/kidschores/translations/es.json
================================================
{
"title": "KidsChores",
"config": {
"step": {
"intro": {
"title": "Bienvenido a KidsChores",
"description": "Este asistente te guiará en la configuración de KidsChores."
},
"points_label": {
"title": "Etiqueta de Puntos",
"description": "Elige una etiqueta e ícono para los puntos.",
"data": {
"points_label": "Etiqueta de Puntos",
"points_icon": "Ícono de Puntos"
}
},
"kid_count": {
"title": "Número de Niños",
"description": "¿Cuántos niños deseas gestionar?",
"data": {
"kid_count": "Número de Niños"
}
},
"kids": {
"title": "Definir Niño/a",
"description": "Introduce el nombre para cada niño.",
"data": {
"kid_name": "Nombre del Niño/a",
"internal_id": "ID Interno",
"ha_user": "Usuario de Home Assistant",
"enable_mobile_notifications": "Habilitar Notificaciones Móviles",
"mobile_notify_service": "Servicio de Notificaciones",
"enable_persistent_notifications": "Habilitar Notificaciones Persistentes"
}
},
"parent_count": {
"title": "Número de Padres",
"description": "¿Cuántos padres deseas definir inicialmente?",
"data": {
"parent_count": "Número de Padres"
}
},
"parents": {
"title": "Definir Padre/Madre",
"description": "Introduce los datos de cada padre.",
"data": {
"parent_name": "Nombre del Padre/Madre",
"ha_user_id": "Usuario de Home Assistant",
"associated_kids": "Niños Asociados",
"enable_mobile_notifications": "Habilitar Notificaciones Móviles",
"mobile_notify_service": "Servicio de Notificaciones",
"enable_persistent_notifications": "Habilitar Notificaciones Persistentes",
"internal_id": "ID Interno"
}
},
"chore_count": {
"title": "Número de Tareas",
"description": "¿Cuántas tareas deseas definir?",
"data": {
"chore_count": "Número de Tareas"
}
},
"chores": {
"title": "Definir Tarea",
"description": "Introduce los datos de cada tarea.",
"data": {
"chore_name": "Nombre de la Tarea",
"internal_id": "ID Interno",
"default_points": "Puntos Predeterminados",
"allow_multiple_claims_per_day": "¿Permitir múltiples reclamaciones por día?",
"partial_allowed": "¿Permitir puntos parciales?",
"shared_chore": "¿Tarea compartida?",
"assigned_kids": "Niños Asignados",
"chore_description": "Descripción (opcional)",
"chore_labels": "Etiquetas de la Tarea",
"icon": "Ícono (mdi:xxx)",
"recurring_frequency": "Frecuencia recurrente",
"custom_interval": "Intervalo de Frecuencia Recurrente Personalizado (usar solo si se configura Frecuencia Recurrente Personalizada)",
"custom_interval_unit": "Periodo de Frecuencia Recurrente Personalizada",
"applicable_days": "Días aplicables",
"due_date": "Fecha de vencimiento",
"notify_on_claim": "Notificar al reclamar",
"notify_on_approval": "Notificar al aprobar",
"notify_on_disapproval": "Notificar al rechazar"
}
},
"badge_count": {
"title": "Número de Insignias",
"description": "¿Cuántas insignias deseas definir?",
"data": {
"badge_count": "Cantidad de Insignias"
}
},
"badges": {
"title": "Definir Insignia",
"description": "Introduce los datos de cada insignia.",
"data": {
"badge_name": "Nombre de la Insignia",
"internal_id": "ID Interno",
"threshold_type": "Tipo de umbral",
"threshold_value": "Valor del umbral",
"points_multiplier": "Multiplicador de puntos",
"icon": "Ícono (mdi:xxx)",
"badge_description": "Descripción (opcional)",
"badge_labels": "Etiquetas de la Insignia"
}
},
"reward_count": {
"title": "Número de Recompensas",
"description": "¿Cuántas recompensas deseas definir?",
"data": {
"reward_count": "Cantidad de Recompensas"
}
},
"rewards": {
"title": "Definir Recompensa",
"description": "Introduce los datos de cada recompensa.",
"data": {
"reward_name": "Nombre de la Recompensa",
"internal_id": "ID Interno",
"reward_cost": "Costo de la Recompensa",
"reward_description": "Descripción (opcional)",
"reward_labels": "Etiquetas de la Recompensa",
"icon": "Ícono (mdi:xxx)"
}
},
"penalty_count": {
"title": "Número de Penalizaciones",
"description": "¿Cuántas penalizaciones deseas definir?",
"data": {
"penalty_count": "Cantidad de Penalizaciones"
}
},
"penalties": {
"title": "Definir Penalización",
"description": "Introduce los datos de cada penalización.",
"data": {
"penalty_name": "Nombre de la Penalización",
"penalty_description": "Descripción (opcional)",
"penalty_labels": "Etiquetas de la Penalización",
"internal_id": "ID Interno",
"penalty_points": "Puntos de penalización (negativos)",
"icon": "Ícono (mdi:xxx)"
}
},
"bonus_count": {
"title": "Número de Bonificaciones",
"description": "¿Cuántas bonificaciones deseas definir?",
"data": {
"bonus_count": "Cantidad de Bonificaciones"
}
},
"bonuses": {
"title": "Definir Bonificación",
"description": "Introduce los datos de cada bonificación.",
"data": {
"bonus_name": "Nombre de la Bonificación",
"bonus_description": "Descripción (opcional)",
"bonus_labels": "Etiquetas de la Bonificación",
"internal_id": "ID Interno",
"bonus_points": "Puntos de bonificación (positivos)",
"icon": "Ícono (mdi:xxx)"
}
},
"achievement_count": {
"title": "Número de Logros",
"description": "¿Cuántos logros deseas definir?",
"data": {
"achievement_count": "Cantidad de Logros"
}
},
"achievements": {
"title": "Definir Logro",
"description": "Introduce los datos de cada logro.",
"data": {
"name": "Nombre del Logro",
"description": "Descripción (opcional)",
"achievement_labels": "Etiquetas del Logro",
"icon": "Ícono (mdi:xxx)",
"assigned_kids": "Niños asignados",
"type": "Tipo de logro",
"selected_chore_id": "Selecciona la tarea asociada",
"criteria": "Criterios (opcional)",
"target_value": "Objetivo del logro",
"reward_points": "Puntos extra por completar el logro",
"internal_id": "ID Interno"
}
},
"challenge_count": {
"title": "Número de Retos",
"description": "¿Cuántos retos deseas definir?",
"data": {
"challenge_count": "Cantidad de Retos"
}
},
"challenges": {
"title": "Definir Reto",
"description": "Introduce los datos de cada reto.",
"data": {
"name": "Nombre del Reto",
"description": "Descripción (opcional)",
"challenge_labels": "Etiquetas del Reto",
"icon": "Ícono (mdi:xxx)",
"assigned_kids": "Niños asignados",
"type": "Tipo de reto",
"selected_chore_id": "Selecciona la tarea asociada al reto (opcional)",
"criteria": "Criterios (opcional)",
"target_value": "Objetivo del reto",
"reward_points": "Puntos extra por completar el reto",
"start_date": "Fecha de inicio",
"end_date": "Fecha de finalización",
"internal_id": "ID Interno"
}
},
"finish": {
"title": "Revisar y Finalizar",
"description": "Revisa la configuración:\n{summary}\nHaz clic en Enviar para finalizar."
}
},
"error": {
"a_chore_must_be_selected": "Debe seleccionarse una tarea",
"duplicate_achievement": "Ya existe un logro con este nombre",
"duplicate_badge": "Ya existe una insignia con este nombre",
"duplicate_challenge": "Ya existe un desafío con este nombre",
"duplicate_chore": "Ya existe una tarea con este nombre",
"duplicate_kid": "Ya existe un niño con este nombre",
"duplicate_parent": "Ya existe un padre/madre con este nombre",
"duplicate_penalty": "Ya existe una penalización con este nombre",
"duplicate_reward": "Ya existe una recompensa con este nombre",
"duplicate_bonus": "Ya existe una bonificación con este nombre",
"due_date_in_past": "La fecha de vencimiento debe estar en el futuro.",
"end_date_in_past": "La fecha de fin debe estar en el futuro.",
"end_date_not_after_start_date": "La fecha de fin debe ser posterior a la fecha de inicio.",
"invalid_achievement_count": "Cantidad de logros no válida",
"invalid_achievement_name": "Nombre de logro no válido",
"invalid_badge": "Insignia no válida",
"invalid_badge_count": "Cantidad de insignias no válida",
"invalid_badge_name": "Nombre de insignia no válido",
"invalid_challenge_count": "Cantidad de desafíos no válida",
"invalid_challenge_name": "Nombre de desafío no válido",
"invalid_chore": "Tarea no válida",
"invalid_chore_count": "Cantidad de tareas no válida",
"invalid_chore_name": "Nombre de tarea no válido",
"invalid_due_date": "Fecha de vencimiento no válida",
"invalid_end_date": "Fecha de fin no válida.",
"invalid_kid_count": "Cantidad de niños no válida",
"invalid_kid_name": "Nombre de niño no válido",
"invalid_parent_count": "Cantidad de padres no válida",
"invalid_parent_name": "Nombre de padre/madre no válido",
"invalid_penalty": "Penalización no válida",
"invalid_penalty_count": "Cantidad de penalizaciones no válida",
"invalid_penalty_name": "Nombre de penalización no válido",
"invalid_reward": "Recompensa no válida",
"invalid_reward_count": "Cantidad de recompensas no válida",
"invalid_reward_name": "Nombre de recompensa no válido",
"invalid_start_date": "Fecha de inicio no válida.",
"invalid_bonus": "Bonificación no válida",
"invalid_bonus_count": "Cantidad de bonificaciones no válida",
"invalid_bonus_name": "Nombre de bonificación no válido",
"start_date_in_past": "La fecha de inicio debe estar en el futuro."
},
"abort": {
"single_instance_allowed": "Solo se puede configurar una única instancia de KidsChores."
}
},
"options": {
"step": {
"init": {
"title": "Opciones de KidsChores",
"description": "Gestiona niños, tareas, insignias, recompensas, penalizaciones o finaliza.",
"data": {
"menu_selection": "Selecciona una opción"
}
},
"manage_entity": {
"title": "Seleccionar Acción",
"description": "Opciones para añadir, editar o eliminar.",
"data": {
"manage_action": "Selecciona una acción"
}
},
"select_entity": {
"title": "Selecciona {entity_type}",
"description": "Selecciona el {entity_type} que deseas {action}.",
"data": {
"entity_name": "Nombre"
}
},
"manage_points": {
"title": "Editar Etiqueta e Ícono de Puntos",
"description": "Cambia la etiqueta e ícono utilizados para representar los puntos.",
"data": {
"points_label": "Etiqueta de Puntos",
"points_icon": "Ícono de Puntos"
}
},
"add_kid": {
"title": "Añadir Niño/a",
"description": "Proporciona los datos para el nuevo niño.",
"data": {
"kid_name": "Nombre del Niño/a",
"ha_user": "Usuario de Home Assistant",
"enable_mobile_notifications": "Habilitar Notificaciones Móviles",
"mobile_notify_service": "Servicio de Notificaciones",
"enable_persistent_notifications": "Habilitar Notificaciones Persistentes",
"internal_id": "ID Interno"
}
},
"add_parent": {
"title": "Añadir Padre/Madre",
"description": "Proporciona los datos para el nuevo padre.",
"data": {
"parent_name": "Nombre del Padre/Madre",
"ha_user_id": "Usuario de Home Assistant",
"associated_kids": "Niños Asociados",
"enable_mobile_notifications": "Habilitar Notificaciones Móviles",
"mobile_notify_service": "Servicio de Notificaciones",
"enable_persistent_notifications": "Habilitar Notificaciones Persistentes",
"internal_id": "ID Interno"
}
},
"add_chore": {
"title": "Añadir Tarea",
"description": "Proporciona los datos de la nueva tarea.",
"data": {
"chore_name": "Nombre de la Tarea",
"internal_id": "ID Interno",
"default_points": "Puntos Predeterminados",
"allow_multiple_claims_per_day": "¿Permitir múltiples reclamaciones por día?",
"partial_allowed": "¿Permitir puntos parciales?",
"shared_chore": "¿Tarea compartida?",
"assigned_kids": "Niños Asignados",
"chore_description": "Descripción (opcional)",
"chore_labels": "Etiquetas de la Tarea",
"icon": "Ícono (mdi:xxx)",
"recurring_frequency": "Frecuencia recurrente",
"custom_interval": "Intervalo de Frecuencia Recurrente Personalizado (usar solo si se configura Frecuencia Recurrente Personalizada)",
"custom_interval_unit": "Periodo de Frecuencia Recurrente Personalizada",
"applicable_days": "Días aplicables",
"due_date": "Fecha de vencimiento",
"notify_on_claim": "Notificar al reclamar",
"notify_on_approval": "Notificar al aprobar",
"notify_on_disapproval": "Notificar al rechazar"
}
},
"add_badge": {
"title": "Añadir Insignia",
"description": "Proporciona los datos de la nueva insignia.",
"data": {
"badge_name": "Nombre de la Insignia",
"internal_id": "ID Interno",
"threshold_type": "Tipo de umbral",
"threshold_value": "Valor del umbral",
"points_multiplier": "Multiplicador de puntos",
"icon": "Ícono (mdi:xxx)",
"badge_description": "Descripción (opcional)",
"badge_labels": "Etiquetas de la Insignia"
}
},
"add_reward": {
"title": "Añadir Recompensa",
"description": "Proporciona los datos de la nueva recompensa.",
"data": {
"reward_name": "Nombre de la Recompensa",
"internal_id": "ID Interno",
"reward_cost": "Costo de la Recompensa",
"reward_description": "Descripción (opcional)",
"reward_labels": "Etiquetas de la Recompensa",
"icon": "Ícono (mdi:xxx)"
}
},
"add_penalty": {
"title": "Añadir Penalización",
"description": "Proporciona los datos de la nueva penalización.",
"data": {
"penalty_name": "Nombre de la Penalización",
"penalty_description": "Descripción (opcional)",
"penalty_labels": "Etiquetas de la Penalización",
"internal_id": "ID Interno",
"penalty_points": "Puntos de penalización (negativos)",
"icon": "Ícono (mdi:xxx)"
}
},
"add_bonus": {
"title": "Añadir Bonificación",
"description": "Proporciona los datos de la nueva bonificación.",
"data": {
"bonus_name": "Nombre de la Bonificación",
"bonus_description": "Descripción (opcional)",
"bonus_labels": "Etiquetas de la Bonificación",
"internal_id": "ID Interno",
"bonus_points": "Puntos de bonificación (positivos)",
"icon": "Ícono (mdi:xxx)"
}
},
"add_achievement": {
"title": "Definir Logro",
"description": "Introduce los datos de cada logro.",
"data": {
"name": "Nombre del Logro",
"description": "Descripción (opcional)",
"achievement_labels": "Etiquetas del Logro",
"icon": "Ícono (mdi:xxx)",
"assigned_kids": "Niños asignados",
"type": "Tipo de logro",
"selected_chore_id": "Selecciona la tarea asociada",
"criteria": "Criterios (opcional)",
"target_value": "Objetivo del logro",
"reward_points": "Puntos extra por completar el logro",
"internal_id": "ID Interno"
}
},
"add_challenge": {
"title": "Definir Reto",
"description": "Introduce los datos de cada reto.",
"data": {
"name": "Nombre del Reto",
"description": "Descripción (opcional)",
"challenge_labels": "Etiquetas del Reto",
"icon": "Ícono (mdi:xxx)",
"assigned_kids": "Niños asignados",
"type": "Tipo de reto",
"selected_chore_id": "Selecciona la tarea asociada al reto (opcional)",
"criteria": "Criterios (opcional)",
"target_value": "Objetivo del reto",
"reward_points": "Puntos extra por completar el reto",
"start_date": "Fecha de inicio",
"end_date": "Fecha de finalización",
"internal_id": "ID Interno"
}
},
"edit_kid": {
"title": "Editar Niño/a",
"description": "Modifica los datos del niño seleccionado.",
"data": {
"kid_name": "Nombre del Niño/a",
"ha_user": "Usuario de Home Assistant",
"enable_mobile_notifications": "Habilitar Notificaciones Móviles",
"mobile_notify_service": "Servicio de Notificaciones",
"enable_persistent_notifications": "Habilitar Notificaciones Persistentes",
"internal_id": "ID Interno"
}
},
"edit_parent": {
"title": "Editar Padre/Madre",
"description": "Modifica los datos del padre seleccionado.",
"data": {
"parent_name": "Nombre del Padre/Madre",
"ha_user_id": "Usuario de Home Assistant",
"associated_kids": "Niños Asociados",
"enable_mobile_notifications": "Habilitar Notificaciones Móviles",
"mobile_notify_service": "Servicio de Notificaciones",
"enable_persistent_notifications": "Habilitar Notificaciones Persistentes",
"internal_id": "ID Interno"
}
},
"edit_chore": {
"title": "Editar Tarea",
"description": "Modifica los datos de la tarea seleccionada.",
"data": {
"chore_name": "Nombre de la Tarea",
"internal_id": "ID Interno",
"default_points": "Puntos Predeterminados",
"allow_multiple_claims_per_day": "¿Permitir múltiples reclamaciones por día?",
"partial_allowed": "¿Permitir puntos parciales?",
"shared_chore": "¿Tarea compartida?",
"assigned_kids": "Niños asignados",
"chore_description": "Descripción (opcional)",
"chore_labels": "Etiquetas de la Tarea",
"icon": "Ícono (mdi:xxx)",
"recurring_frequency": "Frecuencia recurrente",
"custom_interval": "Intervalo de Frecuencia Recurrente Personalizado (usar solo si se configura Frecuencia Recurrente Personalizada)",
"custom_interval_unit": "Periodo de Frecuencia Recurrente Personalizada",
"applicable_days": "Días aplicables",
"due_date": "Fecha de vencimiento",
"notify_on_claim": "Notificar al reclamar",
"notify_on_approval": "Notificar al aprobar",
"notify_on_disapproval": "Notificar al rechazar"
}
},
"edit_badge": {
"title": "Editar Insignia",
"description": "Modifica los datos de la insignia seleccionada.",
"data": {
"badge_name": "Nombre de la Insignia",
"internal_id": "ID Interno",
"threshold_type": "Tipo de umbral",
"threshold_value": "Valor del umbral",
"points_multiplier": "Multiplicador de puntos",
"icon": "Ícono (mdi:xxx)",
"badge_description": "Descripción (opcional)",
"badge_labels": "Etiquetas de la Insignia"
}
},
"edit_reward": {
"title": "Editar Recompensa",
"description": "Modifica los datos de la recompensa seleccionada.",
"data": {
"reward_name": "Nombre de la Recompensa",
"internal_id": "ID Interno",
"reward_cost": "Costo de la Recompensa",
"reward_description": "Descripción (opcional)",
"reward_labels": "Etiquetas de la Recompensa",
"icon": "Ícono (mdi:xxx)"
}
},
"edit_penalty": {
"title": "Editar Penalización",
"description": "Modifica los datos de la penalización seleccionada.",
"data": {
"penalty_name": "Nombre de la Penalización",
"penalty_description": "Descripción (opcional)",
"penalty_labels": "Etiquetas de la Penalización",
"internal_id": "ID Interno",
"penalty_points": "Puntos de penalización (negativos)",
"icon": "Ícono (mdi:xxx)"
}
},
"edit_bonus": {
"title": "Editar Bonificación",
"description": "Modifica los datos de la bonificación seleccionada.",
"data": {
"bonus_name": "Nombre de la Bonificación",
"bonus_description": "Descripción (opcional)",
"bonus_labels": "Etiquetas de la Bonificación",
"internal_id": "ID Interno",
"bonus_points": "Puntos de bonificación (positivos)",
"icon": "Ícono (mdi:xxx)"
}
},
"edit_achievement": {
"title": "Definir Logro",
"description": "Introduce los datos de cada logro.",
"data": {
"name": "Nombre del Logro",
"description": "Descripción (opcional)",
"achievement_labels": "Etiquetas del Logro",
"icon": "Ícono (mdi:xxx)",
"assigned_kids": "Niños asignados",
"type": "Tipo de logro",
"selected_chore_id": "Selecciona la tarea asociada",
"criteria": "Criterios (opcional)",
"target_value": "Objetivo del logro",
"reward_points": "Puntos extra por completar el logro",
"internal_id": "ID Interno"
}
},
"edit_challenge": {
"title": "Definir Reto",
"description": "Introduce los datos de cada reto.",
"data": {
"name": "Nombre del Reto",
"description": "Descripción (opcional)",
"challenge_labels": "Etiquetas del Reto",
"icon": "Ícono (mdi:xxx)",
"assigned_kids": "Niños asignados",
"type": "Tipo de reto",
"selected_chore_id": "Selecciona la tarea asociada al reto (opcional)",
"criteria": "Criterios (opcional)",
"target_value": "Objetivo del reto",
"reward_points": "Puntos extra por completar el reto",
"start_date": "Fecha de inicio",
"end_date": "Fecha de finalización",
"internal_id": "ID Interno"
}
},
"delete_kid": {
"title": "Eliminar Niño/a",
"description": "¿Estás seguro de que deseas eliminar al niño {kid_name}?",
"data": {}
},
"delete_parent": {
"title": "Eliminar Padre/Madre",
"description": "¿Estás seguro de que deseas eliminar al padre {parent_name}?",
"data": {}
},
"delete_chore": {
"title": "Eliminar Tarea",
"description": "¿Estás seguro de que deseas eliminar la tarea {chore_name}?",
"data": {}
},
"delete_badge": {
"title": "Eliminar Insignia",
"description": "¿Estás seguro de que deseas eliminar la insignia {badge_name}?",
"data": {}
},
"delete_reward": {
"title": "Eliminar Recompensa",
"description": "¿Estás seguro de que deseas eliminar la recompensa {reward_name}?",
"data": {}
},
"delete_penalty": {
"title": "Eliminar Penalización",
"description": "¿Estás seguro de que deseas eliminar la penalización {penalty_name}?",
"data": {}
},
"delete_bonus": {
"title": "Eliminar Bonificación",
"description": "¿Estás seguro de que deseas eliminar la bonificación {bonus_name}?",
"data": {}
},
"delete_achievement": {
"title": "Eliminar Logro",
"description": "¿Estás seguro de que deseas eliminar el logro {achievement_name}?",
"data": {}
},
"delete_challenge": {
"title": "Eliminar Reto",
"description": "¿Estás seguro de que deseas eliminar el reto {challenge_name}?",
"data": {}
}
},
"error": {
"a_chore_must_be_selected": "Debe seleccionarse una tarea",
"duplicate_achievement": "Ya existe un logro con este nombre",
"duplicate_badge": "Ya existe una insignia con este nombre",
"duplicate_challenge": "Ya existe un desafío con este nombre",
"duplicate_chore": "Ya existe una tarea con este nombre",
"duplicate_kid": "Ya existe un niño con este nombre",
"duplicate_parent": "Ya existe un padre/madre con este nombre",
"duplicate_penalty": "Ya existe una penalización con este nombre",
"duplicate_reward": "Ya existe una recompensa con este nombre",
"duplicate_bonus": "Ya existe una bonificación con este nombre",
"due_date_in_past": "La fecha de vencimiento debe estar en el futuro.",
"end_date_in_past": "La fecha de fin debe estar en el futuro.",
"end_date_not_after_start_date": "La fecha de fin debe ser posterior a la fecha de inicio.",
"invalid_badge": "Insignia no válida",
"invalid_badge_count": "Cantidad de insignias no válida",
"invalid_chore": "Tarea no válida",
"invalid_chore_count": "Cantidad de tareas no válida",
"invalid_due_date": "Fecha de vencimiento no válida",
"invalid_end_date": "Fecha de fin no válida.",
"invalid_kid_count": "Cantidad de niños no válida",
"invalid_kid_name": "Nombre de niño no válido",
"invalid_penalty": "Penalización no válida",
"invalid_penalty_count": "Cantidad de penalizaciones no válida",
"invalid_bonus": "Bonificación no válida",
"invalid_bonus_count": "Cantidad de bonificaciones no válida",
"invalid_reward": "Recompensa no válida",
"invalid_reward_count": "Cantidad de recompensas no válida",
"invalid_start_date": "Fecha de inicio no válida.",
"start_date_in_past": "La fecha de inicio debe estar en el futuro."
},
"abort": {
"invalid_action": "Acción no válida",
"invalid_achievement": "Logro no válido",
"invalid_badge": "Insignia no válida",
"invalid_challenge": "Desafío no válido",
"invalid_chore": "Tarea no válida",
"invalid_entity": "Entidad no válida",
"invalid_kid": "Niño no válido",
"invalid_parent": "Padre/Madre no válido",
"invalid_penalty": "Penalización no válida",
"invalid_reward": "Recompensa no válida",
"invalid_bonus": "Bonificación no válida",
"no_kid": "No hay niños configurados. Adiciona una entrada primero.",
"no_parent": "No hay padres configurados. Adiciona una entrada primero.",
"no_chore": "No hay tareas configuradas. Adiciona una entrada primero.",
"no_badge": "No hay insignias configuradas. Adiciona una entrada primero.",
"no_reward": "No hay recompensas configuradas. Adiciona una entrada primero.",
"no_penalty": "No hay penalizaciones configuradas. Adiciona una entrada primero.",
"no_bonus": "No hay bonificaciones configuradas. Adiciona una entrada primero.",
"no_achievement": "No hay logros configurados. Adiciona una entrada primero.",
"no_challenge": "No hay desafíos configurados. Adiciona una entrada primero.",
"setup_complete": "Configuración Completa"
}
},
"selector": {
"main_menu": {
"options": {
"manage_points": "Gestionar Puntos",
"manage_kid": "Gestionar Niño/a",
"manage_parent": "Gestionar Padre/Madre",
"manage_chore": "Gestionar Tarea",
"manage_badge": "Gestionar Insignia",
"manage_reward": "Gestionar Recompensa",
"manage_penalty": "Gestionar Penalización",
"manage_bonus": "Gestionar Bonificación",
"manage_achievement": "Gestionar Logro",
"manage_challenge": "Gestionar Reto",
"done": "Finalizar Configuración"
}
},
"manage_actions": {
"options": {
"add": "Añadir",
"edit": "Editar",
"delete": "Eliminar",
"back": "Volver al Menú Principal"
}
},
"recurring_frequency": {
"options": {
"none": "Ninguna",
"daily": "Diaria",
"weekly": "Semanal",
"biweekly": "—",
"monthly": "Mensual",
"custom": "Personalizada"
}
},
"custom_interval_unit": {
"options": {
"days": "Días",
"weeks": "Semanas",
"months": "Meses"
}
},
"applicable_days": {
"options": {
"mon": "Lunes",
"tue": "Martes",
"wed": "Miércoles",
"thu": "Jueves",
"fri": "Viernes",
"sat": "Sábado",
"sun": "Domingo"
}
},
"threshold_type": {
"options": {
"points": "Puntos",
"chore_count": "Cantidad de Tareas"
}
}
},
"services": {
"claim_chore": {
"name": "Reclamar Tarea",
"description": "Un niño reclama una tarea, marcándola como 'reclamada' para aprobación de los padres.",
"fields": {
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El nombre del niño que reclama la tarea.",
"example": "Alice"
},
"chore_name": {
"name": "Nombre de la Tarea",
"description": "El nombre de la tarea a reclamar.",
"example": "Lavar los Platos"
}
}
},
"approve_chore": {
"name": "Aprobar Tarea",
"description": "El padre aprueba la tarea, otorgando puntos.",
"fields": {
"parent_name": {
"name": "Nombre del Padre/Madre",
"description": "El padre que aprueba la tarea.",
"example": "Mamá"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El nombre del niño que realizó la tarea.",
"example": "Alice"
},
"chore_name": {
"name": "Nombre de la Tarea",
"description": "El nombre de la tarea que se está aprobando.",
"example": "Lavar los Platos"
},
"points_awarded": {
"name": "Puntos Otorgados",
"description": "Puntos a otorgar (opcional; por defecto se usan los puntos de la tarea).",
"example": 3
}
}
},
"disapprove_chore": {
"name": "Desaprobar Tarea",
"description": "El padre rechaza una tarea para un niño, revirtiendo su estado.",
"fields": {
"parent_name": {
"name": "Nombre del Padre/Madre",
"description": "El padre que rechaza la tarea.",
"example": "Mamá"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El nombre del niño cuya tarea se está rechazando.",
"example": "Alice"
},
"chore_name": {
"name": "Nombre de la Tarea",
"description": "El nombre de la tarea que se está rechazando.",
"example": "Limpiar la Habitación"
}
}
},
"redeem_reward": {
"name": "Canjear Recompensa",
"description": "Un padre canjea una recompensa para un niño, descontando puntos.",
"fields": {
"parent_name": {
"name": "Nombre del Padre/Madre",
"description": "El padre que autoriza el canje de la recompensa.",
"example": "Mamá"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño que canjea la recompensa.",
"example": "Alice"
},
"reward_name": {
"name": "Nombre de la Recompensa",
"description": "El nombre de la recompensa a canjear.",
"example": "Tiempo Extra de Pantalla"
}
}
},
"approve_reward": {
"name": "Aprobar Recompensa",
"description": "El padre aprueba una recompensa reclamada por un niño.",
"fields": {
"parent_name": {
"name": "Nombre del Padre/Madre",
"description": "El padre que aprueba la recompensa.",
"example": "Mamá"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño que está canjeando la recompensa.",
"example": "Alice"
},
"reward_name": {
"name": "Nombre de la Recompensa",
"description": "El nombre de la recompensa que se está aprobando.",
"example": "Tiempo Extra de Pantalla"
}
}
},
"disapprove_reward": {
"name": "Desaprobar Recompensa",
"description": "El padre rechaza el canje de una recompensa para un niño.",
"fields": {
"parent_name": {
"name": "Nombre del Padre/Madre",
"description": "El padre que rechaza la recompensa.",
"example": "Papá"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño cuyo canje de recompensa se está rechazando.",
"example": "Alice"
},
"reward_name": {
"name": "Nombre de la Recompensa",
"description": "El nombre de la recompensa que se está rechazando.",
"example": "Tiempo Extra de Pantalla"
}
}
},
"apply_penalty": {
"name": "Aplicar Penalización",
"description": "Un padre aplica una penalización para descontar puntos.",
"fields": {
"parent_name": {
"name": "Nombre del Padre/Madre",
"description": "El padre que aplica la penalización.",
"example": "Papá"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño que recibe la penalización.",
"example": "Alice"
},
"penalty_name": {
"name": "Nombre de la Penalización",
"description": "El nombre de la penalización a aplicar.",
"example": "Gritar"
}
}
},
"apply_bonus": {
"name": "Aplicar Bonificación",
"description": "Un padre aplica una bonificación a un niño, otorgando puntos.",
"fields": {
"parent_name": {
"name": "Nombre del Padre/Madre",
"description": "El padre que aplica la bonificación.",
"example": "Mamá"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño que recibe la bonificación.",
"example": "Alice"
},
"bonus_name": {
"name": "Nombre de la Bonificación",
"description": "El nombre de la bonificación a aplicar.",
"example": "Ayuda Extra"
}
}
},
"reset_all_data": {
"name": "Restablecer Todos los Datos",
"description": "Borra por completo los datos de KidsChores del almacenamiento."
},
"reset_all_chores": {
"name": "Restablecer Todas las Tareas",
"description": "Restablece manualmente las tareas a estado Pendiente, eliminando reclamaciones y aprobaciones."
},
"reset_overdue_chores": {
"name": "Restablecer Tareas Vencidas",
"description": "Restablece las tareas vencidas a estado Pendiente y las reprograma según su frecuencia recurrente y la fecha de vencimiento anterior. Opcionalmente, puedes proporcionar un chore_id (o chore_name) para restablecer una tarea específica y, opcionalmente, un kid_name para restablecer la tarea solo para ese niño.",
"fields": {
"chore_id": {
"name": "ID de la Tarea",
"description": "El ID interno de la tarea a restablecer (opcional si se proporciona chore_name).",
"example": "abc123"
},
"chore_name": {
"name": "Nombre de la Tarea",
"description": "El nombre de la tarea a restablecer (opcional si se proporciona chore_id).",
"example": "Lavar los Platos"
},
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El nombre del niño (opcional).",
"example": "Alice"
}
}
},
"set_chore_due_date": {
"name": "Establecer/Restablecer Fecha de Vencimiento de la Tarea",
"description": "Establece (o borra) la fecha de vencimiento de una tarea. Proporciona el nombre de la tarea y, si lo deseas, una nueva fecha de vencimiento. Si no se proporciona una fecha, se borrará la existente. El servicio rechazará las fechas en el pasado.",
"fields": {
"chore_name": {
"name": "Nombre de la Tarea",
"description": "El nombre de la tarea a actualizar",
"example": "Lavar Platos"
},
"due_date": {
"name": "Fecha de Vencimiento",
"description": "La nueva fecha de vencimiento de la tarea. Usa el selector de fecha/hora para elegir una fecha y hora válidas (en tu zona horaria local). Déjalo vacío para borrar la fecha de vencimiento.",
"example": "2025-03-01T23:59:00Z"
}
}
},
"skip_chore_due_date": {
"name": "Saltar Fecha de Vencimiento de la Tarea",
"description": "Salta la fecha de vencimiento actual de una tarea recurrente. Este servicio reprograma inmediatamente la fecha de vencimiento de la tarea según su frecuencia de repetición y restablece su estado a pendiente. Se eliminarán todas las reclamaciones o aprobaciones pendientes.",
"fields": {
"chore_id": {
"name": "ID de la Tarea",
"description": "El ID interno de la tarea a restablecer (opcional si se proporciona chore_name).",
"example": "abc123"
},
"chore_name": {
"name": "Nombre de la Tarea",
"description": "El nombre de la tarea a restablecer (opcional si se proporciona chore_id).",
"example": "Lavar Platos"
}
}
},
"reset_penalties": {
"name": "Restablecer Sanciones",
"description": "Restablece todas las sanciones aplicadas para todos los niños. Opcionalmente, proporciona Nombre de la Sanción para restablecer una sanción específica en todos los niños. Usa Nombre del Niño/a para restablecer todas las sanciones de un/a niño/a en particular. Combina ambos para restablecer una sanción específica de un/a niño/a específico/a.",
"fields": {
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño/a para el cual se restablecerán las sanciones.",
"example": "Alicia"
},
"penalty_name": {
"name": "Nombre de la Sanción",
"description": "El nombre de la sanción a restablecer.",
"example": "Gritar"
}
}
},
"reset_bonuses": {
"name": "Restablecer Bonificaciones",
"description": "Restablece todas las bonificaciones aplicadas para todos los niños. Opcionalmente, proporciona Nombre de la Bonificación para restablecer una bonificación específica en todos los niños. Usa Nombre del Niño/a para restablecer todas las bonificaciones de un/a niño/a en particular. Combina ambos para restablecer una bonificación específica de un/a niño/a específico/a.",
"fields": {
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño/a para el cual se restablecerán las bonificaciones.",
"example": "Alicia"
},
"bonus_name": {
"name": "Nombre de la Bonificación",
"description": "El nombre de la bonificación a restablecer.",
"example": "Ayudar"
}
}
},
"reset_rewards": {
"name": "Restablecer Recompensas",
"description": "Restablece el conteo de reclamaciones y aprobaciones de recompensas para todos los niños. Opcionalmente, proporciona Nombre de la Recompensa para restablecer el conteo de una recompensa específica en todos los niños. Usa Nombre del Niño/a para restablecer el conteo de recompensas de un/a niño/a en particular. Combina ambos para restablecer una recompensa específica de un/a niño/a específico/a.",
"fields": {
"kid_name": {
"name": "Nombre del Niño/a",
"description": "El niño para el cual se restablecerán los conteos de recompensas.",
"example": "Alicia"
},
"reward_name": {
"name": "Nombre de la Recompensa",
"description": "El nombre de la recompensa a restablecer.",
"example": "Helado"
}
}
}
},
"entity": {
"sensor": {
"chore_status_sensor": {
"name": "{kid_name} - Estado - {chore_name}",
"state": {
"pending": "Pendiente",
"approved": "Aprobada",
"claimed": "Reclamada",
"overdue": "Vencida",
"unknown": "Desconocida",
"none": "Ninguna",
"approved_in_part": "Aprobada (parcialmente)",
"claimed_in_part": "Reclamada (parcialmente)"
},
"state_attributes": {
"kid_name": {
"name": "Nombre del Niño/a"
},
"chore_name": {
"name": "Nombre de la Tarea"
},
"shared_chore": {
"name": "Tarea Compartida",
"state": {
"true": "Sí",
"false": "No"
}
},
"recurring_frequency": {
"name": "Frecuencia Recurrente",
"state": {
"none": "Ninguna",
"daily": "Diaria",
"weekly": "Semanal",
"biweekly": "—",
"monthly": "Mensual",
"custom": "Personalizada"
}
},
"applicable_days": {
"name": "Applicable Days",
"state": {
"mon": "Lunes",
"tue": "Martes",
"wed": "Miércoles",
"thu": "Jueves",
"fri": "Viernes",
"sat": "Sábado",
"sun": "Domingo"
}
},
"due_date": {
"name": "Fecha de Vencimiento"
},
"default_points": {
"name": "Puntos Predeterminados"
},
"description": {
"name": "Descripción"
},
"chore_claims_count": {
"name": "—"
},
"chore_approvals_count": {
"name": "—"
},
"chore_current_streak": {
"name": "Racha Actual de la Tarea"
},
"chore_highest_streak": {
"name": "Mejor Racha de la Tarea"
},
"global_state": {
"name": "Estado Global",
"state": {
"pending": "Pendiente",
"approved": "Aprobada",
"claimed": "Reclamada",
"overdue": "Vencida",
"unknown": "Desconocida",
"none": "Ninguna",
"approved_in_part": "Aprobada (parcialmente)",
"claimed_in_part": "Reclamada (parcialmente)",
"independent": "Independiente"
}
},
"partial_allowed": {
"name": "Permitido Parcialmente",
"state": {
"true": "Sí",
"false": "No"
}
},
"allow_multiple_claims_per_day": {
"name": "Permitir Múltiples Reclamaciones por Día",
"state": {
"true": "Sí",
"false": "No"
}
},
"assigned_kids": {
"name": "Niños Asignados"
},
"custom_frequency_interval": {
"name": "Frecuencia Personalizada"
},
"custom_frequency_unit": {
"name": "Periodo de Frecuencia Personalizada",
"state": {
"days": "Días",
"weeks": "Semanas",
"months": "Meses"
}
},
"chore_approvals_today": {
"name": "Aprobaciones de la Tarea Hoy"
},
"labels": {
"name": "Etiquetas"
}
}
},
"kid_points_sensor": {
"name": "{kid_name} - {points}"
},
"kid_max_points_ever_sensor": {
"name": "{kid_name} - Máximo de Puntos Alcanzados"
},
"chores_completed_total_sensor": {
"name": "{kid_name} - Tareas Completadas - Total"
},
"chores_completed_daily_sensor": {
"name": "{kid_name} - Tareas Completadas - Diarias"
},
"chores_completed_weekly_sensor": {
"name": "{kid_name} - Tareas Completadas - Semanales"
},
"chores_completed_monthly_sensor": {
"name": "{kid_name} - Tareas Completadas - Mensuales"
},
"kid_badges_sensor": {
"name": "{kid_name} - Insignias Obtenidas"
},
"kids_highest_badge_sensor": {
"name": "{kid_name} - Insignia",
"state_attributes": {
"kid_name": {
"name": "Nombre del Niño/a"
},
"all_earned_badges": {
"name": "Todas las Insignias Obtenidas"
},
"highest_badge_threshold_value": {
"name": "Mayor Umbral de Insignia"
},
"points_multiplier": {
"name": "Multiplicador de Puntos"
},
"points_to_next_badge": {
"name": "Puntos para la Próxima Insignia"
},
"labels": {
"name": "Etiquetas"
}
}
},
"badge_sensor": {
"name": "Insignia - {badge_name}",
"state_attributes": {
"threshold_type": {
"name": "Tipo de Umbral",
"state": {
"points": "Puntos",
"chore_count": "Cantidad de Tareas"
}
},
"points_multiplier": {
"name": "Multiplicador de Puntos"
},
"descriptionn": {
"name": "Descripción"
},
"kids_earned": {
"name": "Niños que la han Obtenido"
},
"labels": {
"name": "Etiquetas"
}
}
},
"pending_chores_approvals_sensor": {
"name": "Aprobaciones de Tareas Pendientes"
},
"pending_rewards_approvals_sensor": {
"name": "Aprobaciones de Recompensas Pendientes"
},
"reward_claims_sensor": {
"name": "{kid_name} - Reclamaciones - {reward_name}"
},
"reward_approvals_sensor": {
"name": "{kid_name} - Aprobaciones - {reward_name}"
},
"shared_chore_global_status_sensor": {
"name": "{chore_name} - Estado Global",
"state": {
"pending": "Pendiente",
"approved": "Aprobada",
"claimed": "Reclamada",
"overdue": "Vencida",
"unknown": "Desconocida",
"none": "Ninguna",
"approved_in_part": "Aprobada (parcialmente)",
"claimed_in_part": "Reclamada (parcialmente)",
"independent": "Independiente"
},
"state_attributes": {
"chore_name": {
"name": "Nombre de la Tarea"
},
"description": {
"name": "Descripción"
},
"recurring_frequency": {
"name": "Frecuencia Recurrente",
"state": {
"none": "Ninguna",
"daily": "Diaria",
"weekly": "Semanal",
"biweekly": "—",
"monthly": "Mensual",
"custom": "Personalizada"
}
},
"applicable_days": {
"name": "Applicable Days",
"state": {
"mon": "Lunes",
"tue": "Martes",
"wed": "Miércoles",
"thu": "Jueves",
"fri": "Viernes",
"sat": "Sábado",
"sun": "Domingo"
}
},
"due_date": {
"name": "Fecha de Vencimiento"
},
"default_points": {
"name": "Puntos Predeterminados"
},
"partial_allowed": {
"name": "Permitido Parcialmente",
"state": {
"true": "Sí",
"false": "No"
}
},
"allow_multiple_claims_per_day": {
"name": "Permitir Múltiples Reclamaciones por Día",
"state": {
"true": "Sí",
"false": "No"
}
},
"assigned_kids": {
"name": "Niños Asignados"
},
"chore_approvals_today": {
"name": "Aprobaciones de la Tarea Hoy"
},
"labels": {
"name": "Etiquetas"
}
}
},
"reward_status_sensor": {
"name": "{kid_name} - Estado de Recompensa - {reward_name}",
"state": {
"not_claimed": "No Reclamada",
"approved": "Aprobada",
"claimed": "Reclamada",
"unknown": "Desconocida",
"none": "Ninguna"
},
"state_attributes": {
"kid_name": {
"name": "Nombre del Niño/a"
},
"reward_name": {
"name": "Nombre de la Recompensa"
},
"reward_cost": {
"name": "Costo de la Recompensa"
},
"description": {
"name": "Descripción"
},
"labels": {
"name": "Etiquetas"
}
}
},
"chore_claims_sensor": {
"name": "{kid_name} - Reclamaciones - {chore_name}"
},
"chore_approvals_sensor": {
"name": "{kid_name} - Aprobaciones - {chore_name}"
},
"penalty_applies_sensor": {
"name": "{kid_name} - Penalizaciones Aplicadas - {penalty_name}",
"state_attributes": {
"kid_name": {
"name": "Nombre del Niño/a"
},
"penalty_name": {
"name": "Nombre de la Penalización"
},
"descriptionn": {
"name": "Descripción"
},
"penalty_points": {
"name": "Puntos de Penalización"
},
"labels": {
"name": "Etiquetas"
}
}
},
"bonus_applies_sensor": {
"name": "{kid_name} - Bonus Applies - {bonus_name}",
"state_attributes": {
"kid_name": {
"name": "Nombre del Niño/a"
},
"bonus_name": {
"name": "Nombre de la Bonificación"
},
"description": {
"name": "Descripción"
},
"bonus_points": {
"name": "Puntos de Bonificación"
},
"labels": {
"name": "Etiquetas"
}
}
},
"kid_points_earned_daily_sensor": {
"name": "{kid_name} - Puntos Ganados - Diariamente"
},
"kid_points_earned_weekly_sensor": {
"name": "{kid_name} - Puntos Ganados - Semanalmente"
},
"kid_points_earned_monthly_sensor": {
"name": "{kid_name} - Puntos Ganados - Mensualmente"
},
"achievement_state_sensor": {
"name": "Logro - {achievement_name}",
"state_attributes": {
"achievement_name": {
"name": "Nombre del Logro"
},
"description": {
"name": "Descripción"
},
"assigned_kids": {
"name": "Niños Asignados"
},
"type": {
"name": "Tipo",
"state": {
"chore_total": "Tareas Totales",
"chore_streak": "Racha Tareas",
"daily_minimum": "Mínimo Diario"
}
},
"associated_chore": {
"name": "Tarea Asociada"
},
"critera": {
"name": "Criterios"
},
"target_value": {
"name": "Objetivo"
},
"reward_points": {
"name": "Puntos de Recompensa"
},
"kids_earned": {
"name": "Ganado por"
},
"labels": {
"name": "Etiquetas"
}
}
},
"challenge_state_sensor": {
"name": "Desafío - {challenge_name}",
"state_attributes": {
"challenge_name": {
"name": "Nombre del Desafío"
},
"description": {
"name": "Descripción"
},
"assigned_kids": {
"name": "Niños Asignados"
},
"type": {
"name": "Tipo",
"state": {
"total_within_window": "Total en Periodo",
"daily_minimum": "Minimo Diario"
}
},
"associated_chore": {
"name": "Tarea Asociada"
},
"critera": {
"name": "Criterios"
},
"target_value": {
"name": "Objetivo"
},
"reward_points": {
"name": "Puntos de Recompensa"
},
"start_date": {
"name": "Fecha de Inicio"
},
"end_date": {
"name": "Fecha de Finalización"
},
"kids_earned": {
"name": "Ganado por"
},
"labels": {
"name": "Etiquetas"
}
}
},
"achievement_progress_sensor": {
"name": "{kid_name} - Progreso - {achievement_name}",
"state_attributes": {
"achievement_name": {
"name": "Nombre del Logro"
},
"description": {
"name": "Descripción"
},
"assigned_kids": {
"name": "Niños Asignados"
},
"type": {
"name": "Tipo",
"state": {
"chore_total": "Tareas Totales",
"chore_streak": "Racha Tareas",
"daily_minimum": "Mínimo Diario"
}
},
"associated_chore": {
"name": "Tarea Asociada"
},
"critera": {
"name": "Criterios"
},
"target_value": {
"name": "Objetivo"
},
"reward_points": {
"name": "Puntos de Recompensa"
},
"raw_progress": {
"name": "Progreso"
},
"awarded": {
"name": "Otorgado",
"state": {
"true": "Sí",
"false": "No"
}
},
"labels": {
"name": "Etiquetas"
}
}
},
"challenge_progress_sensor": {
"name": "{kid_name} - Progreso - {challenge_name}",
"state_attributes": {
"challenge_name": {
"name": "Nombre del Desafío"
},
"description": {
"name": "Descripción"
},
"assigned_kids": {
"name": "Niños Asignados"
},
"type": {
"name": "Tipo",
"state": {
"total_within_window": "Total en Periodo",
"daily_minimum": "Minimo Diario"
}
},
"associated_chore": {
"name": "Tarea Asociada"
},
"critera": {
"name": "Criterios"
},
"target_value": {
"name": "Objetivo"
},
"reward_points": {
"name": "Puntos de Recompensa"
},
"start_date": {
"name": "Fecha de Inicio"
},
"end_date": {
"name": "Fecha de Finalización"
},
"raw_progress": {
"name": "Progreso"
},
"awarded": {
"name": "Otorgado",
"state": {
"true": "Sí",
"false": "No"
}
},
"labels": {
"name": "Etiquetas"
}
}
},
"kid_highest_streak_sensor": {
"name": "{kid_name} - Mayor Racha",
"state_attributes": {
"streaks_by_achievement": {
"name": "Rachas por Logro"
}
}
},
"chore_streak_sensor": {
"name": "{kid_name} - Racha - {chore_name}",
"state_attributes": {
"last_date": {
"name": "Última Fecha"
},
"raw_streak": {
"name": "Racha Actual"
}
}
}
},
"button": {
"claim_chore_button": {
"name": "{kid_name} - Reclamar Tarea - {chore_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"approve_chore_button": {
"name": "{kid_name} - Aprobar Tarea - {chore_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"disapprove_chore_button": {
"name": "{kid_name} - Desaprobar Tarea - {chore_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"claim_reward_button": {
"name": "{kid_name} - Reclamar Recompensa - {reward_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"approve_reward_button": {
"name": "{kid_name} - Aprobar Recompensa - {reward_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"disapprove_reward_button": {
"name": "{kid_name} - Desaprobar Recompensa - {reward_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"penalty_button": {
"name": "{kid_name} - Aplicar Penalización - {penalty_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"bonus_button": {
"name": "{kid_name} - Aplicar Bonificación - {bonus_name}",
"state_attributes": {
"labels": {
"name": "Etiquetas"
}
}
},
"manual_adjustment_button": {
"name": "{kid_name} {sign_label} {points_label}"
}
}
}
}
================================================
FILE: hacs.json
================================================
{
"name": "KidsChores",
"homeassistant": "2024.12",
"hacs": "1.33.0",
"render_readme": true
}